Browse Source

chore: sync various (#9950)

* chore: some missing changes

Signed-off-by: mertmit <mertmit99@gmail.com>

* chore: sync refactored expanded form

Signed-off-by: mertmit <mertmit99@gmail.com>

---------

Signed-off-by: mertmit <mertmit99@gmail.com>
pull/9496/merge
Mert E. 1 day ago committed by GitHub
parent
commit
ec464a3310
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      packages/nc-gui/assets/nc-icons-v2/eye-off.svg
  2. 8
      packages/nc-gui/assets/nc-icons-v2/file-type-audio.svg
  3. 18
      packages/nc-gui/assets/nc-icons-v2/file-type-compressed.svg
  4. 13
      packages/nc-gui/assets/nc-icons-v2/file-type-csv.svg
  5. 7
      packages/nc-gui/assets/nc-icons-v2/file-type-doc.svg
  6. 13
      packages/nc-gui/assets/nc-icons-v2/file-type-excel.svg
  7. 5
      packages/nc-gui/assets/nc-icons-v2/file-type-pdf.svg
  8. 6
      packages/nc-gui/assets/nc-icons-v2/file-type-presentation.svg
  9. 4
      packages/nc-gui/assets/nc-icons-v2/file-type-unknown.svg
  10. 4
      packages/nc-gui/assets/nc-icons-v2/file-type-unsupported.svg
  11. 5
      packages/nc-gui/assets/nc-icons-v2/file-type-video.svg
  12. 7
      packages/nc-gui/assets/nc-icons-v2/file-type-word.svg
  13. 18
      packages/nc-gui/assets/nc-icons-v2/file-type-zip.svg
  14. 16
      packages/nc-gui/assets/nc-icons/eye-off.svg
  15. 5
      packages/nc-gui/components/ai/PromptWithFields.vue
  16. 2
      packages/nc-gui/components/cell/SingleSelect.vue
  17. 24
      packages/nc-gui/components/cell/attachment/index.vue
  18. 32
      packages/nc-gui/components/cell/attachment/utils.ts
  19. 4
      packages/nc-gui/components/dashboard/settings/base/index.vue
  20. 4
      packages/nc-gui/components/general/WorkspaceIconSelector.vue
  21. 43
      packages/nc-gui/components/nc/DropdownSelect.vue
  22. 66
      packages/nc-gui/components/nc/EditableText.vue
  23. 310
      packages/nc-gui/components/nc/List/RecordItem.vue
  24. 48
      packages/nc-gui/components/nc/List/index.vue
  25. 78
      packages/nc-gui/components/nc/SelectTab.vue
  26. 10
      packages/nc-gui/components/smartsheet/Cell.vue
  27. 6
      packages/nc-gui/components/smartsheet/Gallery.vue
  28. 801
      packages/nc-gui/components/smartsheet/column/AiButtonOptions.vue
  29. 17
      packages/nc-gui/components/smartsheet/expanded-form/Sidebar/index.vue
  30. 389
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  31. 3
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/AttachmentView.vue
  32. 3
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/PreviewBar.vue
  33. 3
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/PreviewCell.vue
  34. 3
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/index.vue
  35. 214
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Fields/Columns.vue
  36. 58
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Fields/MiniColumnsWrapper.vue
  37. 263
      packages/nc-gui/components/smartsheet/expanded-form/presentors/Fields/index.vue
  38. 108
      packages/nc-gui/components/smartsheet/grid/InfiniteTable.vue
  39. 22
      packages/nc-gui/components/virtual-cell/Button.vue
  40. 2
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  41. 7
      packages/nc-gui/composables/useBetaFeatureToggle.ts
  42. 15
      packages/nc-gui/composables/useSharedView.ts
  43. 46
      packages/nc-gui/utils/fileUtils.ts
  44. 47
      packages/nc-gui/utils/iconUtils.ts
  45. 34
      packages/noco-docs/docs/.135.extensions/010.overview.md
  46. 25
      packages/noco-docs/docs/.135.extensions/020.data-exporter.md
  47. 58
      packages/noco-docs/docs/.135.extensions/030.upload-data-from-csv.md
  48. 26
      packages/noco-docs/docs/.135.extensions/040.bulk-update.md
  49. 4
      packages/noco-docs/docs/.135.extensions/050.url-preview.md
  50. 29
      packages/noco-docs/docs/.135.extensions/060.world-clock.md
  51. 8
      packages/noco-docs/docs/.135.extensions/_category_.json
  52. BIN
      packages/noco-docs/static/img/v2/extensions/url-preview.png
  53. BIN
      packages/noco-docs/static/img/v2/extensions/world-clock-1.png
  54. BIN
      packages/noco-docs/static/img/v2/extensions/world-clock-2.png
  55. 25
      packages/nocodb-sdk/src/lib/Api.ts
  56. 8
      packages/nocodb-sdk/src/lib/globals.ts
  57. 5
      packages/nocodb/src/helpers/NcPluginMgrv2.ts
  58. 40
      packages/nocodb/src/models/View.ts
  59. 18
      packages/nocodb/src/services/columns.service.ts
  60. BIN
      tests/playwright/fixtures/sampleFiles/sample.mp3
  61. BIN
      tests/playwright/fixtures/sampleFiles/sample.mp4
  62. BIN
      tests/playwright/fixtures/sampleFiles/sample.pdf
  63. 66
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  64. 6
      tests/playwright/pages/Dashboard/Settings/Miscellaneous.ts
  65. 2
      tests/playwright/tests/db/columns/columnDuration.spec.ts
  66. 127
      tests/playwright/tests/db/features/expandedFormModeFiles.spec.ts

15
packages/nc-gui/assets/nc-icons-v2/eye-off.svg

@ -1,11 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1504_35967)">
<path d="M6.60008 2.82701C7.05897 2.7196 7.52879 2.6659 8.00008 2.66701C12.6667 2.66701 15.3334 8.00034 15.3334 8.00034C14.9287 8.75741 14.4461 9.47017 13.8934 10.127M9.41341 9.41368C9.23032 9.61017 9.00951 9.76778 8.76418 9.87709C8.51885 9.9864 8.25402 10.0452 7.98548 10.0499C7.71693 10.0547 7.45019 10.0053 7.20115 9.90467C6.95212 9.80408 6.7259 9.65436 6.53598 9.46444C6.34606 9.27453 6.19634 9.0483 6.09575 8.79927C5.99516 8.55023 5.94577 8.28349 5.9505 8.01495C5.95524 7.74641 6.01402 7.48157 6.12333 7.23624C6.23264 6.99091 6.39025 6.77011 6.58675 6.58701M11.9601 11.9603C10.8205 12.829 9.43282 13.3103 8.00008 13.3337C3.33341 13.3337 0.666748 8.00034 0.666748 8.00034C1.49601 6.45494 2.64617 5.10475 4.04008 4.04034L11.9601 11.9603Z" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.666748 0.666992L15.3334 15.3337" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1504_35967">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6.60002 2.8267C7.05891 2.71929 7.52873 2.6656 8.00002 2.6667C12.6667 2.6667 15.3334 8.00004 15.3334 8.00004C14.9287 8.75711 14.4461 9.46986 13.8934 10.1267M9.41335 9.41337C9.23026 9.60987 9.00945 9.76747 8.76412 9.87679C8.51879 9.9861 8.25396 10.0449 7.98541 10.0496C7.71687 10.0544 7.45013 10.005 7.20109 9.90436C6.95206 9.80378 6.72583 9.65406 6.53592 9.46414C6.346 9.27422 6.19628 9.048 6.09569 8.79896C5.9951 8.54993 5.9457 8.28318 5.95044 8.01464C5.95518 7.7461 6.01396 7.48127 6.12327 7.23594C6.23258 6.9906 6.39019 6.7698 6.58669 6.5867M11.96 11.96C10.8204 12.8287 9.43276 13.3099 8.00002 13.3334C3.33335 13.3334 0.666687 8.00004 0.666687 8.00004C1.49595 6.45463 2.64611 5.10444 4.04002 4.04004L11.96 11.96Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 2L14 14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

8
packages/nc-gui/assets/nc-icons-v2/file-type-audio.svg

@ -0,0 +1,8 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7332 9.33398H17.7332C16.743 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8097 46.6673 16.7999 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7332 9.33398Z" fill="#485DFF"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M21 31H20C19.4477 31 19 31.4477 19 32V35C19 35.5523 19.4477 36 20 36H21C21.5523 36 22 35.5523 22 35V32C22 31.4477 21.5523 31 21 31Z" fill="#A3ADFF"/>
<path d="M26 29.75H25C24.4477 29.75 24 30.1977 24 30.75V36.25C24 36.8023 24.4477 37.25 25 37.25H26C26.5523 37.25 27 36.8023 27 36.25V30.75C27 30.1977 26.5523 29.75 26 29.75Z" fill="#C2C9FF"/>
<path d="M31 26H30C29.4477 26 29 26.4477 29 27V40C29 40.5523 29.4477 41 30 41H31C31.5523 41 32 40.5523 32 40V27C32 26.4477 31.5523 26 31 26Z" fill="white"/>
<path d="M36 29.75H35C34.4477 29.75 34 30.1977 34 30.75V36.25C34 36.8023 34.4477 37.25 35 37.25H36C36.5523 37.25 37 36.8023 37 36.25V30.75C37 30.1977 36.5523 29.75 36 29.75Z" fill="#C2C9FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

18
packages/nc-gui/assets/nc-icons-v2/file-type-compressed.svg

@ -0,0 +1,18 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#7D7D7D"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M22 9.5H20.5V11H22V9.5Z" fill="#D2D2D2"/>
<path d="M23.5 11H22V12.5H23.5V11Z" fill="#D2D2D2"/>
<path d="M22 15.5H20.5V17H22V15.5Z" fill="#D2D2D2"/>
<path d="M23.5 17H22V18.5H23.5V17Z" fill="#D2D2D2"/>
<path d="M22 21.5H20.5V23H22V21.5Z" fill="#D2D2D2"/>
<path d="M23.5 23H22V24.5H23.5V23Z" fill="#D2D2D2"/>
<path d="M22 12.5H20.5V14H22V12.5Z" fill="#D2D2D2"/>
<path d="M23.5 14H22V15.5H23.5V14Z" fill="#D2D2D2"/>
<path d="M22 18.5H20.5V20H22V18.5Z" fill="#D2D2D2"/>
<path d="M23.5 20H22V21.5H23.5V20Z" fill="#D2D2D2"/>
<path d="M22 24.5H20.5V26H22V24.5Z" fill="#D2D2D2"/>
<path d="M23.5 26H22V27.5H23.5V26Z" fill="#D2D2D2"/>
<path d="M23.5 27.5H20.5V31.5H23.5V27.5Z" fill="#D2D2D2"/>
<path d="M23 29H21V31H23V29Z" fill="#7D7D7D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

13
packages/nc-gui/assets/nc-icons-v2/file-type-csv.svg

@ -0,0 +1,13 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7332 9.33398H17.7332C16.743 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8097 46.6673 16.7999 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7332 9.33398Z" fill="#4ECF7D"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5241 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M23 24H21C20.4477 24 20 24.4477 20 25V27C20 27.5523 20.4477 28 21 28H23C23.5523 28 24 27.5523 24 27V25C24 24.4477 23.5523 24 23 24Z" fill="white"/>
<path d="M23 30H21C20.4477 30 20 30.4477 20 31V33C20 33.5523 20.4477 34 21 34H23C23.5523 34 24 33.5523 24 33V31C24 30.4477 23.5523 30 23 30Z" fill="white"/>
<path d="M23 36H21C20.4477 36 20 36.4477 20 37V39C20 39.5523 20.4477 40 21 40H23C23.5523 40 24 39.5523 24 39V37C24 36.4477 23.5523 36 23 36Z" fill="white"/>
<path d="M29 24H27C26.4477 24 26 24.4477 26 25V27C26 27.5523 26.4477 28 27 28H29C29.5523 28 30 27.5523 30 27V25C30 24.4477 29.5523 24 29 24Z" fill="white"/>
<path d="M29 30H27C26.4477 30 26 30.4477 26 31V33C26 33.5523 26.4477 34 27 34H29C29.5523 34 30 33.5523 30 33V31C30 30.4477 29.5523 30 29 30Z" fill="white"/>
<path d="M29 36H27C26.4477 36 26 36.4477 26 37V39C26 39.5523 26.4477 40 27 40H29C29.5523 40 30 39.5523 30 39V37C30 36.4477 29.5523 36 29 36Z" fill="white"/>
<path d="M35 24H33C32.4477 24 32 24.4477 32 25V27C32 27.5523 32.4477 28 33 28H35C35.5523 28 36 27.5523 36 27V25C36 24.4477 35.5523 24 35 24Z" fill="white"/>
<path d="M35 30H33C32.4477 30 32 30.4477 32 31V33C32 33.5523 32.4477 34 33 34H35C35.5523 34 36 33.5523 36 33V31C36 30.4477 35.5523 30 35 30Z" fill="white"/>
<path d="M35 36H33C32.4477 36 32 36.4477 32 37V39C32 39.5523 32.4477 40 33 40H35C35.5523 40 36 39.5523 36 39V37C36 36.4477 35.5523 36 35 36Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

7
packages/nc-gui/assets/nc-icons-v2/file-type-doc.svg

@ -0,0 +1,7 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#5882FF"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M29 26H21C20.4477 26 20 26.4477 20 27C20 27.5523 20.4477 28 21 28H29C29.5523 28 30 27.5523 30 27C30 26.4477 29.5523 26 29 26Z" fill="white"/>
<path d="M35 32H21C20.4477 32 20 32.4477 20 33C20 33.5523 20.4477 34 21 34H35C35.5523 34 36 33.5523 36 33C36 32.4477 35.5523 32 35 32Z" fill="white"/>
<path d="M35 38H21C20.4477 38 20 38.4477 20 39C20 39.5523 20.4477 40 21 40H35C35.5523 40 36 39.5523 36 39C36 38.4477 35.5523 38 35 38Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

13
packages/nc-gui/assets/nc-icons-v2/file-type-excel.svg

@ -0,0 +1,13 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#4ECF7D"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5241 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<rect x="20" y="24" width="4" height="4" rx="1" fill="white"/>
<rect x="20" y="30" width="4" height="4" rx="1" fill="white"/>
<rect x="20" y="36" width="4" height="4" rx="1" fill="white"/>
<rect x="26" y="24" width="4" height="4" rx="1" fill="white"/>
<rect x="26" y="30" width="4" height="4" rx="1" fill="white"/>
<rect x="26" y="36" width="4" height="4" rx="1" fill="white"/>
<rect x="32" y="24" width="4" height="4" rx="1" fill="white"/>
<rect x="32" y="30" width="4" height="4" rx="1" fill="white"/>
<rect x="32" y="36" width="4" height="4" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

5
packages/nc-gui/assets/nc-icons-v2/file-type-pdf.svg

@ -0,0 +1,5 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7332 9.33398H17.7332C16.743 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8097 46.6673 16.7999 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7332 9.33398Z" fill="#E26D66"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M19 39.286C19 38.708 19.3664 38.1045 20.0991 37.4755C20.8318 36.8465 21.5706 36.345 22.3153 35.971L22.5856 36.345C22.0571 36.7077 21.5135 37.1752 20.955 37.7475C20.3964 38.3198 20.045 38.827 19.9009 39.269C20.9459 38.963 22.5375 36.8153 24.6757 32.826C25.4565 31.3413 26.0751 29.8623 26.5315 28.389C26.1351 27.1763 25.9369 26.0317 25.9369 24.955C25.9369 23.6517 26.2192 23 26.7838 23C27.0601 23 27.2643 23.0113 27.3964 23.034C27.5285 23.0567 27.6517 23.1303 27.7658 23.255C27.8799 23.3797 27.9369 23.5723 27.9369 23.833C27.9369 24.003 27.9189 24.1617 27.8829 24.309L27.3964 24.292C27.3724 23.9973 27.2703 23.765 27.0901 23.595C26.9459 23.8217 26.8739 24.2127 26.8739 24.768C26.8739 25.1987 26.9279 25.7313 27.036 26.366C27.0721 26.162 27.1231 25.8588 27.1892 25.4565C27.2553 25.0542 27.3123 24.751 27.3604 24.547L27.8288 24.598C27.8048 26.3207 27.7207 27.556 27.5766 28.304C28.009 29.5167 28.5285 30.5027 29.1351 31.262C29.7417 32.0213 30.5976 32.6957 31.7027 33.285C32.5195 33.2057 33.2162 33.166 33.7928 33.166C35.9309 33.166 37 33.5627 37 34.356C37 34.4807 36.97 34.6167 36.9099 34.764L36.8559 34.747C36.7958 35.223 36.3934 35.461 35.6486 35.461C34.3994 35.461 32.958 35.053 31.3243 34.237C28.6817 34.4863 26.3453 34.951 24.3153 35.631C22.5495 38.5437 21.1381 40 20.0811 40C19.997 40 19.9129 39.9887 19.8288 39.966C19.7447 39.9433 19.6697 39.9178 19.6036 39.8895C19.5375 39.8612 19.4565 39.8215 19.3604 39.7705C19.2643 39.7195 19.1922 39.6827 19.1441 39.66C19.048 39.5693 19 39.4447 19 39.286ZM24.7297 34.951C26.3393 34.339 28.1291 33.8573 30.0991 33.506C28.8619 32.6787 27.8769 31.4943 27.1441 29.953C26.7117 31.3243 25.9069 32.9903 24.7297 34.951ZM33.4865 34.101C34.5676 34.4977 35.4204 34.696 36.045 34.696C36.2252 34.696 36.3574 34.679 36.4414 34.645C36.4414 34.2823 35.5706 34.101 33.8288 34.101H33.4865Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

6
packages/nc-gui/assets/nc-icons-v2/file-type-presentation.svg

@ -0,0 +1,6 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#FF7C48"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M36 33C36 34.5823 35.5308 36.129 34.6518 37.4446C33.7727 38.7602 32.5233 39.7855 31.0615 40.391C29.5997 40.9965 27.9911 41.155 26.4393 40.8463C24.8874 40.5376 23.462 39.7757 22.3431 38.6569C21.2243 37.538 20.4624 36.1126 20.1537 34.5607C19.845 33.0089 20.0035 31.4003 20.609 29.9385C21.2145 28.4767 22.2398 27.2273 23.5554 26.3482C24.871 25.4692 26.4177 25 28 25V33H36Z" fill="#FFC7B1"/>
<path d="M37 32C37 30.9494 36.7931 29.9091 36.391 28.9385C35.989 27.9679 35.3997 27.086 34.6569 26.3431C33.914 25.6003 33.0321 25.011 32.0615 24.609C31.0909 24.2069 30.0506 24 29 24V32H37Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

4
packages/nc-gui/assets/nc-icons-v2/file-type-unknown.svg

@ -0,0 +1,4 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#B3B3B3"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 592 B

4
packages/nc-gui/assets/nc-icons-v2/file-type-unsupported.svg

@ -0,0 +1,4 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#B3B3B3"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 592 B

5
packages/nc-gui/assets/nc-icons-v2/file-type-video.svg

@ -0,0 +1,5 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7332 9.33398H17.7332C16.743 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8097 46.6673 16.7999 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7332 9.33398Z" fill="#48B9FF"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5241 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<path d="M24 27.7672C24 26.9899 24.848 26.5098 25.5145 26.9097L32.5708 31.1435C33.2182 31.5319 33.2182 32.4701 32.5708 32.8585L25.5145 37.0923C24.848 37.4922 24 37.0121 24 36.2348V27.7672Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 799 B

7
packages/nc-gui/assets/nc-icons-v2/file-type-word.svg

@ -0,0 +1,7 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#5882FF"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<rect x="20" y="26" width="10" height="2" rx="1" fill="white"/>
<rect x="20" y="32" width="16" height="2" rx="1" fill="white"/>
<rect x="20" y="38" width="16" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 784 B

18
packages/nc-gui/assets/nc-icons-v2/file-type-zip.svg

