Browse Source

Merge pull request #8718 from nocodb/develop

pull/8719/head 0.250.0
github-actions[bot] 3 weeks ago committed by GitHub
parent
commit
6e2a3e0328
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      .github/workflows/publish-docs-index-typesense.yml
  2. 2
      package.json
  3. BIN
      packages/nc-gui/assets/img/views/calendar.png
  4. BIN
      packages/nc-gui/assets/img/views/form.png
  5. BIN
      packages/nc-gui/assets/img/views/gallery.png
  6. BIN
      packages/nc-gui/assets/img/views/grid.png
  7. BIN
      packages/nc-gui/assets/img/views/kanban.png
  8. 6
      packages/nc-gui/assets/nc-icons/bell.svg
  9. 11
      packages/nc-gui/assets/nc-icons/check-circle.svg
  10. 20
      packages/nc-gui/assets/nc-icons/drag.svg
  11. 5
      packages/nc-gui/assets/nc-icons/key.svg
  12. 5
      packages/nc-gui/assets/nc-icons/maximize-all.svg
  13. 14
      packages/nc-gui/assets/nc-icons/maximize.svg
  14. 5
      packages/nc-gui/assets/nc-icons/minimize-all.svg
  15. 10
      packages/nc-gui/assets/nc-icons/minimize.svg
  16. 19
      packages/nc-gui/assets/style.scss
  17. 8
      packages/nc-gui/components/cell/Checkbox.vue
  18. 2
      packages/nc-gui/components/cell/Currency.vue
  19. 18
      packages/nc-gui/components/cell/DatePicker.vue
  20. 113
      packages/nc-gui/components/cell/DateTimePicker.vue
  21. 4
      packages/nc-gui/components/cell/Decimal.vue
  22. 6
      packages/nc-gui/components/cell/Duration.vue
  23. 3
      packages/nc-gui/components/cell/Email.vue
  24. 1
      packages/nc-gui/components/cell/Float.vue
  25. 35
      packages/nc-gui/components/cell/GeoData.vue
  26. 5
      packages/nc-gui/components/cell/Integer.vue
  27. 50
      packages/nc-gui/components/cell/Json.vue
  28. 1
      packages/nc-gui/components/cell/MultiSelect.vue
  29. 2
      packages/nc-gui/components/cell/Percent.vue
  30. 3
      packages/nc-gui/components/cell/PhoneNumber.vue
  31. 4
      packages/nc-gui/components/cell/ReadOnlyUser.vue
  32. 31
      packages/nc-gui/components/cell/RichText.vue
  33. 12
      packages/nc-gui/components/cell/RichText/LinkOptions.vue
  34. 10
      packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue
  35. 3
      packages/nc-gui/components/cell/SingleSelect.vue
  36. 1
      packages/nc-gui/components/cell/Text.vue
  37. 31
      packages/nc-gui/components/cell/TextArea.vue
  38. 53
      packages/nc-gui/components/cell/TimePicker.vue
  39. 5
      packages/nc-gui/components/cell/Url.vue
  40. 18
      packages/nc-gui/components/cell/YearPicker.vue
  41. 2
      packages/nc-gui/components/cell/attachment/Image.vue
  42. 1
      packages/nc-gui/components/cell/attachment/Modal.vue
  43. 2
      packages/nc-gui/components/cell/attachment/index.vue
  44. 222
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  45. 9
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  46. 10
      packages/nc-gui/components/dashboard/View.vue
  47. 2
      packages/nc-gui/components/dashboard/settings/Modal.vue
  48. 38
      packages/nc-gui/components/dlg/AirtableImport.vue
  49. 46
      packages/nc-gui/components/dlg/ColumnUpdateConfirm.vue
  50. 6
      packages/nc-gui/components/dlg/InviteDlg.vue
  51. 197
      packages/nc-gui/components/general/AdvanceColorPicker.vue
  52. 5
      packages/nc-gui/components/general/EmojiPicker.vue
  53. 8
      packages/nc-gui/components/general/Loader.vue
  54. 20
      packages/nc-gui/components/general/TruncateText.vue
  55. 10
      packages/nc-gui/components/general/ViewIcon.vue
  56. 21
      packages/nc-gui/components/nc/Button.vue
  57. 2
      packages/nc-gui/components/nc/DateWeekSelector.vue
  58. 18
      packages/nc-gui/components/nc/MenuItem.vue
  59. 26
      packages/nc-gui/components/nc/MonthYearSelector.vue
  60. 49
      packages/nc-gui/components/nc/Switch.vue
  61. 4
      packages/nc-gui/components/nc/TimeSelector.vue
  62. 169
      packages/nc-gui/components/notification/Card.vue
  63. 28
      packages/nc-gui/components/notification/Item.vue
  64. 17
      packages/nc-gui/components/notification/Item/ColumnEvent.vue
  65. 17
      packages/nc-gui/components/notification/Item/FilterViewEvent.vue
  66. 38
      packages/nc-gui/components/notification/Item/ProjectEvent.vue
  67. 12
      packages/nc-gui/components/notification/Item/ProjectInvite.vue
  68. 38
      packages/nc-gui/components/notification/Item/SharedViewEvent.vue
  69. 17
      packages/nc-gui/components/notification/Item/SortViewEvent.vue
  70. 38
      packages/nc-gui/components/notification/Item/TableEvent.vue
  71. 38
      packages/nc-gui/components/notification/Item/ViewEvent.vue
  72. 21
      packages/nc-gui/components/notification/Item/Welcome.vue
  73. 119
      packages/nc-gui/components/notification/Item/Wrapper.vue
  74. 36
      packages/nc-gui/components/notification/Menu.vue
  75. 4
      packages/nc-gui/components/project/AccessSettings.vue
  76. 11
      packages/nc-gui/components/project/AllTables.vue
  77. 12
      packages/nc-gui/components/project/View.vue
  78. 94
      packages/nc-gui/components/shared-view/AskPassword.vue
  79. 4
      packages/nc-gui/components/shared-view/Calendar.vue
  80. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  81. 2
      packages/nc-gui/components/shared-view/Grid.vue
  82. 2
      packages/nc-gui/components/shared-view/Kanban.vue
  83. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  84. 2
      packages/nc-gui/components/smartsheet/Details.vue
  85. 12
      packages/nc-gui/components/smartsheet/Form.vue
  86. 329
      packages/nc-gui/components/smartsheet/Gallery.vue
  87. 1130
      packages/nc-gui/components/smartsheet/Kanban.vue
  88. 8
      packages/nc-gui/components/smartsheet/Map.vue
  89. 7
      packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue
  90. 12
      packages/nc-gui/components/smartsheet/Toolbar.vue
  91. 11
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  92. 76
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  93. 246
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  94. 104
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  95. 19
      packages/nc-gui/components/smartsheet/calendar/SideRecordCard.vue
  96. 39
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  97. 150
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  98. 2
      packages/nc-gui/components/smartsheet/calendar/YearView/index.vue
  99. 158
      packages/nc-gui/components/smartsheet/calendar/index.vue
  100. 17
      packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue
  101. Some files were not shown because too many files have changed in this diff Show More

14
.github/workflows/publish-docs-index-typesense.yml

@ -3,7 +3,9 @@ name: "Publish : Docs search index (Typesense)"
on: on:
# Triggered manually # Triggered manually
workflow_dispatch: workflow_dispatch:
repository_dispatch:
types: trigger-docs-index
jobs: jobs:
doc-indexer: doc-indexer:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -15,12 +17,12 @@ jobs:
uses: celsiusnarhwal/typesense-scraper@v2 uses: celsiusnarhwal/typesense-scraper@v2
with: with:
# The secret containing your Typesense API key. Required. # The secret containing your Typesense API key. Required.
api-key: ${{ secrets.TYPESENSE_API_KEY }} api-key: ${{ secrets.TYPESENSE_API_KEY }}
# The hostname or IP address of your Typesense server. Required. # The hostname or IP address of your Typesense server. Required.
host: ${{ secrets.TYPESENSE_HOST }} host: ${{ secrets.TYPESENSE_HOST }}
# The port on which your Typesense server is listening. Optional. Default: 8108. # The port on which your Typesense server is listening. Optional. Default: 8108.
port: 443 port: 443
# The protocol to use when connecting to your Typesense server. Optional. Default: http. # The protocol to use when connecting to your Typesense server. Optional. Default: http.
protocol: https protocol: https
# The path to your DocSearch config file. Optional. Default: docsearch.config.json. # The path to your DocSearch config file. Optional. Default: docsearch.config.json.
config: ./packages/noco-docs/typesense-scrape-config.json config: ./packages/noco-docs/typesense-scrape-config.json

2
package.json

@ -32,7 +32,7 @@
] ]
}, },
"scripts": { "scripts": {
"bootstrap": "pnpm --filter=nocodb-sdk install && pnpm --filter=nocodb-sdk run build && pnpm --filter=nocodb --filter=nc-gui --filter=playwright install", "bootstrap": "pnpm --filter=nocodb-sdk install && pnpm --filter=nocodb-sdk run build && pnpm --filter=nocodb --filter=nc-mail-templates --filter=nc-gui --filter=playwright install",
"start:frontend": "pnpm --filter=nc-gui run dev", "start:frontend": "pnpm --filter=nc-gui run dev",
"start:backend": "pnpm --filter=nocodb run start", "start:backend": "pnpm --filter=nocodb run start",
"lint:staged:playwright": "cd ./tests/playwright; pnpm dlx lint-staged; cd -", "lint:staged:playwright": "cd ./tests/playwright; pnpm dlx lint-staged; cd -",

BIN
packages/nc-gui/assets/img/views/calendar.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
packages/nc-gui/assets/img/views/form.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
packages/nc-gui/assets/img/views/gallery.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
packages/nc-gui/assets/img/views/grid.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

BIN
packages/nc-gui/assets/img/views/kanban.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

6
packages/nc-gui/assets/nc-icons/bell.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="bell">
<path id="Vector" d="M12 5.33325C12 4.27239 11.5786 3.25497 10.8284 2.50482C10.0783 1.75468 9.06087 1.33325 8 1.33325C6.93913 1.33325 5.92172 1.75468 5.17157 2.50482C4.42143 3.25497 4 4.27239 4 5.33325C4 9.99992 2 11.3333 2 11.3333H14C14 11.3333 12 9.99992 12 5.33325Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M9.15335 14C9.03614 14.2021 8.86791 14.3698 8.6655 14.4864C8.46309 14.6029 8.2336 14.6643 8.00001 14.6643C7.76643 14.6643 7.53694 14.6029 7.33453 14.4864C7.13212 14.3698 6.96389 14.2021 6.84668 14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 801 B

11
packages/nc-gui/assets/nc-icons/check-circle.svg

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="check-circle" clip-path="url(#clip0_494_50692)">
<path id="Vector" d="M14.6667 7.38674V8.00007C14.6659 9.43769 14.2003 10.8365 13.3396 11.988C12.4788 13.1394 11.2689 13.9817 9.89025 14.3893C8.51163 14.797 7.03818 14.748 5.68966 14.2498C4.34113 13.7516 3.18978 12.8308 2.40732 11.6248C1.62485 10.4188 1.2532 8.99212 1.34779 7.55762C1.44239 6.12312 1.99815 4.75762 2.9322 3.66479C3.86625 2.57195 5.12853 1.81033 6.5308 1.4935C7.93307 1.17668 9.40019 1.32163 10.7133 1.90674" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M14.6667 2.66675L8 9.34008L6 7.34008" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_494_50692">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 940 B

20
packages/nc-gui/assets/nc-icons/drag.svg

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.00016 13.3333C5.36835 13.3333 5.66683 13.0349 5.66683 12.6667C5.66683 12.2985 5.36835 12 5.00016 12C4.63197 12 4.3335 12.2985 4.3335 12.6667C4.3335 13.0349 4.63197 13.3333 5.00016 13.3333Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M5.00016 8.66683C5.36835 8.66683 5.66683 8.36835 5.66683 8.00016C5.66683 7.63197 5.36835 7.3335 5.00016 7.3335C4.63197 7.3335 4.3335 7.63197 4.3335 8.00016C4.3335 8.36835 4.63197 8.66683 5.00016 8.66683Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M5.00016 3.99984C5.36835 3.99984 5.66683 3.70136 5.66683 3.33317C5.66683 2.96498 5.36835 2.6665 5.00016 2.6665C4.63197 2.6665 4.3335 2.96498 4.3335 3.33317C4.3335 3.70136 4.63197 3.99984 5.00016 3.99984Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M11.0002 13.3333C11.3684 13.3333 11.6668 13.0349 11.6668 12.6667C11.6668 12.2985 11.3684 12 11.0002 12C10.632 12 10.3335 12.2985 10.3335 12.6667C10.3335 13.0349 10.632 13.3333 11.0002 13.3333Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M11.0002 8.66683C11.3684 8.66683 11.6668 8.36835 11.6668 8.00016C11.6668 7.63197 11.3684 7.3335 11.0002 7.3335C10.632 7.3335 10.3335 7.63197 10.3335 8.00016C10.3335 8.36835 10.632 8.66683 11.0002 8.66683Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M11.0002 3.99984C11.3684 3.99984 11.6668 3.70136 11.6668 3.33317C11.6668 2.96498 11.3684 2.6665 11.0002 2.6665C10.632 2.6665 10.3335 2.96498 10.3335 3.33317C10.3335 3.70136 10.632 3.99984 11.0002 3.99984Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

5
packages/nc-gui/assets/nc-icons/key.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M10.3333 4.99998L12.6667 2.66665M14 1.33331L12.6667 2.66665L14 1.33331ZM7.59333 7.73998C7.93756 8.07962 8.2112 8.48401 8.3985 8.92984C8.5858 9.37568 8.68306 9.85416 8.68468 10.3377C8.68631 10.8213 8.59225 11.3004 8.40794 11.7475C8.22363 12.1946 7.95271 12.6008 7.61076 12.9427C7.26882 13.2847 6.86261 13.5556 6.41554 13.7399C5.96846 13.9242 5.48933 14.0183 5.00575 14.0167C4.52218 14.015 4.0437 13.9178 3.59786 13.7305C3.15203 13.5432 2.74764 13.2695 2.408 12.9253C1.74009 12.2338 1.37051 11.3076 1.37886 10.3462C1.38722 9.38479 1.77284 8.46514 2.45267 7.78531C3.13249 7.10548 4.05214 6.71986 5.01353 6.71151C5.97492 6.70315 6.90113 7.07273 7.59267 7.74065L7.59333 7.73998ZM7.59333 7.73998L10.3333 4.99998L7.59333 7.73998ZM10.3333 4.99998L12.3333 6.99998L14.6667 4.66665L12.6667 2.66665L10.3333 4.99998Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

5
packages/nc-gui/assets/nc-icons/maximize-all.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.33333 2H3.33333C2.97971 2 2.64057 2.14048 2.39052 2.39052C2.14048 2.64057 2 2.97971 2 3.33333V5.33333M14 5.33333V3.33333C14 2.97971 13.8595 2.64057 13.6095 2.39052C13.3594 2.14048 13.0203 2 12.6667 2H10.6667M10.6667 14H12.6667C13.0203 14 13.3594 13.8595 13.6095 13.6095C13.8595 13.3594 14 13.0203 14 12.6667V10.6667M2 10.6667V12.6667C2 13.0203 2.14048 13.3594 2.39052 13.6095C2.64057 13.8595 2.97971 14 3.33333 14H5.33333"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 652 B

14
packages/nc-gui/assets/nc-icons/maximize.svg

@ -1,6 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 14H2V10" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path d="M6 14H2V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M2 14L6.66667 9.33337" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path d="M2 14L6.66667 9.33331" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
<path d="M10 2H14V6" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> stroke-linejoin="round" />
<path d="M13.9999 2L9.33325 6.66667" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 2H14V6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg> <path d="M14 2L9.33331 6.66667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 620 B

5
packages/nc-gui/assets/nc-icons/minimize-all.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.33333 2V4C5.33333 4.35362 5.19286 4.69276 4.94281 4.94281C4.69276 5.19286 4.35362 5.33333 4 5.33333H2M14 5.33333H12C11.6464 5.33333 11.3072 5.19286 11.0572 4.94281C10.8071 4.69276 10.6667 4.35362 10.6667 4V2M10.6667 14V12C10.6667 11.6464 10.8071 11.3072 11.0572 11.0572C11.3072 10.8071 11.6464 10.6667 12 10.6667H14M2 10.6667H4C4.35362 10.6667 4.69276 10.8071 4.94281 11.0572C5.19286 11.3072 5.33333 11.6464 5.33333 12V14"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 652 B

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

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2.6665 9.3335H6.6665V13.3335" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M2 14.0002L6.66667 9.3335" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M13.3335 6.6665H9.3335V2.6665" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M9.3335 6.66667L14.0002 2" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 682 B

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

@ -249,9 +249,26 @@ a {
@apply !rounded-md; @apply !rounded-md;
} }
} }
.nc-select-shadow {
&.ant-select {
&:not(.ant-select-disabled):not(:hover):not(.ant-select-focused) .ant-select-selector,
&:not(.ant-select-disabled):hover.ant-select-disabled .ant-select-selector {
@apply shadow-default;
}
&:hover:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector {
@apply border-gray-300 shadow-hover;
}
&.ant-select-disabled .ant-select-selector {
box-shadow: none;
}
}
}
// select dropdown border style // select dropdown border style
.ant-select-dropdown { .ant-select-dropdown {
@apply border-1 border-gray-200; @apply border-1 border-gray-200 rounded-lg;
.rc-virtual-list-scrollbar { .rc-virtual-list-scrollbar {
@apply !w-1; @apply !w-1;

8
packages/nc-gui/components/cell/Checkbox.vue

@ -25,6 +25,8 @@ const isEditColumnMenu = inject(EditColumnInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false)) const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const readOnly = inject(ReadonlyInj) const readOnly = inject(ReadonlyInj)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false)) const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
@ -110,11 +112,11 @@ useSelectedCellKeyupListener(active, (e) => {
<div <div
class="flex items-center" class="flex items-center"
:class="{ :class="{
'w-full justify-start': isEditColumnMenu || isGallery || isForm, 'w-full justify-start': isEditColumnMenu || isGallery || isKanban || isForm,
'justify-center': !isEditColumnMenu && !isGallery && !isForm, 'justify-center': !isEditColumnMenu && !isGallery && !isKanban && !isForm,
'py-2': isEditColumnMenu, 'py-2': isEditColumnMenu,
}" }"
@click="onClick(true)" @click.stop="onClick(true)"
> >
<Transition name="layout" mode="out-in" :duration="100"> <Transition name="layout" mode="out-in" :duration="100">
<component <component

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

@ -108,7 +108,7 @@ onMounted(() => {
type="number" type="number"
class="nc-cell-field h-full border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0" class="nc-cell-field h-full border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'" :class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'"
:placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''" :placeholder="placeholder"
:disabled="readOnly" :disabled="readOnly"
@blur="onBlur" @blur="onBlur"
@keydown.enter="onKeydownEnter" @keydown.enter="onKeydownEnter"

18
packages/nc-gui/components/cell/DatePicker.vue

@ -144,11 +144,10 @@ watch(editable, (nextValue) => {
const placeholder = computed(() => { const placeholder = computed(() => {
if ( if (
((isForm.value || isExpandedForm.value) && !isDateInvalid.value) || ((isForm.value || isExpandedForm.value) && !isDateInvalid.value) ||
(isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(columnMeta.value) && active.value) (isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(columnMeta.value) && active.value) ||
isEditColumn.value
) { ) {
return dateFormat.value return dateFormat.value
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
} else if (modelValue === null && showNull.value) { } else if (modelValue === null && showNull.value) {
return t('general.null').toUpperCase() return t('general.null').toUpperCase()
} else if (isDateInvalid.value) { } else if (isDateInvalid.value) {
@ -300,8 +299,9 @@ function handleSelectDate(value?: dayjs.Dayjs) {
:overlay-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''} !min-w-[260px]`" :overlay-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''} !min-w-[260px]`"
> >
<div <div
v-bind="$attrs"
:title="localState?.format(dateFormat)" :title="localState?.format(dateFormat)"
class="nc-date-picker h-full flex items-center justify-between ant-picker-input relative group" class="nc-date-picker h-full flex items-center justify-between ant-picker-input relative"
> >
<input <input
ref="datePickerRef" ref="datePickerRef"
@ -320,9 +320,9 @@ function handleSelectDate(value?: dayjs.Dayjs) {
/> />
<GeneralIcon <GeneralIcon
v-if="localState" v-if="localState && !readOnly"
icon="closeCircle" icon="closeCircle"
class="absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer" class="nc-clear-date-icon nc-action-icon absolute right-0 top-[50%] transform -translate-y-1/2 invisible cursor-pointer"
@click.stop="handleSelectDate()" @click.stop="handleSelectDate()"
/> />
</div> </div>
@ -354,7 +354,9 @@ function handleSelectDate(value?: dayjs.Dayjs) {
</template> </template>
<style scoped> <style scoped>
:deep(.ant-picker-input > input) { .nc-cell-field {
@apply !text-current; &:hover .nc-clear-date-icon {
@apply visible;
}
} }
</style> </style>

113
packages/nc-gui/components/cell/DateTimePicker.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { dateFormats, isSystemColumn, isValidTimeFormat, timeFormats } from 'nocodb-sdk' import { dateFormats, isSystemColumn, timeFormats } from 'nocodb-sdk'
interface Props { interface Props {
modelValue?: string | null modelValue?: string | null
@ -9,8 +9,15 @@ interface Props {
} }
const { modelValue, isPk, isUpdatedFromCopyNPaste } = defineProps<Props>() const { modelValue, isPk, isUpdatedFromCopyNPaste } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const timeFormatsObj = {
[timeFormats[0]]: 'hh:mm A',
[timeFormats[1]]: 'hh:mm:ss A',
[timeFormats[2]]: 'hh:mm:ss.SSS A',
}
const { isMssql, isXcdbBase } = useBase() const { isMssql, isXcdbBase } = useBase()
const { showNull, isMobileMode } = useGlobal() const { showNull, isMobileMode } = useGlobal()
@ -183,11 +190,14 @@ watch(
const placeholder = computed(() => { const placeholder = computed(() => {
if ( if (
((isForm.value || isExpandedForm.value) && !isDateInvalid.value) || ((isForm.value || isExpandedForm.value) && !isDateInvalid.value) ||
(isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(column.value) && active.value) (isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(column.value) && active.value) ||
isEditColumn.value
) { ) {
return { dateTime: dateTimeFormat.value, date: dateFormat.value, time: timeFormat.value } return {
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) { dateTime: dateTimeFormat.value,
return t('labels.optional') date: dateFormat.value,
time: parseProp(column.value.meta).is12hrFormat ? `${timeFormat.value} AM` : timeFormat.value,
}
} else if (modelValue === null && showNull.value) { } else if (modelValue === null && showNull.value) {
return t('general.null').toUpperCase() return t('general.null').toUpperCase()
} else if (isDateInvalid.value) { } else if (isDateInvalid.value) {
@ -346,12 +356,21 @@ const handleUpdateValue = (e: Event, _isDatePicker: boolean) => {
return return
} }
if (timeFormat.value === 'HH:mm' && targetValue.length > 5) { targetValue = parseProp(column.value.meta).is12hrFormat
targetValue = targetValue.slice(0, 5) ? targetValue
} .trim()
.toUpperCase()
if (isValidTimeFormat(targetValue, timeFormat.value)) { .replace(/(AM|PM)$/, ' $1')
tempDate.value = dayjs(`${(tempDate.value ?? dayjs()).format('YYYY-MM-DD')} ${targetValue}`) .replace(/\s+/g, ' ')
: targetValue.trim()
const parsedDate = dayjs(
targetValue,
parseProp(column.value.meta).is12hrFormat ? timeFormatsObj[timeFormat.value] : timeFormat.value,
)
if (parsedDate.isValid()) {
tempDate.value = dayjs(`${(tempDate.value ?? dayjs()).format('YYYY-MM-DD')} ${parsedDate.format(timeFormat.value)}`)
} }
} }
} }
@ -384,35 +403,32 @@ function handleSelectTime(value: dayjs.Dayjs) {
open.value = false open.value = false
} }
const selectedTime = computed(() => {
const result = {
value: '',
label: '',
}
if (localState.value) {
const time = localState.value.format(timeFormat.value)
const [hours, minutes] = time.split(':')
result.value = `${hours}:${minutes}`
result.label = time
}
return result
})
const timeCellMaxWidth = computed(() => { const timeCellMaxWidth = computed(() => {
return { return {
[timeFormats[0]]: 'max-w-[65px]', [timeFormats[0]]: {
[timeFormats[1]]: 'max-w-[80px]', 12: 'max-w-[85px]',
[timeFormats[2]]: 'max-w-[110px]', 24: 'max-w-[65px]',
}[timeFormat.value] },
[timeFormats[1]]: {
12: 'max-w-[100px]',
24: 'max-w-[80px]',
},
[timeFormats[2]]: {
12: 'max-w-[130px]',
24: 'max-w-[110px]',
},
}[timeFormat.value][parseProp(column.value.meta).is12hrFormat ? 12 : 24]
}) })
const cellValue = computed(
() =>
localState.value?.format(parseProp(column.value.meta).is12hrFormat ? timeFormatsObj[timeFormat.value] : timeFormat.value) ??
'',
)
</script> </script>
<template> <template>
<div class="nc-cell-field group relative"> <div v-bind="$attrs" class="nc-cell-field relative">
<NcDropdown <NcDropdown
:visible="isOpen" :visible="isOpen"
:placement="isDatePicker ? 'bottomLeft' : 'bottomRight'" :placement="isDatePicker ? 'bottomLeft' : 'bottomRight'"
@ -424,14 +440,18 @@ const timeCellMaxWidth = computed(() => {
> >
<div <div
:title="localState?.format(dateTimeFormat)" :title="localState?.format(dateTimeFormat)"
class="nc-date-picker ant-picker-input flex justify-between gap-2 relative group !w-auto" class="nc-date-picker ant-picker-input flex relative !w-auto gap-2"
:class="{
'justify-between': !isColDisabled,
}"
> >
<div <div
class="flex-none hover:bg-gray-100 px-1 rounded-md box-border w-[60%] max-w-[110px]" class="flex-none rounded-md box-border w-[60%] max-w-[110px]"
:class="{ :class="{
'py-0': isForm, 'py-0': isForm,
'py-0.5': !isForm, 'py-0.5': !isForm && !isColDisabled,
'bg-gray-100': isDatePicker && isOpen, 'bg-gray-100': isDatePicker && isOpen,
'hover:bg-gray-100 px-1': !isColDisabled,
}" }"
> >
<input <input
@ -449,19 +469,20 @@ const timeCellMaxWidth = computed(() => {
/> />
</div> </div>
<div <div
class="flex-none hover:bg-gray-100 px-1 rounded-md box-border flex-1" class="flex-none rounded-md box-border flex-1"
:class="[ :class="[
`${timeCellMaxWidth}`, `${timeCellMaxWidth}`,
{ {
'py-0': isForm, 'py-0': isForm,
'py-0.5': !isForm, 'py-0.5': !isForm && !isColDisabled,
'bg-gray-100': !isDatePicker && isOpen, 'bg-gray-100': !isDatePicker && isOpen,
'hover:bg-gray-100 px-1': !isColDisabled,
}, },
]" ]"
> >
<input <input
ref="timePickerRef" ref="timePickerRef"
:value="selectedTime.value ? `${selectedTime.label}` : ''" :value="cellValue"
:placeholder="typeof placeholder === 'string' ? placeholder : placeholder?.time" :placeholder="typeof placeholder === 'string' ? placeholder : placeholder?.time"
class="nc-time-input w-full !truncate border-transparent outline-none !text-current !bg-transparent !focus:(border-none outline-none ring-transparent)" class="nc-time-input w-full !truncate border-transparent outline-none !text-current !bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="!!isMobileMode || isColDisabled" :readonly="!!isMobileMode || isColDisabled"
@ -497,6 +518,7 @@ const timeCellMaxWidth = computed(() => {
:selected-date="localState" :selected-date="localState"
:min-granularity="30" :min-granularity="30"
is-min-granularity-picker is-min-granularity-picker
:is12hr-format="!!parseProp(column.meta).is12hrFormat"
:is-open="isOpen" :is-open="isOpen"
@update:selected-date="handleSelectTime" @update:selected-date="handleSelectTime"
/> />
@ -506,17 +528,20 @@ const timeCellMaxWidth = computed(() => {
</NcDropdown> </NcDropdown>
<GeneralIcon <GeneralIcon
v-if="localState && (isExpandedForm || isForm || !isGrid)" v-if="localState && (isExpandedForm || isForm || !isGrid || isEditColumn) && !readOnly"
icon="closeCircle" icon="closeCircle"
class="h-4 w-4 absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer" class="nc-clear-date-time-icon nc-action-icon h-4 w-4 absolute right-0 top-[50%] transform -translate-y-1/2 invisible cursor-pointer"
@click.stop="handleSelectDate()" @click.stop="handleSelectDate()"
/> />
</div> </div>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div> <div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template> </template>
<style scoped> <style scoped>
:deep(.ant-picker-input > input) { .nc-cell-field {
@apply !text-current; &:hover .nc-clear-date-time-icon {
@apply visible;
}
} }
</style> </style>

4
packages/nc-gui/components/cell/Decimal.vue

@ -40,6 +40,8 @@ const displayValue = computed(() => {
if (isNaN(Number(_vModel.value))) return null if (isNaN(Number(_vModel.value))) return null
if (meta.value.isLocaleString) return (+Number(_vModel.value).toFixed(meta.value.precision ?? 1)).toLocaleString()
return Number(_vModel.value).toFixed(meta.value.precision ?? 1) return Number(_vModel.value).toFixed(meta.value.precision ?? 1)
}) })
@ -102,7 +104,7 @@ watch(isExpandedFormOpen, () => {
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full" class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full"
type="number" type="number"
:step="precision" :step="precision"
:placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''" :placeholder="placeholder"
style="letter-spacing: 0.06rem" style="letter-spacing: 0.06rem"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop="onKeyDown" @keydown.down.stop="onKeyDown"

6
packages/nc-gui/components/cell/Duration.vue

@ -10,8 +10,6 @@ const { modelValue, showValidationError = true } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { showNull } = useGlobal() const { showNull } = useGlobal()
const column = inject(ColumnInj) const column = inject(ColumnInj)
@ -30,9 +28,7 @@ const isEdited = ref(false)
const durationType = computed(() => parseProp(column?.value?.meta)?.duration || 0) const durationType = computed(() => parseProp(column?.value?.meta)?.duration || 0)
const durationPlaceholder = computed(() => const durationPlaceholder = computed(() => durationOptions[durationType.value].title)
isEditColumn.value ? `(${t('labels.optional')})` : durationOptions[durationType.value].title,
)
const localState = computed({ const localState = computed({
get: () => convertMS2Duration(modelValue, durationType.value), get: () => convertMS2Duration(modelValue, durationType.value),

3
packages/nc-gui/components/cell/Email.vue

@ -81,7 +81,6 @@ watch(
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
class="nc-cell-field w-full outline-none py-1" class="nc-cell-field w-full outline-none py-1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@keydown.left.stop @keydown.left.stop
@ -98,7 +97,7 @@ watch(
<nuxt-link <nuxt-link
v-else-if="validEmail" v-else-if="validEmail"
no-ref no-ref
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link max-w-full" class="py-1 underline inline-block nc-cell-field-link max-w-full"
:href="`mailto:${vModel}`" :href="`mailto:${vModel}`"
target="_blank" target="_blank"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

1
packages/nc-gui/components/cell/Float.vue

@ -53,7 +53,6 @@ const focus: VNodeRef = (el) =>
class="nc-cell-field outline-none px-1 border-none w-full h-full" class="nc-cell-field outline-none px-1 border-none w-full h-full"
type="number" type="number"
step="0.1" step="0.1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@keydown.left.stop @keydown.left.stop

35
packages/nc-gui/components/cell/GeoData.vue

@ -151,25 +151,33 @@ const openInOSM = () => {
v-if="isLoading" v-if="isLoading"
:class="{ 'animate-infinite animate-spin text-gray-500': isLoading }" :class="{ 'animate-infinite animate-spin text-gray-500': isLoading }"
/> />
<a-button class="ml-2" @click="onClickSetCurrentLocation" <a-button class="ml-2 !rounded-lg" @click="onClickSetCurrentLocation">
><component :is="iconMap.currentLocation" class="mr-2" />{{ $t('labels.currentLocation') }}</a-button <div class="flex items-center gap-1">
> <component :is="iconMap.currentLocation" />{{ $t('labels.currentLocation') }}
</div>
</a-button>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item v-if="vModel"> <a-form-item v-if="vModel">
<div class="mr-2 flex flex-row items-end gap-1 text-left"> <div class="mr-2 flex flex-row items-end gap-1 text-left">
<a-button @click="openInOSM" <a-button class="!rounded-lg" @click="openInOSM">
><component :is="iconMap.openInNew" class="mr-2" />{{ $t('activity.map.openInOpenStreetMap') }}</a-button <div class="flex items-center gap-1">
> <component :is="iconMap.openInNew" />{{ $t('activity.map.openInOpenStreetMap') }}
<a-button @click="openInGoogleMaps" </div>
><component :is="iconMap.openInNew" class="mr-2" />{{ $t('activity.map.openInGoogleMaps') }}</a-button </a-button>
> <a-button class="!rounded-lg" @click="openInGoogleMaps">
<div class="flex items-center gap-1">
<component :is="iconMap.openInNew" />{{ $t('activity.map.openInGoogleMaps') }}
</div>
</a-button>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<div class="ml-auto mr-2 w-auto"> <div class="ml-auto mr-2 w-auto">
<a-button type="text" @click="clear">{{ $t('general.cancel') }}</a-button> <a-button type="text" class="!rounded-lg" @click="clear">{{ $t('general.cancel') }}</a-button>
<a-button type="primary" html-type="submit" data-testid="nc-geo-data-save">{{ $t('general.submit') }}</a-button> <a-button type="primary" html-type="submit" data-testid="nc-geo-data-save" class="!rounded-lg">{{
$t('general.submit')
}}</a-button>
</div> </div>
</a-form-item> </a-form-item>
</a-form> </a-form>
@ -179,7 +187,10 @@ const openInOSM = () => {
<style scoped lang="scss"> <style scoped lang="scss">
input[type='number']:focus { input[type='number']:focus {
@apply ring-transparent; @apply ring-transparent shadow-selected;
}
input[type='number'] {
@apply !border-1 !pr-1 rounded-lg;
} }
input[type='number'] { input[type='number'] {

5
packages/nc-gui/components/cell/Integer.vue

@ -28,6 +28,8 @@ const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)! const isForm = inject(IsFormInj)!
const column = inject(ColumnInj, null)!
const _vModel = useVModel(props, 'modelValue', emits) const _vModel = useVModel(props, 'modelValue', emits)
const displayValue = computed(() => { const displayValue = computed(() => {
@ -35,6 +37,8 @@ const displayValue = computed(() => {
if (isNaN(Number(_vModel.value))) return null if (isNaN(Number(_vModel.value))) return null
if (parseProp(column.value.meta).isLocaleString) return Number(_vModel.value).toLocaleString()
return Number(_vModel.value) return Number(_vModel.value)
}) })
@ -96,7 +100,6 @@ function onKeyDown(e: any) {
class="nc-cell-field outline-none py-1 border-none w-full h-full" class="nc-cell-field outline-none py-1 border-none w-full h-full"
:type="inputType" :type="inputType"
style="letter-spacing: 0.06rem" style="letter-spacing: 0.06rem"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown="onKeyDown" @keydown="onKeyDown"
@keydown.down.stop @keydown.down.stop

50
packages/nc-gui/components/cell/Json.vue

@ -19,6 +19,8 @@ const editEnabled = inject(EditModeInj, ref(false))
const active = inject(ActiveCellInj, ref(false)) const active = inject(ActiveCellInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
@ -133,6 +135,27 @@ onClickOutside(inputWrapperRef, (e) => {
watch(isExpanded, () => { watch(isExpanded, () => {
_isExpanded.value = isExpanded.value _isExpanded.value = isExpanded.value
}) })
const stopPropagation = (event: MouseEvent) => {
event.stopPropagation()
}
watch(inputWrapperRef, () => {
if (!isEditColumn.value) return
// stop event propogation in edit to prevent close edit modal on clicking expanded modal overlay
const modal = document.querySelector('.nc-json-expanded-modal') as HTMLElement
if (isExpanded.value && modal?.parentElement) {
modal.parentElement.addEventListener('click', stopPropagation)
modal.parentElement.addEventListener('mousedown', stopPropagation)
modal.parentElement.addEventListener('mouseup', stopPropagation)
} else if (modal?.parentElement) {
modal.parentElement.removeEventListener('click', stopPropagation)
modal.parentElement.removeEventListener('mousedown', stopPropagation)
modal.parentElement.removeEventListener('mouseup', stopPropagation)
}
})
</script> </script>
<template> <template>
@ -142,7 +165,7 @@ watch(isExpanded, () => {
:closable="false" :closable="false"
centered centered
:footer="null" :footer="null"
:wrap-class-name="isExpanded ? '!z-1051' : null" :wrap-class-name="isExpanded ? '!z-1051 nc-json-expanded-modal' : null"
> >
<div v-if="editEnabled && !readOnly" class="flex flex-col w-full" @mousedown.stop @mouseup.stop @click.stop> <div v-if="editEnabled && !readOnly" class="flex flex-col w-full" @mousedown.stop @mouseup.stop @click.stop>
<div class="flex flex-row justify-between pt-1 pb-2 nc-json-action" @mousedown.stop> <div class="flex flex-row justify-between pt-1 pb-2 nc-json-action" @mousedown.stop>
@ -152,12 +175,21 @@ watch(isExpanded, () => {
<CilFullscreen v-else class="h-2.5" /> <CilFullscreen v-else class="h-2.5" />
</a-button> </a-button>
<div v-if="!isForm || isExpanded" class="flex flex-row my-1"> <div v-if="!isForm || isExpanded" class="flex flex-row my-1 space-x-1">
<a-button type="text" size="small" :onclick="clear" <a-button type="text" size="small" class="!rounded-lg" @click="clear"
><div class="text-xs">{{ $t('general.cancel') }}</div></a-button ><div class="text-xs">{{ $t('general.cancel') }}</div></a-button
> >
<a-button type="primary" size="small" :disabled="!!error || localValue === vModel" @click="onSave"> <a-button
:type="!isExpanded ? 'text' : 'primary'"
size="small"
class="nc-save-json-value-btn !rounded-lg"
:class="{
'nc-edit-modal': !isExpanded,
}"
:disabled="!!error || localValue === vModel"
@click="onSave"
>
<div class="text-xs">{{ $t('general.save') }}</div> <div class="text-xs">{{ $t('general.save') }}</div>
</a-button> </a-button>
</div> </div>
@ -170,7 +202,7 @@ watch(isExpanded, () => {
:class="{ 'expanded-editor': isExpanded, 'editor': !isExpanded }" :class="{ 'expanded-editor': isExpanded, 'editor': !isExpanded }"
:hide-minimap="true" :hide-minimap="true"
:disable-deep-compare="true" :disable-deep-compare="true"
:auto-focus="!isForm" :auto-focus="!isForm && !isEditColumn"
@update:model-value="localValue = $event" @update:model-value="localValue = $event"
@keydown.enter.stop @keydown.enter.stop
/> />
@ -182,7 +214,7 @@ watch(isExpanded, () => {
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span> <span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" class="nc-cell-field" /> <LazyCellClampedText v-else :value="vModel ? stringifyProp(vModel) : ''" :lines="rowHeight" class="nc-cell-field" />
</component> </component>
</template> </template>
@ -194,4 +226,10 @@ watch(isExpanded, () => {
.editor { .editor {
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
} }
.nc-save-json-value-btn {
&.nc-edit-modal:not(:disabled) {
@apply !text-brand-500 !hover:text-brand-600;
}
}
</style> </style>

1
packages/nc-gui/components/cell/MultiSelect.vue

@ -469,7 +469,6 @@ const onFocus = () => {
v-model:value="vModel" v-model:value="vModel"
mode="multiple" mode="multiple"
class="w-full overflow-hidden" class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false" :bordered="false"
clear-icon clear-icon
:show-search="!isMobileMode" :show-search="!isMobileMode"

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

@ -145,7 +145,7 @@ const onTabPress = (e: KeyboardEvent) => {
v-model="vModel" v-model="vModel"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1" class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1"
:type="inputType" :type="inputType"
:placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''" :placeholder="placeholder"
@blur="onBlur" @blur="onBlur"
@focus="onFocus" @focus="onFocus"
@keydown.down.stop @keydown.down.stop

3
packages/nc-gui/components/cell/PhoneNumber.vue

@ -65,7 +65,6 @@ watch(
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
class="nc-cell-field w-full outline-none py-1" class="nc-cell-field w-full outline-none py-1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@keydown.left.stop @keydown.left.stop
@ -80,7 +79,7 @@ watch(
<a <a
v-else-if="validPhoneNumber" v-else-if="validPhoneNumber"
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link" class="py-1 underline inline-block nc-cell-field-link"
:href="`tel:${vModel}`" :href="`tel:${vModel}`"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"

4
packages/nc-gui/components/cell/ReadOnlyUser.vue

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserFieldRecordType } from 'nocodb-sdk'
interface Props { interface Props {
modelValue?: string | null modelValue?: UserFieldRecordType[] | UserFieldRecordType | string | null
} }
defineProps<Props>() defineProps<Props>()

31
packages/nc-gui/components/cell/RichText.vue

@ -40,16 +40,28 @@ const rowHeight = inject(RowHeightInj, ref(1 as const))
const readOnlyCell = inject(ReadonlyInj, ref(false)) const readOnlyCell = inject(ReadonlyInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false)) const isGrid = inject(IsGridInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false)) const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const isFocused = ref(false) const isFocused = ref(false)
const keys = useMagicKeys() const keys = useMagicKeys()
const localRowHeight = computed(() => {
if (readOnlyCell.value && !isExpandedFormOpen.value && (isGallery.value || isKanban.value)) return 6
return rowHeight.value
})
const shouldShowLinkOption = computed(() => { const shouldShowLinkOption = computed(() => {
return isFormField.value ? isFocused.value : true return isFormField.value ? isFocused.value : true
}) })
@ -155,7 +167,7 @@ const editor = useEditor({
.turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />')) .turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />'))
.replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n') .replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n')
vModel.value = isFormField.value && markdown === '<br />' ? '' : markdown vModel.value = markdown === '<br />' ? '' : markdown
}, },
editable: !props.readOnly, editable: !props.readOnly,
autofocus: props.autofocus, autofocus: props.autofocus,
@ -220,7 +232,7 @@ if (isFormField.value) {
} }
onMounted(() => { onMounted(() => {
if (fullMode.value || isFormField.value || isForm.value) { if (fullMode.value || isFormField.value || isForm.value || isEditColumn.value) {
setEditorContent(vModel.value, true) setEditorContent(vModel.value, true)
if (fullMode.value || isSurveyForm.value) { if (fullMode.value || isSurveyForm.value) {
@ -320,8 +332,8 @@ onClickOutside(editorDom, (e) => {
'mt-2.5 flex-grow': fullMode, 'mt-2.5 flex-grow': fullMode,
'scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent': !fullMode || (!fullMode && isExpandedFormOpen), 'scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent': !fullMode || (!fullMode && isExpandedFormOpen),
'flex-grow': isExpandedFormOpen, 'flex-grow': isExpandedFormOpen,
[`!overflow-hidden nc-truncate nc-line-clamp-${rowHeightTruncateLines(rowHeight)}`]: [`!overflow-hidden nc-truncate nc-line-clamp-${rowHeightTruncateLines(localRowHeight)}`]:
!fullMode && readOnly && rowHeight && !isExpandedFormOpen && !isForm, !fullMode && readOnly && localRowHeight && !isExpandedFormOpen && !isForm,
}" }"
@keydown.alt.enter.stop @keydown.alt.enter.stop
@keydown.shift.enter.stop @keydown.shift.enter.stop
@ -393,6 +405,17 @@ onClickOutside(editorDom, (e) => {
} }
} }
} }
&.allow-vertical-resize:not(.readonly) {
.ProseMirror {
@apply nc-scrollbar-thin;
overflow-y: auto;
overflow-x: hidden;
resize: vertical;
min-width: 100%;
max-height: min(800px, calc(100vh - 200px)) !important;
}
}
} }
.nc-rich-text-full { .nc-rich-text-full {

12
packages/nc-gui/components/cell/RichText/LinkOptions.vue

@ -11,6 +11,7 @@ const emits = defineEmits(['blur'])
interface Props { interface Props {
editor: Editor editor: Editor
isFormField?: boolean isFormField?: boolean
isComment?: boolean
} }
const { editor, isFormField } = toRefs(props) const { editor, isFormField } = toRefs(props)
@ -164,6 +165,9 @@ const openLink = () => {
const onMountLinkOptions = (e) => { const onMountLinkOptions = (e) => {
if (e?.popper?.style) { if (e?.popper?.style) {
if (props.isComment) {
e.popper.style.left = '-10%'
}
e.popper.style.width = '95%' e.popper.style.width = '95%'
} }
} }
@ -233,14 +237,6 @@ const tabIndex = computed(() => {
<MdiDeleteOutline /> <MdiDeleteOutline />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex flex-row justify-center">
<div
class="flex h-2.5 w-2.5 bg-white border-gray-200 border-r-1 border-b-1 transform rotate-45"
:style="{
boxShadow: '1px 1px 3px rgba(231, 231, 233, 1)',
}"
></div>
</div>
</div> </div>
</div> </div>
</BubbleMenu> </BubbleMenu>

10
packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue

@ -27,6 +27,8 @@ const props = withDefaults(defineProps<Props>(), {
const { editor, embedMode, isFormField, hiddenOptions } = toRefs(props) const { editor, embedMode, isFormField, hiddenOptions } = toRefs(props)
const isEditColumn = inject(EditColumnInj, ref(false))
const cmdOrCtrlKey = computed(() => { const cmdOrCtrlKey = computed(() => {
return isMac() ? '⌘' : 'CTRL' return isMac() ? '⌘' : 'CTRL'
}) })
@ -108,6 +110,7 @@ const showDivider = (options: RichTextBubbleMenuOptions[]) => {
'flex bg-gray-100 px-1 py-1': !isFormField, 'flex bg-gray-100 px-1 py-1': !isFormField,
'embed-mode': embedMode, 'embed-mode': embedMode,
'full-mode': !embedMode, 'full-mode': !embedMode,
'edit-column-mode': isEditColumn,
}" }"
> >
<NcTooltip :placement="tooltipPlacement" :disabled="editor.isActive('codeBlock')"> <NcTooltip :placement="tooltipPlacement" :disabled="editor.isActive('codeBlock')">
@ -172,7 +175,7 @@ const showDivider = (options: RichTextBubbleMenuOptions[]) => {
<MdiFormatUnderline /> <MdiFormatUnderline />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<NcTooltip :placement="tooltipPlacement" :disabled="editor.isActive('codeBlock')"> <NcTooltip v-if="embedMode && !isEditColumn" :placement="tooltipPlacement" :disabled="editor.isActive('codeBlock')">
<template #title> <template #title>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div> <div>
@ -283,7 +286,10 @@ const showDivider = (options: RichTextBubbleMenuOptions[]) => {
<div class="divider"></div> <div class="divider"></div>
</template> </template>
<NcTooltip v-if="embedMode && isOptionVisible(RichTextBubbleMenuOptions.blockQuote)" :placement="tooltipPlacement"> <NcTooltip
v-if="embedMode && !isEditColumn && isOptionVisible(RichTextBubbleMenuOptions.blockQuote)"
:placement="tooltipPlacement"
>
<template #title> {{ $t('labels.blockQuote') }}</template> <template #title> {{ $t('labels.blockQuote') }}</template>
<NcButton <NcButton
size="small" size="small"

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

@ -385,9 +385,8 @@ const onFocus = () => {
v-else v-else
ref="aselect" ref="aselect"
v-model:value="vModel" v-model:value="vModel"
class="w-full overflow-hidden xs:min-h-12" class="w-full overflow-hidden"
:class="{ 'caret-transparent': !hasEditRoles }" :class="{ 'caret-transparent': !hasEditRoles }"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:allow-clear="!column.rqd && editAllowed" :allow-clear="!column.rqd && editAllowed"
:bordered="false" :bordered="false"
:open="isOpen && editAllowed" :open="isOpen && editAllowed"

1
packages/nc-gui/components/cell/Text.vue

@ -35,7 +35,6 @@ const focus: VNodeRef = (el) =>
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
class="nc-cell-field h-full w-full outline-none py-1 bg-transparent" class="nc-cell-field h-full w-full outline-none py-1 bg-transparent"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@keydown.left.stop @keydown.left.stop

31
packages/nc-gui/components/cell/TextArea.vue

@ -21,6 +21,10 @@ const isForm = inject(IsFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false)) const isGrid = inject(IsGridInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
const { showNull } = useGlobal() const { showNull } = useGlobal()
@ -60,6 +64,12 @@ const height = computed(() => {
return rowHeight.value * 36 return rowHeight.value * 36
}) })
const localRowHeight = computed(() => {
if (readOnly.value && !isExpandedFormOpen.value && (isGallery.value || isKanban.value)) return 6
return rowHeight.value
})
const isVisible = ref(false) const isVisible = ref(false)
const inputWrapperRef = ref<HTMLElement | null>(null) const inputWrapperRef = ref<HTMLElement | null>(null)
@ -199,16 +209,15 @@ watch(inputWrapperRef, () => {
> >
<div v-if="isForm && isRichMode" class="w-full"> <div v-if="isForm && isRichMode" class="w-full">
<div <div
class="w-full relative w-full px-0 pb-1" class="w-full relative w-full px-0"
:class="{ :class="{
'pt-11': !readOnly, 'pt-11': !readOnly,
}" }"
> >
<LazyCellRichText <LazyCellRichText
v-model:value="vModel" v-model:value="vModel"
class="!max-h-50"
:class="{ :class="{
'border-t-1 border-gray-100': !readOnly, 'border-t-1 border-gray-100 allow-vertical-resize': !readOnly,
}" }"
:autofocus="false" :autofocus="false"
show-menu show-menu
@ -222,11 +231,11 @@ watch(inputWrapperRef, () => {
class="w-full cursor-pointer nc-readonly-rich-text-wrapper" class="w-full cursor-pointer nc-readonly-rich-text-wrapper"
:class="{ :class="{
'nc-readonly-rich-text-grid ': !isExpandedFormOpen && !isForm, 'nc-readonly-rich-text-grid ': !isExpandedFormOpen && !isForm,
'nc-readonly-rich-text-sort-height': rowHeight === 1 && !isExpandedFormOpen && !isForm, 'nc-readonly-rich-text-sort-height': localRowHeight === 1 && !isExpandedFormOpen && !isForm,
}" }"
:style="{ :style="{
maxHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${21 * rowHeightTruncateLines(rowHeight)}px`, maxHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${21 * rowHeightTruncateLines(localRowHeight)}px`,
minHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${21 * rowHeightTruncateLines(rowHeight)}px`, minHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${21 * rowHeightTruncateLines(localRowHeight)}px`,
}" }"
@dblclick="onExpand" @dblclick="onExpand"
@keydown.enter="onExpand" @keydown.enter="onExpand"
@ -246,8 +255,8 @@ watch(inputWrapperRef, () => {
}" }"
:style="{ :style="{
minHeight: isForm ? '117px' : `${height}px`, minHeight: isForm ? '117px' : `${height}px`,
maxHeight: 'min(800px, calc(100vh - 200px))',
}" }"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:disabled="readOnly" :disabled="readOnly"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.alt.enter.stop @keydown.alt.enter.stop
@ -266,12 +275,12 @@ watch(inputWrapperRef, () => {
<LazyCellClampedText <LazyCellClampedText
v-else-if="rowHeight" v-else-if="rowHeight"
:value="vModel" :value="vModel"
:lines="rowHeightTruncateLines(rowHeight)" :lines="rowHeightTruncateLines(localRowHeight)"
class="nc-text-area-clamped-text" class="nc-text-area-clamped-text"
:style="{ :style="{
'word-break': 'break-word', 'word-break': 'break-word',
'max-height': `${25 * rowHeightTruncateLines(rowHeight)}px`, 'max-height': `${25 * rowHeightTruncateLines(localRowHeight)}px`,
'my-auto': rowHeightTruncateLines(rowHeight) === 1, 'my-auto': rowHeightTruncateLines(localRowHeight) === 1,
}" }"
@click="onTextClick" @click="onTextClick"
/> />
@ -281,7 +290,7 @@ watch(inputWrapperRef, () => {
<NcTooltip <NcTooltip
v-if="!isVisible && !isForm" v-if="!isVisible && !isForm"
placement="bottom" placement="bottom"
class="!absolute !hidden nc-text-area-expand-btn group-hover:block z-3" class="nc-action-icon !absolute !hidden nc-text-area-expand-btn group-hover:block z-3"
:class="{ :class="{
'right-1': isForm, 'right-1': isForm,
'right-0': !isForm, 'right-0': !isForm,

53
packages/nc-gui/components/cell/TimePicker.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { isSystemColumn, isValidTimeFormat } from 'nocodb-sdk' import { isSystemColumn } from 'nocodb-sdk'
interface Props { interface Props {
modelValue?: string | null | undefined modelValue?: string | null | undefined
@ -140,11 +140,10 @@ watch(editable, (nextValue) => {
const placeholder = computed(() => { const placeholder = computed(() => {
if ( if (
((isForm.value || isExpandedForm.value) && !isTimeInvalid.value) || ((isForm.value || isExpandedForm.value) && !isTimeInvalid.value) ||
(isGrid.value && !showNull.value && !isTimeInvalid.value && !isSystemColumn(column.value) && active.value) (isGrid.value && !showNull.value && !isTimeInvalid.value && !isSystemColumn(column.value) && active.value) ||
isEditColumn.value
) { ) {
return 'HH:mm' return parseProp(column.value.meta).is12hrFormat ? 'hh:mm AM' : 'HH:mm'
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
} else if (modelValue === null && showNull.value) { } else if (modelValue === null && showNull.value) {
return t('general.null').toUpperCase() return t('general.null').toUpperCase()
} else if (isTimeInvalid.value) { } else if (isTimeInvalid.value) {
@ -212,6 +211,12 @@ const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
default: default:
if (!_open && /^[0-9a-z]$/i.test(e.key)) { if (!_open && /^[0-9a-z]$/i.test(e.key)) {
open.value = true open.value = true
const targetEl = e.target as HTMLInputElement
const value = targetEl.value
nextTick(() => {
targetEl.value = value
})
} }
} }
} }
@ -256,12 +261,18 @@ const handleUpdateValue = (e: Event) => {
return return
} }
if (targetValue.length > 5) { targetValue = parseProp(column.value.meta).is12hrFormat
targetValue = targetValue.slice(0, 5) ? targetValue
} .trim()
.toUpperCase()
.replace(/(AM|PM)$/, ' $1')
.replace(/\s+/g, ' ')
: targetValue.trim()
if (isValidTimeFormat(targetValue, 'HH:mm')) { const parsedDate = dayjs(targetValue, parseProp(column.value.meta).is12hrFormat ? 'hh:mm A' : 'HH:mm')
tempDate.value = dayjs(`${dayjs().format('YYYY-MM-DD')} ${targetValue}`)
if (parsedDate.isValid()) {
tempDate.value = dayjs(`${dayjs().format('YYYY-MM-DD')} ${parsedDate.format('HH:mm')}`)
} }
} }
@ -284,6 +295,8 @@ function handleSelectTime(value?: dayjs.Dayjs) {
open.value = false open.value = false
} }
const cellValue = computed(() => localState.value?.format(parseProp(column.value.meta).is12hrFormat ? 'hh:mm A' : 'HH:mm') ?? '')
</script> </script>
<template> <template>
@ -296,13 +309,14 @@ function handleSelectTime(value?: dayjs.Dayjs) {
:overlay-class-name="`${randomClass} nc-picker-time ${isOpen ? 'active' : ''} !min-w-[0]`" :overlay-class-name="`${randomClass} nc-picker-time ${isOpen ? 'active' : ''} !min-w-[0]`"
> >
<div <div
v-bind="$attrs"
:title="localState?.format('HH:mm')" :title="localState?.format('HH:mm')"
class="nc-time-picker h-full flex items-center justify-between ant-picker-input relative group" class="nc-time-picker h-full flex items-center justify-between ant-picker-input relative"
> >
<input <input
ref="datePickerRef" ref="datePickerRef"
type="text" type="text"
:value="localState?.format('HH:mm') ?? ''" :value="cellValue"
:placeholder="placeholder" :placeholder="placeholder"
class="nc-time-input border-none outline-none !text-current bg-transparent !focus:(border-none outline-none ring-transparent)" class="nc-time-input border-none outline-none !text-current bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="readOnly || !!isMobileMode" :readonly="readOnly || !!isMobileMode"
@ -316,19 +330,20 @@ function handleSelectTime(value?: dayjs.Dayjs) {
/> />
<GeneralIcon <GeneralIcon
v-if="localState" v-if="localState && !readOnly"
icon="closeCircle" icon="closeCircle"
class="absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer" class="nc-clear-time-icon nc-action-icon absolute right-0 top-[50%] transform -translate-y-1/2 invisible cursor-pointer"
@click.stop="handleSelectTime()" @click.stop="handleSelectTime()"
/> />
</div> </div>
<template #overlay> <template #overlay>
<div class="w-[72px]"> <div class="min-w-[72px]">
<NcTimeSelector <NcTimeSelector
:selected-date="localState" :selected-date="localState"
:min-granularity="30" :min-granularity="30"
is-min-granularity-picker is-min-granularity-picker
:is12hr-format="!!parseProp(column.meta).is12hrFormat"
:is-open="isOpen" :is-open="isOpen"
@update:selected-date="handleSelectTime" @update:selected-date="handleSelectTime"
/> />
@ -337,3 +352,11 @@ function handleSelectTime(value?: dayjs.Dayjs) {
</NcDropdown> </NcDropdown>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div> <div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template> </template>
<style scoped>
.nc-cell-field {
&:hover .nc-clear-time-icon {
@apply visible;
}
}
</style>

5
packages/nc-gui/components/cell/Url.vue

@ -85,7 +85,6 @@ watch(
v-if="!readOnly && editEnabled" v-if="!readOnly && editEnabled"
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
:placeholder="isEditColumn ? $t('labels.enterDefaultUrlOptional') : ''"
class="nc-cell-field outline-none w-full py-1 bg-transparent h-full" class="nc-cell-field outline-none w-full py-1 bg-transparent h-full"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@ -103,7 +102,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay" v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel no-rel
class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link max-w-full" class="py-1 z-3 underline nc-cell-field-link max-w-full"
:to="url" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"
@ -115,7 +114,7 @@ watch(
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay" v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel no-rel
class="py-1 z-3 w-full h-full text-center !no-underline hover:opacity-75 nc-cell-field-link max-w-full" class="py-1 z-3 w-full h-full text-center !no-underline nc-cell-field-link max-w-full"
:to="url" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

18
packages/nc-gui/components/cell/YearPicker.vue

@ -127,11 +127,10 @@ watch(editable, (nextValue) => {
const placeholder = computed(() => { const placeholder = computed(() => {
if ( if (
((isForm.value || isExpandedForm.value) && !isYearInvalid.value) || ((isForm.value || isExpandedForm.value) && !isYearInvalid.value) ||
(isGrid.value && !showNull.value && !isYearInvalid.value && !isSystemColumn(column.value) && active.value) (isGrid.value && !showNull.value && !isYearInvalid.value && !isSystemColumn(column.value) && active.value) ||
isEditColumn.value
) { ) {
return 'YYYY' return 'YYYY'
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
} else if (modelValue === null && showNull.value) { } else if (modelValue === null && showNull.value) {
return t('general.null').toUpperCase() return t('general.null').toUpperCase()
} else if (isYearInvalid.value) { } else if (isYearInvalid.value) {
@ -266,8 +265,9 @@ function handleSelectDate(value?: dayjs.Dayjs) {
:overlay-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''} !min-w-[260px]`" :overlay-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''} !min-w-[260px]`"
> >
<div <div
v-bind="$attrs"
:title="localState?.format('YYYY')" :title="localState?.format('YYYY')"
class="nc-year-picker flex items-center justify-between ant-picker-input relative group" class="nc-year-picker flex items-center justify-between ant-picker-input relative"
> >
<input <input
ref="datePickerRef" ref="datePickerRef"
@ -285,9 +285,9 @@ function handleSelectDate(value?: dayjs.Dayjs) {
/> />
<GeneralIcon <GeneralIcon
v-if="localState" v-if="localState && !readOnly"
icon="closeCircle" icon="closeCircle"
class="absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer" class="nc-clear-year-icon nc-action-icon absolute right-0 top-[50%] transform -translate-y-1/2 invisible cursor-pointer"
@click.stop="handleSelectDate()" @click.stop="handleSelectDate()"
/> />
</div> </div>
@ -309,7 +309,9 @@ function handleSelectDate(value?: dayjs.Dayjs) {
</template> </template>
<style scoped> <style scoped>
:deep(.ant-picker-input > input) { .nc-cell-field {
@apply !text-current; &:hover .nc-clear-year-icon {
@apply visible;
}
} }
</style> </style>

2
packages/nc-gui/components/cell/attachment/Image.vue

@ -14,7 +14,7 @@ const onError = () => index.value++
<template> <template>
<LazyNuxtImg <LazyNuxtImg
v-if="index < props.srcs.length" v-if="index < props.srcs.length"
class="m-auto h-full max-h-full w-auto object-cover" class="m-auto h-full max-h-full w-auto nc-attachment-image object-cover"
:src="props.srcs[index]" :src="props.srcs[index]"
:alt="props?.alt || ''" :alt="props?.alt || ''"
placeholder placeholder

1
packages/nc-gui/components/cell/attachment/Modal.vue

@ -103,7 +103,6 @@ const handleFileDelete = (i: number) => {
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="readOnly" class="text-gray-400">[{{ $t('labels.readOnly') }}]</div>
{{ $t('labels.viewingAttachmentsOf') }} {{ $t('labels.viewingAttachmentsOf') }}
<div class="font-semibold underline">{{ column?.title }}</div> <div class="font-semibold underline">{{ column?.title }}</div>
</div> </div>

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

@ -157,7 +157,7 @@ const onExpand = () => {
const onImageClick = (item: any) => { const onImageClick = (item: any) => {
if (isMobileMode.value && !isExpandedForm.value) return if (isMobileMode.value && !isExpandedForm.value) return
if (!isMobileMode.value && (isGallery.value || (isKanban.value && !isExpandedForm.value))) return if (!isMobileMode.value && (isGallery.value || isKanban.value) && !isExpandedForm.value) return
selectedImage.value = item selectedImage.value = item
} }

222
packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue

@ -55,127 +55,139 @@ onMounted(() => {
<template> <template>
<div class="flex w-full flex-col py-0.9 px-1 border-gray-200 gap-y-1"> <div class="flex w-full flex-col py-0.9 px-1 border-gray-200 gap-y-1">
<NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64"> <div class="flex items-center pr-2 justify-between">
<div <NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64">
class="flex flex-row py-1 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-8" <div
data-testid="nc-sidebar-userinfo" class="flex flex-row py-1 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-8"
> data-testid="nc-sidebar-userinfo"
<GeneralUserIcon :email="user?.email" size="auto" :name="user?.display_name" /> >
<div class="flex truncate"> <GeneralUserIcon :email="user?.email" size="auto" :name="user?.display_name" />
{{ name ? name : user?.email }} <NcTooltip>
</div> <div class="flex max-w-32 truncate">
<GeneralIcon icon="chevronDown" class="flex-none !min-w-5 transform rotate-180 !text-gray-500" /> {{ name ? name : user?.email }}
</div>
<template #overlay>
<NcMenu data-testid="nc-sidebar-userinfo">
<NcMenuItem data-testid="nc-sidebar-user-logout" @click="logout">
<div v-e="['c:user:logout']" class="flex gap-2 items-center">
<GeneralLoader v-if="isLoggingOut" class="!ml-0.5 !mr-0.5 !max-h-4.5 !-mt-0.5" />
<GeneralIcon v-else icon="signout" class="menu-icon" />
<span class="menu-btn"> {{ $t('general.logout') }}</span>
</div> </div>
</NcMenuItem>
<NcDivider />
<a
v-e="['c:nocodb:discord']"
href="https://discord.gg/5RgZmkW"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="ncDiscord" />
<span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:reddit']"
href="https://www.reddit.com/r/NocoDB"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="ncReddit" />
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:twitter']"
href="https://twitter.com/nocodb"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper group">
<GeneralIcon class="social-icon text-gray-500 group-hover:text-gray-800" icon="ncTwitter" />
<span class="menu-btn"> {{ $t('labels.twitter') }} </span>
</NcMenuItem>
</a>
<template v-if="!appInfo.ee">
<NcDivider />
<a-popover key="language" class="lang-menu !py-1.5" placement="rightBottom">
<NcMenuItem>
<div v-e="['c:translate:open']" class="flex gap-2 items-center">
<GeneralIcon icon="translate" class="group-hover:text-black nc-language ml-0.25 menu-icon" />
{{ $t('labels.language') }}
<div class="flex items-center text-gray-400 text-xs">{{ $t('labels.community.communityTranslated') }}</div>
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</NcMenuItem>
<template #content> <template #title>
<div class="bg-white max-h-50vh scrollbar-thin-dull min-w-64 !overflow-auto"> <span>
<LazyGeneralLanguageMenu /> {{ name ? name : user?.email }}
</div> </span>
</template> </template>
</a-popover> </NcTooltip>
</template>
<template v-if="!isMobileMode"> <GeneralIcon icon="chevronDown" class="flex-none !min-w-5 transform rotate-180 !text-gray-500" />
</div>
<template #overlay>
<NcMenu data-testid="nc-sidebar-userinfo">
<NcMenuItem data-testid="nc-sidebar-user-logout" @click="logout">
<div v-e="['c:user:logout']" class="flex gap-2 items-center">
<GeneralLoader v-if="isLoggingOut" class="!ml-0.5 !mr-0.5 !max-h-4.5 !-mt-0.5" />
<GeneralIcon v-else icon="signout" class="menu-icon" />
<span class="menu-btn"> {{ $t('general.logout') }}</span>
</div>
</NcMenuItem>
<NcDivider /> <NcDivider />
<a <a
v-e="['c:nocodb:forum-open']" v-e="['c:nocodb:discord']"
href="https://community.nocodb.com" href="https://discord.gg/5RgZmkW"
target="_blank" target="_blank"
class="!underline-transparent" class="!underline-transparent"
rel="noopener" rel="noopener noreferrer"
> >
<NcMenuItem> <NcMenuItem class="social-icon-wrapper">
<GeneralIcon icon="ncHelp" class="menu-icon mt-0.5" /> <GeneralIcon class="social-icon" icon="ncDiscord" />
<span class="menu-btn"> {{ $t('title.forum') }} </span> <span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem> </NcMenuItem>
</a> </a>
<a <a
v-e="['c:nocodb:docs-open']" v-e="['c:nocodb:reddit']"
href="https://docs.nocodb.com" href="https://www.reddit.com/r/NocoDB"
target="_blank" target="_blank"
class="!underline-transparent" class="!underline-transparent"
rel="noopener" rel="noopener noreferrer"
> >
<NcMenuItem> <NcMenuItem class="social-icon-wrapper">
<GeneralIcon icon="file" class="menu-icon mt-0.5" /> <GeneralIcon class="social-icon" icon="ncReddit" />
<span class="menu-btn"> {{ $t('title.docs') }} </span> <span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
</NcMenuItem> </NcMenuItem>
</a> </a>
<a
<NcDivider /> v-e="['c:nocodb:twitter']"
href="https://twitter.com/nocodb"
<DashboardSidebarEEMenuOption v-if="isEeUI" /> target="_blank"
class="!underline-transparent"
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile"> rel="noopener noreferrer"
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem> >
</nuxt-link> <NcMenuItem class="social-icon-wrapper group">
</template> <GeneralIcon class="social-icon text-gray-500 group-hover:text-gray-800" icon="ncTwitter" />
</NcMenu> <span class="menu-btn"> {{ $t('labels.twitter') }} </span>
</template> </NcMenuItem>
</NcDropdown> </a>
<template v-if="!appInfo.ee">
<NcDivider />
<a-popover key="language" class="lang-menu !py-1.5" placement="rightBottom">
<NcMenuItem>
<div v-e="['c:translate:open']" class="flex gap-2 items-center">
<GeneralIcon icon="translate" class="group-hover:text-black nc-language ml-0.25 menu-icon" />
{{ $t('labels.language') }}
<div class="flex items-center text-gray-400 text-xs">{{ $t('labels.community.communityTranslated') }}</div>
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</NcMenuItem>
<template #content>
<div class="bg-white max-h-50vh scrollbar-thin-dull min-w-64 !overflow-auto">
<LazyGeneralLanguageMenu />
</div>
</template>
</a-popover>
</template>
<template v-if="!isMobileMode">
<NcDivider />
<a
v-e="['c:nocodb:forum-open']"
href="https://community.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="ncHelp" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.forum') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:docs-open']"
href="https://docs.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="file" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.docs') }} </span>
</NcMenuItem>
</a>
<NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link>
</template>
</NcMenu>
</template>
</NcDropdown>
<LazyNotificationMenu />
</div>
<template v-if="isMobileMode || appInfo.ee"></template> <template v-if="isMobileMode || appInfo.ee"></template>
<div v-else class="flex flex-row w-full justify-between pt-0.5 truncate"> <div v-else class="flex flex-row w-full justify-between pt-0.5 truncate">

9
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -206,6 +206,13 @@ const deleteTable = () => {
isOptionsOpen.value = false isOptionsOpen.value = false
isTableDeleteDialogVisible.value = true isTableDeleteDialogVisible.value = true
} }
// TODO: Should find a way to render the components without using the `nextTick` function
const refreshViews = async () => {
isExpanded.value = false
await nextTick()
isExpanded.value = true
}
</script> </script>
<template> <template>
@ -397,7 +404,7 @@ const deleteTable = () => {
:base-id="base.id" :base-id="base.id"
/> />
<DashboardTreeViewViewsList v-if="isExpanded" :table-id="table.id" :base-id="base.id" /> <DashboardTreeViewViewsList v-if="isExpanded" :table-id="table.id" :base-id="base.id" @deleted="refreshViews" />
</div> </div>
</template> </template>

10
packages/nc-gui/components/dashboard/View.vue

@ -137,6 +137,16 @@ function onResize(widthPercent: any) {
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
// If the viewport width is less than 1560px, the max sidebar width should be 20rem
if (viewportWidth.value <= 1560) {
if (width > remToPx(20)) {
sideBarSize.value.old = ((20 * fontSize) / viewportWidth.value) * 100
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
}
}
const widthRem = width / fontSize const widthRem = width / fontSize
if (widthRem < 16) { if (widthRem < 16) {

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

@ -48,8 +48,6 @@ const { $e } = useNuxtApp()
const { t } = useI18n() const { t } = useI18n()
const { isDataSourceLimitReached } = storeToRefs(useBases())
const dataSourcesReload = ref(false) const dataSourcesReload = ref(false)
const tabsInfo: TabGroup = { const tabsInfo: TabGroup = {

38
packages/nc-gui/components/dlg/AirtableImport.vue

@ -54,7 +54,7 @@ const syncSource = ref({
syncLookup: true, syncLookup: true,
syncFormula: false, syncFormula: false,
syncAttachment: true, syncAttachment: true,
syncUsers: true, syncUsers: false,
}, },
}, },
}) })
@ -210,7 +210,7 @@ async function loadSyncSrc() {
syncLookup: true, syncLookup: true,
syncFormula: false, syncFormula: false,
syncAttachment: true, syncAttachment: true,
syncUsers: true, syncUsers: false,
}, },
}, },
} }
@ -403,22 +403,30 @@ function downloadLogs(filename: string) {
</a-checkbox> </a-checkbox>
</div> </div>
<!-- Import Users Columns --> <!-- Import Formula Columns -->
<div class="my-2"> <div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncUsers"> <a-tooltip placement="top">
{{ $t('labels.importUsers') }} <template #title>
</a-checkbox> <span>{{ $t('title.comingSoon') }}</span>
</template>
<a-checkbox v-model:checked="syncSource.details.options.syncFormula" disabled>
{{ $t('labels.importFormulaColumns') }}
</a-checkbox>
</a-tooltip>
</div> </div>
<!-- Import Formula Columns --> <!-- Invite Users
<a-tooltip placement="top"> <div class="my-2">
<template #title> <a-tooltip placement="top">
<span>{{ $t('title.comingSoon') }}</span> <template #title>
</template> <span>{{ $t('title.comingSoon') }}</span>
<a-checkbox v-model:checked="syncSource.details.options.syncFormula" disabled> </template>
{{ $t('labels.importFormulaColumns') }} <a-checkbox v-model:checked="syncSource.details.options.syncUsers" disabled>
</a-checkbox> {{ $t('labels.importUsers') }}
</a-tooltip> </a-checkbox>
</a-tooltip>
</div>
-->
</a-form> </a-form>
<a-divider /> <a-divider />

46
packages/nc-gui/components/dlg/ColumnUpdateConfirm.vue

@ -0,0 +1,46 @@
<script setup lang="ts">
const props = defineProps<{
visible?: boolean
saving?: boolean
}>()
const emit = defineEmits(['submit', 'cancel', 'update:visible'])
const visible = useVModel(props, 'visible', emit)
</script>
<template>
<GeneralModal v-model:visible="visible" size="small">
<div class="flex flex-col p-6" @click.stop>
<div class="flex flex-row pb-2 mb-4 font-medium text-lg border-b-1 border-gray-50 text-gray-800">Field Type Change</div>
<div class="mb-3 text-gray-800">
<div class="flex item-center gap-2 mb-4">
<component :is="iconMap.warning" id="nc-selected-item-icon" class="text-yellow-500 w-10 h-10" />
This action cannot be undone. Converting data types may result in data loss. Proceed with caution!
</div>
</div>
<slot name="entity-preview"></slot>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<NcButton type="secondary" @click="visible = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
key="submit"
autofocus
type="primary"
html-type="submit"
:loading="saving"
data-testid="nc-delete-modal-delete-btn"
@click="emit('submit')"
>
Update
<template #loading> Saving... </template>
</NcButton>
</div>
</div>
</GeneralModal>
</template>

6
packages/nc-gui/components/dlg/InviteDlg.vue

@ -12,8 +12,6 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const { baseRoles, workspaceRoles } = useRoles()
const basesStore = useBases() const basesStore = useBases()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
@ -28,10 +26,6 @@ const orderedRoles = computed(() => {
return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles
}) })
const userRoles = computed(() => {
return props.type === 'base' ? baseRoles.value : workspaceRoles.value
})
const inviteData = reactive({ const inviteData = reactive({
email: '', email: '',
roles: orderedRoles.value.NO_ACCESS, roles: orderedRoles.value.NO_ACCESS,

197
packages/nc-gui/components/general/AdvanceColorPicker.vue

@ -0,0 +1,197 @@
<script lang="ts" setup>
import tinycolor from 'tinycolor2'
import windiColors from 'windicss/colors'
import { themeV3Colors } from '../../utils/colorsUtils'
interface Props {
modelValue?: string | any
isOpen?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
})
const emit = defineEmits(['input', 'closeModal'])
const { isOpen } = toRefs(props)
const vModel = computed({
get: () => props.modelValue,
set: (val) => {
emit('input', val || null)
},
})
const showActiveColorTab = ref<boolean>(false)
const picked = ref<string>(props.modelValue || enumColor.light[0])
const defaultColors = computed<string[][]>(() => {
const colors = [
'gray',
'red',
'green',
'yellow',
'orange',
'pink',
'maroon',
'purple',
'blue',
] as (keyof typeof themeV3Colors)[] & (keyof typeof windiColors)[]
const allColors = []
for (const color of colors) {
if (themeV3Colors[color]) {
allColors.push(color === 'gray' ? Object.values(themeV3Colors[color]).slice(1) : Object.values(themeV3Colors[color]))
} else if (windiColors[color]) {
allColors.push(Object.values(windiColors[color]))
}
}
return allColors
})
const localIsDefaultColorTab = ref<'true' | 'false'>('true')
const isDefaultColorTab = computed({
get: () => {
if (showActiveColorTab.value && vModel.value) {
for (const colorGrp of defaultColors.value) {
if (colorGrp.includes(vModel.value)) {
return 'true'
}
}
return 'false'
}
return localIsDefaultColorTab.value
},
set: (val: 'true' | 'false') => {
localIsDefaultColorTab.value = val
if (showActiveColorTab.value) {
showActiveColorTab.value = false
}
},
})
const selectColor = (color: string, closeModal = false) => {
picked.value = color
if (closeModal) {
emit('closeModal')
}
}
const compare = (colorA: string, colorB: string) => {
if (!colorA || !colorB) return false
return colorA.toLowerCase() === colorB.toLowerCase() || colorA.toLowerCase() === tinycolor(colorB).toHex8String().toLowerCase()
}
watch(picked, (n, _o) => {
vModel.value = n
})
watch(
isOpen,
(newValue) => {
if (newValue) {
showActiveColorTab.value = true
}
},
{
immediate: true,
},
)
</script>
<template>
<div class="nc-advance-color-picker w-[336px] pt-2" click.stop>
<NcTabs v-model:activeKey="isDefaultColorTab" class="nc-advance-color-picker-tab w-full">
<a-tab-pane key="true">
<template #tab>
<div class="tab" data-testid="nc-default-colors-tab">Default colors</div>
</template>
<div class="h-full p-2">
<div class="flex flex-col gap-1">
<div v-for="(colorGroup, i) of defaultColors" :key="i" class="flex">
<div v-for="(color, j) of colorGroup" :key="`color-${i}-${j}`" class="p-1 rounded-md flex h-8 hover:bg-gray-200">
<button
class="color-selector"
:class="{ selected: compare(picked, color) }"
:style="{
backgroundColor: `${color}`,
}"
@click="selectColor(color, true)"
></button>
</div>
</div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="false">
<template #tab>
<div class="tab" data-testid="nc-custom-colors-tab">
<div>Custom colours</div>
</div>
</template>
<div class="h-full p-2">
<LazyGeneralChromeWrapper v-model="picked" class="!w-full !shadow-none" />
</div>
</a-tab-pane>
</NcTabs>
</div>
</template>
<style lang="scss" scoped>
.color-picker {
@apply flex flex-col items-center justify-center bg-white p-2.5;
}
.color-picker-row {
@apply flex flex-row space-x-1;
}
.color-selector {
@apply h-6 w-6 rounded;
-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: white;
}
.color-selector:hover {
filter: brightness(90%);
-webkit-filter: brightness(90%);
}
.color-selector:focus,
.color-selector.selected,
.nc-more-colors-trigger:focus {
outline: none;
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
}
:deep(.vc-chrome-toggle-icon) {
@apply !ml-3;
}
:deep(.ant-tabs) {
@apply !overflow-visible;
.ant-tabs-nav {
@apply px-1;
.ant-tabs-nav-list {
@apply w-[99%] mx-auto gap-6;
.ant-tabs-tab {
@apply flex-1 flex items-center justify-center pt-2 pb-2 text-xs font-semibold;
& + .ant-tabs-tab {
@apply !ml-0;
}
}
}
}
.ant-tabs-content-holder {
.ant-tabs-content {
@apply h-full;
}
}
}
</style>

5
packages/nc-gui/components/general/EmojiPicker.vue

@ -6,7 +6,7 @@ import { EmojiIndex, Picker } from 'emoji-mart-vue-fast/src'
const props = defineProps<{ const props = defineProps<{
emoji?: string | undefined emoji?: string | undefined
size?: 'small' | 'medium' | 'large' | 'xlarge' size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'
readonly?: boolean readonly?: boolean
disableClearing?: boolean disableClearing?: boolean
}>() }>()
@ -80,10 +80,11 @@ const showClearButton = computed(() => {
<template> <template>
<a-dropdown v-model:visible="isOpen" :trigger="['click']" :disabled="readonly"> <a-dropdown v-model:visible="isOpen" :trigger="['click']" :disabled="readonly">
<div <div
class="flex flex-row justify-center items-center select-none rounded-md nc-emoji" class="flex-none flex flex-row justify-center items-center select-none rounded-md nc-emoji"
:class="{ :class="{
'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly, 'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly,
'bg-gray-500 bg-opacity-15': isOpen, 'bg-gray-500 bg-opacity-15': isOpen,
'h-4 w-4 text-[16px] leading-4': size === 'xsmall',
'h-6 w-6 text-lg': size === 'small', 'h-6 w-6 text-lg': size === 'small',
'h-8 w-8 text-xl': size === 'medium', 'h-8 w-8 text-xl': size === 'medium',
'h-10 w-10 text-2xl': size === 'large', 'h-10 w-10 text-2xl': size === 'large',

8
packages/nc-gui/components/general/Loader.vue

@ -2,7 +2,7 @@
import { LoadingOutlined } from '@ant-design/icons-vue' import { LoadingOutlined } from '@ant-design/icons-vue'
const props = defineProps<{ const props = defineProps<{
size?: 'small' | 'medium' | 'large' | 'xlarge' size?: 'small' | 'medium' | 'large' | 'xlarge' | 'regular'
loaderClass?: string loaderClass?: string
}>() }>()
@ -18,17 +18,19 @@ function getFontSize() {
return 'text-xl' return 'text-xl'
case 'xlarge': case 'xlarge':
return 'text-3xl' return 'text-3xl'
case 'regular':
return 'text-[16px] leading-4'
} }
} }
const indicator = h(LoadingOutlined, { const indicator = h(LoadingOutlined, {
class: `!${getFontSize()} flex flex-row items-center !bg-inherit !hover:bg-inherit !text-inherit ${props.loaderClass}}`, class: `!${getFontSize()} flex flex-row items-center !bg-inherit !hover:bg-inherit !text-inherit ${props.loaderClass || ''}}`,
spin: true, spin: true,
}) })
</script> </script>
<template> <template>
<a-spin class="nc-loader flex flex-row items-center" :indicator="indicator" /> <a-spin class="nc-loader !flex flex-row items-center" :indicator="indicator" />
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

20
packages/nc-gui/components/general/TruncateText.vue

@ -1,18 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip'
interface Props { interface Props {
placement?: placement?: TooltipPlacement
| 'top'
| 'left'
| 'right'
| 'bottom'
| 'topLeft'
| 'topRight'
| 'bottomLeft'
| 'bottomRight'
| 'leftTop'
| 'leftBottom'
| 'rightTop'
| 'rightBottom'
length?: number length?: number
} }
@ -30,12 +20,12 @@ const shortName = computed(() =>
</script> </script>
<template> <template>
<a-tooltip v-if="enableTooltip" :placement="placement"> <NcTooltip v-if="enableTooltip" :placement="placement">
<template #title> <template #title>
<slot /> <slot />
</template> </template>
<div class="w-full">{{ shortName }}</div> <div class="w-full">{{ shortName }}</div>
</a-tooltip> </NcTooltip>
<div v-else class="w-full" data-testid="truncate-label"> <div v-else class="w-full" data-testid="truncate-label">
<slot /> <slot />
</div> </div>

10
packages/nc-gui/components/general/ViewIcon.vue

@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
@ -11,11 +10,12 @@ const viewMeta = toRef(props, 'meta')
</script> </script>
<template> <template>
<IcIcon <LazyGeneralEmojiPicker
v-if="viewMeta?.meta?.icon" v-if="viewMeta?.meta?.icon"
:data-testid="`nc-icon-${viewMeta?.meta?.icon}`" :data-testid="`nc-emoji-${viewMeta.meta?.icon}`"
class="text-[16px]" size="xsmall"
:icon="viewMeta?.meta?.icon" :emoji="viewMeta.meta?.icon"
readonly
/> />
<component <component
:is="viewIcons[viewMeta.type]?.icon" :is="viewIcons[viewMeta.type]?.icon"

21
packages/nc-gui/components/nc/Button.vue

@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ButtonType } from 'ant-design-vue/lib/button' import type { ButtonType } from 'ant-design-vue/lib/button'
import { useSlots } from 'vue' import { useSlots } from 'vue'
import type { NcButtonSize } from '~/lib/types'
/** /**
* @description * @description
@ -76,11 +75,12 @@ useEventListener(NcButton, 'mousedown', () => {
<a-button <a-button
ref="NcButton" ref="NcButton"
:class="{ :class="{
small: size === 'small', 'small': size === 'small',
medium: size === 'medium', 'medium': size === 'medium',
xsmall: size === 'xsmall', 'xsmall': size === 'xsmall',
xxsmall: size === 'xxsmall', 'xxsmall': size === 'xxsmall',
focused: isFocused, 'size-xs': size === 'xs',
'focused': isFocused,
}" }"
:disabled="props.disabled" :disabled="props.disabled"
:loading="loading" :loading="loading"
@ -168,6 +168,13 @@ useEventListener(NcButton, 'mousedown', () => {
@apply py-2 px-4 h-10 min-w-10 xs:(h-10.5 max-h-10.5 min-w-10.5 !px-3); @apply py-2 px-4 h-10 min-w-10 xs:(h-10.5 max-h-10.5 min-w-10.5 !px-3);
} }
.nc-button.ant-btn.size-xs {
@apply px-2 py-0 h-7 min-w-7 rounded-lg text-small leading-[18px];
& > div {
@apply gap-x-2;
}
}
.nc-button.ant-btn.xsmall { .nc-button.ant-btn.xsmall {
@apply p-0.25 h-6.25 min-w-6.25 rounded-md; @apply p-0.25 h-6.25 min-w-6.25 rounded-md;
} }
@ -179,7 +186,7 @@ useEventListener(NcButton, 'mousedown', () => {
.nc-button.ant-btn[disabled], .nc-button.ant-btn[disabled],
.ant-btn-text.nc-button.ant-btn[disabled] { .ant-btn-text.nc-button.ant-btn[disabled] {
box-shadow: none !important; box-shadow: none !important;
@apply bg-gray-50 border-0 text-gray-300 cursor-not-allowed md:(hover:bg-gray-50); @apply bg-gray-50 border-0 text-gray-300 !cursor-not-allowed md:(hover:bg-gray-50);
} }
.nc-button.ant-btn-text.ant-btn[disabled] { .nc-button.ant-btn-text.ant-btn[disabled] {

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

@ -191,7 +191,7 @@ const paginate = (action: 'next' | 'prev') => {
<div v-if="!hideCalendar" class="max-w-[320px] rounded-y-xl"> <div v-if="!hideCalendar" class="max-w-[320px] rounded-y-xl">
<div class="py-1 px-2.5 h-10"> <div class="py-1 px-2.5 h-10">
<div <div
class="flex gap-1" class="flex justify-between gap-1"
:class="{ :class="{
'border-b-1 border-gray-200 ': isCellInputField, 'border-b-1 border-gray-200 ': isCellInputField,
}" }"

18
packages/nc-gui/components/nc/MenuItem.vue

@ -1,9 +1,17 @@
<script>
export default {
inheritAttrs: false,
}
</script>
<template> <template>
<a-menu-item class="nc-menu-item"> <div class="w-full">
<div class="nc-menu-item-inner"> <a-menu-item v-bind="$attrs" class="nc-menu-item">
<slot /> <div class="nc-menu-item-inner">
</div> <slot />
</a-menu-item> </div>
</a-menu-item>
</div>
</template> </template>
<style lang="scss"> <style lang="scss">

26
packages/nc-gui/components/nc/MonthYearSelector.vue

@ -93,10 +93,10 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<div <div
class="flex border-b-1 justify-between items-center" class="flex border-b-1 nc-month-picker-pagination justify-between items-center"
:class="{ :class="{
'px-2 py-1 h-10': isCellInputField, 'px-2 py-1 h-10': isCellInputField,
'px-3 py-0.5': !isCellInputField, 'px-2 py-0.5': !isCellInputField,
}" }"
> >
<div class="flex"> <div class="flex">
@ -137,10 +137,10 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
</div> </div>
<div <div
v-if="!hideCalendar" v-if="!hideCalendar"
class="rounded-y-xl py-1 max-w-[350px]" class="rounded-y-xl max-w-[350px]"
:class="{ :class="{
'px-2': isCellInputField, 'px-2 py-1': isCellInputField,
'px-2.5': !isCellInputField, 'px-2.5 py-2': !isCellInputField,
}" }"
> >
<div class="grid grid-cols-4 gap-2"> <div class="grid grid-cols-4 gap-2">
@ -153,10 +153,10 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
'bg-gray-300 !font-weight-600 ': isMonthSelected(month) && isCellInputField, 'bg-gray-300 !font-weight-600 ': isMonthSelected(month) && isCellInputField,
'hover:(border-1 border-gray-200 bg-gray-100)': !isMonthSelected(month), 'hover:(border-1 border-gray-200 bg-gray-100)': !isMonthSelected(month),
'!text-brand-500': dayjs().isSame(month, 'month'), '!text-brand-500': dayjs().isSame(month, 'month'),
'font-weight-400 rounded': isCellInputField, 'font-weight-400': isCellInputField,
'font-medium rounded-lg': !isCellInputField, 'font-medium': !isCellInputField,
}" }"
class="nc-month-item h-8 flex items-center transition-all justify-center text-gray-700 cursor-pointer" class="nc-month-item h-8 flex items-center rounded transition-all justify-center text-gray-700 cursor-pointer"
:title="isCellInputField ? month.format('YYYY-MM') : undefined" :title="isCellInputField ? month.format('YYYY-MM') : undefined"
@click="selectedDate = month" @click="selectedDate = month"
> >
@ -168,14 +168,14 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
v-for="(year, id) in years" v-for="(year, id) in years"
:key="id" :key="id"
:class="{ :class="{
'bg-gray-200 !text-brand-500 !font-bold ': compareYear(year, selectedDate) && !isCellInputField, 'bg-gray-200 !font-bold ': compareYear(year, selectedDate) && !isCellInputField,
'bg-gray-300 !font-weight-600 ': compareYear(year, selectedDate) && isCellInputField, 'bg-gray-300 !text-brand-500 !font-weight-600 ': compareYear(year, selectedDate) && isCellInputField,
'hover:(border-1 border-gray-200 bg-gray-100)': !compareYear(year, selectedDate), 'hover:(border-1 border-gray-200 bg-gray-100)': !compareYear(year, selectedDate),
'!text-brand-500': dayjs().isSame(year, 'year'), '!text-brand-500': dayjs().isSame(year, 'year'),
'font-weight-400 text-gray-700 rounded': isCellInputField, 'font-weight-400 text-gray-700': isCellInputField,
'font-medium text-gray-900 rounded-lg': !isCellInputField, 'font-medium text-gray-900': !isCellInputField,
}" }"
class="nc-year-item h-8 flex items-center transition-all justify-center cursor-pointer" class="nc-year-item h-8 flex items-center rounded transition-all justify-center cursor-pointer"
:title="isCellInputField ? year.format('YYYY') : undefined" :title="isCellInputField ? year.format('YYYY') : undefined"
@click="selectedDate = year" @click="selectedDate = year"
> >

49
packages/nc-gui/components/nc/Switch.vue

@ -1,20 +1,50 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = withDefaults(defineProps<{ checked: boolean; disabled?: boolean; size?: 'default' | 'small' | 'xsmall' }>(), { const props = withDefaults(
size: 'small', defineProps<{
}) checked: boolean
disabled?: boolean
size?: 'default' | 'small' | 'xsmall'
placement?: 'left' | 'right'
loading?: boolean
}>(),
{
size: 'small',
placement: 'left',
loading: false,
},
)
const emit = defineEmits(['change', 'update:checked']) const emit = defineEmits(['change', 'update:checked'])
const checked = useVModel(props, 'checked', emit) const checked = useVModel(props, 'checked', emit)
const { loading } = toRefs(props)
const switchSize = computed(() => (['default', 'small'].includes(props.size) ? props.size : undefined)) const switchSize = computed(() => (['default', 'small'].includes(props.size) ? props.size : undefined))
const onChange = (e: boolean) => { const onChange = (e: boolean, updateValue = false) => {
if (loading.value) return
if (updateValue) {
checked.value = e
}
emit('change', e) emit('change', e)
} }
</script> </script>
<template> <template>
<span
v-if="placement === 'right' && $slots.default"
class="pr-2"
:class="{
'cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}"
@click="onChange(!checked, true)"
>
<slot />
</span>
<a-switch <a-switch
v-model:checked="checked" v-model:checked="checked"
:disabled="disabled" :disabled="disabled"
@ -22,12 +52,21 @@ const onChange = (e: boolean) => {
:class="{ :class="{
'size-xsmall': size === 'xsmall', 'size-xsmall': size === 'xsmall',
}" }"
:loading="loading"
v-bind="$attrs" v-bind="$attrs"
:size="switchSize" :size="switchSize"
@change="onChange" @change="onChange"
> >
</a-switch> </a-switch>
<span v-if="$slots.default" class="cursor-pointer pl-2" @click="checked = !checked"> <span
v-if="placement === 'left' && $slots.default"
class="pl-2"
:class="{
'cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}"
@click="onChange(!checked, true)"
>
<slot /> <slot />
</span> </span>
</template> </template>

4
packages/nc-gui/components/nc/TimeSelector.vue

@ -42,7 +42,7 @@ const handleSelectTime = (time: dayjs.Dayjs) => {
// TODO: 12hr time format & regular time picker // TODO: 12hr time format & regular time picker
const timeOptions = computed(() => { const timeOptions = computed(() => {
return Array.from({ length: is12hrFormat.value ? 12 : 24 }).flatMap((_, h) => { return Array.from({ length: 24 }).flatMap((_, h) => {
return (isMinGranularityPicker.value ? [0, minGranularity.value] : Array.from({ length: 60 })).map((_m, m) => { return (isMinGranularityPicker.value ? [0, minGranularity.value] : Array.from({ length: 60 })).map((_m, m) => {
const time = dayjs() const time = dayjs()
.set('hour', h) .set('hour', h)
@ -89,7 +89,7 @@ onMounted(() => {
:data-testid="`time-option-${time.format('HH:mm')}`" :data-testid="`time-option-${time.format('HH:mm')}`"
@click="handleSelectTime(time)" @click="handleSelectTime(time)"
> >
{{ time.format('HH:mm') }} {{ time.format(is12hrFormat ? 'hh:mm A' : 'HH:mm') }}
</div> </div>
</div> </div>
<div v-else></div> <div v-else></div>

169
packages/nc-gui/components/notification/Card.vue

@ -4,83 +4,126 @@ import InfiniteLoading from 'v3-infinite-loading'
const notificationStore = useNotification() const notificationStore = useNotification()
const { notifications, isRead, pageInfo } = storeToRefs(notificationStore) const { isMobileMode } = useGlobal()
/* const container = ref()
const groupType = computed({
get() { const { height } = useElementSize(container)
return isRead.value ? 'read' : 'unread'
}, const { loadUnReadNotifications, loadReadNotifications, markAllAsRead } = notificationStore
set(value) {
isRead.value = value === 'read' const { unreadNotifications, readNotifications, readPageInfo, unreadPageInfo, notificationTab } = storeToRefs(notificationStore)
notificationStore.loadNotifications()
},
})
*/
</script> </script>
<template> <template>
<div class="min-w-[350px] max-w-[350px] min-h-[400px] !rounded-2xl bg-white rounded-xl nc-card"> <div
<div class="p-3" @click.stop> ref="container"
<div class="flex items-center"> style="box-shadow: 0px -12px 16px -4px rgba(0, 0, 0, 0.1), 0px -4px 6px -2px rgba(0, 0, 0, 0.06)"
<span class="text-md font-medium text-[#212121]"> :style="!isMobileMode ? 'width: min(80svw, 520px);' : ''"
{{ $t('general.notification') }} :class="{
</span> 'max-h-[70vh] h-[620px]': !isMobileMode,
<div class="flex-grow"></div> 'h-[100svh] w-[100svw]': isMobileMode,
<div }"
v-if="!isRead && notifications?.length" class="!rounded-lg pt-4"
class="cursor-pointer text-xs text-gray-500 hover:text-primary" >
@click.stop="notificationStore.markAllAsRead" <div class="space-y-3">
> <div class="flex px-6 justify-between items-center">
{{ $t('activity.markAllAsRead') }} <span class="text-md font-bold text-gray-800" @click.stop> {{ $t('general.notification') }}s </span>
</div>
</div>
</div>
<a-divider class="!my-0" />
<div
class="overflow-y-auto max-h-[max(60vh,500px)] min-h-100"
:class="{
'flex items-center justify-center': !notifications?.length,
}"
>
<template v-if="!notifications?.length">
<div class="flex flex-col gap-2 items-center justify-center">
<div class="text-sm text-gray-400">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px text-gray-400" />
</div>
</template>
<template v-else>
<template v-for="item in notifications" :key="item.id">
<NotificationItem class="" :item="item" />
<a-divider class="!my-0" />
</template>
<InfiniteLoading <NcButton v-if="isMobileMode" size="small" type="secondary">
v-if="notifications && pageInfo && pageInfo.totalRows > notifications.length" <GeneralIcon icon="close" class="text-gray-700" />
@infinite="notificationStore.loadNotifications(true)" </NcButton>
> </div>
<template #spinner> <div
<div class="flex flex-row w-full justify-center mt-2"> v-if="notificationTab !== 'read'"
<a-spin /> :class="{
</div> 'text-gray-400': !unreadNotifications?.length,
}"
class="cursor-pointer right-5 pointer-events-auto top-12.5 z-2 absolute text-[13px] text-gray-600 font-weight-semibold"
@click.stop="markAllAsRead"
>
{{ $t('activity.markAllAsRead') }}
</div>
<NcTabs v-model:activeKey="notificationTab">
<a-tab-pane key="unread">
<template #tab>
<span
:class="{
'font-semibold': notificationTab === 'unread',
}"
class="text-xs"
>
Unread
</span>
</template> </template>
<template #complete> <div
<span></span> class="overflow-y-auto"
:style="`height: ${height - 72}px`"
:class="{
'flex flex-col items-center min-h-[48svh] justify-center': !unreadNotifications?.length,
}"
>
<template v-if="!unreadNotifications?.length">
<div class="text-sm !text-gray-500">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px !text-gray-500" />
</template>
<template v-else>
<NotificationItem v-for="item in unreadNotifications" :key="item.id" :item="item" />
<InfiniteLoading
v-if="unreadNotifications && unreadPageInfo && unreadPageInfo.totalRows > unreadNotifications.length"
@infinite="loadUnReadNotifications(true)"
>
</InfiniteLoading>
</template>
</div>
</a-tab-pane>
<a-tab-pane key="read">
<template #tab>
<span
:class="{
'font-semibold': notificationTab === 'read',
}"
class="text-xs"
>
Read
</span>
</template> </template>
</InfiniteLoading>
</template> <div
class="overflow-y-auto"
:style="!isMobileMode ? `height: ${height - 72}px` : ''"
:class="{
'flex flex-col items-center min-h-[48svh] justify-center': !readNotifications?.length,
}"
>
<template v-if="!readNotifications?.length">
<div class="text-sm text-gray-500">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px text-gray-500" />
</template>
<template v-else>
<NotificationItem v-for="item in readNotifications" :key="item.id" :item="item" />
<InfiniteLoading
v-if="readNotifications && readPageInfo && readPageInfo.totalRows > readNotifications.length"
@infinite="loadReadNotifications(true)"
>
</InfiniteLoading>
</template>
</div>
</a-tab-pane>
</NcTabs>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.nc-card { :deep(.ant-tabs-nav-wrap) {
border: solid 1px #e1e3e6; @apply px-3;
} }
:deep(.ant-tabs-nav-wrap) { :deep(.ant-tabs-tab) {
@apply px-6; @apply pb-1.5 pt-1;
} }
:deep(.ant-tabs-nav) { :deep(.ant-tabs-nav) {

28
packages/nc-gui/components/notification/Item.vue

@ -1,42 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { AppEvents } from 'nocodb-sdk' import { AppEvents } from 'nocodb-sdk'
import type { NotificationType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
item: any item: NotificationType
}>() }>()
const item = toRef(props, 'item') const item = toRef(props, 'item')
const notificationStore = useNotification() const notificationStore = useNotification()
const { markAsRead } = notificationStore const { toggleRead } = notificationStore
</script> </script>
<template> <template>
<div class="select-none" @click="markAsRead(item)"> <div class="select-none" @click="toggleRead(item, item.is_read)">
<NotificationItemWelcome v-if="item.type === AppEvents.WELCOME" :item="item" /> <NotificationItemWelcome v-if="item.type === AppEvents.WELCOME" :item="item" />
<NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" /> <NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" />
<NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" /> <NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" />
<NotificationItemProjectEvent <NotificationItemMentionEvent v-else-if="['mention'].includes(item.type)" :item="item" />
v-else-if="[AppEvents.PROJECT_CREATE, AppEvents.PROJECT_DELETE, AppEvents.PROJECT_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemTableEvent
v-else-if="[AppEvents.TABLE_CREATE, AppEvents.TABLE_DELETE, AppEvents.TABLE_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemViewEvent
v-else-if="[AppEvents.VIEW_CREATE, AppEvents.VIEW_DELETE, AppEvents.VIEW_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemSharedViewEvent
v-else-if="[AppEvents.SHARED_VIEW_CREATE, AppEvents.SHARED_VIEW_DELETE, AppEvents.SHARED_VIEW_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemWorkspaceEvent
v-else-if="[AppEvents.WORKSPACE_CREATE, AppEvents.WORKSPACE_DELETE, AppEvents.WORKSPACE_UPDATE].includes(item.type)"
:item="item"
/>
<span v-else /> <span v-else />
</div> </div>
</template> </template>

17
packages/nc-gui/components/notification/Item/ColumnEvent.vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item">
<div class="text-xs">
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong>
</div>
</NotificationItemWrapper>
</template>

17
packages/nc-gui/components/notification/Item/FilterViewEvent.vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item">
<div class="text-xs">
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong>
</div>
</NotificationItemWrapper>
</template>

38
packages/nc-gui/components/notification/Item/ProjectEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.PROJECT_CREATE:
return 'created'
case AppEvents.PROJECT_UPDATE:
return 'updated'
case AppEvents.PROJECT_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.PROJECT_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
Base
<GeneralProjectIcon style="vertical-align: middle" :type="item.body.type" /> <strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

12
packages/nc-gui/components/notification/Item/ProjectInvite.vue

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProjectInviteEventType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
item: any item: ProjectInviteEventType
}>() }>()
const { navigateToProject } = useGlobal() const { navigateToProject } = useGlobal()
@ -9,10 +11,10 @@ const item = toRef(props, 'item')
</script> </script>
<template> <template>
<NotificationItemWrapper :item="item" @click="navigateToProject({ baseId: item.body.id })"> <NotificationItemWrapper :item="item" @click="navigateToProject({ baseId: item.body.base.id })">
<div class="text-xs"> <div>
<strong>{{ item.body.invited_by }}</strong> has invited you to collaborate on <span class="font-semibold">{{ item.body.user.display_name ?? item.body.user.email }}</span> has invited you to collaborate
<!-- <GeneralProjectIcon style="vertical-align: middle" :type="item.body.type" /> <strong>{{ item.body.title }}</strong> base. --> on <span class="font-semibold">{{ item.body.base.title }}</span> base.
</div> </div>
</NotificationItemWrapper> </NotificationItemWrapper>
</template> </template>

38
packages/nc-gui/components/notification/Item/SharedViewEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.VIEW_CREATE:
return 'created'
case AppEvents.VIEW_UPDATE:
return 'updated'
case AppEvents.VIEW_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.VIEW_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
Shared view
<strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

17
packages/nc-gui/components/notification/Item/SortViewEvent.vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item">
<div class="text-xs">
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong>
</div>
</NotificationItemWrapper>
</template>

38
packages/nc-gui/components/notification/Item/TableEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { TableEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: TableEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.TABLE_CREATE:
return 'created'
case AppEvents.TABLE_UPDATE:
return 'updated'
case AppEvents.TABLE_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.TABLE_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
Table
<strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

38
packages/nc-gui/components/notification/Item/ViewEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.VIEW_CREATE:
return 'created'
case AppEvents.VIEW_UPDATE:
return 'updated'
case AppEvents.VIEW_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.VIEW_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
View
<strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

21
packages/nc-gui/components/notification/Item/Welcome.vue

@ -1,26 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WelcomeEventType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
item: any item: WelcomeEventType
}>() }>()
const router = useRouter()
const route = router.currentRoute
const item = toRef(props, 'item') const item = toRef(props, 'item')
const navigateToHome = () => {
if (route.value.path !== '/') {
navigateTo(`/`)
}
}
</script> </script>
<template> <template>
<NotificationItemWrapper :item="item" @click="navigateToHome"> <NotificationItemWrapper :item="item">
<template #avatar> <div>Welcome to <span class="font-semibold">NocoDB!</span> Were excited to have you onboard.</div>
<img src="~/assets/img/icons/64x64.png" class="w-6" />
</template>
<div class="text-xs">Welcome to <strong>NocoHUB!</strong> Were excited to have you onboard.</div>
</NotificationItemWrapper> </NotificationItemWrapper>
</template> </template>

119
packages/nc-gui/components/notification/Item/Wrapper.vue

@ -1,94 +1,73 @@
<script setup lang="ts"> <script setup lang="ts">
import { timeAgo } from 'nocodb-sdk' import type { NotificationType } from 'nocodb-sdk'
import { timeAgo } from '~/utils/datetimeUtils'
const props = defineProps<{ const props = defineProps<{
item: { item: NotificationType
created_at: any
}
}>() }>()
const item = toRef(props, 'item') const item = toRef(props, 'item')
const { isMobileMode } = useGlobal()
const notificationStore = useNotification() const notificationStore = useNotification()
const { markAsRead } = notificationStore const { toggleRead, deleteNotification } = notificationStore
</script> </script>
<template> <template>
<div <div class="flex pl-6 pr-4 w-full overflow-x-hidden group py-4 hover:bg-gray-50 gap-3 relative cursor-pointer">
class="flex items-center gap-1 cursor-pointer nc-notification-item-wrapper" <div class="w-9.625">
:class="{
active: !item.is_read,
}"
>
<div class="nc-notification-dot" :class="{ active: !item.is_read }"></div>
<div class="nc-avatar-wrapper">
<slot name="avatar"> <slot name="avatar">
<div class="nc-notification-avatar"></div> <img src="~assets/img/brand/nocodb-logo.svg" alt="NocoDB" class="w-8" />
</slot> </slot>
</div> </div>
<div class="flex-grow ml-3">
<div class="flex items-center"> <div class="text-[13px] min-h-12 w-full leading-5">
<slot /> <slot />
</div> </div>
<div <div v-if="item" class="text-xs whitespace-nowrap absolute right-4.1 bottom-5 text-gray-600">
v-if="item" {{ timeAgo(item.created_at) }}
class="text-xs text-gray-500 mt-1" </div>
<div class="flex items-start">
<NcTooltip v-if="!item.is_read">
<template #title>
<span>Mark as read</span>
</template>
<NcButton
:class="{
'!opacity-100': isMobileMode,
}"
type="secondary"
class="!border-0 transition-all duration-100 opacity-0 !group-hover:opacity-100"
size="xsmall"
@click.stop="() => toggleRead(item)"
>
<GeneralIcon icon="check" class="text-gray-700" />
</NcButton>
</NcTooltip>
<NcDropdown
v-else
:class="{ :class="{
'text-primary': !item.is_read, '!opacity-100': isMobileMode,
}" }"
class="transition-all duration-100 opacity-0 !group-hover:opacity-100"
> >
{{ timeAgo(item.created_at) }} <NcButton size="xsmall" type="secondary" @click.stop>
</div> <GeneralIcon icon="threeDotVertical" />
</div> </NcButton>
<div @click.stop>
<a-dropdown>
<GeneralIcon v-if="!item.is_read" icon="threeDotVertical" class="nc-notification-menu-icon" />
<template #overlay> <template #overlay>
<a-menu> <NcMenu>
<a-menu-item @click="markAsRead(item)"> <NcMenuItem @click.stop="() => toggleRead(item)"> Mark as unread </NcMenuItem>
<div class="p-2 text-xs">Mark as read</div> <NcDivider />
</a-menu-item> <NcMenuItem class="!text-red-500 !hover:bg-red-50" @click.stop="deleteNotification(item)"> Delete </NcMenuItem>
</a-menu> </NcMenu>
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss"></style>
.nc-avatar-wrapper {
@apply min-w-6 h-6 flex items-center justify-center;
}
.nc-notification-avatar {
@apply w-6 h-6 rounded-full text-white font-weight-bold uppercase bg-gray-100;
font-size: 0.7rem;
}
.nc-notification-dot {
@apply min-w-2 min-h-2 mr-1 rounded-full;
&.active {
@apply bg-accent bg-opacity-100;
}
}
.nc-notification-item-wrapper {
.nc-notification-menu-icon {
@apply !text-12px text-gray-500 opacity-0 transition-opacity duration-200 cursor-pointer;
}
&:hover {
.nc-notification-menu-icon {
@apply opacity-100;
}
}
&.active {
@apply bg-primary bg-opacity-4;
}
@apply py-3 px-3;
}
</style>

36
packages/nc-gui/components/notification/Menu.vue

@ -1,38 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
const notificationStore = useNotification() const notificationStore = useNotification()
const { loadNotifications, markAsOpened } = notificationStore const { unreadCount } = toRefs(notificationStore)
onMounted(async () => {
await loadNotifications()
})
const onOpen = (visible: boolean) => {
if (visible) {
markAsOpened()
}
}
</script> </script>
<template> <template>
<div class="cursor-pointer flex items-center"> <div class="cursor-pointer flex items-center">
<a-dropdown :trigger="['click']" @visible-change="onOpen"> <NcDropdown overlay-class-name="!shadow-none" placement="bottomRight" :trigger="['click']">
<div class="relative leading-none"> <NcButton size="small" class="!border-none !bg-gray-50" type="secondary">
<span
v-if="unreadCount"
:key="unreadCount"
class="bg-red-500 w-2 h-2 border-1 border-white rounded-[6px] absolute top-[5px] left-[15px]"
></span>
<GeneralIcon icon="notification" /> <GeneralIcon icon="notification" />
<GeneralIcon icon="menuDown" /> </NcButton>
<span v-if="!notificationStore.isOpened && notificationStore.unreadCount" class="nc-count-badge">{{
notificationStore.unreadCount
}}</span>
</div>
<template #overlay> <template #overlay>
<NotificationCard /> <NotificationCard />
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</template> </template>
<style scoped>
.nc-count-badge {
@apply absolute flex items-center top-[-6px] right-[-6px] px-1 min-w-[14px] h-[14px] rounded-full bg-accent bg-opacity-100 text-white !text-[9px] !z-21;
}
</style>

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

@ -153,11 +153,11 @@ onMounted(async () => {
}) })
const selected = reactive<{ const selected = reactive<{
[key: number]: boolean [key: string]: boolean
}>({}) }>({})
const toggleSelectAll = (value: boolean) => { const toggleSelectAll = (value: boolean) => {
filteredCollaborators.value.forEach((_, i) => { filteredCollaborators.value.forEach((_) => {
selected[_.id] = value selected[_.id] = value
}) })
} }

11
packages/nc-gui/components/project/AllTables.vue

@ -1,16 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { SourceType, TableType } from 'nocodb-sdk' import type { SourceType, TableType } from 'nocodb-sdk'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import NcTooltip from '~/components/nc/Tooltip.vue'
const { activeTables } = storeToRefs(useTablesStore()) const { activeTables } = storeToRefs(useTablesStore())
const { openTable } = useTablesStore() const { openTable } = useTablesStore()
const { openedProject, isDataSourceLimitReached } = storeToRefs(useBases()) const { openedProject } = storeToRefs(useBases())
const { base } = useBase() const { base } = useBase()
const isNewBaseModalOpen = ref(false)
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -68,12 +65,6 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
close(1000) close(1000)
} }
} }
const onCreateBaseClick = () => {
if (isDataSourceLimitReached.value) return
isNewBaseModalOpen.value = true
}
</script> </script>
<template> <template>

12
packages/nc-gui/components/project/View.vue

@ -3,14 +3,14 @@ import { useTitle } from '@vueuse/core'
import NcLayout from '~icons/nc-icons/layout' import NcLayout from '~icons/nc-icons/layout'
const props = defineProps<{ const props = defineProps<{
baseId: string baseId?: string
}>() }>()
const basesStore = useBases() const basesStore = useBases()
const { openedProject, activeProjectId, basesUser, bases } = storeToRefs(basesStore) const { openedProject, activeProjectId, basesUser, bases } = storeToRefs(basesStore)
const { activeTables, activeTable } = storeToRefs(useTablesStore()) const { activeTables, activeTable } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace()) const { activeWorkspace } = storeToRefs(useWorkspace())
const { navigateToProjectPage } = useBase() const { navigateToProjectPage } = useBase()
@ -35,17 +35,11 @@ const currentBase = computedAsync(async () => {
const { isUIAllowed, baseRoles } = useRoles() const { isUIAllowed, baseRoles } = useRoles()
const { base } = storeToRefs(useBase())
const { projectPageTab } = storeToRefs(useConfigStore()) const { projectPageTab } = storeToRefs(useConfigStore())
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const baseSettingsState = ref('') const userCount = computed(() => (activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0))
const userCount = computed(() =>
isEeUI ? workspaceUserCount : activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0,
)
watch( watch(
() => route.value.query?.page, () => route.value.query?.page,

94
packages/nc-gui/components/shared-view/AskPassword.vue

@ -1,9 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core' import type { VNodeRef } from '@vue/runtime-core'
import type { InputPassword } from 'ant-design-vue' import type { InputPassword } from 'ant-design-vue'
import { ViewTypes } from 'nocodb-sdk'
import gridImage from '~/assets/img/views/grid.png'
import galleryImage from '~/assets/img/views/gallery.png'
import kanbanImage from '~/assets/img/views/kanban.png'
import calendarImage from '~/assets/img/views/calendar.png'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
viewType?: ViewTypes
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -16,47 +22,103 @@ const { loadSharedView } = useSharedView()
const formState = ref({ password: undefined }) const formState = ref({ password: undefined })
const passwordError = ref<string | null>(null)
const onFinish = async () => { const onFinish = async () => {
try { try {
await loadSharedView(route.params.viewId as string, formState.value.password) await loadSharedView(route.params.viewId as string, formState.value.password)
vModel.value = false vModel.value = false
} catch (e: any) { } catch (e: any) {
console.error(e) const error = await extractSdkResponseErrorMsgv2(e)
message.error(await extractSdkResponseErrorMsg(e)) console.error(error.message)
if (error.error === NcErrorType.INVALID_SHARED_VIEW_PASSWORD) {
passwordError.value = error.message
} else {
message.error(error.message)
}
} }
} }
const focus: VNodeRef = (el: typeof InputPassword) => el?.$el?.querySelector('input').focus() const focus: VNodeRef = (el: typeof InputPassword) => {
return el && el?.focus?.()
}
watch(
() => formState.value.password,
() => {
passwordError.value = null
},
)
const bgImageName = computed(() => {
switch (props.viewType) {
case ViewTypes.GRID:
return gridImage
case ViewTypes.GALLERY:
return galleryImage
case ViewTypes.KANBAN:
return kanbanImage
case ViewTypes.CALENDAR:
return calendarImage
default:
return gridImage
}
})
</script> </script>
<template> <template>
<NcModal v-model:visible="vModel" c size="small" :class="{ active: vModel }" :mask-closable="false"> <NcModal
<template #header> v-model:visible="vModel"
<div class="flex flex-row items-center gap-x-2"> c
<GeneralIcon icon="key" /> size="small"
:class="{ active: vModel }"
:mask-closable="false"
:mask-style="{
backgroundColor: 'rgba(255, 255, 255, 0.64)',
backdropFilter: 'blur(8px)',
}"
>
<div class="flex flex-col gap-5">
<div class="flex flex-row items-center gap-x-2 text-base font-weight-700 text-gray-800">
<GeneralIcon icon="ncKey" class="!text-base w-5 h-5" />
{{ $t('msg.thisSharedViewIsProtected') }} {{ $t('msg.thisSharedViewIsProtected') }}
</div> </div>
</template>
<div class="mt-2">
<a-form ref="formRef" :model="formState" name="create-new-table-form" @finish="onFinish"> <a-form ref="formRef" :model="formState" name="create-new-table-form" @finish="onFinish">
<a-form-item name="password" :rules="[{ required: true, message: $t('msg.error.signUpRules.passwdRequired') }]"> <a-form-item
name="password"
:rules="[{ required: true, message: $t('msg.error.signUpRules.passwdRequired') }]"
class="!mb-0"
>
<a-input-password <a-input-password
ref="focus" :ref="focus"
v-model:value="formState.password" v-model:value="formState.password"
class="nc-input-md" class="!rounded-lg !text-small"
hide-details hide-details
size="large"
:placeholder="$t('msg.enterPassword')" :placeholder="$t('msg.enterPassword')"
/> />
<Transition name="layout">
<div v-if="passwordError" class="mb-2 text-sm text-red-500">{{ passwordError }}</div>
</Transition>
</a-form-item> </a-form-item>
</a-form> </a-form>
<div class="flex flex-row justify-end gap-x-2 mt-6"> <div class="flex flex-row justify-end gap-x-2">
<NcButton type="primary" html-type="submit" @click="onFinish" <NcButton
>{{ $t('general.unlock') }} :disabled="!formState.password"
type="primary"
size="small"
html-type="submit"
class="!px-2"
data-testid="nc-shared-view-password-submit-btn"
@click="onFinish"
>
{{ $t('objects.view') }}
<template #loading> {{ $t('msg.verifyingPassword') }}</template> <template #loading> {{ $t('msg.verifyingPassword') }}</template>
</NcButton> </NcButton>
</div> </div>
</div> </div>
</NcModal> </NcModal>
<img alt="view image" :src="bgImageName" class="fixed inset-0 w-full h-full" />
</template> </template>

4
packages/nc-gui/components/shared-view/Calendar.vue

@ -25,10 +25,10 @@ useProvideCalendarViewStore(meta, sharedView, true, nestedFilters)
</script> </script>
<template> <template>
<div class="nc-container h-full mt-1.5 px-12"> <div class="nc-container h-full">
<div class="flex flex-col h-full flex-1 min-w-0"> <div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar /> <LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50"> <div class="h-full flex-1 min-w-0 min-h-0">
<LazySmartsheetCalendar /> <LazySmartsheetCalendar />
</div> </div>
</div> </div>

2
packages/nc-gui/components/shared-view/Gallery.vue

@ -23,7 +23,7 @@ useProvideKanbanViewStore(meta, sharedView)
</script> </script>
<template> <template>
<div class="nc-container h-full mt-1.5 px-12"> <div class="nc-container h-full">
<div class="flex flex-col h-full flex-1 min-w-0"> <div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar /> <LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50"> <div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50">

2
packages/nc-gui/components/shared-view/Grid.vue

@ -45,7 +45,7 @@ watch(
</script> </script>
<template> <template>
<div class="nc-container flex flex-col h-full mt-1.5 px-12"> <div class="nc-container flex flex-col h-full">
<LazySmartsheetToolbar /> <LazySmartsheetToolbar />
<LazySmartsheetGrid /> <LazySmartsheetGrid />
</div> </div>

2
packages/nc-gui/components/shared-view/Kanban.vue

@ -23,7 +23,7 @@ useProvideKanbanViewStore(meta, sharedView, true)
</script> </script>
<template> <template>
<div class="nc-container h-full mt-1.5 px-12"> <div class="nc-container h-full">
<div class="flex flex-col h-full flex-1 min-w-0"> <div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar /> <LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50"> <div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50">

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

@ -186,7 +186,7 @@ const onContextmenu = (e: MouseEvent) => {
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" /> <LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" /> <LazyCellText v-else v-model="vModel" />
<div <div
v-if="((isPublic && readOnly && !isForm) || (isSystemColumn(column) && !isAttachment(column))) && !isTextArea(column)" v-if="((isPublic && readOnly && !isForm) || isSystemColumn(column)) && !isAttachment(column) && !isTextArea(column)"
class="nc-locked-overlay" class="nc-locked-overlay"
/> />
</template> </template>

2
packages/nc-gui/components/smartsheet/Details.vue

@ -76,7 +76,7 @@ watch(openedSubTab, () => {
<template #tab> <template #tab>
<div class="tab" data-testid="nc-apis-tab"> <div class="tab" data-testid="nc-apis-tab">
<GeneralIcon icon="code" class="tab-icon" :class="{}" /> <GeneralIcon icon="code" class="tab-icon" :class="{}" />
<div>{{ $t('labels.apis') }}</div> <div>{{ $t('labels.apiSnippet') }}</div>
</div> </div>
</template> </template>
<SmartsheetDetailsApi v-if="base && meta && view" /> <SmartsheetDetailsApi v-if="base && meta && view" />

12
packages/nc-gui/components/smartsheet/Form.vue

@ -85,6 +85,7 @@ const {
validateInfos, validateInfos,
validate, validate,
clearValidate, clearValidate,
fieldMappings,
} = useProvideFormViewStore(meta, view, formViewData, updateFormView, isEditable) } = useProvideFormViewStore(meta, view, formViewData, updateFormView, isEditable)
const { preFillFormSearchParams } = storeToRefs(useViewsStore()) const { preFillFormSearchParams } = storeToRefs(useViewsStore())
@ -198,9 +199,9 @@ async function submitForm() {
} }
try { try {
await validate([...Object.keys(formState.value)]) await validate(Object.keys(formState.value).map((title) => fieldMappings.value[title]))
} catch (e: any) { } catch (e: any) {
if (e.errorFields.length) { if (e?.errorFields?.length) {
message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty')) message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty'))
return return
} }
@ -586,7 +587,7 @@ watch(
updatePreFillFormSearchParams() updatePreFillFormSearchParams()
try { try {
await validate([...Object.keys(formState.value)]) await validate(Object.keys(formState.value).map((title) => fieldMappings.value[title]))
} catch {} } catch {}
}, },
{ {
@ -1103,9 +1104,10 @@ useEventListener(
<div class="nc-form-field-body"> <div class="nc-form-field-body">
<div class="mt-2"> <div class="mt-2">
<a-form-item <a-form-item
:name="element.title" v-if="fieldMappings[element.title]"
:name="fieldMappings[element.title]"
class="!my-0 nc-input-required-error nc-form-input-item" class="!my-0 nc-input-required-error nc-form-input-item"
v-bind="validateInfos[element.title]" v-bind="validateInfos[fieldMappings[element.title]]"
> >
<LazySmartsheetDivDataCell class="relative" @click.stop> <LazySmartsheetDivDataCell class="relative" @click.stop>
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell

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

@ -46,6 +46,8 @@ const router = useRouter()
const { getPossibleAttachmentSrc } = useAttachment() const { getPossibleAttachmentSrc } = useAttachment()
const { isMobileMode } = useGlobal()
const fieldsWithoutDisplay = computed(() => fields.value.filter((f) => !isPrimary(f))) const fieldsWithoutDisplay = computed(() => fields.value.filter((f) => !isPrimary(f)))
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv && fields.value.includes(c)) ?? null) const displayField = computed(() => meta.value?.columns?.find((c) => c.pv && fields.value.includes(c)) ?? null)
@ -56,12 +58,12 @@ const coverImageColumn: any = computed(() =>
: {}, : {},
) )
const isRowEmpty = (record: any, col: any) => { const coverImageObjectFitClass = computed(() => {
const val = record.row[col.title] const fk_cover_image_object_fit = parseProp(galleryData.value?.meta)?.fk_cover_image_object_fit || CoverImageObjectFit.FIT
if (!val) return true
return Array.isArray(val) && val.length === 0 if (fk_cover_image_object_fit === CoverImageObjectFit.FIT) return '!object-contain'
} if (fk_cover_image_object_fit === CoverImageObjectFit.COVER) return '!object-cover'
})
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('dataEdit')) const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
@ -75,9 +77,9 @@ const contextMenu = computed({
} }
}, },
}) })
const contextMenuTarget = ref<{ row: number } | null>(null) const contextMenuTarget = ref<{ row: RowType; index: number } | null>(null)
const showContextMenu = (e: MouseEvent, target?: { row: number }) => { const showContextMenu = (e: MouseEvent, target?: { row: RowType; index: number }) => {
if (isSqlView.value) return if (isSqlView.value) return
e.preventDefault() e.preventDefault()
if (target) { if (target) {
@ -100,6 +102,7 @@ const attachments = (record: any): Attachment[] => {
const expandForm = (row: RowType, state?: Record<string, any>) => { const expandForm = (row: RowType, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!) const rowId = extractPkFromRow(row.row, meta.value!.columns!)
expandedFormRowState.value = state
if (rowId && !isPublic.value) { if (rowId && !isPublic.value) {
router.push({ router.push({
@ -110,7 +113,6 @@ const expandForm = (row: RowType, state?: Record<string, any>) => {
}) })
} else { } else {
expandedFormRow.value = row expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true expandedFormDlg.value = true
} }
} }
@ -181,61 +183,75 @@ watch(
</script> </script>
<template> <template>
<a-dropdown <NcDropdown
v-model:visible="contextMenu" v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu" overlay-class-name="nc-dropdown-grid-context-menu"
> >
<template #overlay> <template #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false"> <NcMenu @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget.row)"> <NcMenuItem v-if="contextMenuTarget" @click="expandForm(contextMenuTarget.row)">
<div v-e="['a:row:delete']" class="nc-base-menu-item"> <div v-e="['a:row:expand-record']" class="flex items-center gap-2">
<component :is="iconMap.expand" class="flex" />
<!-- Expand Record -->
{{ $t('activity.expandRecord') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="contextMenuTarget?.index !== undefined"
class="!text-red-600 !hover:bg-red-50"
@click="deleteRow(contextMenuTarget.index)"
>
<div v-e="['a:row:delete']" class="flex items-center gap-2">
<component :is="iconMap.delete" class="flex" />
<!-- Delete Row --> <!-- Delete Row -->
{{ $t('activity.deleteRow') }} {{ $t('activity.deleteRow') }}
</div> </div>
</a-menu-item> </NcMenuItem>
<!-- <a-menu-item v-if="contextMenuTarget" @click="openNewRecordFormHook.trigger()"> --> <!-- <NcMenuItem v-if="contextMenuTarget" @click="openNewRecordFormHook.trigger()"> -->
<!-- <div v-e="['a:row:insert']" class="nc-base-menu-item"> --> <!-- <div v-e="['a:row:insert']" class="flex items-center gap-2"> -->
<!-- &lt;!&ndash; Insert New Row &ndash;&gt; --> <!-- &lt;!&ndash; Insert New Row &ndash;&gt; -->
<!-- {{ $t('activity.insertRow') }} --> <!-- {{ $t('activity.insertRow') }} -->
<!-- </div> --> <!-- </div> -->
<!-- </a-menu-item> --> <!-- </NcMenuItem> -->
</a-menu> </NcMenu>
</template> </template>
<div <div
class="flex flex-col w-full nc-gallery nc-scrollbar-md bg-[#fbfbfb]" class="flex flex-col w-full nc-gallery nc-scrollbar-md bg-gray-50"
data-testid="nc-gallery-wrapper" data-testid="nc-gallery-wrapper"
style="height: calc(100% - var(--topbar-height) + 0.7rem)" :style="{ height: isMobileMode ? 'calc(100% - var(--topbar-height))' : 'calc(100% - var(--topbar-height) + 0.7rem)' }"
:class="{ :class="{
'!overflow-hidden': isViewDataLoading, '!overflow-hidden': isViewDataLoading,
}" }"
> >
<div v-if="isViewDataLoading" class="flex flex-col h-full"> <div v-if="isViewDataLoading" class="flex flex-col h-full">
<div class="flex flex-row p-3 !pr-1 gap-x-2 flex-wrap gap-y-2"> <div class="nc-gallery-container-skeleton grid gap-3 p-3">
<a-skeleton-input v-for="index of Array(20)" :key="index" class="!min-w-60.5 !h-96 !rounded-md overflow-hidden" /> <a-skeleton-input v-for="index of Array(20)" :key="index" class="!min-w-60.5 !h-96 !rounded-md overflow-hidden" />
</div> </div>
</div> </div>
<div v-else class="nc-gallery-container grid gap-3 my-4 px-3"> <div v-else class="nc-gallery-container grid gap-3 p-3">
<div v-for="(record, rowIndex) in data" :key="`record-${record.row.id}`"> <div v-for="(record, rowIndex) in data" :key="`record-${record.row.id}`">
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<a-card <a-card
class="!rounded-lg h-full border-gray-200 border-1 group overflow-hidden break-all max-w-[450px] shadow-sm hover:shadow-md cursor-pointer" class="!rounded-xl h-full border-gray-200 border-1 group overflow-hidden break-all max-w-[450px] cursor-pointer"
:body-style="{ padding: '0px' }" :body-style="{ padding: '16px !important' }"
:data-testid="`nc-gallery-card-${record.row.id}`" :data-testid="`nc-gallery-card-${record.row.id}`"
@click="expandFormClick($event, record)" @click="expandFormClick($event, record)"
@contextmenu="showContextMenu($event, { row: rowIndex })" @contextmenu="showContextMenu($event, { row: record, index: rowIndex })"
> >
<template v-if="galleryData?.fk_cover_image_col_id" #cover> <template v-if="galleryData?.fk_cover_image_col_id" #cover>
<a-carousel <a-carousel
v-if="!reloadAttachments && attachments(record).length" v-if="!reloadAttachments && attachments(record).length"
class="gallery-carousel !border-b-1 !border-gray-200" class="gallery-carousel !border-b-1 !border-gray-200 min-h-52"
arrows arrows
> >
<template #customPaging> <template #customPaging>
<a> <a>
<div class="pt-[12px]"> <div>
<div></div> <div></div>
</div> </div>
</a> </a>
@ -243,17 +259,25 @@ watch(
<template #prevArrow> <template #prevArrow>
<div class="z-10 arrow"> <div class="z-10 arrow">
<MdiChevronLeft <NcButton
class="text-gray-700 w-6 h-6 absolute left-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition" type="secondary"
/> size="xsmall"
class="!absolute !left-1.5 !bottom-[-90px] !opacity-0 !group-hover:opacity-100 !rounded-lg cursor-pointer"
>
<GeneralIcon icon="arrowLeft" class="text-gray-700 w-4 h-4" />
</NcButton>
</div> </div>
</template> </template>
<template #nextArrow> <template #nextArrow>
<div class="z-10 arrow"> <div class="z-10 arrow">
<MdiChevronRight <NcButton
class="text-gray-700 w-6 h-6 absolute right-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition" type="secondary"
/> size="xsmall"
class="!absolute !right-1.5 !bottom-[-90px] !opacity-0 !group-hover:opacity-100 !rounded-lg cursor-pointer"
>
<GeneralIcon icon="arrowRight" class="text-gray-700 w-4 h-4" />
</NcButton>
</div> </div>
</template> </template>
@ -261,7 +285,8 @@ watch(
<LazyCellAttachmentImage <LazyCellAttachmentImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)" v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`" :key="`carousel-${record.row.id}-${index}`"
class="h-52 !object-contain" class="h-52"
:class="[`${coverImageObjectFitClass}`]"
:srcs="getPossibleAttachmentSrc(attachment)" :srcs="getPossibleAttachmentSrc(attachment)"
@click="expandFormClick($event, record)" @click="expandFormClick($event, record)"
/> />
@ -271,75 +296,87 @@ watch(
<img class="object-contain w-[48px] h-[48px]" src="~assets/icons/FileIconImageBox.png" /> <img class="object-contain w-[48px] h-[48px]" src="~assets/icons/FileIconImageBox.png" />
</div> </div>
</template> </template>
<h2 v-if="displayField" class="text-base mt-3 mx-3 font-bold"> <div class="flex flex-col gap-3 !children:pointer-events-none">
<LazySmartsheetVirtualCell <h2 v-if="displayField" class="nc-card-display-value-wrapper">
v-if="isVirtualCol(displayField)" <template v-if="!isRowEmpty(record, displayField)">
v-model="record.row[displayField.title]"
class="!text-brand-500"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField.title]"
class="!text-brand-500"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</h2>
<div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`">
<div class="flex flex-col first:mt-3 ml-2 !pr-3.5 !mb-[0.75rem] rounded-lg w-full">
<div class="flex flex-row w-full justify-start scale-75">
<div class="w-full pb-1 text-gray-300">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
:hide-menu="true"
:hide-icon="true"
/>
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" :hide-icon="true" />
</div>
</div>
<div
v-if="!isRowEmpty(record, col)"
class="flex flex-row w-full text-gray-700 px-1 mt-[-0.25rem] items-center justify-start"
>
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(col)" v-if="isVirtualCol(displayField)"
v-model="record.row[col.title]" v-model="record.row[displayField.title]"
:column="col" class="!text-brand-500"
:column="displayField"
:row="record" :row="record"
/> />
<LazySmartsheetCell <LazySmartsheetCell
v-else v-else
v-model="record.row[col.title]" v-model="record.row[displayField.title]"
:column="col" class="!text-brand-500"
:column="displayField"
:edit-enabled="false" :edit-enabled="false"
:read-only="true" :read-only="true"
/> />
</template>
<template v-else> - </template>
</h2>
<div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`">
<div class="flex flex-col rounded-lg w-full">
<div class="flex flex-row w-full justify-start">
<div class="nc-card-col-header w-full !children:text-gray-500">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="true" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" />
</div>
</div>
<div
v-if="!isRowEmpty(record, col)"
class="flex flex-row w-full text-gray-800 items-center justify-start min-h-7 py-1"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="record.row[col.title]"
:column="col"
:row="record"
class="!text-gray-800"
/>
<LazySmartsheetCell
v-else
v-model="record.row[col.title]"
:column="col"
:edit-enabled="false"
:read-only="true"
class="!text-gray-800"
/>
</div>
<div v-else class="flex flex-row w-full h-7 pl-1 items-center justify-start">-</div>
</div> </div>
<div v-else class="flex flex-row w-full h-[1.375rem] pl-1 items-center justify-start">-</div>
</div> </div>
</div> </div>
</a-card> </a-card>
</LazySmartsheetRow> </LazySmartsheetRow>
</div> </div>
<template v-if="data.length <= 4">
<div v-for="index of Array(8 - data.length)" :key="index" class="nc-empty-card"></div>
</template>
</div> </div>
</div> </div>
</a-dropdown> </NcDropdown>
<LazySmartsheetPagination v-model:pagination-data="paginationData" show-api-timing :change-page="changePage" /> <LazySmartsheetPagination
v-model:pagination-data="paginationData"
align-count-on-right
show-api-timing
:change-page="changePage"
/>
<Suspense> <Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:row="expandedFormRow" :row="expandedFormRow"
:load-row="!isPublic"
:state="expandedFormRowState" :state="expandedFormRowState"
:meta="meta" :meta="meta"
:view="view" :view="view"
@ -350,19 +387,22 @@ watch(
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg && meta?.id" v-if="expandedFormOnRowIdDlg && meta?.id"
v-model="expandedFormOnRowIdDlg" v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }" :row="expandedFormRow ?? { row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta" :meta="meta"
:load-row="!isPublic"
:row-id="route.query.rowId" :row-id="route.query.rowId"
:view="view" :view="view"
show-next-prev-icons show-next-prev-icons
:expand-form="expandForm"
@next="navigateToSiblingRow(NavigateDir.NEXT)" @next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)" @prev="navigateToSiblingRow(NavigateDir.PREV)"
/> />
</Suspense> </Suspense>
</template> </template>
<style scoped> <style lang="scss" scoped>
.nc-gallery-container { .nc-gallery-container,
.nc-gallery-container-skeleton {
@apply auto-rows-[1fr] grid-cols-[repeat(auto-fit,minmax(250px,1fr))]; @apply auto-rows-[1fr] grid-cols-[repeat(auto-fit,minmax(250px,1fr))];
} }
@ -371,8 +411,7 @@ watch(
} }
.ant-carousel.gallery-carousel :deep(.slick-dots) { .ant-carousel.gallery-carousel :deep(.slick-dots) {
@apply !w-auto absolute h-auto bottom-[-15px] absolute h-auto; @apply !w-full max-w-[calc(100%_-_36%)] absolute left-0 right-0 bottom-[-18px] h-6 overflow-x-auto nc-scrollbar-thin !mx-auto;
height: auto;
} }
.ant-carousel.gallery-carousel :deep(.slick-dots li div > div) { .ant-carousel.gallery-carousel :deep(.slick-dots li div > div) {
@ -394,4 +433,124 @@ watch(
.ant-carousel.gallery-carousel :deep(.slick-next) { .ant-carousel.gallery-carousel :deep(.slick-next) {
@apply right-0; @apply right-0;
} }
:deep(.ant-card) {
@apply transition-all duration-0.3s;
box-shadow: 0px 2px 4px -2px rgba(0, 0, 0, 0.06), 0px 4px 4px -2px rgba(0, 0, 0, 0.02);
&:hover {
@apply !border-gray-300;
box-shadow: 0px 0px 24px 0px rgba(0, 0, 0, 0.1), 0px 0px 8px 0px rgba(0, 0, 0, 0.04);
.nc-action-icon {
@apply invisible;
}
}
}
.nc-card-display-value-wrapper {
@apply my-0 text-xl leading-8 text-gray-600;
.nc-cell,
.nc-virtual-cell {
@apply text-xl leading-8;
:deep(.nc-cell-field),
:deep(input),
:deep(textarea),
:deep(.nc-cell-field-link) {
@apply !text-xl leading-8 text-gray-600;
}
}
}
.nc-card-col-header {
:deep(.nc-cell-icon),
:deep(.nc-virtual-cell-icon) {
@apply ml-0 !w-3.5 !h-3.5;
}
}
:deep(.nc-cell),
:deep(.nc-virtual-cell) {
@apply text-small leading-[18px];
.nc-cell-field,
input,
textarea,
.nc-cell-field-link {
@apply !text-small !leading-[18px];
}
}
:deep(.nc-cell) {
&.nc-cell-longtext {
.long-text-wrapper {
@apply min-h-1;
.nc-readonly-rich-text-wrapper {
@apply !min-h-1;
}
.nc-rich-text {
@apply pl-0;
.tiptap.ProseMirror {
@apply -ml-1 min-h-1;
}
}
}
}
&.nc-cell-checkbox {
@apply children:pl-0;
}
&.nc-cell-singleselect .nc-cell-field > div {
@apply flex items-center;
}
&.nc-cell-multiselect .nc-cell-field > div {
@apply h-5;
}
&.nc-cell-email,
&.nc-cell-phonenumber {
@apply flex items-center;
}
&.nc-cell-email,
&.nc-cell-phonenumber,
&.nc-cell-url {
.nc-cell-field-link {
@apply py-0;
}
}
}
:deep(.nc-virtual-cell) {
.nc-links-wrapper {
@apply py-0 children:min-h-4;
}
&.nc-virtual-cell-linktoanotherrecord {
.chips-wrapper {
@apply min-h-4 !children:min-h-4;
.chip.group {
@apply my-0;
}
}
}
&.nc-virtual-cell-lookup {
.nc-lookup-cell {
@apply !h-5.5;
.nc-cell-lookup-scroll {
@apply py-0 h-auto;
}
}
}
&.nc-virtual-cell-formula {
.nc-cell-field {
@apply py-0;
}
}
&.nc-virtual-cell-qrcode,
&.nc-virtual-cell-barcode {
@apply children:justify-start;
}
}
</style> </style>

1130
packages/nc-gui/components/smartsheet/Kanban.vue

File diff suppressed because it is too large Load Diff

8
packages/nc-gui/components/smartsheet/Map.vue

@ -51,7 +51,8 @@ const getMapCenterLocalStorageKey = (viewId: string) => `mapView.${viewId}.cente
const expandForm = (row: Row, state?: Record<string, any>) => { const expandForm = (row: Row, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!) const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
if (rowId && !isPublic.value) {
router.push({ router.push({
query: { query: {
...route.query, ...route.query,
@ -236,6 +237,7 @@ const count = computed(() => paginationData.value.totalRows)
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:row="expandedFormRow" :row="expandedFormRow"
:load-row="!isPublic"
:state="expandedFormRowState" :state="expandedFormRowState"
:meta="meta" :meta="meta"
:view="view" :view="view"
@ -245,9 +247,11 @@ const count = computed(() => paginationData.value.totalRows)
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg && meta?.id" v-if="expandedFormOnRowIdDlg && meta?.id"
v-model="expandedFormOnRowIdDlg" v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }" :row="expandedFormRow ?? { row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta" :meta="meta"
:load-row="!isPublic"
:row-id="route.query.rowId" :row-id="route.query.rowId"
:expand-form="expandForm"
:view="view" :view="view"
/> />
</Suspense> </Suspense>

7
packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue

@ -15,13 +15,6 @@ const { loadData } = useViewData(meta, view)
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
provide(IsGridInj, ref(false)) provide(IsGridInj, ref(false))
const isRowEmpty = (record: any, col: any) => {
const val = record.row[col.title]
if (!val) return true
return Array.isArray(val) && val.length === 0
}
reloadViewDataHook?.on(async () => { reloadViewDataHook?.on(async () => {
await loadData() await loadData()
}) })

12
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
const { isGrid, isGallery, isKanban, isMap, isCalendar } = useSmartsheetStoreOrThrow() const { isGrid, isGallery, isKanban, isMap, isCalendar } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore()) const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
@ -16,13 +14,11 @@ const isTab = computed(() => {
if (!isCalendar.value) return false if (!isCalendar.value) return false
return width.value > 1200 return width.value > 1200
}) })
const { allowCSVDownload } = useSharedView()
</script> </script>
<template> <template>
<div <div
v-if="!isMobileMode || isCalendar" v-if="!isMobileMode"
ref="containerRef" ref="containerRef"
:class="{ :class="{
'px-4': isMobileMode, 'px-4': isMobileMode,
@ -47,10 +43,10 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarCalendarRange v-if="isCalendar" /> <LazySmartsheetToolbarCalendarRange v-if="isCalendar" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarStackedBy v-if="isKanban" /> <LazySmartsheetToolbarStackedBy v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" /> <LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarGroupByMenu v-if="isGrid" /> <LazySmartsheetToolbarGroupByMenu v-if="isGrid" />
@ -65,8 +61,6 @@ const { allowCSVDownload } = useSharedView()
<!-- <LazySmartsheetToolbarQrScannerButton v-if="isMobileMode && (isGrid || isKanban || isGallery)" /> --> <!-- <LazySmartsheetToolbarQrScannerButton v-if="isMobileMode && (isGrid || isKanban || isGallery)" /> -->
<LazySmartsheetToolbarExport v-if="isPublic && allowCSVDownload" />
<div class="flex-1" /> <div class="flex-1" />
</template> </template>

11
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -40,10 +40,13 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
<template> <template>
<div <div
class="nc-virtual-cell w-full flex items-center" class="nc-virtual-cell w-full flex items-center"
:class="{ :class="[
'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm, `nc-virtual-cell-${(column.uidt || 'default').toLowerCase()}`,
'nc-display-value-cell': isPrimary(column) && !isForm, {
}" 'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm,
'nc-display-value-cell': isPrimary(column) && !isForm,
},
]"
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
> >

76
packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue

@ -26,6 +26,8 @@ const meta = inject(MetaInj, ref())
const fields = inject(FieldsInj, ref()) const fields = inject(FieldsInj, ref())
const isPublic = inject(IsPublicInj, ref(false))
const { fields: _fields } = useViewColumnsOrThrow() const { fields: _fields } = useViewColumnsOrThrow()
const fieldStyles = computed(() => { const fieldStyles = computed(() => {
@ -56,6 +58,29 @@ const hours = computed(() => {
return hours return hours
}) })
const currTime = ref(dayjs())
const overlayTop = computed(() => {
const perRecordHeight = 52
const minutes = currTime.value.minute() + currTime.value.hour() * 60
const top = (perRecordHeight / 60) * minutes
return top
})
onMounted(() => {
const intervalId = setInterval(() => {
currTime.value = dayjs()
}, 10000) // 10000 ms = 10 seconds
// Clean up the interval when the component is unmounted
onUnmounted(() => {
clearInterval(intervalId)
})
})
const calculateNewDates = useMemoize( const calculateNewDates = useMemoize(
({ ({
endDate, endDate,
@ -166,12 +191,21 @@ const getMaxOverlaps = ({
return maxOverlaps return maxOverlaps
} }
let maxOverlaps = 1
const id = row.rowMeta.id as string const id = row.rowMeta.id as string
if (graph.has(id)) { if (graph.has(id)) {
maxOverlaps = dfs(id) dfs(id)
} }
return maxOverlaps
const overlapIterations: Array<number> = []
columnArray
.flat()
.filter((record) => visited.has(record.rowMeta.id!))
.forEach((record) => {
overlapIterations.push(record.rowMeta.overLapIteration!)
})
return Math.max(...overlapIterations)
} }
const recordsAcrossAllRange = computed<{ const recordsAcrossAllRange = computed<{
@ -246,8 +280,8 @@ const recordsAcrossAllRange = computed<{
const heightInPixels = Math.max(endDate.diff(startDate, 'minute'), perRecordHeight) const heightInPixels = Math.max(endDate.diff(startDate, 'minute'), perRecordHeight)
const style: Partial<CSSStyleDeclaration> = { const style: Partial<CSSStyleDeclaration> = {
height: `${heightInPixels - 8}px`, height: `${heightInPixels - 2}px`,
top: `${topInPixels + 4}px`, top: `${topInPixels + 1}px`,
} }
// This property is used to determine which side the record should be rounded. It can be top, bottom, both or none // This property is used to determine which side the record should be rounded. It can be top, bottom, both or none
@ -298,8 +332,8 @@ const recordsAcrossAllRange = computed<{
const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 52, perRecordHeight) const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 52, perRecordHeight)
style = { style = {
...style, ...style,
top: `${topInPixels + 4}px`, top: `${topInPixels + 1}px`,
height: `${heightInPixels - 8}px`, height: `${heightInPixels - 2}px`,
} }
recordsByRange.push({ recordsByRange.push({
@ -681,13 +715,13 @@ const stopDrag = (event: MouseEvent) => {
} }
const dragStart = (event: MouseEvent, record: Row) => { const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
let target = event.target as HTMLElement let target = event.target as HTMLElement
isDragging.value = false isDragging.value = false
// We use a timeout to determine if the user is dragging or clicking on the record // We use a timeout to determine if the user is dragging or clicking on the record
dragTimeout.value = setTimeout(() => { dragTimeout.value = setTimeout(() => {
if (!isUIAllowed('dataEdit')) return
isDragging.value = true isDragging.value = true
while (!target.classList.contains('draggable-record')) { while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement target = target.parentElement as HTMLElement
@ -868,6 +902,24 @@ watch(
data-testid="nc-calendar-day-view" data-testid="nc-calendar-day-view"
@drop="dropEvent" @drop="dropEvent"
> >
<div
v-if="!isPublic && dayjs().isSame(selectedDate, 'day')"
class="absolute ml-2 pointer-events-none w-full z-4"
:style="{
top: `${overlayTop}px`,
}"
>
<div class="flex w-full items-center">
<span
class="text-brand-500 text-xs rounded-md border-1 pointer-events-auto px-0.5 border-brand-200 cursor-pointer bg-brand-50"
@click="newRecord(dayjs())"
>
{{ dayjs().format('hh:mm A') }}
</span>
<div class="flex-1 border-b-1 border-brand-500"></div>
</div>
</div>
<div> <div>
<div <div
v-for="(hour, index) in hours" v-for="(hour, index) in hours"
@ -895,7 +947,7 @@ watch(
@dblclick="newRecord(hour)" @dblclick="newRecord(hour)"
> >
<NcDropdown <NcDropdown
v-if="calendarRange.length > 1" v-if="calendarRange.length > 1 && !isPublic"
:class="{ :class="{
'!block': hour.isSame(selectedTime), '!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime), '!hidden': !hour.isSame(selectedTime),
@ -903,7 +955,7 @@ watch(
auto-close auto-close
> >
<NcButton <NcButton
class="!group-hover:block mr-4 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute" class="!group-hover:block mr-12 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall" size="xsmall"
type="secondary" type="secondary"
> >
@ -944,12 +996,12 @@ watch(
</template> </template>
</NcDropdown> </NcDropdown>
<NcButton <NcButton
v-else v-else-if="!isPublic"
:class="{ :class="{
'!block': hour.isSame(selectedTime), '!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime), '!hidden': !hour.isSame(selectedTime),
}" }"
class="!group-hover:block mr-4 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute" class="!group-hover:block mr-12 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall" size="xsmall"
type="secondary" type="secondary"
@click=" @click="

246
packages/nc-gui/components/smartsheet/calendar/MonthView.vue

@ -14,6 +14,7 @@ const {
sideBarFilterOption, sideBarFilterOption,
displayField, displayField,
calendarRange, calendarRange,
viewMetaProperties,
showSideMenu, showSideMenu,
updateRowProperty, updateRowProperty,
} = useCalendarViewStoreOrThrow() } = useCalendarViewStoreOrThrow()
@ -26,12 +27,24 @@ const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const maxVisibleDays = computed(() => {
return viewMetaProperties.value?.hide_weekend ? 5 : 7
})
const days = computed(() => { const days = computed(() => {
let days = []
if (isMondayFirst.value) { if (isMondayFirst.value) {
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
} else { } else {
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
} }
if (maxVisibleDays.value === 5) {
days = days.filter((day) => day !== 'Sat' && day !== 'Sun')
}
return days
}) })
const calendarGridContainer = ref() const calendarGridContainer = ref()
@ -42,6 +55,14 @@ const isDayInPagedMonth = (date: dayjs.Dayjs) => {
return date.month() === selectedMonth.value.month() return date.month() === selectedMonth.value.month()
} }
const getDayIndex = (date: dayjs.Dayjs) => {
let dayIndex = date.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
return dayIndex
}
const dragElement = ref<HTMLElement | null>(null) const dragElement = ref<HTMLElement | null>(null)
const draggingId = ref<string | null>(null) const draggingId = ref<string | null>(null)
@ -115,7 +136,7 @@ const recordsToDisplay = computed<{
}>(() => { }>(() => {
if (!dates.value || !calendarRange.value) return [] if (!dates.value || !calendarRange.value) return []
const perWidth = gridContainerWidth.value / 7 const perWidth = gridContainerWidth.value / maxVisibleDays.value
const perHeight = gridContainerHeight.value / dates.value.length const perHeight = gridContainerHeight.value / dates.value.length
const perRecordHeight = 24 const perRecordHeight = 24
@ -176,6 +197,12 @@ const recordsToDisplay = computed<{
width: `${perWidth}px`, width: `${perWidth}px`,
} }
if (maxVisibleDays.value === 5) {
if (dayIndex === 5 || dayIndex === 6) {
style.display = 'none'
}
}
// Number of records in that day // Number of records in that day
const recordIndex = recordsInDay[dateKey].count const recordIndex = recordsInDay[dateKey].count
@ -364,12 +391,15 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeC
if (!fromCol) return { newRow: null, updateProperty: [] } if (!fromCol) return { newRow: null, updateProperty: [] }
const week = Math.floor(percentY * dates.value.length) const week = Math.floor(percentY * dates.value.length)
const day = Math.floor(percentX * 7) const day = Math.floor(percentX * maxVisibleDays.value)
let newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null let newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null
if (!newStartDate) return { newRow: null, updateProperty: [] } if (!newStartDate) return { newRow: null, updateProperty: [] }
const fromDate = dayjs(dragRecord.value.row[fromCol.title!]) let fromDate = dayjs(dragRecord.value.row[fromCol.title!])
if (!fromDate.isValid()) {
fromDate = dayjs()
}
newStartDate = newStartDate.add(fromDate.hour(), 'hour').add(fromDate.minute(), 'minute').add(fromDate.second(), 'second') newStartDate = newStartDate.add(fromDate.hour(), 'hour').add(fromDate.minute(), 'minute').add(fromDate.second(), 'second')
@ -461,7 +491,7 @@ const onResize = (event: MouseEvent) => {
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
const week = Math.floor(percentY * dates.value.length) const week = Math.floor(percentY * dates.value.length)
const day = Math.floor(percentX * 7) const day = Math.floor(percentX * maxVisibleDays.value)
let updateProperty: string[] = [] let updateProperty: string[] = []
let newRow: Row let newRow: Row
@ -567,11 +597,12 @@ const stopDrag = (event: MouseEvent) => {
} }
const dragStart = (event: MouseEvent, record: Row) => { const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit') || resizeInProgress.value || !record.rowMeta.id) return if (resizeInProgress.value || !record.rowMeta.id) return
let target = event.target as HTMLElement let target = event.target as HTMLElement
isDragging.value = false isDragging.value = false
dragTimeout.value = setTimeout(() => { dragTimeout.value = setTimeout(() => {
if (!isUIAllowed('dataEdit')) return
isDragging.value = true isDragging.value = true
while (!target.classList.contains('draggable-record')) { while (!target.classList.contains('draggable-record')) {
@ -670,11 +701,17 @@ const addRecord = (date: dayjs.Dayjs) => {
<template> <template>
<div v-if="calendarRange" class="h-full prevent-select relative" data-testid="nc-calendar-month-view"> <div v-if="calendarRange" class="h-full prevent-select relative" data-testid="nc-calendar-month-view">
<div class="grid grid-cols-7"> <div
class="grid"
:class="{
'grid-cols-7': maxVisibleDays === 7,
'grid-cols-5': maxVisibleDays === 5,
}"
>
<div <div
v-for="(day, index) in days" v-for="(day, index) in days"
:key="index" :key="index"
class="text-center bg-gray-50 py-1 border-b-1 border-r-1 last:border-r-0 border-gray-200 font-regular uppercase text-xs text-gray-500" class="text-center bg-gray-50 py-1 border-r-1 last:border-r-0 border-gray-200 font-semibold leading-4 uppercase text-[10px] text-gray-500"
> >
{{ day }} {{ day }}
</div> </div>
@ -690,50 +727,61 @@ const addRecord = (date: dayjs.Dayjs) => {
style="height: calc(100% - 1.59rem)" style="height: calc(100% - 1.59rem)"
@drop="dropEvent" @drop="dropEvent"
> >
<div v-for="(week, weekIndex) in dates" :key="weekIndex" class="grid grid-cols-7 grow" data-testid="nc-calendar-month-week"> <div
<div v-for="(week, weekIndex) in dates"
v-for="(day, dateIndex) in week" :key="weekIndex"
:key="`${weekIndex}-${dateIndex}`" :class="{
:class="{ 'grid-cols-7': maxVisibleDays === 7,
'border-brand-500 border-1 !border-r-1 border-b-1': 'grid-cols-5': maxVisibleDays === 5,
isDateSelected(day) || (focusedDate && dayjs(day).isSame(focusedDate, 'day')), }"
'!text-gray-400': !isDayInPagedMonth(day), class="grid grow"
'!bg-gray-50': day.get('day') === 0 || day.get('day') === 6, data-testid="nc-calendar-month-week"
}" >
class="text-right relative group last:border-r-0 transition text-sm h-full border-r-1 border-b-1 border-gray-200 font-medium hover:bg-gray-50 text-gray-800 bg-white" <template v-for="(day, dateIndex) in week">
data-testid="nc-calendar-month-day" <div
@click="selectDate(day)" v-if="maxVisibleDays === 5 ? day.get('day') !== 0 && day.get('day') !== 6 : true"
@dblclick="addRecord(day)" :key="`${weekIndex}-${dateIndex}`"
> :class="{
<div v-if="isUIAllowed('dataEdit')" class="flex justify-between p-1"> 'border-brand-500 border-1 !border-r-1 border-b-1':
<span isDateSelected(day) || (focusedDate && dayjs(day).isSame(focusedDate, 'day')),
:class="{ '!text-gray-400': !isDayInPagedMonth(day),
block: !isDateSelected(day), '!bg-gray-50 !hover:bg-gray-100': day.get('day') === 0 || day.get('day') === 6,
hidden: isDateSelected(day), 'border-t-1': weekIndex === 0,
}" }"
class="group-hover:hidden" class="text-right relative group last:border-r-0 transition text-sm h-full border-r-1 border-b-1 border-gray-200 font-medium hover:bg-gray-50 text-gray-800 bg-white"
></span> data-testid="nc-calendar-month-day"
@click="selectDate(day)"
<NcDropdown v-if="calendarRange.length > 1" auto-close> @dblclick="addRecord(day)"
<NcButton >
<div v-if="isUIAllowed('dataEdit')" class="flex justify-between p-1">
<span
:class="{ :class="{
'!block': isDateSelected(day), block: !isDateSelected(day),
'!hidden': !isDateSelected(day), hidden: isDateSelected(day),
}" }"
class="!group-hover:block rounded" class="group-hover:hidden"
size="small" ></span>
type="secondary"
> <NcDropdown v-if="calendarRange.length > 1" auto-close>
<component :is="iconMap.plus" class="h-4 w-4" /> <NcButton
</NcButton> :class="{
<template #overlay> '!block': isDateSelected(day),
<NcMenu class="w-64"> '!hidden': !isDateSelected(day),
<NcMenuItem> Select date field to add </NcMenuItem> }"
<NcMenuItem class="!group-hover:block rounded"
v-for="(range, index) in calendarRange" size="small"
:key="index" type="secondary"
class="text-gray-800 font-semibold text-sm" >
@click=" <component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<template #overlay>
<NcMenu class="w-64">
<NcMenuItem> Select date field to add </NcMenuItem>
<NcMenuItem
v-for="(range, index) in calendarRange"
:key="index"
class="text-gray-800 font-semibold text-sm"
@click="
() => { () => {
const record = { const record = {
row: { row: {
@ -743,25 +791,25 @@ const addRecord = (date: dayjs.Dayjs) => {
emit('newRecord', record) emit('newRecord', record)
} }
" "
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" /> <LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" />
<span class="ml-1">{{ range.fk_from_col!.title }}</span> <span class="ml-1">{{ range.fk_from_col!.title }}</span>
</div> </div>
</NcMenuItem> </NcMenuItem>
</NcMenu> </NcMenu>
</template> </template>
</NcDropdown> </NcDropdown>
<NcButton <NcButton
v-else v-else
:class="{ :class="{
'!block': isDateSelected(day), '!block': isDateSelected(day),
'!hidden': !isDateSelected(day), '!hidden': !isDateSelected(day),
}" }"
class="!group-hover:block !w-6 !h-6 !rounded" class="!group-hover:block !w-6 !h-6 !rounded"
size="xsmall" size="xsmall"
type="secondary" type="secondary"
@click=" @click="
() => { () => {
const record = { const record = {
row: { row: {
@ -771,35 +819,36 @@ const addRecord = (date: dayjs.Dayjs) => {
emit('newRecord', record) emit('newRecord', record)
} }
" "
>
<component :is="iconMap.plus" />
</NcButton>
<span
:class="{
'bg-brand-50 text-brand-500 !font-bold': day.isSame(dayjs(), 'date'),
}"
class="px-1.3 py-1 text-sm leading-3 font-medium rounded-lg"
>
{{ day.format('DD') }}
</span>
</div>
<div v-if="!isUIAllowed('dataEdit')" class="leading-3 p-3">{{ dayjs(day).format('DD') }}</div>
<NcButton
v-if="
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')] &&
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflow &&
!draggingId
"
v-e="`['c:calendar:month-view-more']`"
class="!absolute bottom-1 right-1 text-center min-w-4.5 mx-auto z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(day)"
> >
<component :is="iconMap.plus" /> <span class="text-xs px-1"> + {{ recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflowCount }} </span>
</NcButton> </NcButton>
<span
:class="{
'bg-brand-50 text-brand-500 !font-bold': day.isSame(dayjs(), 'date'),
}"
class="px-1.3 py-1 text-sm font-medium rounded-lg"
>
{{ day.format('DD') }}
</span>
</div> </div>
<div v-if="!isUIAllowed('dataEdit')" class="p-3">{{ dayjs(day).format('DD') }}</div> </template>
<NcButton
v-if="
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')] &&
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflow &&
!draggingId
"
v-e="`['c:calendar:month-view-more']`"
class="!absolute bottom-1 right-1 text-center min-w-4.5 mx-auto z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(day)"
>
<span class="text-xs px-1"> + {{ recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflowCount }} </span>
</NcButton>
</div>
</div> </div>
</div> </div>
<div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container"> <div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container">
@ -825,7 +874,6 @@ const addRecord = (date: dayjs.Dayjs) => {
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" :resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:selected="dragRecord?.rowMeta?.id === record.rowMeta.id || resizeRecord?.rowMeta?.id === record.rowMeta.id" :selected="dragRecord?.rowMeta?.id === record.rowMeta.id || resizeRecord?.rowMeta?.id === record.rowMeta.id"
@resize-start="onResizeStart" @resize-start="onResizeStart"
@dblclick.stop="emit('expandRecord', record)"
> >
<template v-if="calDataType === UITypes.DateTime" #time> <template v-if="calDataType === UITypes.DateTime" #time>
<span class="text-xs font-medium text-gray-400"> <span class="text-xs font-medium text-gray-400">
@ -857,4 +905,8 @@ const addRecord = (date: dayjs.Dayjs) => {
-ms-user-select: none; /* IE 10 and IE 11 */ -ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */ user-select: none; /* Standard syntax */
} }
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
</style> </style>

104
packages/nc-gui/components/smartsheet/calendar/SideMenu.vue

@ -9,6 +9,10 @@ const props = defineProps<{
const emit = defineEmits(['expandRecord', 'newRecord']) const emit = defineEmits(['expandRecord', 'newRecord'])
interface Attachment {
url: string
}
const INFINITY_SCROLL_THRESHOLD = 100 const INFINITY_SCROLL_THRESHOLD = 100
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
@ -21,6 +25,10 @@ const { height } = useWindowSize()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const { fields } = useViewColumnsOrThrow()
const { getPossibleAttachmentSrc } = useAttachment()
const { t } = useI18n() const { t } = useI18n()
const { const {
@ -45,6 +53,23 @@ const {
const sideBarListRef = ref<VNodeRef | null>(null) const sideBarListRef = ref<VNodeRef | null>(null)
const coverImageColumns: any = computed(() => {
if (!fields.value || !meta.value?.columns) return
return meta.value.columns.find((c) => c.uidt === UITypes.Attachment && fields.value?.find((f) => f.fk_column_id === c.id).show)
})
const attachments = (record: any): Attachment[] => {
const col = coverImageColumns.value
try {
if (col?.title && record.row[col.title]) {
return typeof record.row[col.title] === 'string' ? JSON.parse(record.row[col.title]) : record.row[col.title]
}
return []
} catch (e) {
return []
}
}
const pushToArray = (arr: Array<Row>, record: Row, range) => { const pushToArray = (arr: Array<Row>, record: Row, range) => {
arr.push({ arr.push({
...record, ...record,
@ -381,9 +406,14 @@ onClickOutside(searchRef, toggleSearch)
> >
<div class="flex px-4 items-center gap-3"> <div class="flex px-4 items-center gap-3">
<span class="capitalize font-medium text-gray-700">{{ $t('objects.records') }}</span> <span class="capitalize font-medium text-gray-700">{{ $t('objects.records') }}</span>
<NcSelect v-model:value="sideBarFilterOption" class="w-full !text-gray-600" data-testid="nc-calendar-sidebar-filter"> <NcSelect
v-model:value="sideBarFilterOption"
size="small"
class="w-full !h-7 !text-gray-600"
data-testid="nc-calendar-sidebar-filter"
>
<a-select-option v-for="option in options" :key="option.value" :value="option.value" class="!text-gray-600"> <a-select-option v-for="option in options" :key="option.value" :value="option.value" class="!text-gray-600">
<div class="flex items-center w-full justify-between gap-2"> <div class="flex items-center h-7 w-full justify-between gap-2">
<div class="truncate"> <div class="truncate">
<NcTooltip :title="option.label" placement="top" show-on-truncate-only> <NcTooltip :title="option.label" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template> <template #title>{{ option.label }}</template>
@ -429,7 +459,7 @@ onClickOutside(searchRef, toggleSearch)
v-if="!showSearch" v-if="!showSearch"
data-testid="nc-calendar-sidebar-search-btn" data-testid="nc-calendar-sidebar-search-btn"
size="small" size="small"
class="!h-7" class="!h-7 !rounded-md"
type="secondary" type="secondary"
@click="clickSearch" @click="clickSearch"
> >
@ -444,7 +474,7 @@ onClickOutside(searchRef, toggleSearch)
v-if="isUIAllowed('dataEdit') && props.visible" v-if="isUIAllowed('dataEdit') && props.visible"
v-e="['c:calendar:calendar-sidemenu-new-record-btn']" v-e="['c:calendar:calendar-sidemenu-new-record-btn']"
data-testid="nc-calendar-side-menu-new-btn" data-testid="nc-calendar-side-menu-new-btn"
class="!h-7" class="!h-7 !rounded-md"
size="small" size="small"
type="secondary" type="secondary"
@click="newRecord" @click="newRecord"
@ -497,8 +527,8 @@ onClickOutside(searchRef, toggleSearch)
:from-date=" :from-date="
record.rowMeta.range?.fk_from_col record.rowMeta.range?.fk_from_col
? calDataType === UITypes.Date ? calDataType === UITypes.Date
? dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('DD MMM') ? dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('D MMM')
: dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('DD MMM • HH:mm A') : dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('D MMM • h:mm a')
: null : null
" "
:invalid=" :invalid="
@ -521,6 +551,50 @@ onClickOutside(searchRef, toggleSearch)
@dragstart="dragStart($event, record)" @dragstart="dragStart($event, record)"
@dragover.prevent @dragover.prevent
> >
<template v-if="coverImageColumns" #image>
<a-carousel
v-if="attachments(record).length"
class="gallery-carousel rounded-md !border-1 !border-gray-200"
arrows
>
<template #customPaging>
<a>
<div class="pt-[12px]">
<div></div>
</div>
</a>
</template>
<template #prevArrow>
<div class="z-10 arrow">
<MdiChevronLeft
class="text-gray-700 w-6 h-6 absolute left-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition"
/>
</div>
</template>
<template #nextArrow>
<div class="z-10 arrow">
<MdiChevronRight
class="text-gray-700 w-6 h-6 absolute right-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition"
/>
</div>
</template>
<template v-for="(attachment, index) in attachments(record)">
<LazyCellAttachmentImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`"
class="h-10 !w-10 !object-contain"
:srcs="getPossibleAttachmentSrc(attachment)"
/>
</template>
</a-carousel>
<div v-else class="h-10 w-10 !flex flex-row !border-1 rounded-md !border-gray-200 items-center justify-center">
<img class="object-contain w-[40px] h-[40px]" src="~assets/icons/FileIconImageBox.png" />
</div>
</template>
<template v-if="!isRowEmpty(record, displayField)"> <template v-if="!isRowEmpty(record, displayField)">
<LazySmartsheetPlainCell v-model="record.row[displayField!.title!]" :column="displayField" /> <LazySmartsheetPlainCell v-model="record.row[displayField!.title!]" :column="displayField" />
</template> </template>
@ -582,4 +656,20 @@ onClickOutside(searchRef, toggleSearch)
</div> </div>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped>
:deep(.nc-attachment-image) {
@apply rounded-md;
}
:deep(.ant-select-selector) {
@apply !h-7;
}
:deep(.nc-month-picker-pagination) {
@apply !border-b-0;
}
:deep(.nc-date-week-header) {
@apply !border-b-0;
}
</style>

19
packages/nc-gui/components/smartsheet/calendar/SideRecordCard.vue

@ -16,17 +16,7 @@ const props = withDefaults(defineProps<Props>(), {
</script> </script>
<template> <template>
<div <div class="border-1 cursor-pointer h-14 border-gray-200 flex gap-2 items-center rounded-lg">
:class="{
'bg-maroon-50': props.color === 'maroon',
'bg-blue-50': props.color === 'blue',
'bg-green-50': props.color === 'green',
'bg-yellow-50': props.color === 'yellow',
'bg-pink-50': props.color === 'pink',
'bg-purple-50': props.color === 'purple',
}"
class="border-1 cursor-pointer h-14 border-gray-200 flex gap-2 items-center rounded-lg"
>
<div class="flex items-center pl-2 gap-2"> <div class="flex items-center pl-2 gap-2">
<span <span
:class="{ :class="{
@ -39,11 +29,14 @@ const props = withDefaults(defineProps<Props>(), {
}" }"
class="block h-10 w-1 rounded" class="block h-10 w-1 rounded"
></span> ></span>
<slot name="image" />
<div class="flex gap-1 flex-col"> <div class="flex gap-1 flex-col">
<span class="text-sm max-w-56 font-medium truncate text-gray-800"> <span class="text-[13px] leading-4 max-w-56 font-medium truncate text-gray-800">
<slot /> <slot />
</span> </span>
<span v-if="showDate" class="text-xs font-medium text-gray-500">{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span> <span v-if="showDate" class="text-xs font-medium leading-4 text-gray-600"
>{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span
>
</div> </div>
</div> </div>

39
packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue

@ -5,8 +5,20 @@ import type { Row } from '~/lib/types'
const emits = defineEmits(['expandRecord', 'newRecord']) const emits = defineEmits(['expandRecord', 'newRecord'])
const { selectedDateRange, formattedData, formattedSideBarData, calendarRange, selectedDate, displayField, updateRowProperty } = const {
useCalendarViewStoreOrThrow() selectedDateRange,
formattedData,
formattedSideBarData,
calendarRange,
selectedDate,
displayField,
updateRowProperty,
viewMetaProperties,
} = useCalendarViewStoreOrThrow()
const maxVisibleDays = computed(() => {
return viewMetaProperties.value?.hide_weekend ? 5 : 7
})
const container = ref<null | HTMLElement>(null) const container = ref<null | HTMLElement>(null)
@ -41,12 +53,17 @@ const getFieldStyle = (field: ColumnType) => {
// Calculate the dates of the week // Calculate the dates of the week
const weekDates = computed(() => { const weekDates = computed(() => {
let startOfWeek = dayjs(selectedDateRange.value.start) let startOfWeek = dayjs(selectedDateRange.value.start)
const endOfWeek = dayjs(selectedDateRange.value.end) let endOfWeek = dayjs(selectedDateRange.value.end)
if (maxVisibleDays.value === 5) {
endOfWeek = endOfWeek.subtract(2, 'day')
}
const datesArray = [] const datesArray = []
while (startOfWeek.isBefore(endOfWeek) || startOfWeek.isSame(endOfWeek, 'day')) { while (startOfWeek.isBefore(endOfWeek) || startOfWeek.isSame(endOfWeek, 'day')) {
datesArray.push(dayjs(startOfWeek)) datesArray.push(dayjs(startOfWeek))
startOfWeek = startOfWeek.add(1, 'day') startOfWeek = startOfWeek.add(1, 'day')
} }
return datesArray return datesArray
}) })
@ -111,7 +128,7 @@ const calendarData = computed(() => {
} }
const recordsInRange: Array<Row> = [] const recordsInRange: Array<Row> = []
const perDayWidth = containerWidth.value / 7 const perDayWidth = containerWidth.value / maxVisibleDays.value
calendarRange.value.forEach((range) => { calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col const fromCol = range.fk_from_col
@ -284,7 +301,7 @@ const onResize = (event: MouseEvent) => {
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!]) const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!]) const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const day = Math.floor(percentX * 7) const day = Math.floor(percentX * maxVisibleDays.value)
let updateProperty: string[] = [] let updateProperty: string[] = []
let updateRecord: Row let updateRecord: Row
@ -370,7 +387,7 @@ const calculateNewRow = (event: MouseEvent, updateSideBarData?: boolean) => {
// Calculate the day index based on the percentage of the width // Calculate the day index based on the percentage of the width
// The day index is a number between 0 and 6 // The day index is a number between 0 and 6
const day = Math.floor(percentX * 7) const day = Math.floor(percentX * maxVisibleDays.value)
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range // Calculate the new start date based on the day index by adding the day index to the start date of the selected date range
const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day') const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day')
@ -467,13 +484,13 @@ const stopDrag = (event: MouseEvent) => {
} }
const dragStart = (event: MouseEvent, record: Row) => { const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
if (resizeInProgress.value) return if (resizeInProgress.value) return
let target = event.target as HTMLElement let target = event.target as HTMLElement
isDragging.value = false isDragging.value = false
dragTimeout.value = setTimeout(() => { dragTimeout.value = setTimeout(() => {
if (!isUIAllowed('dataEdit')) return
isDragging.value = true isDragging.value = true
while (!target.classList.contains('draggable-record')) { while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement target = target.parentElement as HTMLElement
@ -557,8 +574,10 @@ const addRecord = (date: dayjs.Dayjs) => {
:key="weekIndex" :key="weekIndex"
:class="{ :class="{
'!border-brand-500 !border-b-gray-200': dayjs(date).isSame(selectedDate, 'day'), '!border-brand-500 !border-b-gray-200': dayjs(date).isSame(selectedDate, 'day'),
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
}" }"
class="w-1/7 cursor-pointer text-center text-[10px] font-semibold leading-4 flex items-center justify-center uppercase text-gray-500 w-full py-1 border-gray-200 border-l-gray-50 border-t-gray-50 last:border-r-0 border-1 bg-gray-50" class="cursor-pointer text-center text-[10px] font-semibold leading-4 flex items-center justify-center uppercase text-gray-500 w-full py-1 border-gray-200 border-l-gray-50 border-t-gray-50 last:border-r-0 border-1 bg-gray-50"
@click="selectDate(date)" @click="selectDate(date)"
@dblclick="addRecord(date)" @dblclick="addRecord(date)"
> >
@ -572,8 +591,10 @@ const addRecord = (date: dayjs.Dayjs) => {
:class="{ :class="{
'!border-1 !border-t-0 border-brand-500': dayjs(date).isSame(selectedDate, 'day'), '!border-1 !border-t-0 border-brand-500': dayjs(date).isSame(selectedDate, 'day'),
'!bg-gray-50': date.get('day') === 0 || date.get('day') === 6, '!bg-gray-50': date.get('day') === 0 || date.get('day') === 6,
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
}" }"
class="flex cursor-pointer flex-col border-r-1 min-h-[100vh] last:border-r-0 items-center w-1/7" class="flex cursor-pointer flex-col border-r-1 min-h-[100vh] last:border-r-0 items-center"
data-testid="nc-calendar-week-day" data-testid="nc-calendar-week-day"
@click="selectDate(date)" @click="selectDate(date)"
@dblclick="addRecord(date)" @dblclick="addRecord(date)"

150
packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue

@ -11,6 +11,7 @@ const {
formattedSideBarData, formattedSideBarData,
calendarRange, calendarRange,
displayField, displayField,
viewMetaProperties,
selectedTime, selectedTime,
updateRowProperty, updateRowProperty,
sideBarFilterOption, sideBarFilterOption,
@ -25,6 +26,8 @@ const scrollContainer = ref<null | HTMLElement>(null)
const { width: containerWidth } = useElementSize(container) const { width: containerWidth } = useElementSize(container)
const isPublic = inject(IsPublicInj, ref(false))
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
@ -47,6 +50,50 @@ const fieldStyles = computed(() => {
) )
}) })
const getDayIndex = (date: dayjs.Dayjs) => {
let dayIndex = date.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
return dayIndex
}
const maxVisibleDays = computed(() => {
return viewMetaProperties.value?.hide_weekend ? 5 : 7
})
const currTime = ref(dayjs())
const overlayStyle = computed(() => {
if (!containerWidth.value)
return {
top: 0,
left: 0,
}
const left = (containerWidth.value / maxVisibleDays.value) * getDayIndex(currTime.value)
const minutes = currTime.value.hour() * 60 + currTime.value.minute()
const top = (52 / 60) * minutes
return {
width: `${containerWidth.value / maxVisibleDays.value}px`,
top: `${top}px`,
left: `${left}px`,
}
})
onMounted(() => {
const intervalId = setInterval(() => {
currTime.value = dayjs()
}, 10000) // 10000 ms = 10 seconds
// Clean up the interval when the component is unmounted
onUnmounted(() => {
clearInterval(intervalId)
})
})
const getFieldStyle = (field: ColumnType) => { const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id) return fieldStyles.value.get(field.id)
} }
@ -85,7 +132,11 @@ const calculateNewDates = useMemoize(
const datesHours = computed(() => { const datesHours = computed(() => {
const datesHours: Array<Array<dayjs.Dayjs>> = [] const datesHours: Array<Array<dayjs.Dayjs>> = []
let startOfWeek = dayjs(selectedDateRange.value.start) ?? dayjs().startOf('week') let startOfWeek = dayjs(selectedDateRange.value.start) ?? dayjs().startOf('week')
const endOfWeek = dayjs(selectedDateRange.value.end) ?? dayjs().endOf('week') let endOfWeek = dayjs(selectedDateRange.value.end) ?? dayjs().endOf('week')
if (maxVisibleDays.value === 5) {
endOfWeek = endOfWeek.subtract(2, 'day')
}
while (startOfWeek.isSameOrBefore(endOfWeek)) { while (startOfWeek.isSameOrBefore(endOfWeek)) {
const hours: Array<dayjs.Dayjs> = [] const hours: Array<dayjs.Dayjs> = []
@ -107,14 +158,6 @@ const datesHours = computed(() => {
return datesHours return datesHours
}) })
const getDayIndex = (date: dayjs.Dayjs) => {
let dayIndex = date.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
return dayIndex
}
const getGridTime = (date: dayjs.Dayjs, round = false) => { const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute() const gridCalc = date.hour() * 60 + date.minute()
if (round) { if (round) {
@ -201,8 +244,20 @@ const getMaxOverlaps = ({
let maxOverlaps = 1 let maxOverlaps = 1
if (graph.has(id)) { if (graph.has(id)) {
maxOverlaps = dfs(id) dfs(id)
} }
const overlapIterations: Array<number> = []
columnArray[dayIndex]
.flat()
.filter((record) => visited.has(record.rowMeta.id!))
.forEach((record) => {
overlapIterations.push(record.rowMeta.overLapIteration!)
})
maxOverlaps = Math.max(...overlapIterations)
return { maxOverlaps, dayIndex, overlapIndex } return { maxOverlaps, dayIndex, overlapIndex }
} }
@ -224,11 +279,15 @@ const recordsAcrossAllRange = computed<{
records: [], records: [],
gridTimeMap: new Map(), gridTimeMap: new Map(),
} }
const perWidth = containerWidth.value / 7 const perWidth = containerWidth.value / maxVisibleDays.value
const perHeight = 52 const perHeight = 52
const scheduleStart = dayjs(selectedDateRange.value.start).startOf('day') const scheduleStart = dayjs(selectedDateRange.value.start).startOf('day')
const scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day') let scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day')
if (maxVisibleDays.value === 5) {
scheduleEnd = scheduleEnd.subtract(2, 'day')
}
const columnArray: Array<Array<Array<Row>>> = [[[]]] const columnArray: Array<Array<Array<Row>>> = [[[]]]
const gridTimeMap = new Map< const gridTimeMap = new Map<
@ -489,17 +548,25 @@ const recordsAcrossAllRange = computed<{
}) })
const dayIndex = record.rowMeta.dayIndex ?? tDayIndex const dayIndex = record.rowMeta.dayIndex ?? tDayIndex
let display = 'block'
if (maxVisibleDays.value === 5) {
if (dayIndex === 5 || dayIndex === 6) {
display = 'none'
}
}
record.rowMeta.numberOfOverlaps = maxOverlaps record.rowMeta.numberOfOverlaps = maxOverlaps
let width = 0 let width = 0
let left = 100 let left = 100
const majorLeft = dayIndex * perWidth const majorLeft = dayIndex * perWidth
let display = 'block'
if (record.rowMeta.overLapIteration! - 1 > 2) { if (record.rowMeta.overLapIteration! - 1 > 2) {
display = 'none' display = 'none'
} else { } else {
width = 100 / Math.min(maxOverlaps, 3) / 7 width = 100 / Math.min(maxOverlaps, 3) / maxVisibleDays.value
left = width * (overlapIndex - 1) left = width * (overlapIndex - 1)
} }
record.rowMeta.style = { record.rowMeta.style = {
@ -563,7 +630,7 @@ const onResize = (event: MouseEvent) => {
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!]) const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!]) const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const day = Math.floor(percentX * 7) const day = Math.floor(percentX * maxVisibleDays.value)
const hour = Math.floor(percentY * 23) const hour = Math.floor(percentY * 23)
const minutes = Math.round((percentY * 24 * 60) % 60) const minutes = Math.round((percentY * 24 * 60) % 60)
@ -656,7 +723,7 @@ const calculateNewRow = (
if (!fromCol) return { newRow: null, updatedProperty: [] } if (!fromCol) return { newRow: null, updatedProperty: [] }
const day = Math.max(0, Math.min(6, Math.floor(percentX * 7))) const day = Math.max(0, Math.min(6, Math.floor(percentX * maxVisibleDays.value)))
const hour = Math.max(0, Math.min(23, Math.floor(percentY * 24))) const hour = Math.max(0, Math.min(23, Math.floor(percentY * 24)))
const minutes = Math.round(((percentY * 24 * 60) % 60) / 15) * 15 const minutes = Math.round(((percentY * 24 * 60) % 60) / 15) * 15
@ -762,13 +829,13 @@ const stopDrag = (event: MouseEvent) => {
} }
const dragStart = (event: MouseEvent, record: Row) => { const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
if (resizeInProgress.value) return if (resizeInProgress.value) return
let target = event.target as HTMLElement let target = event.target as HTMLElement
isDragging.value = false isDragging.value = false
dragTimeout.value = setTimeout(() => { dragTimeout.value = setTimeout(() => {
if (!isUIAllowed('dataEdit')) return
isDragging.value = true isDragging.value = true
while (!target.classList.contains('draggable-record')) { while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement target = target.parentElement as HTMLElement
@ -877,14 +944,32 @@ watch(
data-testid="nc-calendar-week-view" data-testid="nc-calendar-week-view"
@drop="dropEvent" @drop="dropEvent"
> >
<div
v-if="!isPublic && dayjs().isBetween(selectedDateRange.start, selectedDateRange.end)"
class="absolute ml-16 mt-7 pointer-events-none z-4"
:style="overlayStyle"
>
<div class="flex w-full items-center">
<span
class="text-brand-500 rounded-md text-xs border-1 pointer-events-auto px-0.5 border-brand-200 cursor-pointer bg-brand-50"
@click="addRecord(dayjs())"
>
{{ dayjs().format('hh:mm A') }}
</span>
<div class="flex-1 border-b-1 border-brand-500"></div>
</div>
</div>
<div class="flex sticky h-6 z-1 top-0 pl-16 bg-gray-50 w-full"> <div class="flex sticky h-6 z-1 top-0 pl-16 bg-gray-50 w-full">
<div <div
v-for="date in datesHours" v-for="date in datesHours"
:key="date[0].toISOString()" :key="date[0].toISOString()"
:class="{ :class="{
'text-brand-500': date[0].isSame(dayjs(), 'date'), 'text-brand-500': date[0].isSame(dayjs(), 'date'),
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
}" }"
class="w-1/7 text-center text-[10px] font-semibold leading-4 flex items-center justify-center uppercase text-gray-500 w-full py-1 border-gray-200 last:border-r-0 border-b-1 border-l-1 border-r-0 bg-gray-50" class="text-center text-[10px] font-semibold leading-4 flex items-center justify-center uppercase text-gray-500 w-full py-1 border-gray-200 last:border-r-0 border-b-1 border-l-1 border-r-0 bg-gray-50"
> >
{{ dayjs(date[0]).format('DD ddd') }} {{ dayjs(date[0]).format('DD ddd') }}
</div> </div>
@ -899,15 +984,27 @@ watch(
</div> </div>
</div> </div>
<div ref="container" class="absolute ml-16 flex w-[calc(100%-64px)]"> <div ref="container" class="absolute ml-16 flex w-[calc(100%-64px)]">
<div v-for="(date, index) in datesHours" :key="index" class="h-full w-1/7 mt-7.1" data-testid="nc-calendar-week-day"> <div
v-for="(date, index) in datesHours"
:key="index"
:class="{
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
}"
class="h-full mt-5.95"
data-testid="nc-calendar-week-day"
>
<div <div
v-for="(hour, hourIndex) in date" v-for="(hour, hourIndex) in date"
:key="hourIndex" :key="hourIndex"
:class="{ :class="{
'border-1 !border-brand-500 !bg-gray-100':
hour.isSame(selectedTime, 'hour') && (hour.get('day') === 6 || hour.get('day') === 0),
'border-1 !border-brand-500 bg-gray-50': hour.isSame(selectedTime, 'hour'), 'border-1 !border-brand-500 bg-gray-50': hour.isSame(selectedTime, 'hour'),
'!bg-gray-50': hour.get('day') === 0 || hour.get('day') === 6, 'bg-gray-50 hover:bg-gray-100': hour.get('day') === 0 || hour.get('day') === 6,
'hover:bg-gray-50': hour.get('day') !== 0 && hour.get('day') !== 6,
}" }"
class="text-center relative transition h-13 text-sm text-gray-500 w-full hover:bg-gray-50 py-1 border-transparent border-1 border-x-gray-100 border-t-gray-100 border-l-gray-200" class="text-center relative transition h-13 text-sm text-gray-500 w-full py-1 border-transparent border-1 border-x-gray-100 border-t-gray-100 border-l-gray-200"
data-testid="nc-calendar-week-hour" data-testid="nc-calendar-week-hour"
@dblclick="addRecord(hour)" @dblclick="addRecord(hour)"
@click=" @click="
@ -934,17 +1031,18 @@ watch(
</div> </div>
</div> </div>
<div <div class="absolute pointer-events-none inset-0 overflow-hidden !mt-5.95" data-testid="nc-calendar-week-record-container">
class="absolute pointer-events-none inset-0 overflow-hidden !mt-[29px]"
data-testid="nc-calendar-week-record-container"
>
<template v-for="(record, rowIndex) in recordsAcrossAllRange.records" :key="rowIndex"> <template v-for="(record, rowIndex) in recordsAcrossAllRange.records" :key="rowIndex">
<div <div
v-if="record.rowMeta.style?.display !== 'none'" v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`" :data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta!.id" :data-unique-id="record.rowMeta!.id"
:style="record.rowMeta!.style " :style="record.rowMeta!.style "
class="absolute transition draggable-record w-1/7 group cursor-pointer pointer-events-auto" :class="{
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
}"
class="absolute transition draggable-record group cursor-pointer pointer-events-auto"
@mousedown.stop="dragStart($event, record)" @mousedown.stop="dragStart($event, record)"
@mouseleave="hoverRecord = null" @mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id" @mouseover="hoverRecord = record.rowMeta.id"

2
packages/nc-gui/components/smartsheet/calendar/YearView/index.vue

@ -22,7 +22,7 @@ const handleResize = () => {
if (width.value > 1250) { if (width.value > 1250) {
size.value = 'medium' size.value = 'medium'
cols.value = 4 cols.value = 4
} else if (width.value > 850) { } else if (width.value > 950) {
size.value = 'medium' size.value = 'medium'
cols.value = 3 cols.value = 3
} else if (width.value > 680) { } else if (width.value > 680) {

158
packages/nc-gui/components/smartsheet/calendar/index.vue

@ -8,11 +8,13 @@ const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref()) const view = inject(ActiveViewInj, ref())
const { isMobileMode } = useGlobal()
const reloadViewMetaHook = inject(ReloadViewMetaHookInj) const reloadViewMetaHook = inject(ReloadViewMetaHookInj)
const reloadViewDataHook = inject(ReloadViewDataHookInj) const reloadViewDataHook = inject(ReloadViewDataHookInj)
const { isMobileMode } = useGlobal() const isPublic = inject(IsPublicInj, ref(false))
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
@ -45,14 +47,15 @@ const expandedFormOnRowIdDlg = computed({
get() { get() {
return !!route.query.rowId return !!route.query.rowId
}, },
set(val) { set(value) {
if (!val) if (!value) {
router.push({ router.push({
query: { query: {
...route.query, ...route.query,
rowId: undefined, rowId: undefined,
}, },
}) })
}
}, },
}) })
@ -64,7 +67,10 @@ const expandedFormRowState = ref<Record<string, any>>()
const expandRecord = (row: RowType, state?: Record<string, any>) => { const expandRecord = (row: RowType, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!) const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
expandedFormRowState.value = state
if (rowId && !isPublic.value) {
router.push({ router.push({
query: { query: {
...route.query, ...route.query,
@ -73,12 +79,12 @@ const expandRecord = (row: RowType, state?: Record<string, any>) => {
}) })
} else { } else {
expandedFormRow.value = row expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true expandedFormDlg.value = true
} }
} }
const newRecord = (row: RowType) => { const newRecord = (row: RowType) => {
if (isPublic.value) return
$e('c:calendar:new-record', activeCalendarView.value) $e('c:calendar:new-record', activeCalendarView.value)
expandRecord({ expandRecord({
row: { row: {
@ -114,77 +120,97 @@ reloadViewDataHook?.on(async (params: void | { shouldShowLoading?: boolean }) =>
</script> </script>
<template> <template>
<div class="flex h-full relative flex-row" data-testid="nc-calendar-wrapper"> <template v-if="isMobileMode">
<div class="flex flex-col w-full"> <div class="pl-6 pr-[120px] py-6 bg-white flex-col justify-start items-start gap-2.5 inline-flex">
<template v-if="calendarRange?.length && !isCalendarMetaLoading"> <div class="text-gray-500 text-5xl font-semibold leading-16">
<LazySmartsheetCalendarYearView v-if="activeCalendarView === 'year'" /> {{ $t('general.available') }}<br />{{ $t('title.inDesktop') }}
<template v-if="!isCalendarDataLoading"> </div>
<LazySmartsheetCalendarMonthView <div class="text-gray-500 text-base font-medium leading-normal">
v-if="activeCalendarView === 'month'" {{ $t('msg.calendarViewNotSupportedOnMobile') }}
@expand-record="expandRecord" </div>
@new-record="newRecord" </div>
/> </template>
<LazySmartsheetCalendarWeekViewDateField <template v-else>
v-else-if="activeCalendarView === 'week' && calDataType === UITypes.Date" <div class="flex h-full relative flex-row" data-testid="nc-calendar-wrapper">
@expand-record="expandRecord" <div class="flex flex-col w-full">
@new-record="newRecord" <template v-if="calendarRange?.length && !isCalendarMetaLoading">
/> <LazySmartsheetCalendarYearView v-if="activeCalendarView === 'year'" />
<LazySmartsheetCalendarWeekViewDateTimeField <template v-if="!isCalendarDataLoading">
v-else-if="activeCalendarView === 'week' && calDataType === UITypes.DateTime" <LazySmartsheetCalendarMonthView
@expand-record="expandRecord" v-if="activeCalendarView === 'month'"
@new-record="newRecord" @expand-record="expandRecord"
/> @new-record="newRecord"
<LazySmartsheetCalendarDayViewDateField />
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.Date" <LazySmartsheetCalendarWeekViewDateField
@expand-record="expandRecord" v-else-if="activeCalendarView === 'week' && calDataType === UITypes.Date"
@new-record="newRecord" @expand-record="expandRecord"
/> @new-record="newRecord"
<LazySmartsheetCalendarDayViewDateTimeField />
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.DateTime" <LazySmartsheetCalendarWeekViewDateTimeField
@expand-record="expandRecord" v-else-if="activeCalendarView === 'week' && calDataType === UITypes.DateTime"
@new-record="newRecord" @expand-record="expandRecord"
/> @new-record="newRecord"
/>
<LazySmartsheetCalendarDayViewDateField
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.Date"
@expand-record="expandRecord"
@new-record="newRecord"
/>
<LazySmartsheetCalendarDayViewDateTimeField
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.DateTime"
@expand-record="expandRecord"
@new-record="newRecord"
/>
</template>
<div
v-if="isCalendarDataLoading && activeCalendarView !== 'year'"
class="flex w-full items-center h-full justify-center"
>
<GeneralLoader size="xlarge" />
</div>
</template> </template>
<template v-else-if="isCalendarMetaLoading">
<div v-if="isCalendarDataLoading && activeCalendarView !== 'year'" class="flex w-full items-center h-full justify-center"> <div class="flex w-full items-center h-full justify-center">
<GeneralLoader size="xlarge" /> <GeneralLoader size="xlarge" />
</div> </div>
</template> </template>
<template v-else-if="isCalendarMetaLoading"> <template v-else>
<div class="flex w-full items-center h-full justify-center"> <div class="flex w-full items-center h-full justify-center">
<GeneralLoader size="xlarge" /> {{ $t('activity.noRange') }}
</div> </div>
</template> </template>
<template v-else> </div>
<div class="flex w-full items-center h-full justify-center"> <LazySmartsheetCalendarSideMenu :visible="showSideMenu" @expand-record="expandRecord" @new-record="newRecord" />
{{ $t('activity.noRange') }}
</div>
</template>
</div> </div>
<LazySmartsheetCalendarSideMenu :visible="showSideMenu" @expand-record="expandRecord" @new-record="newRecord" />
</div>
<Suspense> <Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
close-after-save :row="expandedFormRow"
:meta="meta" :load-row="!isPublic"
:row="expandedFormRow" :state="expandedFormRowState"
:state="expandedFormRowState" :meta="meta"
:view="view" :view="view"
/> />
</Suspense> </Suspense>
<Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg && meta?.id" v-if="expandedFormOnRowIdDlg && meta?.id"
v-model="expandedFormOnRowIdDlg" v-model="expandedFormOnRowIdDlg"
close-after-save close-after-save
:load-row="!isPublic"
:meta="meta" :meta="meta"
:row="{ row: {}, oldRow: {}, rowMeta: {} }" :state="expandedFormRowState"
:row="{
row: {},
oldRow: {},
rowMeta: {},
}"
:row-id="route.query.rowId" :row-id="route.query.rowId"
:expand-form="expandRecord"
:view="view" :view="view"
/> />
</Suspense> </template>
</template> </template>

17
packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue

@ -32,7 +32,7 @@ vModel.value.au = !!vModel.value.au */
</script> </script>
<template> <template>
<div class="p-4 border-[0.1px] radius-1 rounded-md border-grey w-full flex flex-col gap-2"> <div class="p-4 border-[0.1px] radius-1 rounded-lg border-grey w-full flex flex-col gap-2">
<template v-if="props.advancedDbOptions"> <template v-if="props.advancedDbOptions">
<div class="flex justify-between w-full gap-1"> <div class="flex justify-between w-full gap-1">
<a-form-item label="NN"> <a-form-item label="NN">
@ -72,7 +72,11 @@ vModel.value.au = !!vModel.value.au */
</div> </div>
<a-form-item :label="$t('labels.databaseType')" v-bind="validateInfos.dt"> <a-form-item :label="$t('labels.databaseType')" v-bind="validateInfos.dt">
<a-select v-model:value="vModel.dt" dropdown-class-name="nc-dropdown-db-type " class="!mt-0.5" @change="onDataTypeChange"> <a-select v-model:value="vModel.dt" dropdown-class-name="nc-dropdown-db-type" @change="onDataTypeChange">
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700" />
</template>
<a-select-option v-for="type in dataTypes" :key="type" :value="type"> <a-select-option v-for="type in dataTypes" :key="type" :value="type">
<div class="flex gap-2 items-center justify-between"> <div class="flex gap-2 items-center justify-between">
{{ type }} {{ type }}
@ -85,19 +89,14 @@ vModel.value.au = !!vModel.value.au */
<a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')"> <a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')">
<a-input <a-input
v-model:value="vModel.dtxp" v-model:value="vModel.dtxp"
class="!rounded-md !mt-0.5" class="!rounded-lg"
:disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)" :disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)"
@input="onAlter" @input="onAlter"
/> />
</a-form-item> </a-form-item>
<a-form-item v-if="sqlUi.showScale(vModel)" label="Scale"> <a-form-item v-if="sqlUi.showScale(vModel)" label="Scale">
<a-input <a-input v-model:value="vModel.dtxs" class="!rounded-lg" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
v-model:value="vModel.dtxs"
class="!rounded-md !mt-0.5"
:disabled="!sqlUi.columnEditable(vModel)"
@input="onAlter"
/>
</a-form-item> </a-form-item>
<LazySmartsheetColumnPgBinaryOptions v-if="isPg(meta?.source_id) && vModel.dt === 'bytea'" v-model:value="vModel" /> <LazySmartsheetColumnPgBinaryOptions v-if="isPg(meta?.source_id) && vModel.dt === 'bytea'" v-model:value="vModel" />

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

Loading…
Cancel
Save