@ -0,0 +1,18 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.7333 9.33398H17.7333C16.7431 9.33398 15.0452 9.54249 14.1601 10.4275C13.2751 11.3125 13.0667 13.0105 13.0667 14.0007V42.934C13.0667 43.9242 13.46 44.8737 14.1601 45.5738C14.8602 46.274 15.8098 46.6673 16.8 46.6673H39.2C40.1902 46.6673 41.1397 46.274 41.8398 45.5738C42.54 44.8737 42.9333 43.9242 42.9333 42.934V20.534L31.7333 9.33398Z" fill="#7D7D7D"/>
<path d="M31.7333 9.33398V16.534C31.7333 18.7431 33.5242 20.534 35.7333 20.534H42.9333" fill="white" fill-opacity="0.25"/>
<rect x="20.5" y="9.5" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="22" y="11" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="20.5" y="15.5" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="22" y="17" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="20.5" y="21.5" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="22" y="23" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="20.5" y="12.5" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="22" y="14" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="20.5" y="18.5" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="22" y="20" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="20.5" y="24.5" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="22" y="26" width="1.5" height="1.5" fill="#D2D2D2"/>
<rect x="20.5" y="27.5" width="3" height="4" fill="#D2D2D2"/>
<rect x="21" y="29" width="2" height="2" fill="#7D7D7D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

16
packages/nc-gui/assets/nc-icons/eye-off.svg

@ -1,14 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_453_14719)">
<path
d="M6.59984 2.82676C7.05873 2.71935 7.52855 2.66566 7.99984 2.66676C12.6665 2.66676 15.3332 8.0001 15.3332 8.0001C14.9285 8.75717 14.4459 9.46992 13.8932 10.1268M9.41317 9.41343C9.23007 9.60993 9.00927 9.76754 8.76394 9.87685C8.51861 9.98616 8.25377 10.0449 7.98523 10.0497C7.71669 10.0544 7.44995 10.005 7.20091 9.90443C6.95188 9.80384 6.72565 9.65412 6.53573 9.4642C6.34582 9.27428 6.1961 9.04806 6.09551 8.79902C5.99492 8.54999 5.94552 8.28325 5.95026 8.0147C5.955 7.74616 6.01378 7.48133 6.12309 7.236C6.2324 6.99067 6.39001 6.76986 6.5865 6.58677M11.9598 11.9601C10.8202 12.8288 9.43258 13.31 7.99984 13.3334C3.33317 13.3334 0.666504 8.0001 0.666504 8.0001C1.49576 6.4547 2.64593 5.1045 4.03984 4.0401L11.9598 11.9601Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M0.666504 0.666748L15.3332 15.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</g>
<defs>
<clipPath id="clip0_453_14719">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6.60002 2.8267C7.05891 2.71929 7.52873 2.6656 8.00002 2.6667C12.6667 2.6667 15.3334 8.00004 15.3334 8.00004C14.9287 8.75711 14.4461 9.46986 13.8934 10.1267M9.41335 9.41337C9.23026 9.60987 9.00945 9.76747 8.76412 9.87679C8.51879 9.9861 8.25396 10.0449 7.98541 10.0496C7.71687 10.0544 7.45013 10.005 7.20109 9.90436C6.95206 9.80378 6.72583 9.65406 6.53592 9.46414C6.346 9.27422 6.19628 9.048 6.09569 8.79896C5.9951 8.54993 5.9457 8.28318 5.95044 8.01464C5.95518 7.7461 6.01396 7.48127 6.12327 7.23594C6.23258 6.9906 6.39019 6.7698 6.58669 6.5867M11.96 11.96C10.8204 12.8287 9.43276 13.3099 8.00002 13.3334C3.33335 13.3334 0.666687 8.00004 0.666687 8.00004C1.49595 6.45463 2.64611 5.10444 4.04002 4.04004L11.96 11.96Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 2L14 14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

5
packages/nc-gui/components/ai/PromptWithFields.vue

@ -21,6 +21,10 @@ const props = withDefaults(
autoFocus: true,
promptFieldTagClassName: '',
suggestionIconClassName: '',
/**
* Use \n to show placeholder as preline
* @example: :placeholder="`Enter prompt here...\n\neg : Categorise this {Notes}`"
*/
placeholder: 'Write your prompt here...',
},
)
@ -177,6 +181,7 @@ onMounted(async () => {
.tiptap p.is-editor-empty:first-child::before {
@apply text-gray-500;
content: attr(data-placeholder);
white-space: pre-line; /* Preserve line breaks */
float: left;
height: 0;
pointer-events: none;

2
packages/nc-gui/components/cell/SingleSelect.vue

@ -47,8 +47,6 @@ const { $api } = useNuxtApp()
const searchVal = ref()
const { getMeta } = useMetas()
const { isUIAllowed, isMetaReadOnly } = useRoles()
const { isPg, isMysql } = useBase()

24
packages/nc-gui/components/cell/attachment/index.vue

@ -57,6 +57,7 @@ const {
isReadonly,
storedFiles,
removeFile,
updateAttachmentTitle,
} = useProvideAttachmentCell(updateModelValue)
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
@ -150,7 +151,7 @@ const openAttachmentModal = () => {
}
const open = (e: Event) => {
e.stopPropagation()
e?.stopPropagation()
openAttachmentModal()
}
@ -220,6 +221,14 @@ const attachmentSize = computed(() => {
return 'tiny'
}
})
defineExpose({
openFilePicker: open,
downloadAttachment,
renameAttachment: renameFile,
removeAttachment: onRemoveFileClick,
updateAttachmentTitle,
})
</script>
<template>
@ -252,9 +261,16 @@ const attachmentSize = computed(() => {
@click="onFileClick(item)"
/>
<component :is="FileIcon(item.icon)" v-else-if="item.icon" :height="45" :width="45" @click="selectedFile = item" />
<component
:is="FileIcon(item.icon)"
v-else-if="item.icon"
:height="45"
:width="45"
class="text-white"
@click="selectedFile = item"
/>
<IcOutlineInsertDriveFile v-else :height="45" :width="45" @click="selectedFile = item" />
<GeneralIcon v-else icon="ncFileTypeUnknown" :height="45" :width="45" class="text-white" @click="selectedFile = item" />
</div>
<div class="relative px-1 flex" :title="item.title">
@ -419,7 +435,7 @@ const attachmentSize = computed(() => {
>
<component :is="FileIcon(item.icon)" v-if="item.icon" />
<IcOutlineInsertDriveFile v-else />
<GeneralIcon v-else icon="ncFileTypeUnknown" class="text-white" />
</div>
</NcTooltip>
</template>

32
packages/nc-gui/components/cell/attachment/utils.ts

@ -2,16 +2,11 @@ import type { AttachmentReqType, AttachmentType } from 'nocodb-sdk'
import { populateUniqueFileName } from 'nocodb-sdk'
import DOMPurify from 'isomorphic-dompurify'
import RenameFile from './RenameFile.vue'
import MdiPdfBox from '~icons/mdi/pdf-box'
import MdiFileWordOutline from '~icons/mdi/file-word-outline'
import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box'
import MdiFileExcelOutline from '~icons/mdi/file-excel-outline'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
export const getReadableFileSize = (sizeInBytes: number) => {
const i = Math.min(Math.floor(Math.log(sizeInBytes) / Math.log(1024)), 4)
return `${(sizeInBytes / 1024 ** i).toFixed(2) * 1} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`
}
import MdiPdfBox from '~icons/nc-icons-v2/file-type-pdf'
import MdiFileWordOutline from '~icons/nc-icons-v2/file-type-word'
import MdiFilePowerpointBox from '~icons/nc-icons-v2/file-type-presentation'
import MdiFileExcelOutline from '~icons/nc-icons-v2/file-type-csv'
import IcOutlineInsertDriveFile from '~icons/nc-icons-v2/file-type-unknown'
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => {
@ -105,7 +100,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
attachments.value.splice(i, 1)
selectedVisibleItems.value.splice(i, 1)
updateModelValue(JSON.stringify(attachments.value))
updateModelValue(attachments.value)
}
}
@ -248,7 +243,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
if (!data) return
newAttachments.push(...data)
}
if (newAttachments?.length) updateModelValue(JSON.stringify([...attachments.value, ...newAttachments]))
if (newAttachments?.length) updateModelValue([...attachments.value, ...newAttachments])
}
async function uploadViaUrl(url: AttachmentReqType | AttachmentReqType[], returnError = false) {
@ -271,14 +266,18 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
}
function updateAttachmentTitle(idx: number, title: string) {
attachments.value[idx].title = title
updateModelValue(attachments.value)
}
async function renameFile(attachment: AttachmentType, idx: number, updateSelectedFile?: boolean) {
return new Promise<boolean>((resolve) => {
isRenameModalOpen.value = true
const { close } = useDialog(RenameFile, {
title: attachment.title,
onRename: (newTitle: string) => {
attachments.value[idx].title = newTitle
updateModelValue(JSON.stringify(attachments.value))
updateAttachmentTitle(idx, newTitle)
close()
if (updateSelectedFile) {
@ -359,9 +358,9 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
await apiPromise().then((res) => {
if (res?.path) {
window.open(`${baseURL}/${res.path}`, '_blank')
window.open(`${baseURL}/${res.path}`, '_self')
} else if (res?.url) {
window.open(res.url, '_blank')
window.open(res.url, '_self')
} else {
message.error('Failed to download file')
}
@ -440,6 +439,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
videoStream,
permissionGranted,
isRenameModalOpen,
updateAttachmentTitle,
}
},
'useAttachmentCell',

4
packages/nc-gui/components/dashboard/settings/base/index.vue

@ -7,9 +7,7 @@ const { isFeatureEnabled } = useBetaFeatureToggle()
const router = useRouter()
const activeMenu = ref(
isEeUI && hasPermissionForSnapshots.value ? 'snapshots' : 'visibility',
)
const activeMenu = ref(isEeUI && hasPermissionForSnapshots.value ? 'snapshots' : 'visibility')
const selectMenu = (option: string) => {
if (!hasPermissionForSnapshots.value && option === 'snapshots') {

4
packages/nc-gui/components/general/WorkspaceIconSelector.vue

@ -3,9 +3,9 @@ import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { Upload } from 'ant-design-vue'
import data from 'emoji-mart-vue-fast/data/apple.json'
import { EmojiIndex, Picker } from 'emoji-mart-vue-fast/src'
import { WorkspaceIconType } from '#imports'
import 'emoji-mart-vue-fast/css/emoji-mart.css'
import { PublicAttachmentScope } from 'nocodb-sdk'
import { WorkspaceIconType } from '#imports'
interface Props {
icon: string | Record<string, any>
@ -67,6 +67,8 @@ const handleRemoveIcon = (closeDropdown = true) => {
vIcon.value = ''
vIconType.value = ''
emits('submit')
if (closeDropdown) {
isOpen.value = false
}

43
packages/nc-gui/components/nc/DropdownSelect.vue

@ -0,0 +1,43 @@
<script lang="ts" setup>
/* interface */
const props = defineProps<{
disabled?: boolean
tooltip?: string
items: {
label: string
value: string
}[]
}>()
const modelValue = defineModel<string>()
</script>
<template>
<NcTooltip :disabled="!props.tooltip">
<template #title>
{{ props.tooltip }}
</template>
<NcDropdown :disabled="props.disabled" :class="{ 'pointer-events-none opacity-50': props.disabled }">
<slot />
<template #overlay>
<div class="flex flex-col gap-1 p-1">
<div
v-for="item of props.items"
:key="item.value"
class="flex items-center justify-between px-2 py-1 rounded-md transition-colors hover:bg-gray-100 cursor-pointer"
:class="{
'bg-gray-100': modelValue === item.value,
}"
@click="modelValue = item.value"
>
<span>
{{ item.label }}
</span>
<GeneralIcon v-if="modelValue === item.value" icon="check" class="text-primary" />
</div>
</div>
</template>
</NcDropdown>
</NcTooltip>
</template>

66
packages/nc-gui/components/nc/EditableText.vue

@ -0,0 +1,66 @@
<script lang="ts" setup>
/* interface */
const props = defineProps<{
disabled?: boolean
}>()
const modelValue = defineModel<string>()
/* internal text */
const internalText = ref('')
watch(
modelValue,
(value) => {
internalText.value = value || ''
},
{ immediate: true },
)
/* edit mode */
const inputRef = ref()
const isInEditMode = ref(false)
function goToEditMode() {
if (props.disabled) {
return
}
isInEditMode.value = true
nextTick(() => {
inputRef.value?.select?.()
})
}
function finishEdit() {
isInEditMode.value = false
modelValue.value = internalText.value
}
function cancelEdit() {
isInEditMode.value = false
internalText.value = modelValue.value || ''
}
</script>
<template>
<div class="inline-block">
<template v-if="!isInEditMode">
<span :class="{ 'cursor-pointer': !disabled }" @dblclick="goToEditMode()">
{{ internalText }}
</span>
</template>
<template v-else>
<a-input
ref="inputRef"
v-model:value="internalText"
class="!rounded-lg !w-72"
@blur="finishEdit()"
@keyup.enter="finishEdit()"
@keyup.esc="cancelEdit()"
/>
</template>
</div>
</template>

310
packages/nc-gui/components/nc/List/RecordItem.vue

@ -0,0 +1,310 @@
<script lang="ts" setup>
import { type ColumnType, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
const props = withDefaults(
defineProps<{
row: Row
columns: (ColumnType & { [key: string]: any })[]
attachmentColumn?: ColumnType
displayValueColumn?: ColumnType
isLoading?: boolean
isSelected?: boolean
displayValueClassName?: string
}>(),
{
isLoading: false,
isSelected: false,
displayValueClassName: '',
},
)
provide(IsExpandedFormOpenInj, ref(true))
provide(RowHeightInj, ref(1 as const))
provide(IsFormInj, ref(false))
const { row: currentRow, columns: allColumns, isSelected, isLoading } = toRefs(props)
useProvideSmartsheetRowStore(currentRow)
const readOnly = inject(ReadonlyInj, ref(false))
const { isMobileMode } = useGlobal()
const { getPossibleAttachmentSrc } = useAttachment()
interface Attachment {
url: string
title: string
type: string
mimetype: string
}
const displayValueColumn = computed(() => {
return props.displayValueColumn || (allColumns.value || []).find((c) => c?.pv ?? null) || (allColumns.value || [])?.[0]
})
const displayValue = computed(() => {
return displayValueColumn.value?.title ? currentRow.value.row[displayValueColumn.value?.title] : null
})
const attachmentColumn = computed(() => {
return props.attachmentColumn || (allColumns.value || []).find((c) => isAttachment(c))
})
const attachments: ComputedRef<Attachment[]> = computed(() => {
try {
if (attachmentColumn.value?.title && currentRow.value.row[attachmentColumn.value.title]) {
return typeof currentRow.value.row[attachmentColumn.value.title] === 'string'
? JSON.parse(currentRow.value.row[attachmentColumn.value.title])
: currentRow.value.row[attachmentColumn.value.title]
}
return []
} catch (e) {
return []
}
})
const columnsToRender = computed(() => {
return allColumns.value
.filter((c) => {
const isDisplayValueColumn = c.id === displayValueColumn.value?.id
return (
!isDisplayValueColumn && !isSystemColumn(c) && !isPrimary(c) && !isLinksOrLTAR(c) && !isAiButton(c) && !isAttachment(c)
)
})
.sort((a, b) => {
return (a.meta?.defaultViewColOrder ?? Infinity) - (b.meta?.defaultViewColOrder ?? Infinity)
})
.slice(0, isMobileMode.value ? 1 : 3)
})
</script>
<template>
<div class="nc-list-item-wrapper group px-[1px] hover:bg-gray-50 border-y-1 border-gray-200 border-t-transparent w-full">
<a-card
tabindex="0"
class="nc-list-item !outline-none transition-all relative group-hover:bg-gray-50 cursor-auto"
:class="{
'!bg-white': isLoading,
'!hover:bg-white': readOnly,
}"
:body-style="{ padding: '6px 10px !important', borderRadius: 0 }"
:hoverable="false"
>
<div class="flex items-center gap-3">
<template v-if="attachmentColumn">
<div v-if="attachments && attachments.length">
<a-carousel autoplay class="!w-11 !h-11 !max-h-11 !max-w-11">
<template #customPaging> </template>
<template v-for="(attachmentObj, index) in attachments">
<LazyCellAttachmentPreviewImage
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`"
class="!w-11 !h-11 !max-h-11 !max-w-11object-cover !rounded-l-xl"
:srcs="getPossibleAttachmentSrc(attachmentObj, 'tiny')"
/>
</template>
</a-carousel>
</div>
<div
v-else
class="h-11 w-11 !min-h-11 !min-w-11 !max-h-11 !max-w-11 !flex flex-row items-center !rounded-l-xl justify-center"
>
<GeneralIcon class="w-full h-full !text-6xl !leading-10 !text-transparent rounded-lg" icon="fileImage" />
</div>
</template>
<div class="flex-1 flex flex-col gap-1 justify-center overflow-hidden">
<div
v-if="displayValueColumn && displayValue"
class="flex justify-start font-semibold text-brand-500 nc-display-value"
:class="displayValueClassName"
>
<NcTooltip class="truncate leading-[20px]" show-on-truncate-only>
<template #title>
<LazySmartsheetPlainCell
v-model="displayValue"
:column="displayValueColumn"
class="field-config-plain-cell-value"
/>
</template>
<LazySmartsheetPlainCell
v-model="displayValue"
:column="displayValueColumn"
class="field-config-plain-cell-value"
/>
</NcTooltip>
</div>
<div v-if="columnsToRender.length > 0" class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 min-h-5">
<div v-for="column in columnsToRender" :key="column.id" class="sm:(w-1/3 max-w-1/3 overflow-hidden)">
<div v-if="!isRowEmpty({ row }, column)" class="flex flex-col gap-[-1]">
<NcTooltip class="z-10 flex" placement="bottomLeft" :arrow-point-at-center="false">
<template #title>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(column)"
class="text-gray-100 !text-sm nc-link-record-cell-tooltip"
:column="column"
hide-menu
/>
<LazySmartsheetHeaderCell
v-else
class="text-gray-100 !text-sm nc-link-record-cell-tooltip"
:column="column"
hide-menu
/>
</template>
<div class="nc-link-record-cell flex w-full max-w-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(column)"
:model-value="currentRow.row[column.title!]"
:row="currentRow"
:column="column"
/>
<LazySmartsheetCell
v-else
:model-value="currentRow.row[column.title!]"
:column="column"
:edit-enabled="false"
read-only
/>
</div>
</NcTooltip>
</div>
<div v-else class="flex flex-row w-full max-w-72 h-5 pl-1 items-center justify-start">-</div>
</div>
</div>
</div>
<slot name="extraRight">
<div class="min-w-5 flex-none">
<Transition>
<GeneralIcon v-if="isSelected" icon="circleCheckSolid" class="flex-none text-primary w-5 h-5" />
</Transition>
</div>
</slot>
</div>
</a-card>
</div>
</template>
<style lang="scss" scoped>
:deep(.slick-list) {
@apply rounded-lg;
}
.nc-list-item-link-unlink-btn {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
.nc-link-record-cell {
:deep(.nc-cell),
:deep(.nc-virtual-cell) {
@apply !text-small !text-gray-600 ml-1;
.nc-cell-field,
.nc-cell-field-link,
input,
textarea {
@apply !text-small !p-0 m-0;
}
&:not(.nc-display-value-cell) {
@apply text-gray-600;
font-weight: 500;
.nc-cell-field,
input,
textarea {
@apply text-gray-600;
font-weight: 500;
}
}
.nc-cell-field,
a.nc-cell-field-link,
input,
textarea {
@apply !p-0 m-0;
}
&.nc-cell-longtext {
@apply leading-[18px];
textarea {
@apply pr-2;
}
.long-text-wrapper {
@apply !min-h-4;
.nc-rich-text-grid {
@apply pl-0 -ml-1;
}
}
}
.ant-picker-input {
@apply text-small leading-4;
font-weight: 500;
input {
@apply text-small leading-4;
font-weight: 500;
}
}
.ant-select:not(.ant-select-customize-input) {
.ant-select-selector {
@apply !border-none flex-nowrap pr-4.5;
}
.ant-select-arrow {
@apply right-[3px];
}
}
}
}
.nc-link-record-cell-tooltip {
@apply !bg-transparent !hover:bg-transparent;
:deep(.nc-cell-icon) {
@apply !ml-0;
}
:deep(.name) {
@apply !text-small;
}
}
</style>
<style lang="scss">
.nc-list-item {
@apply border-1 border-transparent;
&:focus-visible {
@apply border-brand-500;
box-shadow: 0 0 0 1px #3366ff;
}
&:hover {
.nc-text-area-expand-btn {
@apply !hidden;
}
}
.long-text-wrapper {
@apply select-none pointer-events-none;
.nc-readonly-rich-text-wrapper {
@apply !min-h-5 !max-h-5;
}
.nc-rich-text-embed {
@apply -mt-0.5;
.nc-textarea-rich-editor {
@apply !overflow-hidden;
.ProseMirror {
@apply !overflow-hidden line-clamp-1 h-[18px] pt-0.4;
}
}
}
}
}
</style>

48
packages/nc-gui/components/nc/List.vue → packages/nc-gui/components/nc/List/index.vue

@ -5,7 +5,7 @@ export type MultiSelectRawValueType = Array<string | number>
export type RawValueType = string | number | MultiSelectRawValueType
interface ListItem {
export interface NcListItemType {
value?: RawValueType
label?: string
[key: string]: any
@ -14,11 +14,11 @@ interface ListItem {
/**
* Props interface for the List component
*/
interface Props {
export interface NcListProps {
/** The currently selected value */
value: RawValueType
/** The list of items to display */
list: ListItem[]
list: NcListItemType[]
/**
* The key to use for accessing the value from a list item
* @default 'value'
@ -38,6 +38,8 @@ interface Props {
closeOnSelect?: boolean
/** Placeholder text for the search input */
searchInputPlaceholder?: string
/** Show search input box always */
showSearchAlways?: boolean
/** Whether to show the currently selected option */
showSelectedOption?: boolean
/**
@ -46,7 +48,7 @@ interface Props {
*/
itemHeight?: number
/** Custom filter function for list items */
filterOption?: (input: string, option: ListItem, index: Number) => boolean
filterOption?: (input: string, option: NcListItemType, index: Number) => boolean
/**
* Indicates whether the component allows multiple selections.
*/
@ -55,24 +57,31 @@ interface Props {
* The minimum number of items required in the list to enable search functionality.
*/
minItemsForSearch?: number
containerClassName?: string
itemClassName?: string
}
interface Emits {
(e: 'update:value', value: RawValueType): void
(e: 'update:open', open: boolean): void
(e: 'change', option: ListItem): void
(e: 'change', option: NcListItemType): void
}
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<NcListProps>(), {
open: false,
closeOnSelect: true,
searchInputPlaceholder: '',
searchInputPlaceholder: 'Search',
showSearchAlways: false,
showSelectedOption: true,
optionValueKey: 'value',
optionLabelKey: 'label',
itemHeight: 38,
isMultiSelect: false,
minItemsForSearch: 4,
containerClassName: '',
itemClassName: '',
})
const emits = defineEmits<Emits>()
@ -85,7 +94,9 @@ const vOpen = useVModel(props, 'open', emits)
const { optionValueKey, optionLabelKey } = props
const { closeOnSelect, showSelectedOption } = toRefs(props)
const { closeOnSelect, showSelectedOption, containerClassName, itemClassName } = toRefs(props)
const slots = useSlots()
const listRef = ref<HTMLDivElement>()
@ -97,7 +108,9 @@ const activeOptionIndex = ref(-1)
const showHoverEffectOnSelectedOption = ref(true)
const isSearchEnabled = computed(() => props.list.length > props.minItemsForSearch)
const isSearchEnabled = computed(
() => props.showSearchAlways || slots.headerExtraLeft || slots.headerExtraRight || props.list.length > props.minItemsForSearch,
)
const keyDown = ref(false)
@ -107,7 +120,7 @@ const keyDown = ref(false)
*
* @returns Filtered list of options
*
* @typeparam ListItem - The type of items in the list
* @typeparam NcListItemType - The type of items in the list
*/
const list = computed(() => {
const query = searchQuery.value.toLowerCase()
@ -176,7 +189,7 @@ const handleResetHoverEffect = (clearActiveOption = false, newActiveIndex?: numb
* This function is responsible for handling the selection of an option from the list.
* It updates the model value, emits a change event, and optionally closes the dropdown.
*/
const handleSelectOption = (option: ListItem, index?: number) => {
const handleSelectOption = (option: NcListItemType, index?: number) => {
if (!option?.[optionValueKey]) return
if (index !== undefined) {
@ -322,12 +335,13 @@ watch(
@keydown.enter.prevent="handleSelectOption(list[activeOptionIndex])"
>
<template v-if="isSearchEnabled">
<div class="w-full px-2" @click.stop>
<div class="w-full px-2 flex items-center gap-2" @click.stop>
<slot name="headerExtraLeft"> </slot>
<a-input
ref="inputRef"
v-model:value="searchQuery"
:placeholder="searchInputPlaceholder"
class="nc-toolbar-dropdown-search-field-input !pl-2 !pr-1.5"
class="nc-toolbar-dropdown-search-field-input !pl-2 !pr-1.5 flex-1"
allow-clear
:bordered="false"
@keydown.enter.stop="handleKeydownEnter"
@ -335,6 +349,7 @@ watch(
>
<template #prefix> <GeneralIcon icon="search" class="nc-search-icon h-3.5 w-3.5 mr-1" /> </template
></a-input>
<slot name="headerExtraRight"> </slot>
</div>
<NcDivider />
</template>
@ -345,10 +360,8 @@ watch(
<div class="h-auto !max-h-[247px]">
<div
v-bind="containerProps"
class="nc-list !h-auto w-full nc-scrollbar-thin px-2 pb-2"
:style="{
maxHeight: '247px !important',
}"
class="nc-list !h-auto w-full nc-scrollbar-thin px-2 pb-2 !max-h-[247px]"
:class="containerClassName"
>
<div v-bind="wrapperProps">
<div
@ -362,6 +375,7 @@ watch(
'bg-gray-100 ': showHoverEffectOnSelectedOption && compareVModel(option[optionValueKey]),
'bg-gray-100 nc-list-option-active': activeOptionIndex === idx,
},
`${itemClassName}`,
]"
@mouseover="handleResetHoverEffect(true, idx)"
@click="handleSelectOption(option, idx)"

78
packages/nc-gui/components/nc/SelectTab.vue

@ -0,0 +1,78 @@
<script lang="ts" setup>
/**
* @description
* Tabbed select component
*
* @example
* <NcSelectTab :items="items" v-model="modelValue" />
*/
interface Props {
disabled?: boolean
tooltip?: string
items: {
icon: keyof typeof iconMap
title?: string
value: string
}[]
}
const props = defineProps<Props>()
const modelValue = defineModel<string>()
</script>
<template>
<NcTooltip :disabled="!props.tooltip">
<template #title>
{{ props.tooltip }}
</template>
<div
class="flex flex-row p-1 bg-gray-200 rounded-lg gap-x-0.5"
:class="{
'!cursor-not-allowed opacity-50': props.disabled,
}"
>
<div
v-for="item of props.items"
:key="item.value"
v-e="[`c:project:mode:${item.value}`]"
class="tab"
:class="{
'pointer-events-none': props.disabled,
'active': modelValue === item.value,
}"
@click="modelValue = item.value"
>
<GeneralIcon :icon="item.icon" class="tab-icon" />
<div v-if="item.title" class="tab-title nc-tab">
{{ $t(item.title) }}
</div>
</div>
</div>
</NcTooltip>
</template>
<style scoped>
.tab {
@apply flex flex-row items-center h-6 justify-center px-2 py-1 rounded-md gap-x-2 text-gray-600 hover:text-black cursor-pointer transition-all duration-300 select-none;
}
.tab-icon {
font-size: 1rem !important;
@apply w-4;
}
.tab .tab-title {
@apply min-w-0;
word-break: keep-all;
white-space: nowrap;
display: inline;
line-height: 0.95;
}
.active {
@apply bg-white text-brand-600 hover:text-brand-600;
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
</style>

10
packages/nc-gui/components/smartsheet/Cell.vue

@ -173,11 +173,17 @@ const currentDate = () => {
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
>
<template v-if="column">
<GeneralLoader v-if="isGenerating" />
<div v-if="isGenerating" class="flex items-center gap-2 w-full">
<GeneralLoader />
<NcTooltip class="truncate max-w-[calc(100%_-_24px)]" show-on-truncate-only>
<template #title> {{ $t('general.generating') }} </template>
{{ $t('general.generating') }}
</NcTooltip>
</div>
<LazyCellTextArea v-else-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellAttachment v-else-if="isAttachment(column)" ref="attachmentCell" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect
v-else-if="isSingleSelect(column)"
v-model="vModel"

6
packages/nc-gui/components/smartsheet/Gallery.vue

@ -390,11 +390,7 @@ reloadViewDataHook?.on(async () => {
</NcMenuItem>
</NcMenu>
</template>
<div
:class="{
'h-full': totalRows < 30,
}"
>
<div class="flex-1">
<div :key="containerHeight" class="relative" :style="{ height: `${containerHeight}px` }">
<div :style="{ height: `${placeholderAboveHeight}px` }"></div>
<div class="nc-gallery-container grid gap-3 p-3">

801
packages/nc-gui/components/smartsheet/column/AiButtonOptions.vue

@ -53,11 +53,13 @@ const fieldTitle = computed(() => {
)
})
const previewOutputRow = ref<Row>({
const defaultRow = {
row: {},
oldRow: {},
rowMeta: {},
})
}
const previewOutputRow = ref<Row>(defaultRow)
const generatingPreview = ref(false)
@ -65,19 +67,9 @@ const isAlreadyGenerated = ref(false)
const isLoadingViewData = ref(false)
const loadViewData = async () => {
if (!formattedData.value.length && !isLoadingViewData.value) {
isLoadingViewData.value = true
await loadData(undefined, false)
await ncDelay(250)
isLoadingViewData.value = false
}
}
const inputFieldPlaceholder = 'Enter prompt here...\n\n eg : Categorise this {Notes}'
const displayField = computed(() => meta.value?.columns?.find((c) => c?.pv) ?? null)
const displayField = computed(() => meta.value?.columns?.find((c) => c?.pv) || meta.value?.columns?.[0] || null)
const sampleRecords = computed<
{
@ -158,6 +150,22 @@ const outputColumnIds = computed({
},
})
const loadViewData = async (selectDefaultRecord = false) => {
if (!formattedData.value.length && !isLoadingViewData.value) {
isLoadingViewData.value = true
await loadData(undefined, false)
await ncDelay(250)
isLoadingViewData.value = false
}
if (selectDefaultRecord && sampleRecords.value.length) {
selectedRecordPk.value = sampleRecords.value[0].value
}
}
const removeFromOutputFieldOptions = (id: string) => {
outputColumnIds.value = outputColumnIds.value.filter((op) => op !== id)
}
@ -199,19 +207,18 @@ enum ExpansionPanelKeys {
output = 'output',
}
const expansionPanel = ref<ExpansionPanelKeys[]>([ExpansionPanelKeys.output])
const expansionInputPanel = ref<ExpansionPanelKeys[]>([])
const handleUpdateExpansionPanel = (key: ExpansionPanelKeys) => {
if (expansionPanel.value.includes(key)) {
expansionPanel.value = expansionPanel.value.filter((k) => k !== key)
const handleUpdateExpansionInputPanel = () => {
if (expansionInputPanel.value.includes(ExpansionPanelKeys.input)) {
expansionInputPanel.value = []
} else {
if (key === ExpansionPanelKeys.input && !inputColumns.value.length) {
return
}
expansionPanel.value.push(key)
expansionInputPanel.value = [ExpansionPanelKeys.input]
}
}
const expansionOutputPanel = ref<ExpansionPanelKeys[]>([ExpansionPanelKeys.output])
// provide the following to override the default behavior and enable input fields like in form
provide(ActiveCellInj, ref(true))
provide(IsFormInj, ref(true))
@ -219,10 +226,14 @@ provide(IsFormInj, ref(true))
watch(isOpenConfigModal, (newValue) => {
if (newValue) {
isAiButtonConfigModalOpen.value = true
loadViewData(true)
} else {
setTimeout(() => {
isAiButtonConfigModalOpen.value = false
}, 500)
isOpenSelectOutputFieldDropdown.value = false
isOpenSelectRecordDropdown.value = false
}
})
@ -232,18 +243,6 @@ watch(isOpenSelectRecordDropdown, (newValue) => {
}
})
watch(
() => inputColumns.value.length,
(newValue) => {
if (newValue) return
handleUpdateExpansionPanel(ExpansionPanelKeys.input)
},
{
immediate: true,
},
)
const previewPanelDom = ref<HTMLElement>()
const isPreviewPanelOnScrollTop = ref(false)
@ -259,6 +258,12 @@ const checkScrollTopMoreThanZero = () => {
return false
}
const handleResetOutput = () => {
isAlreadyGenerated.value = false
previewOutputRow.value = { row: {}, oldRow: {}, rowMeta: {} }
}
watch(
[() => outputColumnIds.value.length, () => vModel.value.formula_raw?.length],
() => {
@ -329,7 +334,6 @@ onBeforeUnmount(() => {
<NcButton
size="small"
type="primary"
theme="ai"
:disabled="disableSubmitBtn || saving"
:label="submitBtnLabel.label"
:loading-label="submitBtnLabel.loadingLabel"
@ -349,17 +353,11 @@ onBeforeUnmount(() => {
<div class="h-[calc(100%_-_58px)]">
<div class="h-full flex">
<!-- Left side -->
<div class="h-full w-1/2 nc-scrollbar-thin">
<a-form
v-model="vModel"
no-style
layout="vertical"
class="nc-ai-button-config-left-section flex flex-col gap-6 h-full"
@submit.prevent
>
<div class="flex items-center">
<div class="h-full w-1/2 border-r-1 border-r-nc-border-gray-medium">
<div class="border-b-1 border-b-nc-border-gray-medium py-2.5 w-full">
<div class="flex items-center mx-auto px-6 w-full max-w-[568px]">
<div class="text-base text-nc-content-gray font-bold flex-1">
{{ $t('labels.configuration') }}
{{ $t('general.configure') }}
</div>
<div class="-my-1.5">
<AiSettings
@ -376,16 +374,27 @@ onBeforeUnmount(() => {
</AiSettings>
</div>
</div>
</div>
<a-form
v-model="vModel"
no-style
layout="vertical"
class="nc-ai-button-config-left-section flex flex-col h-[calc(100%_-_45px)] nc-scrollbar-thin"
@submit.prevent
>
<a-form-item class="!my-0" v-bind="validateInfos.formula_raw">
<template #label>
<span> Input Prompt </span>
</template>
<div class="nc-prompt-input-wrapper bg-nc-bg-gray-light rounded-lg w-full">
<div class="flex flex-col gap-2">
<div class="font-bold">Input Prompt</div>
<div class="text-small leading-[18px] text-nc-content-gray-subtle2">
Include at least one field in your prompt. Optionally, specify how the field's data should guide the AI's
response and the format for the output.
</div>
</div>
<div class="nc-prompt-input-wrapper bg-nc-bg-gray-light rounded-lg w-full mt-2">
<AiPromptWithFields
v-model="vModel.formula_raw"
:options="availableFields"
placeholder="Enter prompt here..."
:placeholder="inputFieldPlaceholder"
prompt-field-tag-class-name="!bg-nc-bg-gray-medium !text-nc-content-gray"
/>
<div class="rounded-b-lg flex items-center gap-2 p-1">
@ -398,11 +407,17 @@ onBeforeUnmount(() => {
</div>
</a-form-item>
<a-form-item v-bind="validateInfos.output_column_ids" class="!my-0">
<div class="flex items-center">
<span class="flex-1"> Select fields to generate data </span>
<NcDropdown v-model:visible="isOpenSelectOutputFieldDropdown" placement="bottomRight">
<a-form-item v-bind="validateInfos.output_column_ids" class="!mb-0 !mt-7">
<div class="flex flex-col gap-2">
<div class="font-bold">Choose Output Fields To Be Generated by AI</div>
<div class="text-small leading-[18px] text-nc-content-gray-subtle2">
Choose the fields where the AI-generated data will be applied.
</div>
<NcDropdown
v-model:visible="isOpenSelectOutputFieldDropdown"
placement="bottomRight"
overlay-class-name="overflow-hidden"
>
<NcButton size="small" type="secondary" @click.stop>
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="!text-current" />
@ -419,9 +434,14 @@ onBeforeUnmount(() => {
option-label-key="title"
option-value-key="id"
:close-on-select="false"
class="!w-auto"
is-multi-select
show-search-always
container-class-name="!max-h-[171px]"
>
<template #listItem="{ option, isSelected }">
<NcCheckbox :checked="isSelected()" />
<div class="inline-flex items-center gap-2 flex-1 truncate">
<component :is="cellIcon(option)" class="!mx-0" />
<NcTooltip class="truncate flex-1" show-on-truncate-only>
@ -431,22 +451,27 @@ onBeforeUnmount(() => {
{{ option?.title }}
</NcTooltip>
</div>
<NcCheckbox :checked="isSelected()" />
</template>
<template #headerExtraRight>
<NcBadge :border="false" color="brand"
>{{ outputColumnIds.length }}
{{ $t(`objects.${outputColumnIds?.length === 1 ? 'field' : 'fields'}`) }}
</NcBadge>
</template>
</NcList>
</template>
</NcDropdown>
</div>
<div v-if="outputFieldOptions.length" class="flex flex-wrap gap-2 mt-2">
<div v-if="outputFieldOptions.length" class="flex flex-wrap gap-3 mt-3">
<template v-for="op in outputFieldOptions">
<a-tag v-if="outputColumnIds.includes(op.id)" :key="op.id" class="nc-ai-button-output-field">
<div class="flex flex-row items-center gap-1 py-[3px] text-sm">
<component :is="cellIcon(op)" class="!mx-0" />
<div class="flex flex-row items-center gap-1 py-[2px] text-sm">
<component :is="cellIcon(op)" class="!mx-0 !mr-1 opacity-80" />
<span>{{ op.title }}</span>
<div class="flex items-center p-0.5 mt-0.5">
<GeneralIcon
icon="close"
class="h-4 w-4 cursor-pointer opacity-80"
class="h-4 w-4 cursor-pointer opacity-60"
@click="removeFromOutputFieldOptions(op.id)"
/>
</div>
@ -460,313 +485,369 @@ onBeforeUnmount(() => {
<!-- Right side -->
<div
ref="previewPanelDom"
class="h-full w-1/2 bg-nc-bg-gray-extralight nc-scrollbar-thin flex flex-col relative"
class="h-full w-1/2 bg-[#FCF8FF] flex flex-col relative"
@scroll.passive="checkScrollTopMoreThanZero"
>
<div
class="nc-ai-button-config-right-section !pt-6 sticky top-0 bg-nc-bg-gray-extralight z-10"
:class="{
'border-b-1 border-nc-border-gray-medium': isPreviewPanelOnScrollTop,
}"
>
<div class="text-base text-nc-content-gray font-bold">
{{ $t('labels.preview') }}
<div class="border-b-1 border-b-nc-border-gray-medium py-2.5 w-full">
<div class="flex items-center mx-auto px-6 w-full max-w-[568px]">
<div class="text-base text-nc-content-gray font-bold flex-1">Test Data Generation</div>
</div>
</div>
<div class="flex flex-col gap-6 h-[calc(100%_-_45px)] nc-scrollbar-thin py-6">
<div v-if="aiError" class="nc-ai-button-config-right-section">
<div class="py-3 pl-3 pr-2 flex items-center gap-3 bg-nc-bg-red-light rounded-lg w-full">
<GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-4 h-4" />
<div class="text-sm text-nc-content-gray-subtle flex-1 max-w-[calc(100%_-_24px)]">
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>
{{ aiError }}
</template>
{{ aiError }}
</NcTooltip>
</div>
</div>
</div>
<a-form-item class="!mb-0 !mt-2">
<div class="mb-2 text-sm text-nc-content-gray-subtle2">Select sample record</div>
<div class="flex items-center relative rounded-lg border-1 border-purple-200 bg-purple-50 h-8">
<NcDropdown
v-model:visible="isOpenSelectRecordDropdown"
placement="bottomLeft"
overlay-class-name="!min-w-64"
>
<div
class="absolute left-0 top-0 flex-1 flex items-center gap-2 px-2 cursor-pointer h-8 rounded-lg rounded-r-none bg-white border-1 border-purple-200 transition-all -mt-[1px] -ml-[1px]"
:class="{
'w-[calc(100%_-_132.5px)]': !(aiLoading && generatingPreview),
'w-[calc(100%_-_145.5px)]': aiLoading && generatingPreview,
'!rounded-r-lg shadow-selected-ai border-nc-border-purple z-11': isOpenSelectRecordDropdown,
'shadow-default hover:shadow-hover': !isOpenSelectRecordDropdown,
}"
>
<NcTooltip
v-if="selectedRecord?.label"
class="truncate flex-1 text-nc-content-purple-dark font-semibold"
show-on-truncate-only
:disabled="isOpenSelectRecordDropdown"
>
<template #title>
<LazySmartsheetPlainCell v-model="selectedRecord.label" :column="displayField" />
</template>
<LazySmartsheetPlainCell v-model="selectedRecord.label" :column="displayField" />
</NcTooltip>
<div v-else class="flex-1 text-nc-content-gray-muted">- Select record -</div>
<GeneralIcon
icon="chevronDown"
class="flex-none opacity-60"
:class="{
'transform rotate-180': isOpenSelectRecordDropdown,
}"
/>
</div>
<template #overlay>
<div
v-if="isLoadingViewData"
class="w-full relative flex flex-col items-center justify-center gap-2 min-h-25 text-nc-content-brand"
>
<GeneralLoader size="large" class="flex-none" />
Loading records
</div>
<NcList
v-else
v-model:value="selectedRecordPk"
v-model:open="isOpenSelectRecordDropdown"
:list="sampleRecords"
>
<template #listItem="{ option, isSelected }">
<div class="inline-flex items-center gap-2 flex-1 truncate">
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
<div>
<LazySmartsheetPlainCell v-model="option.label" :column="displayField" />
<div class="nc-ai-button-config-right-section">
<div class="px-3 py-1 text-nc-content-gray-subtle2 text-sm">Input fields</div>
<div class="flex-1 flex border-1 bg-white border-nc-border-gray-medium rounded-xl mt-2 w-full">
<a-collapse
v-model:active-key="expansionInputPanel"
ghost
class="flex-1 nc-ai-button-config-right-collapse nc-collapse-input w-full"
>
<template #expandIcon> </template>
<a-collapse-panel :key="ExpansionPanelKeys.input" collapsible="disabled">
<template #header>
<div
class="flex w-full p-2"
:class="{
'border-b-1 border-nc-border-gray-medium': expansionInputPanel.includes(ExpansionPanelKeys.input),
}"
>
<div class="w-full text-sm text-nc-content-gray-subtle2 font-bold flex items-center gap-2.5 min-h-7">
<NcButton
icon-only
type="text"
size="xs"
class="!px-1"
@click.stop="handleUpdateExpansionInputPanel()"
>
<template #icon>
<GeneralIcon
:icon="expansionInputPanel.includes(ExpansionPanelKeys.input) ? 'eye' : 'eyeSlash'"
/>
</template>
</NcButton>
<NcDropdown
v-model:visible="isOpenSelectRecordDropdown"
placement="bottom"
overlay-class-name="overflow-hidden"
>
<NcButton
size="xs"
type="text"
class="flex-1 children:children:w-full !font-bold !text-sm"
:class="{
'!text-gray-900 !bg-gray-100': isOpenSelectRecordDropdown,
}"
@click.stop
>
<NcTooltip
v-if="selectedRecordPk && displayField"
class="w-full text-left truncate !leading-6"
show-on-truncate-only
>
<template #title>
<LazySmartsheetPlainCell
v-model="selectedRecord.row.row[displayField.title]"
:column="displayField"
class="!leading-6"
/>
</template>
<LazySmartsheetPlainCell
v-model="selectedRecord.row.row[displayField.title]"
:column="displayField"
class="!leading-6"
/>
</NcTooltip>
<div v-else class="flex-1 flex items-center gap-2">- Select Record -</div>
<GeneralLoader v-if="isLoadingViewData && !isOpenSelectRecordDropdown" size="regular" />
<GeneralIcon
v-else
icon="chevronDown"
class="!text-current opacity-70 flex-none transform transition-transform duration-250 w-4 h-4 ml-1"
:class="{ '!rotate-180': isOpenSelectRecordDropdown }"
/>
</NcButton>
<template #overlay>
<div
v-if="isLoadingViewData"
class="min-w-[500px] max-w-[576px] min-h-[296px] relative flex flex-col items-center justify-center gap-2 min-h-25 text-nc-content-brand"
>
<GeneralLoader size="large" class="flex-none" />
Loading records
</div>
<NcList
v-else
v-model:value="selectedRecordPk"
v-model:open="isOpenSelectRecordDropdown"
:list="sampleRecords"
show-search-always
search-input-placeholder="Search records"
:item-height="60"
class="!w-auto min-w-[500px] max-w-[576px]"
container-class-name="!px-0 !pb-0"
item-class-name="!rounded-none !p-0 !bg-none !hover:bg-none"
@update:value="handleResetOutput"
>
<template #listItem="{ option, isSelected }">
<NcListRecordItem
:row="option.row || {}"
:columns="meta?.columns || []"
:is-selected="isSelected()"
class="!cursor-pointer"
display-value-class-name="!text-nc-content-gray"
/>
</template>
</NcList>
</template>
<LazySmartsheetPlainCell v-model="option.label" :column="displayField" />
</NcTooltip>
<GeneralIcon
v-if="isSelected()"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</NcDropdown>
</div>
</template>
</NcList>
</template>
</NcDropdown>
<NcTooltip
:disabled="!!(selectedRecordPk && outputColumnIds.length && vModel.formula_raw)"
class="absolute right-0 top-0"
>
<template #title>
{{
!vModel.formula_raw
? 'Prompt required for AI Button'
: !outputColumnIds.length
? 'At least one output field is required for preview'
: !selectedRecordPk
? 'Select sample record first'
: ''
}}
</template>
<NcButton
size="small"
type="secondary"
class="nc-ai-button-test-generate"
:disabled="aiLoading || !selectedRecordPk || !outputColumnIds.length || !vModel.formula_raw"
:loading="aiLoading && generatingPreview"
@click.stop="generate"
>
<template #icon>
<GeneralIcon icon="ncAutoAwesome" class="h-4 w-4" />
</template>
<template #loadingIcon>
<GeneralLoader class="!text-current" size="regular" />
</div>
</template>
<div class="flex items-center gap-2">
{{ aiLoading && generatingPreview ? 'Test Generating' : 'Test Generate' }}
</div>
</NcButton>
</NcTooltip>
</div>
</a-form-item>
<div v-if="aiError" class="py-3 pl-3 pr-2 flex items-center gap-3 bg-nc-bg-red-light rounded-lg">
<GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-4 h-4" />
<div class="text-sm text-nc-content-gray-subtle flex-1 max-w-[calc(100%_-_24px)]">
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>
{{ aiError }}
</template>
{{ aiError }}
</NcTooltip>
<div v-if="!inputColumns.length" class="flex-1 flex text-nc-content-gray-muted text-small leading-[18px]">
No input fields selected
</div>
<div v-else class="flex flex-col gap-4 w-full">
<LazySmartsheetRow :key="selectedRecordPk" :row="selectedRecord.row">
<template v-for="field in inputColumns">
<a-form-item
v-if="field.title"
:key="`${field.id}-${generatingPreview}`"
:name="field.title"
class="!my-0 nc-input-required-error"
>
<div class="flex items-center gap-2 text-nc-content-gray-subtle2 mb-2">
<component :is="cellIcon(field)" class="!mx-0" />
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ field?.title }}
</template>
{{ field?.title }}
</NcTooltip>
</div>
<LazySmartsheetDivDataCell
class="relative flex items-center min-h-8 children:h-full bg-nc-bg-gray-extralight max-w-full"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(field),
'!select-text nc-readonly-div-data-cell': !isReadOnlyVirtualCell(field),
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="selectedRecord?.row?.row?.[field.title]"
class="mt-0 nc-input nc-cell"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="true"
/>
<LazySmartsheetCell
v-else
:model-value="selectedRecord?.row?.row?.[field.title]"
class="nc-input truncate"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:edit-enabled="true"
:read-only="true"
/>
</LazySmartsheetDivDataCell>
</a-form-item>
</template>
</LazySmartsheetRow>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</div>
<a-collapse v-model:active-key="expansionPanel" ghost class="flex-1 flex flex-col">
<template #expandIcon> </template>
<a-collapse-panel
:key="ExpansionPanelKeys.input"
collapsible="disabled"
class="nc-ai-button-config-right-section"
<div
class="flex justify-center nc-ai-button-test-generate-wrapper"
:class="{
'text-nc-border-gray-dark': !(selectedRecordPk && outputColumnIds.length && vModel.formula_raw),
'text-nc-content-purple-dark': !!(selectedRecordPk && outputColumnIds.length && vModel.formula_raw),
}"
>
<template #header>
<div class="flex">
<div
class="text-sm text-nc-content-gray-subtle2 font-bold flex items-center gap-2.5 min-h-7"
@click="handleUpdateExpansionPanel(ExpansionPanelKeys.input)"
>
Input fields
<template v-if="inputColumns.length">
<a-tag class="!rounded-md !bg-nc-bg-brand !text-nc-content-brand !border-none !mx-0">
{{ inputColumns.length }}</a-tag
<div class="h-2.5 w-2.5 flex-none absolute -top-[30px] border-1 border-current rounded-full bg-current"></div>
<NcTooltip :disabled="!!(selectedRecordPk && outputColumnIds.length && inputColumns.length)">
<template #title>
<div class="flex flex-col gap-2">
<div>Preview checklist</div>
<div class="flex gap-2">
<div
class="h-4 w-4 mt-0.5 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
:class="
inputColumns.length
? 'bg-nc-bg-green-dark text-nc-content-green-dark'
: 'bg-nc-bg-red-dark text-nc-content-red-dark'
"
>
<NcButton size="xs" type="text" class="hover:!bg-nc-bg-gray-dark !px-1">
<GeneralIcon
icon="arrowRight"
class="transform"
:class="{
'rotate-270': expansionPanel.includes(ExpansionPanelKeys.input),
}"
/>
</NcButton>
</template>
</div>
</div>
</template>
<div class="flex flex-col gap-4">
<LazySmartsheetRow :row="selectedRecord.row">
<template v-for="field in inputColumns">
<a-form-item
v-if="field.title"
:key="`${field.id}-${generatingPreview}`"
:name="field.title"
class="!my-0 nc-input-required-error"
>
<div class="flex items-center gap-2 text-nc-content-gray-subtle2 mb-2">
<component :is="cellIcon(field)" class="!mx-0" />
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ field?.title }}
</template>
{{ field?.title }}
</NcTooltip>
<GeneralIcon :icon="inputColumns.length ? 'check' : 'ncX'" />
</div>
<LazySmartsheetDivDataCell
class="relative flex items-center min-h-8 children:h-full"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(field),
'!select-text nc-readonly-div-data-cell': !isReadOnlyVirtualCell(field),
}"
<div>Use at least 1 Field in your Input prompt</div>
</div>
<div class="flex gap-2">
<div
class="h-4 w-4 mt-0.5 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
:class="
outputColumnIds.length
? 'bg-nc-bg-green-dark text-nc-content-green-dark'
: 'bg-nc-bg-red-dark text-nc-content-red-dark'
"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="selectedRecord?.row?.row?.[field.title]"
class="mt-0 nc-input nc-cell"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="true"
/>
<LazySmartsheetCell
v-else
:model-value="selectedRecord?.row?.row?.[field.title]"
class="nc-input truncate"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:edit-enabled="true"
:read-only="true"
/>
</LazySmartsheetDivDataCell>
</a-form-item>
</template>
</LazySmartsheetRow>
</div>
</a-collapse-panel>
<a-collapse-panel
:key="ExpansionPanelKeys.output"
collapsible="disabled"
class="nc-ai-button-config-right-section nc-output-field-collapse-panel flex-1"
>
<template #header>
<div class="flex">
<div
class="text-sm text-nc-content-gray-subtle2 font-bold flex items-center gap-2.5 min-h-7"
@click="handleUpdateExpansionPanel(ExpansionPanelKeys.output)"
>
Output fields
<a-tag
v-if="outputColumnIds.length"
class="!rounded-md !bg-nc-bg-brand !text-nc-content-brand !border-none !mx-0"
>
{{ outputColumnIds.length }}</a-tag
>
<NcButton size="xs" type="text" class="hover:!bg-nc-bg-gray-dark !px-1">
<GeneralIcon
icon="arrowRight"
class="transform"
:class="{
'rotate-270': expansionPanel.includes(ExpansionPanelKeys.output),
}"
/>
</NcButton>
</div>
</div>
</template>
<div v-if="!outputColumnIds.length" class="flex-1 flex items-center justify-center">
<GeneralIcon icon="ncAutoAwesome" class="h-[177px] w-[177px] !text-purple-100" />
</div>
<div v-else class="flex flex-col gap-4">
<LazySmartsheetRow :row="previewOutputRow">
<template v-for="field in outputFieldOptions">
<a-form-item
v-if="field.title && outputColumnIds.includes(field.id)"
:key="field.id"
:name="field.title"
class="!my-0 nc-input-required-error"
>
<div class="flex items-center gap-2 text-nc-content-gray-subtle2 mb-2">
<component :is="cellIcon(field)" class="!mx-0" />
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ field?.title }}
</template>
{{ field?.title }}
</NcTooltip>
<GeneralIcon :icon="outputColumnIds.length ? 'check' : 'ncX'" />
</div>
<LazySmartsheetDivDataCell
class="relative min-h-8 flex items-center children:h-full"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(field),
'!select-text nc-readonly-div-data-cell': !isReadOnlyVirtualCell(field),
}"
<div>Choose at least 1 Output Field</div>
</div>
<div class="flex gap-2">
<div
class="h-4 w-4 mt-0.5 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
:class="
selectedRecordPk
? 'bg-nc-bg-green-dark text-nc-content-green-dark'
: 'bg-nc-bg-red-dark text-nc-content-red-dark'
"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="previewOutputRow.row[field.title]"
class="mt-0 nc-input nc-cell"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="true"
/>
<LazySmartsheetCell
v-else
v-model="previewOutputRow.row[field.title]"
class="nc-input truncate"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:edit-enabled="true"
:read-only="true"
/>
</LazySmartsheetDivDataCell>
</a-form-item>
<GeneralIcon :icon="selectedRecordPk ? 'check' : 'ncX'" />
</div>
<div>Select sample record</div>
</div>
</div>
</template>
<NcButton
size="small"
theme="ai"
type="primary"
class="nc-ai-button-test-generate"
:disabled="aiLoading || !selectedRecordPk || !outputColumnIds.length || !inputColumns.length"
:loading="aiLoading && generatingPreview"
@click.stop="generate"
>
<template #icon>
<GeneralIcon icon="ncAutoAwesome" class="h-4 w-4" />
</template>
<template #loadingIcon>
<GeneralLoader class="!text-current" size="regular" />
</template>
</LazySmartsheetRow>
<div class="flex items-center gap-2">
{{
aiLoading && generatingPreview
? isAlreadyGenerated
? 'Re-generating data'
: 'Generating data'
: isAlreadyGenerated
? 'Re-generate data'
: 'Generate data'
}}
</div>
</NcButton>
</NcTooltip>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="8"
viewBox="0 0 10 8"
fill="none"
class="absolute -bottom-[24px] z-10"
>
<path
d="M5.86603 7.5C5.48113 8.16667 4.51887 8.16667 4.13397 7.5L0.669873 1.5C0.284973 0.833334 0.766099 0 1.5359 0H8.4641C9.2339 0 9.71503 0.833333 9.33013 1.5L5.86603 7.5Z"
fill="currentColor"
/>
</svg>
</div>
<div class="nc-ai-button-config-right-section">
<div
class="flex-1 flex border-1 bg-white border-nc-border-gray-medium rounded-xl w-full"
:class="{
'nc-is-already-generated': isAlreadyGenerated,
}"
>
<a-collapse
v-model:active-key="expansionOutputPanel"
ghost
class="flex-1 nc-ai-button-config-right-collapse nc-collapse-output w-full"
>
<template #expandIcon> </template>
<a-collapse-panel :key="ExpansionPanelKeys.output" collapsible="disabled">
<template #header> </template>
<div
v-if="!outputColumnIds.length"
class="flex-1 flex text-nc-content-gray-muted text-small leading-[18px]"
>
No output fields selected
</div>
<div v-else class="flex flex-col gap-4 w-full">
<LazySmartsheetRow :row="previewOutputRow">
<template v-for="field in outputFieldOptions">
<a-form-item
v-if="field.title && outputColumnIds.includes(field.id)"
:key="field.id"
:name="field.title"
class="!my-0 nc-input-required-error"
>
<div class="flex items-center gap-2 text-nc-content-gray-subtle2 mb-2">
<component :is="cellIcon(field)" class="!mx-0" />
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ field?.title }}
</template>
{{ field?.title }}
</NcTooltip>
</div>
<LazySmartsheetDivDataCell
class="relative min-h-8 flex items-center children:h-full max-w-full"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(field),
'!select-text nc-readonly-div-data-cell': !isReadOnlyVirtualCell(field),
'bg-nc-bg-gray-extralight': !isAlreadyGenerated,
'bg-nc-bg-purple-light': isAlreadyGenerated,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="previewOutputRow.row[field.title]"
class="mt-0 nc-input nc-cell"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="true"
/>
<LazySmartsheetCell
v-else
v-model="previewOutputRow.row[field.title]"
class="nc-input truncate"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:edit-enabled="true"
:read-only="true"
/>
</LazySmartsheetDivDataCell>
</a-form-item>
</template>
</LazySmartsheetRow>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</div>
</div>
</div>
@ -806,20 +887,30 @@ onBeforeUnmount(() => {
.nc-ai-button-config-left-section {
@apply mx-auto p-6 w-full max-w-[568px];
width: min(100%, 568px);
}
.nc-ai-button-config-right-section {
@apply mx-auto p-4 w-full max-w-[576px] flex flex-col gap-4;
@apply mx-auto px-6 w-full max-w-[576px] flex flex-col;
width: min(100%, 576px);
.nc-is-already-generated {
box-shadow: 0px 12px 16px -4px rgba(75, 23, 123, 0.12), 0px 4px 6px -2px rgba(75, 23, 123, 0.08);
}
}
.nc-ai-button-output-field {
@apply cursor-pointer !rounded-md !bg-nc-bg-gray-medium !text-nc-content-gray hover:!bg-nc-bg-gray-dark !border-none !mx-0;
}
.nc-ai-button-test-generate {
@apply !rounded-l-none -m-[1px] border-l-0 border-purple-200 !bg-nc-bg-purple-light !text-nc-content-purple-dark hover:(!bg-nc-bg-purple-dark);
.nc-ai-button-test-generate-wrapper {
@apply relative;
&:disabled {
@apply !text-nc-content-purple-light !hover:(text-nc-content-purple-light bg-nc-bg-purple-light);
&:before {
@apply content-[''] h-[24px] block border-r-1 absolute -top-[24px] border-current;
}
&:after {
@apply content-[''] h-[24px] block border-r-1 absolute -bottom-[24px] border-current;
}
}
@ -831,7 +922,7 @@ onBeforeUnmount(() => {
}
:deep(.ant-collapse-content-box) {
@apply !px-0 !pb-0 !pt-3;
@apply !p-4;
}
:deep(.ant-collapse-item) {

17
packages/nc-gui/components/smartsheet/expanded-form/Sidebar/index.vue

@ -1,12 +1,27 @@
<script setup lang="ts">
const props = defineProps<{
store: ReturnType<typeof useProvideExpandedFormStore>
showFieldsTab?: boolean
}>()
const { appInfo } = useGlobal()
const tab = ref<'comments' | 'audits'>('comments')
const tab = ref<'fields' | 'comments' | 'audits'>(props.showFieldsTab ? 'fields' : 'comments')
</script>
<template>
<div class="flex flex-col bg-white !h-full w-full rounded-br-2xl overflow-hidden">
<NcTabs v-model:activeKey="tab" class="h-full">
<a-tab-pane v-if="props.showFieldsTab" key="fields" class="w-full h-full">
<template #tab>
<div v-e="['c:row-expand:fields']" class="flex items-center gap-2">
<GeneralIcon icon="fields" class="w-4 h-4" />
<span class="<lg:hidden"> Fields </span>
</div>
</template>
<SmartsheetExpandedFormPresentorsFieldsMiniColumnsWrapper :store="props.store" />
</a-tab-pane>
<a-tab-pane key="comments" class="w-full h-full">
<template #tab>
<div v-e="['c:row-expand:comment']" class="flex items-center gap-2">

389
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -1,17 +1,9 @@
<script setup lang="ts">
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import {
ViewTypes,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
isSystemColumn,
isVirtualCol,
} from 'nocodb-sdk'
import { ViewTypes, isSystemColumn } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import NcModal from '../../nc/Modal.vue'
import MdiChevronDown from '~icons/mdi/chevron-down'
interface Props {
modelValue?: boolean
@ -49,6 +41,8 @@ const { copy } = useClipboard()
const { isMobileMode } = useGlobal()
const { isFeatureEnabled } = useBetaFeatureToggle()
const { fieldsMap, isLocalMode } = useViewColumnsOrThrow()
const { t } = useI18n()
@ -71,8 +65,6 @@ const route = useRoute()
const router = useRouter()
const isPublic = inject(IsPublicInj, ref(false))
// to check if a expanded form which is not yet saved exist or not
const isUnsavedFormExist = ref(false)
@ -82,8 +74,6 @@ const isRecordLinkCopied = ref(false)
const { isUIAllowed } = useRoles()
const readOnly = computed(() => !isUIAllowed('dataEdit') || isPublic.value)
const expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
@ -117,6 +107,23 @@ const fields = computedInject(FieldsInj, (_fields) => {
return _fields?.value ?? []
})
const tableTitle = computed(() => meta.value?.title)
const { setCurrentViewExpandedFormMode } = useSharedView()
const activeViewMode = ref(props.view?.expanded_record_mode ?? 'field')
watch(activeViewMode, async (v) => {
const viewId = props.view?.id
if (!viewId) return
if (v === 'field') {
await setCurrentViewExpandedFormMode(viewId, v)
} else if (v === 'attachment') {
const firstAttachmentField = fields.value?.find((f) => f.uidt === 'Attachment')
await setCurrentViewExpandedFormMode(viewId, v, props.view?.attachment_mode_column_id ?? firstAttachmentField?.id)
}
})
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv && fields.value?.includes(c)) ?? null)
const hiddenFields = computed(() => {
@ -130,12 +137,6 @@ const hiddenFields = computed(() => {
.filter((col) => !isSystemColumn(col))
})
const showHiddenFields = ref(false)
const toggleHiddenFields = () => {
showHiddenFields.value = !showHiddenFields.value
}
const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta)
@ -144,6 +145,8 @@ const isLoading = ref(true)
const isSaving = ref(false)
const expandedFormStore = useProvideExpandedFormStore(meta, row)
const {
commentsDrawer,
changedColumns,
@ -159,7 +162,7 @@ const {
loadComments,
loadAudits,
clearColumns,
} = useProvideExpandedFormStore(meta, row)
} = expandedFormStore
reloadViewDataTrigger.on(async () => {
await _loadRow(rowId.value, false, true)
@ -238,12 +241,12 @@ const save = async () => {
await _save(undefined, undefined, {
kanbanClbk,
})
_loadRow()
await _loadRow()
}
if (!props.skipReload) {
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
await reloadTrigger?.trigger()
await reloadViewDataTrigger?.trigger()
}
isUnsavedFormExist.value = false
@ -277,6 +280,7 @@ const save = async () => {
const isPreventChangeModalOpen = ref(false)
const isCloseModalOpen = ref(false)
const interruptedDirectionToGo = ref<'next' | 'prev' | undefined>(undefined)
const discardPreventModal = () => {
// when user click on next or previous button
@ -298,11 +302,21 @@ const discardPreventModal = () => {
const onNext = async () => {
if (changedColumns.value.size > 0) {
isPreventChangeModalOpen.value = true
interruptedDirectionToGo.value = 'next'
return
}
loadingEmit('next')
}
const onPrev = async () => {
if (changedColumns.value.size > 0) {
isPreventChangeModalOpen.value = true
interruptedDirectionToGo.value = 'prev'
return
}
loadingEmit('prev')
}
const copyRecordUrl = async () => {
await copy(
encodeURI(
@ -319,8 +333,13 @@ const saveChanges = async () => {
if (isPreventChangeModalOpen.value) {
isUnsavedFormExist.value = false
await save()
loadingEmit('next')
if (interruptedDirectionToGo.value) {
loadingEmit(interruptedDirectionToGo.value)
} else {
loadingEmit('next')
}
isPreventChangeModalOpen.value = false
interruptedDirectionToGo.value = undefined
}
if (isCloseModalOpen.value) {
isCloseModalOpen.value = false
@ -506,10 +525,6 @@ watch(rowId, async (nRow) => {
await triggerRowLoad(nRow)
})
const showRightSections = computed(() => {
return !isNew.value && commentsDrawer.value && isUIAllowed('commentList')
})
const preventModalStatus = computed({
get: () => isCloseModalOpen.value || isPreventChangeModalOpen.value,
set: (v) => {
@ -537,24 +552,12 @@ const onIsExpandedUpdate = (v: boolean) => {
}
}
const isReadOnlyVirtualCell = (column: ColumnType) => {
return (
isRollup(column) ||
isFormula(column) ||
isBarcode(column) ||
isLookup(column) ||
isQrCode(column) ||
isSystemColumn(column) ||
isCreatedOrLastModifiedTimeCol(column) ||
isCreatedOrLastModifiedByCol(column)
)
}
const mentionedCell = ref('')
// Small hack. We need to scroll to the bottom of the form after its mounted and back to top.
// So that tab to next row works properly, as otherwise browser will focus to save button
// when we reach to the bottom of the visual scrollable area, not the actual bottom of the form
// todo: this seems to not be needed anymore. check if we can remove it
watch([expandedFormScrollWrapper, isLoading], () => {
if (isMobileMode.value) return
@ -645,9 +648,9 @@ export default {
</div>
</div>
<div
class="flex min-h-7 flex-shrink-0 w-full items-center nc-expanded-form-header relative p-4 xs:(px-2 py-0 min-h-[48px]) justify-between"
class="flex gap-2 min-h-7 flex-shrink-0 w-full items-center nc-expanded-form-header p-4 xs:(px-2 py-0 min-h-[48px]) border-b-1 border-gray-200"
>
<div class="flex-1 flex gap-4 lg:w-100 <lg:max-w-[calc(100%_-_178px)] xs:(max-w-[calc(100%_-_44px)])">
<div class="flex gap-2">
<div class="flex gap-2">
<NcTooltip v-if="props.showNextPrevIcons">
<template #title> {{ renderAltOrOptlKey() }} + </template>
@ -656,7 +659,7 @@ export default {
class="nc-prev-arrow !w-7 !h-7 !text-gray-500 !disabled:text-gray-300"
type="text"
size="xsmall"
@click="loadingEmit('prev')"
@click="onPrev"
>
<GeneralIcon icon="chevronDown" class="transform rotate-180" />
</NcButton>
@ -677,14 +680,13 @@ export default {
<div v-if="isLoading" class="flex items-center">
<a-skeleton-input active class="!h-6 !sm:mr-14 !w-52 !rounded-md !overflow-hidden" size="small" />
</div>
<div
v-else
class="flex-1 flex items-center gap-3 max-w-[calc(100%_-_108px)] xs:(flex-row-reverse justify-end)"
:class="{
'xs:max-w-[calc(100%_-_52px)]': isNew,
'xs:max-w-[calc(100%_-_82px)]': !isNew,
}"
>
<div v-else class="flex-1 flex items-center gap-2 xs:(flex-row-reverse justify-end)">
<div class="hidden md:flex items-center rounded-lg bg-gray-100 px-2 py-1 gap-2">
<GeneralIcon icon="table" />
<span class="nc-expanded-form-table-name">
{{ tableTitle }}
</span>
</div>
<div
v-if="row.rowMeta?.new || props.newRecordHeader"
class="flex items-center truncate font-bold text-gray-800 text-base overflow-hidden"
@ -693,14 +695,27 @@ export default {
</div>
<div
v-else-if="displayValue && !row?.rowMeta?.new"
class="flex items-center font-bold text-gray-800 text-base max-w-[300px] xs:(w-auto max-w-[calc(100%_-_82px)]) overflow-hidden"
class="flex items-center font-bold text-gray-800 text-base overflow-hidden"
>
<span class="truncate">
<span class="truncate w-[128px]">
<LazySmartsheetPlainCell v-model="displayValue" :column="displayField" />
</span>
</div>
</div>
</div>
<div class="ml-auto md:mx-auto">
<NcSelectTab
v-if="isEeUI && isFeatureEnabled(FEATURE_FLAG.EXPANDED_FORM_FILE_PREVIEW_MODE)"
v-model="activeViewMode"
class="nc-expanded-form-mode-switch"
:disabled="!isUIAllowed('viewCreateOrEdit')"
:tooltip="!isUIAllowed('viewCreateOrEdit') ? 'You do not have permission to change view mode.' : undefined"
:items="[
{ icon: 'fields', value: 'field' },
{ icon: 'file', value: 'attachment' },
]"
/>
</div>
<div class="flex gap-2">
<NcTooltip v-if="!isMobileMode && isUIAllowed('dataEdit')">
<template #title> {{ renderAltOrOptlKey() }} + S </template>
@ -797,232 +812,46 @@ export default {
</NcButton>
</div>
</div>
<div ref="wrapper" class="flex flex-grow flex-row h-[calc(100%_-_4rem)] w-full border-t-1 border-gray-200">
<div
:class="{
'w-full': !showRightSections,
'flex-1': showRightSections,
}"
class="h-full flex xs:w-full flex-col overflow-hidden"
>
<div
ref="expandedFormScrollWrapper"
class="flex flex-col flex-grow gap-6 h-full max-h-full nc-scrollbar-thin items-center w-full p-4 xs:(px-4 pt-4 pb-2 gap-6) children:max-w-[588px] <lg:(children:max-w-[450px])"
>
<div
v-for="(col, i) of fields"
v-show="!isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="col.title"
:class="`nc-expand-col-${col.title}`"
:col-id="col.id"
:data-testid="`nc-expand-col-${col.title}`"
class="nc-expanded-form-row w-full"
>
<div class="flex items-start flex-row sm:(gap-x-2) <lg:(flex-col w-full) nc-expanded-cell min-h-[37px]">
<div class="w-45 <lg:(w-full px-0 mb-1) h-[37px] xs:(h-auto) flex items-center rounded-lg overflow-hidden">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
class="nc-expanded-cell-header h-full flex-none"
/>
<LazySmartsheetHeaderCell v-else :column="col" class="nc-expanded-cell-header flex-none" />
</div>
<template v-if="isLoading">
<a-skeleton-input
active
class="h-[37px] flex-none <lg:!w-full lg:flex-1 !rounded-lg !overflow-hidden"
size="small"
/>
</template>
<template v-else>
<SmartsheetDivDataCell
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="bg-white flex-1 <lg:w-full px-1 min-h-[37px] flex items-center relative"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(col),
'!select-text nc-readonly-div-data-cell': readOnly,
'nc-mentioned-cell': col.id === mentionedCell,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:class="{
'px-1': isReadOnlyVirtualCell(col),
}"
:column="col"
:read-only="readOnly"
:row="_row"
/>
<LazySmartsheetCell
v-else
v-model="_row.row[col.title]"
:active="true"
:column="col"
:edit-enabled="true"
:read-only="readOnly"
@update:model-value="changedColumns.add(col.title)"
/>
</SmartsheetDivDataCell>
</template>
</div>
</div>
<div v-if="hiddenFields.length > 0" class="flex w-full <lg:(px-1) items-center py-6">
<div class="flex-grow h-px mr-1 bg-gray-100"></div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
class="flex-shrink !text-sm overflow-hidden !text-gray-500 !font-weight-500"
type="secondary"
@click="toggleHiddenFields"
>
{{ showHiddenFields ? `Hide ${hiddenFields.length} hidden` : `Show ${hiddenFields.length} hidden` }}
{{ hiddenFields.length > 1 ? `fields` : `field` }}
<MdiChevronDown :class="showHiddenFields ? 'transform rotate-180' : ''" class="ml-1" />
</NcButton>
<div class="flex-grow h-px ml-1 bg-gray-100"></div>
</div>
<template v-if="hiddenFields.length > 0 && showHiddenFields">
<div
v-for="(col, i) of hiddenFields"
v-show="isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="`${col.id}-${col.title}`"
:class="`nc-expand-col-${col.title}`"
:data-testid="`nc-expand-col-${col.title}`"
class="nc-expanded-form-row w-full"
>
<div class="flex items-start flex-row sm:(gap-x-2) <lg:(flex-col w-full) nc-expanded-cell min-h-[37px]">
<div class="w-45 <lg:(w-full px-0) h-[37px] xs:(h-auto) flex items-center rounded-lg overflow-hidden">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
is-hidden-col
class="nc-expanded-cell-header flex-none"
/>
<LazySmartsheetHeaderCell v-else :column="col" is-hidden-col class="nc-expanded-cell-header flex-none" />
</div>
<template v-if="isLoading">
<a-skeleton-input
active
class="h-[37px] flex-none <lg:!w-full lg:flex-1 !rounded-lg !overflow-hidden"
size="small"
/>
</template>
<template v-else>
<LazySmartsheetDivDataCell
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="bg-white flex-1 <lg:w-full px-1 min-h-[37px] flex items-center relative"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(col),
'!bg-gray-50 !select-text nc-readonly-div-data-cell': readOnly,
'nc-mentioned-cell': col.id === mentionedCell,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:column="col"
:read-only="readOnly"
:row="_row"
/>
<LazySmartsheetCell
v-else
v-model="_row.row[col.title]"
:active="true"
:column="col"
:edit-enabled="true"
:read-only="readOnly"
@update:model-value="changedColumns.add(col.title)"
/>
</LazySmartsheetDivDataCell>
</template>
</div>
</div>
</template>
</div>
<div
v-if="isUIAllowed('dataEdit')"
class="w-full flex items-center justify-end px-2 xs:(p-0 gap-x-4 justify-between)"
:class="{
'xs(border-t-1 border-gray-200)': !isNew,
}"
>
<div v-if="!isNew && isMobileMode" class="p-2">
<NcDropdown placement="bottomRight" class="p-2">
<NcButton :disabled="isLoading" class="nc-expand-form-more-actions" type="secondary" size="small">
<GeneralIcon :class="isLoading ? 'text-gray-300' : 'text-gray-700'" class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem class="text-gray-700" @click="_loadRow()">
<div v-e="['c:row-expand:reload']" class="flex gap-2 items-center" data-testid="nc-expanded-form-reload">
<component :is="iconMap.reload" class="cursor-pointer" />
{{ $t('general.reload') }}
</div>
</NcMenuItem>
<NcMenuItem v-if="rowId" class="text-gray-700" @click="!isNew ? copyRecordUrl() : () => {}">
<div
v-e="['c:row-expand:copy-url']"
class="flex gap-2 items-center"
data-testid="nc-expanded-form-copy-url"
>
<component :is="iconMap.copy" class="cursor-pointer nc-duplicate-row" />
{{ $t('labels.copyRecordURL') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('dataEdit') && !isNew"
v-e="['c:row-expand:delete']"
class="!text-red-500 !hover:bg-red-50"
@click="!isNew && onDeleteRowClick()"
>
<div data-testid="nc-expanded-form-delete">
<component :is="iconMap.delete" class="cursor-pointer nc-delete-row" />
Delete record
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
<div v-if="isNew && isMobileMode"></div>
<div v-if="isMobileMode" class="p-2">
<NcButton
v-e="['c:row-expand:save']"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist"
:loading="isSaving"
class="nc-expand-form-save-btn !xs:(text-sm) !px-2"
:class="{
'!h-7': !isMobileMode,
}"
data-testid="nc-expanded-form-save"
type="primary"
:size="isMobileMode ? 'small' : 'xsmall'"
@click="save"
>
<div class="xs:px-1">{{ newRecordSubmitBtnText ?? isNew ? 'Create Record' : 'Save Record' }}</div>
</NcButton>
</div>
</div>
<div v-else class="p-2"></div>
</div>
<div
v-if="showRightSections && !isUnsavedDuplicatedRecordExist"
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
class="nc-comments-drawer border-l-1 relative border-gray-200 bg-gray-50 w-1/3 max-w-[340px] min-w-0 h-full xs:hidden rounded-br-2xl"
>
<SmartsheetExpandedFormSidebar />
</div>
<div ref="wrapper" class="flex-grow h-[calc(100%_-_4rem)] w-full">
<template v-if="activeViewMode === 'field'">
<SmartsheetExpandedFormPresentorsFields
:store="expandedFormStore"
:row-id="rowId"
:fields="fields ?? []"
:hidden-fields="hiddenFields"
:is-unsaved-duplicated-record-exist="isUnsavedDuplicatedRecordExist"
:is-unsaved-form-exist="isUnsavedFormExist"
:is-loading="isLoading"
:is-saving="isSaving"
:new-record-submit-btn-text="newRecordSubmitBtnText"
@copy:record-url="copyRecordUrl()"
@delete:row="onDeleteRowClick()"
@save="save()"
@update:model-value="emits('update:modelValue', $event)"
@created-record="emits('createdRecord', $event)"
@update-row-comment-count="emits('updateRowCommentCount', $event)"
/>
</template>
<template v-else-if="activeViewMode === 'attachment'">
<SmartsheetExpandedFormPresentorsAttachments
:store="expandedFormStore"
:row-id="rowId"
:view="props.view"
:fields="fields ?? []"
:hidden-fields="hiddenFields"
:is-unsaved-duplicated-record-exist="isUnsavedDuplicatedRecordExist"
:is-unsaved-form-exist="isUnsavedFormExist"
:is-loading="isLoading"
:is-saving="isSaving"
:new-record-submit-btn-text="newRecordSubmitBtnText"
@copy:record-url="copyRecordUrl()"
@delete:row="onDeleteRowClick()"
@save="save()"
@update:model-value="emits('update:modelValue', $event)"
@created-record="emits('createdRecord', $event)"
@update-row-comment-count="emits('updateRowCommentCount', $event)"
/>
</template>
</div>
</div>
</component>

3
packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/AttachmentView.vue

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

3
packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/PreviewBar.vue

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

3
packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/PreviewCell.vue

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

3
packages/nc-gui/components/smartsheet/expanded-form/presentors/Attachments/index.vue

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

214
packages/nc-gui/components/smartsheet/expanded-form/presentors/Fields/Columns.vue

@ -0,0 +1,214 @@
<script lang="ts" setup>
import {
type ColumnType,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
isSystemColumn,
isVirtualCol,
} from 'nocodb-sdk'
/* interface */
const props = defineProps<{
store: ReturnType<typeof useProvideExpandedFormStore>
fields: ColumnType[]
hiddenFields: ColumnType[]
isLoading: boolean
forceVerticalMode?: boolean
}>()
const isLoading = toRef(props, 'isLoading')
const isPublic = inject(IsPublicInj, ref(false))
/* stores */
const { changedColumns, isNew, loadRow: _loadRow, row: _row } = props.store
const { isUIAllowed } = useRoles()
const { isMobileMode } = useGlobal()
/* flags */
const readOnly = computed(() => !isUIAllowed('dataEdit') || isPublic.value)
/* initial focus and scroll fix */
const cellWrapperEl = ref()
const mentionedCell = ref('')
/* hidden fields */
const showHiddenFields = ref(false)
function toggleHiddenFields() {
showHiddenFields.value = !showHiddenFields.value
}
/* utilities */
function isReadOnlyVirtualCell(column: ColumnType) {
return (
isRollup(column) ||
isFormula(column) ||
isBarcode(column) ||
isLookup(column) ||
isQrCode(column) ||
isSystemColumn(column) ||
isCreatedOrLastModifiedTimeCol(column) ||
isCreatedOrLastModifiedByCol(column)
)
}
</script>
<template>
<div
ref="expandedFormScrollWrapper"
class="flex flex-col flex-grow gap-6 h-full max-h-full nc-scrollbar-thin items-center w-full p-4 xs:(px-4 pt-4 pb-2 gap-6) children:max-w-[588px] <lg:(children:max-w-[450px])"
>
<div
v-for="(col, i) of fields"
v-show="!isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="col.title"
:class="`nc-expand-col-${col.title}`"
:col-id="col.id"
:data-testid="`nc-expand-col-${col.title}`"
class="nc-expanded-form-row w-full"
>
<div
class="flex items-start nc-expanded-cell min-h-[37px]"
:class="{
'flex-row sm:(gap-x-2) <lg:(flex-col w-full)': !props.forceVerticalMode,
'flex-col w-full': props.forceVerticalMode,
}"
>
<div
class="flex items-center rounded-lg overflow-hidden"
:class="{
'w-45 <lg:(w-full px-0 mb-1) h-[37px] xs:(h-auto)': !props.forceVerticalMode,
'w-full px-0 mb-1 h-auto': props.forceVerticalMode,
}"
>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
class="nc-expanded-cell-header h-full flex-none"
/>
<LazySmartsheetHeaderCell v-else :column="col" class="nc-expanded-cell-header flex-none" />
</div>
<template v-if="isLoading">
<a-skeleton-input active class="h-[37px] flex-none <lg:!w-full lg:flex-1 !rounded-lg !overflow-hidden" size="small" />
</template>
<template v-else>
<SmartsheetDivDataCell
v-if="col.title"
:ref="(el: any) => { if (i) cellWrapperEl = el }"
class="bg-white flex-1 <lg:w-full px-1 min-h-[37px] flex items-center relative"
:class="{
'w-full': props.forceVerticalMode,
'!select-text nc-system-field': isReadOnlyVirtualCell(col),
'!select-text nc-readonly-div-data-cell': readOnly,
'nc-mentioned-cell': col.id === mentionedCell,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:class="{
'px-1': isReadOnlyVirtualCell(col),
}"
:column="col"
:read-only="readOnly"
:row="_row"
/>
<LazySmartsheetCell
v-else
v-model="_row.row[col.title]"
:active="true"
:column="col"
:edit-enabled="true"
:read-only="readOnly"
@update:model-value="changedColumns.add(col.title)"
/>
</SmartsheetDivDataCell>
</template>
</div>
</div>
<div v-if="hiddenFields.length > 0" class="flex w-full <lg:(px-1) items-center py-6">
<div class="flex-grow h-px mr-1 bg-gray-100" />
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
class="flex-shrink !text-sm overflow-hidden !text-gray-500 !font-weight-500"
type="secondary"
@click="toggleHiddenFields"
>
{{ showHiddenFields ? `Hide ${hiddenFields.length} hidden` : `Show ${hiddenFields.length} hidden` }}
{{ hiddenFields.length > 1 ? `fields` : `field` }}
<GeneralIcon icon="chevronDown" :class="showHiddenFields ? 'transform rotate-180' : ''" class="ml-1" />
</NcButton>
<div class="flex-grow h-px ml-1 bg-gray-100" />
</div>
<template v-if="hiddenFields.length > 0 && showHiddenFields">
<div
v-for="(col, i) of hiddenFields"
v-show="isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="`${col.id}-${col.title}`"
:class="`nc-expand-col-${col.title}`"
:data-testid="`nc-expand-col-${col.title}`"
class="nc-expanded-form-row w-full"
>
<div class="flex items-start flex-row sm:(gap-x-2) <lg:(flex-col w-full) nc-expanded-cell min-h-[37px]">
<div class="w-45 <lg:(w-full px-0) h-[37px] xs:(h-auto) flex items-center rounded-lg overflow-hidden">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
is-hidden-col
class="nc-expanded-cell-header flex-none"
/>
<LazySmartsheetHeaderCell v-else :column="col" is-hidden-col class="nc-expanded-cell-header flex-none" />
</div>
<template v-if="isLoading">
<a-skeleton-input active class="h-[37px] flex-none <lg:!w-full lg:flex-1 !rounded-lg !overflow-hidden" size="small" />
</template>
<template v-else>
<LazySmartsheetDivDataCell
v-if="col.title"
:ref="(el: any) => { if (i) cellWrapperEl = el }"
class="bg-white flex-1 <lg:w-full px-1 min-h-[37px] flex items-center relative"
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(col),
'!bg-gray-50 !select-text nc-readonly-div-data-cell': readOnly,
'nc-mentioned-cell': col.id === mentionedCell,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:column="col"
:read-only="readOnly"
:row="_row"
/>
<LazySmartsheetCell
v-else
v-model="_row.row[col.title]"
:active="true"
:column="col"
:edit-enabled="true"
:read-only="readOnly"
@update:model-value="changedColumns.add(col.title)"
/>
</LazySmartsheetDivDataCell>
</template>
</div>
</div>
</template>
</div>
</template>

58
packages/nc-gui/components/smartsheet/expanded-form/presentors/Fields/MiniColumnsWrapper.vue

@ -0,0 +1,58 @@
<script setup lang="ts">
import { isSystemColumn } from 'nocodb-sdk'
/* interface */
const props = defineProps<{
store: ReturnType<typeof useProvideExpandedFormStore>
}>()
const meta = inject(MetaInj, ref())
/* flags */
const maintainDefaultViewOrder = ref(false)
const useMetaFields = ref(false)
const { fieldsMap, isLocalMode } = useViewColumnsOrThrow()
/* fields */
const injectedFields = computedInject(FieldsInj, (_fields) => {
if (useMetaFields.value) {
if (maintainDefaultViewOrder.value) {
return (meta.value?.columns ?? [])
.filter((col) => !isSystemColumn(col))
.sort((a, b) => {
return (a.meta?.defaultViewColOrder ?? Infinity) - (b.meta?.defaultViewColOrder ?? Infinity)
})
}
return (meta.value?.columns ?? []).filter((col) => !isSystemColumn(col))
}
return _fields?.value ?? []
})
const fields = computed(() => injectedFields.value ?? [])
const hiddenFields = computed(() => {
// todo: figure out when meta.value is undefined
return (meta.value?.columns ?? [])
.filter(
(col) =>
!fields.value?.includes(col) &&
(isLocalMode.value && col?.id && fieldsMap.value[col.id] ? fieldsMap.value[col.id]?.initialShow : true),
)
.filter((col) => !isSystemColumn(col))
})
</script>
<template>
<SmartsheetExpandedFormPresentorsFieldsColumns
:store="props.store"
:fields="fields"
:hidden-fields="hiddenFields"
:is-loading="false"
force-vertical-mode
/>
</template>

263
packages/nc-gui/components/smartsheet/expanded-form/presentors/Fields/index.vue

@ -0,0 +1,263 @@
<script setup lang="ts">
import { type ColumnType } from 'nocodb-sdk'
/* interface */
const props = defineProps<{
store: ReturnType<typeof useProvideExpandedFormStore>
rowId?: string
fields: ColumnType[]
hiddenFields: ColumnType[]
isUnsavedDuplicatedRecordExist: boolean
isUnsavedFormExist: boolean
isLoading: boolean
isSaving: boolean
newRecordSubmitBtnText?: string
}>()
const emits = defineEmits(['copy:record-url', 'delete:row', 'save'])
const rowId = toRef(props, 'rowId')
const fields = toRef(props, 'fields')
const hiddenFields = toRef(props, 'hiddenFields')
const isUnsavedDuplicatedRecordExist = toRef(props, 'isUnsavedDuplicatedRecordExist')
const isUnsavedFormExist = toRef(props, 'isUnsavedFormExist')
const isLoading = toRef(props, 'isLoading')
const isSaving = toRef(props, 'isSaving')
const newRecordSubmitBtnText = toRef(props, 'newRecordSubmitBtnText')
/* stores */
const { commentsDrawer, changedColumns, isNew, loadRow: _loadRow, row: _row } = props.store
const { isUIAllowed } = useRoles()
const { isMobileMode } = useGlobal()
/* flags */
const showRightSections = computed(() => !isNew.value && commentsDrawer.value && isUIAllowed('commentList'))
const canEdit = computed(() => isUIAllowed('dataEdit'))
</script>
<script lang="ts">
export default {
name: 'ExpandedFormPresentorsFields',
}
</script>
<template>
<div class="h-full flex flex-row">
<div
class="h-full flex xs:w-full flex-col overflow-hidden"
:class="{
'w-full': !showRightSections,
'flex-1': showRightSections,
}"
>
<SmartsheetExpandedFormPresentorsFieldsColumns
:store="props.store"
:fields="fields"
:hidden-fields="hiddenFields"
:is-loading="isLoading"
/>
<div
v-if="canEdit"
class="w-full flex items-center justify-end px-2 xs:(p-0 gap-x-4 justify-between)"
:class="{
'xs(border-t-1 border-gray-200)': !isNew,
}"
>
<div v-if="!isNew && isMobileMode" class="p-2">
<NcDropdown placement="bottomRight" class="p-2">
<NcButton :disabled="isLoading" class="nc-expand-form-more-actions" type="secondary" size="small">
<GeneralIcon :class="isLoading ? 'text-gray-300' : 'text-gray-700'" class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem class="text-gray-700" @click="_loadRow()">
<div v-e="['c:row-expand:reload']" class="flex gap-2 items-center" data-testid="nc-expanded-form-reload">
<component :is="iconMap.reload" class="cursor-pointer" />
{{ $t('general.reload') }}
</div>
</NcMenuItem>
<NcMenuItem v-if="rowId" class="text-gray-700" @click="!isNew ? emits('copy:record-url') : () => {}">
<div v-e="['c:row-expand:copy-url']" class="flex gap-2 items-center" data-testid="nc-expanded-form-copy-url">
<component :is="iconMap.copy" class="cursor-pointer nc-duplicate-row" />
{{ $t('labels.copyRecordURL') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('dataEdit') && !isNew"
v-e="['c:row-expand:delete']"
class="!text-red-500 !hover:bg-red-50"
@click="!isNew && emits('delete:row')"
>
<div data-testid="nc-expanded-form-delete">
<component :is="iconMap.delete" class="cursor-pointer nc-delete-row" />
Delete record
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
<div v-if="isMobileMode" class="p-2">
<NcButton
v-e="['c:row-expand:save']"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist"
:loading="isSaving"
class="nc-expand-form-save-btn !xs:(text-sm) !px-2"
:class="{
'!h-7': !isMobileMode,
}"
data-testid="nc-expanded-form-save"
type="primary"
:size="isMobileMode ? 'small' : 'xsmall'"
@click="emits('save')"
>
<div class="xs:px-1">{{ newRecordSubmitBtnText ?? isNew ? 'Create Record' : 'Save Record' }}</div>
</NcButton>
</div>
</div>
<div v-else class="p-2" />
</div>
<div
v-if="showRightSections && !isUnsavedDuplicatedRecordExist"
class="nc-comments-drawer border-l-1 relative border-gray-200 bg-gray-50 w-1/3 max-w-[400px] min-w-0 h-full xs:hidden rounded-br-2xl"
:class="{
active: commentsDrawer && isUIAllowed('commentList'),
}"
>
<SmartsheetExpandedFormSidebar :store="store" />
</div>
</div>
</template>
<style lang="scss">
.nc-drawer-expanded-form {
@apply xs:my-0;
.ant-drawer-content-wrapper {
@apply !h-[90vh];
.ant-drawer-content {
@apply rounded-t-2xl;
}
}
}
.nc-expanded-cell-header {
@apply w-full text-gray-500 !font-weight-500 !text-sm xs:(text-gray-600 mb-2 !text-small) pr-3;
svg.nc-cell-icon,
svg.nc-virtual-cell-icon {
@apply !w-3.5 !h-3.5;
}
}
.nc-expanded-cell-header > :nth-child(2) {
@apply !text-sm xs:!text-small;
}
.nc-expanded-cell-header > :first-child {
@apply !text-md pl-2 xs:(pl-0 -ml-0.5);
}
.nc-expanded-cell-header:not(.nc-cell-expanded-form-header) > :first-child {
@apply pl-0;
}
.nc-drawer-expanded-form .nc-modal {
@apply !p-0;
}
</style>
<style lang="scss" scoped>
:deep(.ant-select-selector) {
@apply !xs:(h-full);
}
.nc-data-cell {
@apply !rounded-lg;
transition: all 0.3s;
&:not(.nc-readonly-div-data-cell):not(.nc-system-field):not(.nc-attachment-cell):not(.nc-virtual-cell-button) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&:not(:focus-within):hover:not(.nc-readonly-div-data-cell):not(.nc-system-field):not(.nc-virtual-cell-button) {
@apply !border-1;
&:not(.nc-attachment-cell):not(.nc-virtual-cell-button) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24);
}
}
&.nc-readonly-div-data-cell,
&.nc-system-field {
@apply !border-gray-200;
.nc-cell,
.nc-virtual-cell {
@apply text-gray-400;
}
}
&.nc-readonly-div-data-cell:focus-within,
&.nc-system-field:focus-within {
@apply !border-gray-200;
}
&:focus-within:not(.nc-readonly-div-data-cell):not(.nc-system-field) {
@apply !shadow-selected;
}
&:has(.nc-virtual-cell-qrcode .nc-qrcode-container),
&:has(.nc-virtual-cell-barcode .nc-barcode-container) {
@apply !border-none px-0 !rounded-none;
:deep(.nc-virtual-cell-qrcode),
:deep(.nc-virtual-cell-barcode) {
@apply px-0;
& > div {
@apply !px-0;
}
.barcode-wrapper {
@apply ml-0;
}
}
:deep(.nc-virtual-cell-qrcode) {
img {
@apply !h-[84px] border-1 border-solid border-gray-200 rounded;
}
}
:deep(.nc-virtual-cell-barcode) {
.nc-barcode-container {
@apply border-1 rounded-lg border-gray-200 h-[64px] max-w-full p-2;
svg {
@apply !h-full;
}
}
}
}
}
.nc-mentioned-cell {
box-shadow: 0px 0px 0px 2px var(--ant-primary-color-outline) !important;
@apply !border-brand-500 !border-1;
}
.nc-data-cell:focus-within {
@apply !border-1 !border-brand-500;
}
:deep(.nc-system-field input) {
@apply bg-transparent;
}
:deep(.nc-data-cell .nc-cell .nc-cell-field) {
@apply px-2;
}
:deep(.nc-data-cell .nc-virtual-cell .nc-cell-field) {
@apply px-2;
}
:deep(.nc-data-cell .nc-cell-field.nc-lookup-cell .nc-cell-field) {
@apply px-0;
}
</style>

108
packages/nc-gui/components/smartsheet/grid/InfiniteTable.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
import {
ButtonActionsType,
type ButtonType,
type ColumnReqType,
type ColumnType,
type TableType,
@ -135,6 +137,8 @@ const { addLTARRef, syncLTARRefs, clearLTARCell, cleaMMCell } = useSmartsheetLta
const { loadViewAggregate } = useViewAggregateOrThrow()
const { generateRows, generatingRows, generatingColumnRows, generatingColumns, aiIntegrations } = useNocoAi()
// Element refs
const smartTable = ref(null)
@ -985,6 +989,71 @@ const deleteSelectedRangeOfRows = () => {
})
}
const isSelectedOnlyAI = computed(() => {
// selectedRange
if (selectedRange.start.col === selectedRange.end.col) {
const field = fields.value[selectedRange.start.col]
return {
enabled: field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai,
disabled: !ncIsArrayIncludes(aiIntegrations.value, (field?.colOptions as ButtonType)?.fk_integration_id, 'id'),
}
}
return {
enabled: false,
disabled: false,
}
})
const generateAIBulk = async () => {
if (!isSelectedOnlyAI.value.enabled || !meta?.value?.id || !meta.value.columns) return
const field = fields.value[selectedRange.start.col]
if (!field.id) return
const rows = Array.from(cachedRows.value.values()).slice(selectedRange.start.row, selectedRange.end.row + 1)
if (!rows || rows.length === 0) return
let outputColumnIds = [field.id]
const isAiButton = field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai
if (isAiButton) {
outputColumnIds =
ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0
? field.colOptions.output_column_ids.split(',')
: []
}
const pks = rows.map((row) => extractPkFromRow(row.row, meta.value!.columns!)).filter((pk) => pk !== null)
generatingRows.value.push(...pks)
generatingColumnRows.value.push(field.id)
generatingColumns.value.push(...outputColumnIds)
const res = await generateRows(meta.value.id, field.id, pks)
if (res) {
// find rows using pk and update with generated rows
for (const row of res) {
const oldRow = Array.from(cachedRows.value.values()).find(
(r) => extractPkFromRow(r.row, meta.value!.columns!) === extractPkFromRow(row, meta.value!.columns!),
)
if (oldRow) {
oldRow.row = { ...oldRow.row, ...row }
}
}
}
generatingRows.value = generatingRows.value.filter((pk) => !pks.includes(pk))
generatingColumnRows.value = generatingColumnRows.value.filter((v) => v !== field.id)
generatingColumns.value = generatingColumns.value.filter((v) => !outputColumnIds?.includes(v))
}
onClickOutside(tableBodyEl, (e) => {
// do nothing if mousedown on the scrollbar (scrolling)
if (scrolling.value || resizingColumn.value) {
@ -2344,6 +2413,29 @@ watch(
{{ $t('activity.deleteSelectedRow') }}
</div>
</NcMenuItem>
<NcTooltip
v-if="contextMenuTarget && hasEditPermission && !isDataReadOnly && isSelectedOnlyAI.enabled"
:disabled="!isSelectedOnlyAI.disabled"
>
<template #title>
{{
aiIntegrations.length ? $t('tooltip.aiIntegrationReConfigure') : $t('tooltip.aiIntegrationAddAndReConfigure')
}}
</template>
<NcMenuItem
class="nc-base-menu-item"
data-testid="context-menu-item-bulk"
:disabled="isSelectedOnlyAI.disabled"
@click="generateAIBulk"
>
<div class="flex gap-2 items-center">
<GeneralIcon icon="ncAutoAwesome" class="h-4 w-4" />
<!-- Generate All -->
Generate {{ selectedRange.isSingleCell() ? 'Cell' : 'All' }}
</div>
</NcMenuItem>
</NcTooltip>
<NcMenuItem
v-if="contextMenuTarget"
class="nc-base-menu-item"
@ -2521,7 +2613,7 @@ watch(
@apply text-black !bg-gray-50;
}
td,
td:not(.nc-grid-add-new-cell-item),
th {
@apply border-gray-100 border-solid border-r bg-gray-100 p-0;
min-height: 32px !important;
@ -2546,11 +2638,11 @@ watch(
@apply !border-b-1;
}
td {
td:not(.nc-grid-add-new-cell-item) {
@apply bg-white border-b;
}
td:not(:first-child) {
td:not(:first-child):not(.nc-grid-add-new-cell-item) {
@apply px-3;
&.align-top {
@ -2668,7 +2760,7 @@ watch(
border-spacing: 0;
}
td {
td:not(.nc-grid-add-new-cell-item) {
text-overflow: ellipsis;
}
@ -2731,7 +2823,7 @@ watch(
z-index: 5;
}
tbody td:not(.placeholder-column):nth-child(1) {
tbody td:not(.placeholder-column):not(.nc-grid-add-new-cell-item):nth-child(1) {
position: sticky !important;
left: 0;
z-index: 4;
@ -2760,7 +2852,7 @@ watch(
@apply border-r-1 !border-r-gray-50;
}
tbody td:not(.placeholder-column):nth-child(2) {
tbody td:not(.placeholder-column):not(.nc-grid-add-new-cell-item):nth-child(2) {
@apply border-r-1 !border-r-gray-50;
}
}
@ -2820,7 +2912,7 @@ watch(
&:not(.selected-row):has(+ .selected-row) {
td.nc-grid-cell:not(.active),
td:nth-child(2):not(.active) {
td:nth-child(2):not(.active):not(.nc-grid-add-new-cell-item) {
@apply border-b-gray-200;
}
}
@ -2829,7 +2921,7 @@ watch(
&:not(.mouse-down):has(+ :hover) {
&:not(.selected-row) {
td.nc-grid-cell:not(.active),
td:nth-child(2):not(.active) {
td:nth-child(2):not(.active):not(.nc-grid-add-new-cell-item) {
@apply border-b-gray-200;
}
}

22
packages/nc-gui/components/virtual-cell/Button.vue

@ -14,16 +14,6 @@ const { currentRow } = useSmartsheetRowStoreOrThrow()
const { generateRows, generatingRows, generatingColumnRows, generatingColumns, aiIntegrations } = useNocoAi()
const isFieldAiIntegrationAvailable = computed(() => {
if (column.value?.colOptions?.type !== ButtonActionsType.Ai) return true
const fkIntegrationId = column.value?.colOptions?.fk_integration_id
if (!fkIntegrationId) return false
return ncIsArrayIncludes(aiIntegrations.value, fkIntegrationId, 'id')
})
const meta = inject(MetaInj, ref())
const isGrid = inject(IsGridInj, ref(false))
@ -47,6 +37,14 @@ const pk = computed(() => {
return extractPkFromRow(currentRow.value?.row, meta.value.columns)
})
const isFieldAiIntegrationAvailable = computed(() => {
if (column.value?.colOptions?.type !== ButtonActionsType.Ai) return true
const fkIntegrationId = column.value?.colOptions?.fk_integration_id
return !!fkIntegrationId
})
const generate = async () => {
if (!meta?.value?.id || !meta.value.columns || !column?.value?.id) return
@ -130,6 +128,8 @@ const componentProps = computed(() => {
} else if (column.value.colOptions.type === ButtonActionsType.Ai) {
return {
disabled:
isPublic.value ||
!isUIAllowed('dataEdit') ||
!isFieldAiIntegrationAvailable.value ||
isLoading.value ||
(pk.value &&
@ -148,7 +148,7 @@ const componentProps = computed(() => {
}"
class="w-full flex items-center"
>
<NcTooltip :disabled="isFieldAiIntegrationAvailable" class="flex">
<NcTooltip :disabled="isFieldAiIntegrationAvailable || isPublic || !isUIAllowed('dataEdit')" class="flex">
<template #title>
{{ aiIntegrations.length ? $t('tooltip.aiIntegrationReConfigure') : $t('tooltip.aiIntegrationAddAndReConfigure') }}
</template>

2
packages/nc-gui/components/virtual-cell/components/ListItem.vue

@ -266,6 +266,8 @@ const displayValue = computed(() => {
}
}
.nc-link-record-cell-tooltip {
@apply !bg-transparent !hover:bg-transparent;
:deep(.nc-cell-icon) {
@apply !ml-0;
}

7
packages/nc-gui/composables/useBetaFeatureToggle.ts

@ -50,6 +50,13 @@ const FEATURES = [
enabled: false,
isEngineering: true,
},
{
id: 'expanded_form_file_preview_mode',
title: 'Expanded form file preview mode',
description: 'Preview mode allow you to see attachments inline',
enabled: false,
isEngineering: true,
},
]
export const FEATURE_FLAG = Object.fromEntries(FEATURES.map((feature) => [feature.id.toUpperCase(), feature.id])) as Record<

15
packages/nc-gui/composables/useSharedView.ts

@ -421,6 +421,19 @@ export function useSharedView() {
} as RequestParams)
}
const setCurrentViewExpandedFormMode = async (viewId: string, mode: 'field' | 'attachment', columnId?: string) => {
await $api.dbView.update(viewId, {
expanded_record_mode: mode,
attachment_mode_column_id: columnId,
})
}
const setCurrentViewExpandedFormAttachmentColumn = async (viewId: string, columnId: string) => {
await $api.dbView.update(viewId, {
attachment_mode_column_id: columnId,
})
}
return {
sharedView,
loadSharedView,
@ -441,5 +454,7 @@ export function useSharedView() {
formColumns,
allowCSVDownload,
fetchCount,
setCurrentViewExpandedFormMode,
setCurrentViewExpandedFormAttachmentColumn,
}
}

46
packages/nc-gui/utils/fileUtils.ts

@ -19,7 +19,6 @@ const videoExt = [
'webm',
'mpg',
'mp2',
'mp3',
'mpeg',
'ogg',
'mp4',
@ -36,8 +35,19 @@ const videoExt = [
'ts',
]
const wordExt = ['txt', 'doc', 'docx']
const excelExt = ['xls', 'xlsx', 'csv']
const presentationExt = ['ppt', 'pptx']
const zipExt = ['zip', 'rar']
const officeExt = [
'txt',
...wordExt,
...excelExt,
...presentationExt,
...zipExt,
'css',
'html',
'php',
@ -46,12 +56,6 @@ const officeExt = [
'h',
'hpp',
'js',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'pdf',
'pages',
'ai',
@ -63,9 +67,6 @@ const officeExt = [
'ps',
'ttf',
'xps',
'zip',
'rar',
'csv',
]
const isAudio = (name: string, mimetype?: string) => {
@ -84,11 +85,27 @@ const isPdf = (name: string, mimetype?: string) => {
return name?.toLowerCase().endsWith('.pdf') || mimetype?.startsWith('application/pdf')
}
const isWord = (name: string, _mimetype?: string) => {
return wordExt.some((e) => name?.toLowerCase().endsWith(`.${e}`))
}
const isExcel = (name: string, _mimetype?: string) => {
return excelExt.some((e) => name?.toLowerCase().endsWith(`.${e}`))
}
const isPresentation = (name: string, _mimetype?: string) => {
return presentationExt.some((e) => name?.toLowerCase().endsWith(`.${e}`))
}
const isOffice = (name: string, _mimetype?: string) => {
return officeExt.some((e) => name?.toLowerCase().endsWith(`.${e}`))
}
export { isImage, imageExt, isVideo, isPdf, isOffice, isAudio }
const isZip = (name: string, _mimetype?: string) => {
return zipExt.some((e) => name?.toLowerCase().endsWith(`.${e}`))
}
export { isImage, imageExt, isVideo, isPdf, isOffice, isAudio, isZip, isWord, isExcel, isPresentation }
// Ref : https://stackoverflow.com/a/12002275
// Tested in Mozilla Firefox browser, Chrome
@ -146,3 +163,8 @@ export function extractImageSrcFromRawHtml(rawText: string) {
return imgElement.getAttribute('src')
}
}
export const getReadableFileSize = (sizeInBytes: number) => {
const i = Math.min(Math.floor(Math.log(sizeInBytes) / Math.log(1024)), 4)
return `${(sizeInBytes / 1024 ** i).toFixed(2) * 1} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`
}

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

@ -574,6 +574,17 @@ import NcPlusAi from '~icons/nc-icons/plus-ai'
import NcPlusMultiple from '~icons/nc-icons/plus-multiple'
import NcPlusSquareSolid from '~icons/nc-icons/plus-square-solid'
/* file types */
import NcFileTypeExcel from '~icons/nc-icons-v2/file-type-csv'
import NcFileTypePdf from '~icons/nc-icons-v2/file-type-pdf'
import NcFileTypeWord from '~icons/nc-icons-v2/file-type-word'
import NcFileTypePresentation from '~icons/nc-icons-v2/file-type-presentation'
import NcFileTypeVideo from '~icons/nc-icons-v2/file-type-video'
import NcFileTypeAudio from '~icons/nc-icons-v2/file-type-audio'
import NcFileTypeZip from '~icons/nc-icons-v2/file-type-zip'
import NcFileTypeUnknown from '~icons/nc-icons-v2/file-type-unknown'
// keep it for reference
// todo: remove it after all icons are migrated
/* export const iconMapOld = {
@ -1364,6 +1375,14 @@ export const iconMap = {
ncPlusAi: h(NcPlusAi, { stroke: 'transparent' }),
ncPlusMultiple: NcPlusMultiple,
ncPlusSquareSolid: h(NcPlusSquareSolid, { stroke: 'transparent' }),
ncFileTypeExcel: NcFileTypeExcel,
ncFileTypePdf: NcFileTypePdf,
ncFileTypeWord: NcFileTypeWord,
ncFileTypePresentation: NcFileTypePresentation,
ncFileTypeVideo: NcFileTypeVideo,
ncFileTypeAudio: NcFileTypeAudio,
ncFileTypeZip: NcFileTypeZip,
ncFileTypeUnknown: NcFileTypeUnknown,
}
export const getMdiIcon = (type: string): any => {
@ -3715,6 +3734,34 @@ export const searchableMap = {
icon: h(NcAutoAwesome, { stroke: 'transparent' }),
keywords: ['Magic', 'ai', 'autoAwesome', 'awesome'],
},
ncFileTypePdf: {
icon: NcFileTypePdf,
keywords: ['pdf', 'document', 'file', 'adobe', 'reader'],
},
ncFileTypeWord: {
icon: NcFileTypeWord,
keywords: ['word', 'document', 'file', 'microsoft', 'writer'],
},
ncFileTypePresentation: {
icon: NcFileTypePresentation,
keywords: ['powerpoint', 'presentation', 'file', 'microsoft', 'office'],
},
ncFileTypeVideo: {
icon: NcFileTypeVideo,
keywords: ['video', 'movie', 'file', 'media', 'player'],
},
ncFileTypeAudio: {
icon: NcFileTypeAudio,
keywords: ['audio', 'music', 'file', 'media', 'player'],
},
ncFileTypeZip: {
icon: NcFileTypeZip,
keywords: ['zip', 'archive', 'file', 'compression', 'zipper'],
},
ncFileTypeUnknown: {
icon: NcFileTypeUnknown,
keywords: ['unknown', 'file', 'type', 'extension', 'unknown'],
},
}
export const searchIcons = (searchTerm: string) => {

34
packages/noco-docs/docs/.135.extensions/010.overview.md

@ -1,12 +1,12 @@
---
title: 'Overview'
title: 'Extensions overview'
description: 'Overview of the extensions available in NocoDB'
tags: ['Extensions', 'Overview']
keywords: ['Extensions overview', 'Add Extensions', 'Extension framework', 'Extension settings']
---
:::note
The Extensions feature is currently available only on NocoDB Cloud and is not supported in self-hosted instances.
The Extensions feature is available exclusively on NocoDB Cloud and is not supported in self-hosted instances.
:::
Extensions are modular components designed to expand and customize your experience with NocoDB. Functioning like plugins, extensions introduce new features and functionalities beyond the platform's core capabilities, allowing users to adapt NocoDB to suit unique requirements and workflows.
@ -21,27 +21,29 @@ This section provides an overview of the Extensions framework, detailing how to
Extensions can be developed using JavaScript and Vue.js, offering a flexible approach to adding new features to your NocoDB instance. The ability to develop custom extensions will be available soon, allowing you to tailor NocoDB to meet specific needs and workflows. Stay tuned for more updates!
Extensions side panel can be toggled from the top right corner of the NocoDB interface using `Extensions` button.
The Extensions side panel can be toggled using the `Extensions` button located in the top-right corner of the NocoDB interface.
[//]: # (![Extensions side panel]&#40;/img/v2/extensions/extensions-side-panel.png&#41;)
## Extension Marketplace
## Extensions Marketplace
Extensions Marketplace is a centralized hub where you can explore, discover, and install extensions to enhance your NocoDB experience. The marketplace offers a wide range of extensions developed by the NocoDB team and the community, providing a diverse selection of features to choose from.
The Extensions Marketplace is a centralized hub where you can explore, discover, and install extensions to enhance your NocoDB experience. The marketplace offers a wide range of extensions developed by the NocoDB team and the community, providing a diverse selection of features to choose from.
Extensions available currently are:
[//]: # (- [Data Exporter]&#40;/extensions/data-exporter&#41;)
[//]: # (- [Import CSV]&#40;/extensions/import-csv&#41;)
The following extensions are currently available in the marketplace:
- [Data Exporter](/extensions/data-exporter)
- [Upload Data from CSV](/extensions/upload-data-from-csv)
- [Bulk Update](/extensions/bulk-update)
- [URL Preview](/extensions/url-preview)
- [World Clock](/extensions/world-clock)
- [Org Chart](/extensions/org-chart)
Note that, the ability to develop custom extensions is not supported currently.
## Adding Extensions
To add an extension to your NocoDB instance, open extensions side panel and follow these steps:
1. Click on the `Add Extension` button in the Extensions side panel.
2. Click on the `Add` button to add the extension to your NocoDB instance. You can also click on the extension card to view more details about the extension.
1. Click the `Add Extension` button in the Extensions side panel.
2. Click the `Add` button to add the extension to your NocoDB instance. You can also click on the extension card to view more details about the extension.
:::info
- The extensions installed are specific to the base and are not shared across workspace.
@ -53,17 +55,17 @@ To add an extension to your NocoDB instance, open extensions side panel and foll
## Managing Extensions
Once extension is installed, you can
After installing an extension, you can manage it using the following options:
### Visibility
### 1. Adjust Visibility
Use the `expand` icon to view the extension in full screen mode.
<img src="/img/v2/extensions/expand.png" alt="Extension visibility" width="480"/>
### Reorder Extension
### 2. Reorder Extensions
Use the `drag-drop` icon to reorder the extensions in the side panel.
<img src="/img/v2/extensions/reorder.png" alt="Reorder" width="480"/>
### Other actions
### 3. Additional Actions
Use the `More` button to perform the following actions:
- Rename extension
- Duplicate extension

25
packages/noco-docs/docs/.135.extensions/020.data-exporter.md

@ -6,20 +6,20 @@ keywords: ['Data Exporter', 'Export data', 'Export JSON', 'Export CSV', 'Export
---
## Overview
Data Exporter extension is designed to simplify the process of exporting data from your NocoDB tables. With just a few clicks, you can effortlessly download CSV files for any specific table and view within your base. The download process is handled asynchronously in the background, ensuring that your application usage remains uninterrupted. Once your file is ready, you’ll receive a notification, allowing you to download the CSV into your local machine at your convenience.
The Data Exporter extension is designed to simplify the process of exporting data from your NocoDB tables. With just a few clicks, you can effortlessly download CSV files for any specific table and view within your base. The download process is handled asynchronously in the background, ensuring that your application usage remains uninterrupted. Once your file is ready, you’ll receive a notification, allowing you to download the CSV into your local machine at your convenience.
## Exporting Data
To export data from your NocoDB tables, follow the steps outlined below:
1. Select the table & associated view you wish to export.
2. Optionally, configure **Separator** & **Encoding** as required.
- Default separator is a Comma `,`.
- Other options supported are Semicolon `;`, Pipe `|` and `Tab`
3. Click on the **Export** button.
Follow these steps to export data from your NocoDB tables:
1. Select the table and associated view you wish to export.
2. Configure optional settings for Separator and Encoding:
- Default separator: Comma `,`
- Other options: Semicolon `;`, Pipe `|` and `Tab`
3. Click the **Export** button.
4. Once the export is complete, the file will be listed in the **Recent Exports** section.
5. Click on the **Download** button to save the CSV file to your local system.
5. Click the **Download** button to save the CSV file to your local device.
:::note
Separator & Encoding configurations are possible only from expanded extension panel
Separator and Encoding configurations are only accessible from the expanded extension panel.
:::
![Data Exporter](/img/v2/extensions/data-exporter.png)
@ -29,8 +29,9 @@ Separator & Encoding configurations are possible only from expanded extension pa
The files exported have a limited lifespan and are automatically removed after 6 hours. The files listed under the **Recent Exports** section are only visible to you and are not shared with other users. You can manage your exports by downloading or removing them as needed.
### Downloading Exports
- In the **Recent Exports** section, locate the file you wish to download.
- Click the **Download** icon next to the export to save it to your device.
1. In the Recent Exports section, locate the desired file.
2. Click the Download icon next to the file to save it to your device.
### Removing Exports
- If you no longer need a particular export, click the `x` icon next to it to remove it from the list.
1. Locate it in the Recent Exports section.
2. Click the `x` icon to remove it from the list.

58
packages/noco-docs/docs/.135.extensions/030.upload-data-from-csv.md

@ -6,39 +6,49 @@ keywords: ['Import CSV', 'Import data', 'CSV import', 'Data import', 'CSV files'
---
## Overview
The Import CSV Extension in NocoDB allows users to import data from CSV files directly into existing tables. This tool helps in adding new records, updating existing ones, and efficiently mapping CSV columns to NocoDB fields.
The Import CSV extension in NocoDB allows users to import data from CSV files directly into existing tables. This tool enables you to efficiently add new records, update existing ones, and map CSV columns to NocoDB fields with precision.
## Importing Data
### Mode
The Import CSV Extension supports two modes of data import:
- **Add Records**: This option adds all records from the CSV file as new entries in the specified table.
- **Merge Records**: This option updates existing records based on a specified field (referred to as **merge field** in this context) in the CSV file.
### Modes of Import
The Import CSV Extension supports two modes of data import:
&nbsp; **1. Add Records**: Import all records from the CSV file as new entries in the selected table. No existing records are modified.
&nbsp; **2. Merge Records**: Update existing records based on a designated **merge field** (a unique field used for matching) while optionally adding new records. The available options under this mode are:
- *Create New Records Only*
- Only adds new records from the CSV. Existing records are not updated. New records are identified based on the merge field specified.
- *Update Existing Records Only*
- Only updates records that already exist in the table based on the merge field. New records are not added.
- *Create and Update Records*
- Adds new records and updates existing ones as needed. New and existing records are identified based on the merge field.
**Merge Field** is field that will be used to match records from the CSV file with existing records in NocoDB table for the purpose of updating.
**Field Mapping:** You can select the fields from the CSV file to import and map them to the corresponding fields in the NocoDB table.
### Steps
To import data from a CSV file into NocoDB, drag-drop (or upload) the CSV file into the extension area and follow these steps:
1. Select the table you want to import the data into. By default, current active table is selected.
2. Select the mode of import: **Add Records** or **Merge Records**. If you choose Merge Records, you will have to additionally
- Select "Import Type" :
- *Create New Records Only*,
- *Update Existing Records Only*, or
- *Create and Update Records*.
- Select "Merge Field". Merge field typically will be your Primary Key or a unique identifier. Composite keys are not supported.
3. **Use first record as header**: If the first row of the CSV file contains the column headers, enable this option to use them as the field names.
4. Map the CSV columns to the NocoDB fields.
5. Click on the **Import** button to start the import process.
- Adds new records and updates existing ones as needed, based on the merge field.
**Merge Field:** The merge field is the key field used to match records in the CSV file with those in the NocoDB table for updating purposes. Typically, this is the Primary Key or another unique identifier. Composite keys are not supported.
**Field Mapping:** Easily map CSV columns to corresponding fields in the NocoDB table. You have the flexibility to import only the fields you need.
### Steps to Import
Follow these steps to import data from a CSV file into NocoDB:
1. Drag and drop or upload your CSV file into the Import CSV Extension area.
2. Select the table you want to import the data into. By default, current active table is selected.
3. Choose the mode of import:
- **Add Records** or
- **Merge Records** with one of the following **Import Types**:
- *Create New Records Only*
- *Update Existing Records Only*
- *Create and Update Records*
4. Set the **Merge Field** (for Merge Records mode): Select the field that will be used to match CSV records with existing table records.
5. **Use first record as header**: If the first row of the CSV file contains the column headers, enable this option to use them as the field names.
6. Map the columns from the CSV file to the corresponding fields in the NocoDB table.
7. Click the **Import** button to start the import process.
![Import CSV](/img/v2/extensions/upload-csv.png)
Once you've configured the import settings and mapped your fields, NocoDB will process the CSV file and update the table according to your specified settings. A summary of new, updated, and unchanged records will be displayed at the bottom of the interface.
### Post-Import Summary
Once the import is complete, NocoDB will display a summary detailing:
- The number of new records added
- The number of existing records updated
- Any records left unchanged
This streamlined process ensures your data is imported accurately and efficiently.

26
packages/noco-docs/docs/.135.extensions/040.bulk-update.md

@ -6,19 +6,19 @@ keywords: ['Bulk Update', 'Update multiple records', 'Update data', 'Update reco
---
## Overview
Bulk Update extension in NocoDB allows you to update multiple records in a view simultaneously. This feature is particularly useful when you need to make the same change across multiple records, saving you time and effort.
The Bulk Update extension in NocoDB allows you to update multiple records in a view simultaneously. This feature is particularly useful when you need to make the same change across multiple records, saving you time and effort.
## Updating Records
To update multiple records in a table, follow the steps outlined below:
1. Select the table & associated view you wish to update.
2. Click on the **New Action** button.
3. Select **Field** you want to update.
4. Select Update **Type**:
- **Set** Value: Set a specific value for the field.
- **Clear** Value: Clear the field value.
5. Select **Value** you want to set for the field, if option selected is **Set** Value.
6. Click on the **Update Records** button to apply the changes.
7. A confirmation dialog will appear. Number of fields and records to be updated will be displayed. Click on **Confirm Update** to proceed with the update.
Follow these steps to update multiple records in a table:
1. Select the table and associated view you wish to update.
2. Click the **New Action** button.
3. Choose the **Field** you want to update.
4. Select an **Update Type**:
- **Set Value**: Set a specific value for the field.
- **Clear Value**: Clear the field value.
5. If you selected **Set Value**, provide the **Value** to be applied.
6. Click the **Update Records** button to apply the changes.
7. A confirmation dialog will display the number of fields and records to be updated. Review the details and click **Confirm Update** to proceed.
![Bulk Update](/img/v2/extensions/bulk-update.png)
@ -31,11 +31,11 @@ The actions added are listed in the **Actions** section. You can:
- **Disable** an action by toggling the switch next to the action item. Disabling an action will prevent it from being applied to the records.
:::warning
- Bulk updates can't be undone. Ensure you review the changes before confirming the update.
- Bulk updates are irreversible. Ensure you review the changes before confirming the update.
:::
:::info
- Bulk updates are applied to all records in the view, including those that are not visible on the current page.
- Bulk updates are applied to all records in the view, including those not visible on the current page.
- Bulk updates are specific to the view and are not applied to the entire table.
:::

4
packages/noco-docs/docs/.135.extensions/050.url-preview.md

@ -7,10 +7,10 @@ keywords: ['URL Preview', 'Preview URLs', 'Preview links', 'Preview URL content'
## Overview
The URL Preview Extension in NocoDB allows you to preview URLs directly within your NocoDB instance. This feature is particularly useful when you need to quickly view the content of a URL without leaving your workspace. This extension enhances the data visualization capabilities of NocoDB by providing rich, contextual previews for supported online platforms and services.
The URL Preview extension in NocoDB allows you to preview URLs directly within your NocoDB instance. This feature is particularly useful when you need to quickly view the content of a URL without leaving your workspace. This extension enhances the data visualization capabilities of NocoDB by providing rich, contextual previews for supported online platforms and services.
## Previewing URLs
To preview a URL in NocoDB, just select a cell from URL field. A preview of the URL content will be displayed in the extension sub-panel, allowing you to view the content without navigating away from your workspace.
To preview a URL in NocoDB, just select a cell from the URL field. A preview of the URL content will be displayed in the extension sub-panel, allowing you to view the content without navigating away from your workspace.
![URL Preview](/img/v2/extensions/url-preview.png)

29
packages/noco-docs/docs/.135.extensions/060.world-clock.md

@ -0,0 +1,29 @@
---
title: 'World Clock'
description: 'Display multiple time zone clocks with in NocoDB'
tags: ['Extensions', 'World Clock', 'Time Zone', 'Clock', 'Time']
keywords: ['World Clock', 'Time Zone Clock', 'Clock', 'Time Zone', 'Time']
---
## Overview
The World Clock extension in NocoDB allows you to display multiple time zone clocks within your NocoDB instance. This feature is particularly useful when you need to track time across different regions or collaborate with team members in different time zones. The extension enhances the user experience by providing a visual representation of time zones, making it easier to manage global operations and schedules.
<img src="/img/v2/extensions/world-clock-2.png" alt="Analog" width="380"/>
<img src="/img/v2/extensions/world-clock-1.png" alt="Digital" width="420"/>
### Adding Clock
To add a clock, expand World clock extension, click `+ Add City` and select City from the dropdown available. You can add a maximum of 4 clocks per instance of an extension.
- **Clock Name**: Enter a name for the clock to identify it easily. It defaults to the selected City name.
- **Theme**: Choose one amongst the available themes for the clock display.
### Clock Display Settings
The following are the global settings that will be applied to all the clocks configured.
- **Clock Type**: Choose between **Analog**, **Digital**, or **Both** for the display format.
- **Show Numbers**: For analog clocks, you can additionally configure if hour numbers are to be displayed on the clock dial.
- **Time Format**: Choose between 12H and 24H format for the clock display.

8
packages/noco-docs/docs/.135.extensions/_category_.json

@ -0,0 +1,8 @@
{
"label": "Extensions ☁",
"collapsible": true,
"collapsed": true,
"link": {
"type": "generated-index"
}
}

BIN
packages/noco-docs/static/img/v2/extensions/url-preview.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

After

Width:  |  Height:  |  Size: 718 KiB

BIN
packages/noco-docs/static/img/v2/extensions/world-clock-1.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
packages/noco-docs/static/img/v2/extensions/world-clock-2.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

25
packages/nocodb-sdk/src/lib/Api.ts

@ -3293,6 +3293,31 @@ export interface ExtensionType {
order?: number;
}
/**
* Model for Snapshot
*/
export interface SnapshotType {
/** Unique ID */
id?: IdType;
/** Title of the Snapshot */
title?: string;
/** Foreign Key to Base */
base_id?: IdType;
/** Foreign Key to Snapshot Base */
snapshot_base_id?: IdType;
/** Foreign Key to Workspace */
fk_workspace_id?: IdType;
/**
* Date of creation
* @format date
*/
created_at?: string;
/** User ID of the creator */
created_by?: IdType;
/** Status of the Snapshot */
status?: string;
}
export interface ExtensionReqType {
/** Unique Base ID */
base_id?: IdType;

8
packages/nocodb-sdk/src/lib/globals.ts

@ -44,6 +44,14 @@ export enum RelationTypes {
ONE_TO_ONE = 'oo',
}
export const ExpandedFormMode = {
FIELD: 'field',
ATTACHMENT: 'attachment',
} as const;
export type ExpandedFormModeType =
(typeof ExpandedFormMode)[keyof typeof ExpandedFormMode];
export enum ExportTypes {
EXCEL = 'excel',
CSV = 'csv',

5
packages/nocodb/src/helpers/NcPluginMgrv2.ts

@ -132,7 +132,10 @@ class NcPluginMgrv2 {
* NC_S3_REGION
* */
if (process.env.NC_S3_BUCKET_NAME && (process.env.NC_S3_REGION || process.env.NC_S3_ENDPOINT)) {
if (
process.env.NC_S3_BUCKET_NAME &&
(process.env.NC_S3_REGION || process.env.NC_S3_ENDPOINT)
) {
const s3Plugin = await Plugin.getPlugin(S3PluginConfig.id);
const s3CfgData: Record<string, any> = {
bucket: process.env.NC_S3_BUCKET_NAME,

40
packages/nocodb/src/models/View.ts

@ -1,11 +1,17 @@
import {
CommonAggregations,
ExpandedFormMode,
isSystemColumn,
UITypes,
ViewTypes,
} from 'nocodb-sdk';
import { Logger } from '@nestjs/common';
import type { BoolType, ColumnReqType, ViewType } from 'nocodb-sdk';
import type {
BoolType,
ColumnReqType,
ExpandedFormModeType,
ViewType,
} from 'nocodb-sdk';
import type { NcContext } from '~/interface/config';
import Model from '~/models/Model';
import FormView from '~/models/FormView';
@ -41,6 +47,7 @@ import {
} from '~/utils/modelUtils';
import { LinkToAnotherRecordColumn } from '~/models';
import { cleanCommandPaletteCache } from '~/helpers/commandPaletteHelpers';
import { isEE } from '~/utils';
const { v4: uuidv4 } = require('uuid');
@ -1265,6 +1272,8 @@ export default class View implements ViewType {
meta?: any;
owned_by?: string;
created_by?: string;
expanded_record_mode?: ExpandedFormModeType;
attachment_mode_column_id?: string;
},
includeCreatedByAndUpdateBy = false,
ncMeta = Noco.ncMeta,
@ -1279,8 +1288,17 @@ export default class View implements ViewType {
'meta',
'uuid',
...(includeCreatedByAndUpdateBy ? ['owned_by', 'created_by'] : []),
...(isEE ? ['expanded_record_mode', 'attachment_mode_column_id'] : []),
]);
if (isEE) {
if (!updateObj?.attachment_mode_column_id) {
updateObj.expanded_record_mode = ExpandedFormMode.FIELD;
} else {
updateObj.expanded_record_mode = ExpandedFormMode.ATTACHMENT;
}
}
const oldView = await this.get(context, viewId, ncMeta);
// set meta
@ -1992,6 +2010,8 @@ export default class View implements ViewType {
calendar_range?: Partial<CalendarRange>[];
created_by: string;
owned_by: string;
expanded_record_mode?: ExpandedFormModeType;
attachment_mode_column_id?: string;
},
model: {
getColumns: (context: NcContext, ncMeta?) => Promise<Column[]>;
@ -2011,8 +2031,17 @@ export default class View implements ViewType {
'created_by',
'owned_by',
'lock_type',
...(isEE ? ['expanded_record_mode', 'attachment_mode_column_id'] : []),
]);
if (isEE) {
if (!insertObj?.attachment_mode_column_id) {
insertObj.expanded_record_mode = ExpandedFormMode.FIELD;
} else {
insertObj.expanded_record_mode = ExpandedFormMode.ATTACHMENT;
}
}
if (!insertObj.order) {
// get order value
insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.VIEWS, {
@ -2424,4 +2453,13 @@ export default class View implements ViewType {
async delete(context: NcContext, ncMeta = Noco.ncMeta) {
await View.delete(context, this.id, ncMeta);
}
static async updateIfColumnUsedAsExpandedMode(
_context: NcContext,
_columnId: string,
_modelId: string,
_ncMeta = Noco.ncMeta,
) {
return;
}
}

18
packages/nocodb/src/services/columns.service.ts

@ -1573,6 +1573,17 @@ export class ColumnsService {
});
}
if (
column.uidt === UITypes.Attachment &&
colBody.uidt !== UITypes.Attachment
) {
await View.updateIfColumnUsedAsExpandedMode(
context,
column.id,
column.fk_model_id,
);
}
// Get all the columns in the table and return
await table.getColumns(context);
@ -2694,6 +2705,13 @@ export class ColumnsService {
);
}
await View.updateIfColumnUsedAsExpandedMode(
context,
column.id,
column.fk_model_id,
ncMeta,
);
this.appHooksService.emit(AppEvents.COLUMN_DELETE, {
table,
column,

BIN
tests/playwright/fixtures/sampleFiles/sample.mp3

Binary file not shown.

BIN
tests/playwright/fixtures/sampleFiles/sample.mp4

Binary file not shown.

BIN
tests/playwright/fixtures/sampleFiles/sample.pdf

Binary file not shown.

66
tests/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -2,7 +2,6 @@ import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { DashboardPage } from '..';
import { DateTimeCellPageObject } from '../common/Cell/DateTimeCell';
import { getTextExcludeIconText } from '../../../tests/utils/general';
export class ExpandedFormPage extends BasePage {
readonly dashboard: DashboardPage;
@ -12,6 +11,19 @@ export class ExpandedFormPage extends BasePage {
readonly btn_save: Locator;
readonly btn_moreActions: Locator;
readonly btn_nextField: Locator;
readonly btn_previousField: Locator;
readonly span_tableName: Locator;
readonly span_modeFields: Locator;
readonly span_modeFiles: Locator;
readonly cnt_filesModeContainer: Locator;
readonly cnt_filesNoAttachmentField: Locator;
readonly cnt_filesAttachmentHeader: Locator;
readonly cnt_filesCurrentFieldTitle: Locator;
readonly cnt_filesCurrentAttachmentTitle: Locator;
readonly cnt_filesNoAttachment: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
@ -22,6 +34,18 @@ export class ExpandedFormPage extends BasePage {
this.btn_save = this.dashboard.get().locator('button.nc-expand-form-save-btn');
this.btn_moreActions = this.get().locator('.nc-expand-form-more-actions');
this.btn_nextField = this.get().locator('.nc-expanded-form-header button.nc-button.nc-next-arrow');
this.btn_previousField = this.get().locator('.nc-expanded-form-header button.nc-button.nc-prev-arrow');
this.span_tableName = this.get().locator('.nc-expanded-form-header').last().locator('.nc-expanded-form-table-name');
this.span_modeFields = this.get().locator('.nc-expanded-form-mode-switch').last().locator('.tab').nth(0);
this.span_modeFiles = this.get().locator('.nc-expanded-form-mode-switch').last().locator('.tab').nth(1);
this.cnt_filesModeContainer = this.get().locator('.nc-files-mode-container');
this.cnt_filesNoAttachmentField = this.get().locator('.nc-files-no-attachment-field');
this.cnt_filesAttachmentHeader = this.get().locator('.nc-files-attachment-header');
this.cnt_filesCurrentFieldTitle = this.get().locator('.nc-files-current-field-title');
this.cnt_filesCurrentAttachmentTitle = this.get().locator('.nc-files-current-attachment-title');
this.cnt_filesNoAttachment = this.get().locator('.nc-files-no-attachment');
}
get() {
@ -224,4 +248,44 @@ export class ExpandedFormPage extends BasePage {
// press escape to close the expanded form
await this.rootPage.keyboard.press('Escape');
}
async moveToNextField() {
await this.btn_nextField.click();
}
async moveToPreviousField() {
await this.btn_previousField.click();
}
async verifyTableNameShown({ name }: { name: string }) {
return await expect(this.span_tableName).toContainText(name);
}
async verifyIsInFieldsMode() {
return await expect(this.span_modeFields).toHaveClass(/active/);
}
async verifyIsInFilesMode() {
return await expect(this.span_modeFiles).toHaveClass(/active/);
}
async switchToFieldsMode() {
await this.span_modeFields.click();
}
async switchToFilesMode() {
await this.span_modeFiles.click();
}
async verifyFilesViewerMode({ mode }: { mode: 'image' | 'video' | 'audio' | 'pdf' | 'unsupported' }) {
await expect(this.get().locator(`.nc-files-mode-container .nc-files-viewer-${mode}`)).toBeVisible();
}
async verifyPreviewCellsCount({ count }: { count: number }) {
await expect(this.get().locator(`.nc-files-mode-container .nc-files-preview-cell`)).toHaveCount(count);
}
async selectNthFilePreviewCell({ index }: { index: number }) {
await this.get().locator(`.nc-files-mode-container .nc-files-preview-cell`).nth(index).click();
}
}

6
tests/playwright/pages/Dashboard/Settings/Miscellaneous.ts

@ -13,7 +13,12 @@ export class MiscSettingsPage extends BasePage {
return this.settings.get().locator(`[data-testid="nc-settings-subtab-visibility"]`);
}
async selectTab(tab: 'snapshots-tab' | 'visibility-tab') {
await this.settings.get().getByTestId(tab).click();
}
async clickShowM2MTables() {
await this.settings.get().locator(`[data-testid="visibility-tab"]`).click();
const clickAction = () => this.get().locator('.nc-settings-meta-misc-m2m').first().click();
await this.waitForResponse({
uiAction: clickAction,
@ -23,6 +28,7 @@ export class MiscSettingsPage extends BasePage {
}
async clickShowNullEmptyFilters() {
await this.settings.get().locator(`[data-testid="visibility-tab"]`).click();
await this.waitForResponse({
uiAction: () => this.rootPage.locator('.nc-settings-show-null-and-empty-in-filter').first().click(),
requestUrlPathToMatch: '/api/v1/db/meta/projects',

2
tests/playwright/tests/db/columns/columnDuration.spec.ts

@ -56,7 +56,7 @@ test.describe('Duration column', () => {
await unsetup(context);
});
test('Create duration column', async () => {
test.skip('Create duration column', async () => {
await dashboard.treeView.createTable({ title: 'tablex', baseTitle: context.base.title });
// Create duration column
await dashboard.grid.column.create({

127
tests/playwright/tests/db/features/expandedFormModeFiles.spec.ts

@ -0,0 +1,127 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import setup, { unsetup } from '../../../setup';
// Todo: Enable this test once we enable this feature for all
test.describe.skip('Expanded form files mode', () => {
let dashboard: DashboardPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: false });
dashboard = new DashboardPage(page, context.base);
});
test.afterEach(async () => {
await unsetup(context);
});
async function addFileToRow(rowIndex: number, filePathAppned: string[]) {
await dashboard.grid.cell.attachment.addFile({
index: rowIndex,
columnHeader: 'testAttach',
filePath: filePathAppned.map(filePath => `${process.cwd()}/fixtures/sampleFiles/${filePath}`),
});
await dashboard.rootPage.waitForTimeout(500);
await dashboard.grid.cell.attachment.verifyFileCount({
index: rowIndex,
columnHeader: 'testAttach',
count: filePathAppned.length,
});
}
test('Mode switch and functionality', async () => {
test.slow();
await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid.column.create({
title: 'testAttach',
type: 'Attachment',
});
await addFileToRow(0, ['1.json']);
await addFileToRow(2, ['1.json', '2.json']);
await dashboard.grid.openExpandedRow({ index: 0 });
await dashboard.expandedForm.verifyTableNameShown({ name: 'Country' });
await dashboard.expandedForm.verifyIsInFieldsMode();
await dashboard.expandedForm.switchToFilesMode();
await dashboard.expandedForm.verifyIsInFilesMode();
await expect(dashboard.expandedForm.cnt_filesModeContainer).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesNoAttachmentField).not.toBeVisible();
await expect(dashboard.expandedForm.cnt_filesAttachmentHeader).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesCurrentAttachmentTitle).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesNoAttachment).not.toBeVisible();
await expect(dashboard.expandedForm.cnt_filesCurrentFieldTitle).toHaveText('testAttach');
await expect(dashboard.expandedForm.cnt_filesCurrentAttachmentTitle).toHaveText('1.json');
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'unsupported' });
});
test('Various file types correct rendering', async () => {
test.slow();
await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid.column.create({
title: 'testAttach',
type: 'Attachment',
});
await addFileToRow(1, ['1.json', 'sampleImage.jpeg', 'sample.mp4', 'sample.pdf', 'sample.mp3']);
await addFileToRow(2, ['1.json']);
await dashboard.grid.openExpandedRow({ index: 0 });
await dashboard.expandedForm.switchToFilesMode();
await dashboard.expandedForm.verifyIsInFilesMode();
await expect(dashboard.expandedForm.cnt_filesModeContainer).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesNoAttachmentField).not.toBeVisible();
await expect(dashboard.expandedForm.cnt_filesAttachmentHeader).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesCurrentAttachmentTitle).not.toBeVisible();
await expect(dashboard.expandedForm.cnt_filesNoAttachment).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesCurrentFieldTitle).toHaveText('testAttach');
await dashboard.expandedForm.close();
await dashboard.grid.openExpandedRow({ index: 1 });
await dashboard.expandedForm.verifyIsInFieldsMode();
await dashboard.expandedForm.switchToFilesMode();
await dashboard.expandedForm.verifyIsInFilesMode();
await expect(dashboard.expandedForm.cnt_filesModeContainer).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesNoAttachmentField).not.toBeVisible();
await expect(dashboard.expandedForm.cnt_filesAttachmentHeader).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesCurrentAttachmentTitle).toBeVisible();
await expect(dashboard.expandedForm.cnt_filesNoAttachment).not.toBeVisible();
await expect(dashboard.expandedForm.cnt_filesCurrentFieldTitle).toHaveText('testAttach');
await dashboard.expandedForm.verifyPreviewCellsCount({ count: 5 });
await expect(dashboard.expandedForm.cnt_filesCurrentAttachmentTitle).toHaveText('1.json');
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'unsupported' });
await dashboard.expandedForm.selectNthFilePreviewCell({ index: 1 });
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'image' });
await dashboard.expandedForm.selectNthFilePreviewCell({ index: 2 });
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'video' });
await dashboard.expandedForm.selectNthFilePreviewCell({ index: 3 });
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'pdf' });
await dashboard.expandedForm.selectNthFilePreviewCell({ index: 4 });
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'audio' });
await dashboard.expandedForm.moveToNextField();
await dashboard.expandedForm.verifyIsInFilesMode();
await dashboard.expandedForm.verifyPreviewCellsCount({ count: 5 });
await expect(dashboard.expandedForm.cnt_filesCurrentAttachmentTitle).toHaveText('1.json');
await dashboard.expandedForm.verifyFilesViewerMode({ mode: 'unsupported' });
});
});
Loading…
Cancel
Save