Browse Source

Merge commit '0df8b99953c1b1a3d151427abfe637c59dc56829' into NCDBOSS-21

pull/4934/head
gitstart 2 years ago
parent
commit
16a0a01081
  1. 2
      .github/uffizzi/docker-compose.uffizzi.yml
  2. 12
      .github/workflows/release-nocodb.yml
  3. 2
      docker-compose/mysql/docker-compose.yml
  4. 2
      docker-compose/nginx-proxy-manager/docker-compose.yml
  5. 24
      packages/nc-gui/assets/style.scss
  6. 92
      packages/nc-gui/assets/style/fonts.css
  7. 2
      packages/nc-gui/components.d.ts
  8. 13
      packages/nc-gui/components/account/License.vue
  9. 2
      packages/nc-gui/components/cell/ClampedText.vue
  10. 7
      packages/nc-gui/components/cell/attachment/Carousel.vue
  11. 50
      packages/nc-gui/components/cell/attachment/Modal.vue
  12. 85
      packages/nc-gui/components/cell/attachment/RenameFile.vue
  13. 14
      packages/nc-gui/components/cell/attachment/index.vue
  14. 178
      packages/nc-gui/components/cell/attachment/utils.ts
  15. 132
      packages/nc-gui/components/smartsheet/column/AttachmentOptions.vue
  16. 10
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  17. 190
      packages/nc-gui/components/smartsheet/column/utils.ts
  18. 11
      packages/nc-gui/components/virtual-cell/QrCode.vue
  19. 4
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  20. 2
      packages/nc-gui/composables/useGlobal/state.ts
  21. 2
      packages/nc-gui/composables/useGlobal/types.ts
  22. 97
      packages/nc-gui/composables/useKanbanViewStore.ts
  23. 59
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  24. 7
      packages/nc-gui/composables/useMultiSelect/index.ts
  25. 3
      packages/nc-gui/lang/ar.json
  26. 3
      packages/nc-gui/lang/bn_IN.json
  27. 3
      packages/nc-gui/lang/cs.json
  28. 3
      packages/nc-gui/lang/da.json
  29. 3
      packages/nc-gui/lang/de.json
  30. 3
      packages/nc-gui/lang/en.json
  31. 3
      packages/nc-gui/lang/es.json
  32. 3
      packages/nc-gui/lang/eu.json
  33. 3
      packages/nc-gui/lang/fa.json
  34. 3
      packages/nc-gui/lang/fi.json
  35. 89
      packages/nc-gui/lang/fr.json
  36. 3
      packages/nc-gui/lang/he.json
  37. 3
      packages/nc-gui/lang/hi.json
  38. 3
      packages/nc-gui/lang/hr.json
  39. 3
      packages/nc-gui/lang/id.json
  40. 3
      packages/nc-gui/lang/it.json
  41. 3
      packages/nc-gui/lang/ja.json
  42. 3
      packages/nc-gui/lang/ko.json
  43. 3
      packages/nc-gui/lang/lv.json
  44. 3
      packages/nc-gui/lang/nl.json
  45. 3
      packages/nc-gui/lang/no.json
  46. 17
      packages/nc-gui/lang/pl.json
  47. 3
      packages/nc-gui/lang/pt.json
  48. 3
      packages/nc-gui/lang/pt_BR.json
  49. 3
      packages/nc-gui/lang/ru.json
  50. 3
      packages/nc-gui/lang/sk.json
  51. 3
      packages/nc-gui/lang/sl.json
  52. 3
      packages/nc-gui/lang/sv.json
  53. 3
      packages/nc-gui/lang/th.json
  54. 3
      packages/nc-gui/lang/tr.json
  55. 3
      packages/nc-gui/lang/uk.json
  56. 3
      packages/nc-gui/lang/vi.json
  57. 3
      packages/nc-gui/lang/zh-Hans.json
  58. 3
      packages/nc-gui/lang/zh-Hant.json
  59. 2
      packages/nc-gui/package-lock.json
  60. 6
      packages/nc-gui/tsconfig.json
  61. 2
      packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts
  62. 2
      packages/nc-lib-gui/package.json
  63. 12
      packages/noco-docs/package-lock.json
  64. 4
      packages/nocodb-sdk/package-lock.json
  65. 2
      packages/nocodb-sdk/package.json
  66. 2
      packages/nocodb-sdk/src/lib/Api.ts
  67. 30
      packages/nocodb/docker-compose.yml
  68. 32
      packages/nocodb/package-lock.json
  69. 4
      packages/nocodb/package.json
  70. 2
      packages/nocodb/src/lib/Noco.ts
  71. 7
      packages/nocodb/src/lib/meta/NcMetaIOImpl.ts
  72. 27
      packages/nocodb/src/lib/meta/api/attachmentApis.ts
  73. 8
      packages/nocodb/src/lib/meta/api/dataApis/helpers.ts
  74. 3
      packages/nocodb/src/lib/meta/api/utilApis.ts
  75. 18
      packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts
  76. 2
      packages/nocodb/src/lib/models/Base.ts
  77. 2
      packages/nocodb/src/lib/models/View.ts
  78. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  79. 178
      packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts
  80. 6
      scripts/sdk/swagger.json
  81. BIN
      tests/playwright/fixtures/sampleFiles/Image/1.jpeg
  82. BIN
      tests/playwright/fixtures/sampleFiles/Image/2.png
  83. BIN
      tests/playwright/fixtures/sampleFiles/Image/3.jpeg
  84. BIN
      tests/playwright/fixtures/sampleFiles/Image/4.jpeg
  85. BIN
      tests/playwright/fixtures/sampleFiles/Image/5.jpeg
  86. BIN
      tests/playwright/fixtures/sampleFiles/Image/6_bigSize.png
  87. 2
      tests/playwright/pages/Base.ts
  88. 140
      tests/playwright/pages/Dashboard/Grid/Column/Attachment.ts
  89. 7
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  90. 43
      tests/playwright/pages/Dashboard/Grid/index.ts
  91. 36
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  92. 145
      tests/playwright/tests/columnAttachments.spec.ts

2
.github/uffizzi/docker-compose.uffizzi.yml

@ -31,7 +31,7 @@ services:
MYSQL_PASSWORD: password MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password MYSQL_ROOT_PASSWORD: password
MYSQL_USER: noco MYSQL_USER: noco
image: "mysql:5.7" image: "mysql:8.0.32"
deploy: deploy:
resources: resources:
limits: limits:

12
.github/workflows/release-nocodb.yml

@ -103,12 +103,12 @@ jobs:
NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}"
# Close all issues with target tags 'Status: Ready for Next Release' # Close all issues with target tags 'Status: Ready for Next Release'
close-issues: # close-issues:
needs: [release-docker, process-input] # needs: [release-docker, process-input]
uses: ./.github/workflows/release-close-issue.yml # uses: ./.github/workflows/release-close-issue.yml
with: # with:
issue_label: '🚀 Status: Ready for Next Release' # issue_label: '🚀 Status: Ready for Next Release'
version: ${{ needs.process-input.outputs.target_tag }} # version: ${{ needs.process-input.outputs.target_tag }}
# Publish Docs # Publish Docs
publish-docs: publish-docs:

2
docker-compose/mysql/docker-compose.yml

@ -27,7 +27,7 @@ services:
- "-h" - "-h"
- localhost - localhost
timeout: 20s timeout: 20s
image: "mysql:5.7" image: "mysql:8.0.32"
restart: always restart: always
volumes: volumes:
- "db_data:/var/lib/mysql" - "db_data:/var/lib/mysql"

2
docker-compose/nginx-proxy-manager/docker-compose.yml

@ -46,7 +46,7 @@ services:
- "-h" - "-h"
- localhost - localhost
timeout: 20s timeout: 20s
image: "mysql:5.7" image: "mysql:8.0.32"
networks: networks:
- default - default
restart: always restart: always

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

@ -121,37 +121,37 @@ a {
@-webkit-keyframes gradient { @-webkit-keyframes gradient {
0% { 0% {
background-position: 0% 22% background-position: 0% 22%;
} }
50% { 50% {
background-position: 100% 79% background-position: 100% 79%;
} }
100% { 100% {
background-position: 0% 22% background-position: 0% 22%;
} }
} }
@-moz-keyframes gradient { @-moz-keyframes gradient {
0% { 0% {
background-position: 0% 22% background-position: 0% 22%;
} }
50% { 50% {
background-position: 100% 79% background-position: 100% 79%;
} }
100% { 100% {
background-position: 0% 22% background-position: 0% 22%;
} }
} }
@keyframes gradient { @keyframes gradient {
0% { 0% {
background-position: 0% 22% background-position: 0% 22%;
} }
50% { 50% {
background-position: 100% 79% background-position: 100% 79%;
} }
100% { 100% {
background-position: 0% 22% background-position: 0% 22%;
} }
} }
@ -232,7 +232,8 @@ a {
.ant-dropdown-menu-submenu { .ant-dropdown-menu-submenu {
@apply !py-0; @apply !py-0;
.ant-dropdown-menu, .ant-menu { .ant-dropdown-menu,
.ant-menu {
@apply m-0 p-0; @apply m-0 p-0;
} }
@ -261,7 +262,8 @@ a {
@apply m-0; @apply m-0;
} }
.ant-dropdown-menu-item, .ant-menu-item { .ant-dropdown-menu-item,
.ant-menu-item {
@apply py-0; @apply py-0;
} }

92
packages/nc-gui/assets/style/fonts.css

@ -5,11 +5,16 @@
font-weight: 400; font-weight: 400;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.eot'); /* IE9 Compat Modes */ src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.eot'); /* IE9 Compat Modes */
src: local(''), src: local(''),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.eot?#iefix')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2') format('woff2'), /* Super Modern Browsers */ format('embedded-opentype'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff') format('woff'), /* Modern Browsers */ /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.ttf') format('truetype'), /* Safari, Android, iOS */ format('woff2'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.svg#Roboto') format('svg'); /* Legacy iOS */ /* Super Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff') format('woff'),
/* Modern Browsers */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.ttf')
format('truetype'),
/* Safari, Android, iOS */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.svg#Roboto') format('svg'); /* Legacy iOS */
} }
/* roboto-italic - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ /* roboto-italic - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face { @font-face {
@ -18,11 +23,16 @@
font-weight: 400; font-weight: 400;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.eot'); /* IE9 Compat Modes */ src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.eot'); /* IE9 Compat Modes */
src: local(''), src: local(''),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.eot?#iefix')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2') format('woff2'), /* Super Modern Browsers */ format('embedded-opentype'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff') format('woff'), /* Modern Browsers */ /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.ttf') format('truetype'), /* Safari, Android, iOS */ format('woff2'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.svg#Roboto') format('svg'); /* Legacy iOS */ /* Super Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff') format('woff'),
/* Modern Browsers */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.ttf')
format('truetype'),
/* Safari, Android, iOS */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.svg#Roboto') format('svg'); /* Legacy iOS */
} }
/* roboto-700 - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ /* roboto-700 - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */
@ -32,11 +42,16 @@
font-weight: 700; font-weight: 700;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.eot'); /* IE9 Compat Modes */ src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.eot'); /* IE9 Compat Modes */
src: local(''), src: local(''),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.eot?#iefix')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2') format('woff2'), /* Super Modern Browsers */ format('embedded-opentype'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff') format('woff'), /* Modern Browsers */ /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.ttf') format('truetype'), /* Safari, Android, iOS */ format('woff2'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.svg#Roboto') format('svg'); /* Legacy iOS */ /* Super Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff') format('woff'),
/* Modern Browsers */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.ttf')
format('truetype'),
/* Safari, Android, iOS */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.svg#Roboto') format('svg'); /* Legacy iOS */
} }
/* roboto-700italic - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ /* roboto-700italic - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */
@ -46,11 +61,17 @@
font-weight: 700; font-weight: 700;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.eot'); /* IE9 Compat Modes */ src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.eot'); /* IE9 Compat Modes */
src: local(''), src: local(''),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.eot?#iefix')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2') format('woff2'), /* Super Modern Browsers */ format('embedded-opentype'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff') format('woff'), /* Modern Browsers */ /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.ttf') format('truetype'), /* Safari, Android, iOS */ format('woff2'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.svg#Roboto') format('svg'); /* Legacy iOS */ /* Super Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff') format('woff'),
/* Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.ttf') format('truetype'),
/* Safari, Android, iOS */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.svg#Roboto')
format('svg'); /* Legacy iOS */
} }
/* roboto-900 - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ /* roboto-900 - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */
@ -60,11 +81,16 @@
font-weight: 900; font-weight: 900;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.eot'); /* IE9 Compat Modes */ src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.eot'); /* IE9 Compat Modes */
src: local(''), src: local(''),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.eot?#iefix')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.woff2') format('woff2'), /* Super Modern Browsers */ format('embedded-opentype'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.woff') format('woff'), /* Modern Browsers */ /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.woff2')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.ttf') format('truetype'), /* Safari, Android, iOS */ format('woff2'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.svg#Roboto') format('svg'); /* Legacy iOS */ /* Super Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.woff') format('woff'),
/* Modern Browsers */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.ttf')
format('truetype'),
/* Safari, Android, iOS */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.svg#Roboto') format('svg'); /* Legacy iOS */
} }
/* roboto-900italic - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ /* roboto-900italic - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */
@ -74,11 +100,17 @@
font-weight: 900; font-weight: 900;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.eot'); /* IE9 Compat Modes */ src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.eot'); /* IE9 Compat Modes */
src: local(''), src: local(''),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.eot?#iefix')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.woff2') format('woff2'), /* Super Modern Browsers */ format('embedded-opentype'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.woff') format('woff'), /* Modern Browsers */ /* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.woff2')
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.ttf') format('truetype'), /* Safari, Android, iOS */ format('woff2'),
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.svg#Roboto') format('svg'); /* Legacy iOS */ /* Super Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.woff') format('woff'),
/* Modern Browsers */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.ttf') format('truetype'),
/* Safari, Android, iOS */
url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.svg#Roboto')
format('svg'); /* Legacy iOS */
} }
/* Vazirmatn */ /* Vazirmatn */

2
packages/nc-gui/components.d.ts vendored

@ -69,6 +69,7 @@ declare module '@vue/runtime-core' {
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker'] ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle'] ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger'] AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
BiFiletypeJson: typeof import('~icons/bi/filetype-json')['default'] BiFiletypeJson: typeof import('~icons/bi/filetype-json')['default']
@ -138,7 +139,6 @@ declare module '@vue/runtime-core' {
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default'] MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiChevronUp: typeof import('~icons/mdi/chevron-up')['default']
MdiClose: typeof import('~icons/mdi/close')['default'] MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default'] MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']

13
packages/nc-gui/components/account/License.vue

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi,useGlobal } from '#imports' import { extractSdkResponseErrorMsg, useApi, useGlobal } from '#imports'
const { api, isLoading } = useApi() const { api, isLoading } = useApi()
@ -24,7 +24,7 @@ const setLicense = async () => {
try { try {
await api.orgLicense.set({ key: key }) await api.orgLicense.set({ key: key })
message.success('License key updated') message.success('License key updated')
await loadAppInfo(); await loadAppInfo()
} catch (e) { } catch (e) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -32,17 +32,16 @@ const setLicense = async () => {
} }
loadLicense() loadLicense()
</script> </script>
<template> <template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull"> <div class="h-full overflow-y-scroll scrollbar-thin-dull">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div> <div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div>
<div class="mx-auto w-150"> <div class="mx-auto w-150">
<div> <div>
<a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea> <a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea>
</div> </div>
<div class="text-center"> <a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button></div> <div class="text-center"><a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button></div>
</div> </div>
</div> </div>
</template> </template>

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

@ -19,6 +19,6 @@ onMounted(() => {
<template> <template>
<div ref="wrapper"> <div ref="wrapper">
<text-clamp :key="key" class="w-full h-full break-all" :text="props.value || ''" :max-lines="props.lines" /> <text-clamp :key="key" class="w-full h-full break-all" :text="`${props.value || ''}`" :max-lines="props.lines" />
</div> </div>
</template> </template>

7
packages/nc-gui/components/cell/attachment/Carousel.vue

@ -70,13 +70,13 @@ onClickOutside(carouselRef, () => {
> >
<template #prevArrow> <template #prevArrow>
<div class="custom-slick-arrow left-2 z-1"> <div class="custom-slick-arrow left-2 z-1">
<MaterialSymbolsArrowCircleLeftRounded class="bg-white rounded-full" /> <MaterialSymbolsArrowCircleLeftRounded class="rounded-full" />
</div> </div>
</template> </template>
<template #nextArrow> <template #nextArrow>
<div class="custom-slick-arrow !right-2 z-1"> <div class="custom-slick-arrow !right-2 z-1">
<MaterialSymbolsArrowCircleRightRounded class="bg-white rounded-full" /> <MaterialSymbolsArrowCircleRightRounded class="rounded-full" />
</div> </div>
</template> </template>
@ -105,6 +105,9 @@ onClickOutside(carouselRef, () => {
</template> </template>
<style scoped> <style scoped>
.ant-carousel :deep(.custom-slick-arrow .nc-icon):hover {
@apply !bg-white;
}
.ant-carousel :deep(.slick-dots) { .ant-carousel :deep(.slick-dots) {
@apply relative mt-4; @apply relative mt-4;
} }

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

@ -20,6 +20,9 @@ const {
downloadFile, downloadFile,
updateModelValue, updateModelValue,
selectedImage, selectedImage,
selectedVisibleItems,
bulkDownloadFiles,
renameFile,
} = useAttachmentCell()! } = useAttachmentCell()!
// todo: replace placeholder var // todo: replace placeholder var
@ -44,15 +47,30 @@ function onClick(item: Record<string, any>) {
selectedImage.value = item selectedImage.value = item
modalVisible.value = false modalVisible.value = false
const stopHandle = watch(selectedImage, (nextImage, _, onCleanup) => { const stopHandle = watch(selectedImage, (nextImage) => {
if (!nextImage) { if (!nextImage) {
setTimeout(() => { setTimeout(() => {
modalVisible.value = true modalVisible.value = true
}, 50) }, 50)
stopHandle?.() stopHandle?.()
} }
})
}
onCleanup(() => stopHandle?.()) function onRemoveFileClick(title: any, i: number) {
Modal.confirm({
title: `Do you want to delete '${title}'?`,
wrapClassName: 'nc-modal-attachment-delete',
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
onOk() {
try {
removeFile(i)
} catch (e: any) {
message.error(e.message)
}
},
}) })
} }
</script> </script>
@ -71,6 +89,7 @@ function onClick(item: Record<string, any>) {
<div <div
v-if="isSharedForm || (!readOnly && isUIAllowed('tableAttachment') && !isPublic && !isLocked)" v-if="isSharedForm || (!readOnly && isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attach-file group" class="nc-attach-file group"
data-testid="attachment-expand-file-picker-button"
@click="open" @click="open"
> >
<MaterialSymbolsAttachFile class="transform group-hover:(text-accent scale-120)" /> <MaterialSymbolsAttachFile class="transform group-hover:(text-accent scale-120)" />
@ -82,6 +101,10 @@ function onClick(item: Record<string, any>) {
Viewing Attachments of Viewing Attachments of
<div class="font-semibold underline">{{ column?.title }}</div> <div class="font-semibold underline">{{ column?.title }}</div>
</div> </div>
<div v-if="selectedVisibleItems.includes(true)" class="flex flex-1 items-center gap-3 justify-end mr-[30px]">
<a-button type="primary" class="nc-attachment-download-all" @click="bulkDownloadFiles"> Bulk Download </a-button>
</div>
</div> </div>
</template> </template>
@ -100,23 +123,37 @@ function onClick(item: Record<string, any>) {
<div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6"> <div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6">
<div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1"> <div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1">
<a-card class="nc-attachment-item group"> <a-card class="nc-attachment-item group">
<a-checkbox
v-model:checked="selectedVisibleItems[i]"
class="nc-attachment-checkbox group-hover:(opacity-100)"
:class="{ '!opacity-100': selectedVisibleItems[i] }"
/>
<a-tooltip v-if="!readOnly"> <a-tooltip v-if="!readOnly">
<template #title> Remove File </template> <template #title> Remove File </template>
<MdiCloseCircle <MdiCloseCircle
v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublic && !isLocked)" v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attachment-remove" class="nc-attachment-remove"
@click.stop="removeFile(i)" @click.stop="onRemoveFileClick(item.title, i)"
/> />
</a-tooltip> </a-tooltip>
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> Download file </template> <template #title> Download File </template>
<div class="nc-attachment-download group-hover:(opacity-100)"> <div class="nc-attachment-download group-hover:(opacity-100)">
<MdiDownload @click.stop="downloadFile(item)" /> <MdiDownload @click.stop="downloadFile(item)" />
</div> </div>
</a-tooltip> </a-tooltip>
<a-tooltip placement="bottom">
<template #title> Rename File </template>
<div class="nc-attachment-download group-hover:(opacity-100) mr-[35px]">
<MdiEditOutline @click.stop="renameFile(item, i)" />
</div>
</a-tooltip>
<div <div
:class="[dragging ? 'cursor-move' : 'cursor-pointer']" :class="[dragging ? 'cursor-move' : 'cursor-pointer']"
class="nc-attachment h-full w-full flex items-center justify-center" class="nc-attachment h-full w-full flex items-center justify-center"
@ -195,6 +232,11 @@ function onClick(item: Record<string, any>) {
@apply active:(ring border-0 ring-accent); @apply active:(ring border-0 ring-accent);
} }
.nc-attachment-checkbox {
@apply absolute top-2 left-2;
@apply transition-opacity duration-150 ease-in opacity-0;
}
.nc-attachment-remove { .nc-attachment-remove {
@apply absolute top-2 right-2 bg-white; @apply absolute top-2 right-2 bg-white;
@apply hover:(ring ring-red-500); @apply hover:(ring ring-red-500);

85
packages/nc-gui/components/cell/attachment/RenameFile.vue

@ -0,0 +1,85 @@
<script lang="ts" setup>
import { generateUniqueName, onKeyStroke, onMounted, reactive, ref } from '#imports'
const props = defineProps<{
title: string
}>()
const emit = defineEmits<{
(event: 'rename', value: string): void
(event: 'cancel'): void
}>()
const inputEl = ref()
const visible = ref(true)
const form = reactive({
title: props.title,
})
function renameFile(fileName: string) {
visible.value = false
emit('rename', fileName)
}
async function useRandomName() {
form.title = await generateUniqueName()
}
const rules = {
title: [{ required: true, message: 'title is required.' }],
}
function onCancel() {
visible.value = false
emit('cancel')
}
onKeyStroke('Escape', onCancel)
onMounted(() => {
inputEl.value.select()
inputEl.value.focus()
})
</script>
<template>
<a-modal
:visible="visible"
:closable="false"
:mask-closable="false"
destroy-on-close
title="Rename file"
class="nc-attachment-rename-modal"
width="min(100%, 620px)"
:footer="null"
centered
@cancel="onCancel"
>
<div class="flex flex-col items-center justify-center h-full">
<a-form class="w-full h-full" no-style :model="form" @finish="renameFile(form.title)">
<a-form-item class="w-full" name="title" :rules="rules.title">
<a-input ref="inputEl" v-model:value="form.title" class="w-full" :placeholder="$t('general.rename')" />
</a-form-item>
<div class="flex items-center justify-center gap-6 w-full mt-4">
<button class="scaling-btn bg-opacity-100" type="submit">
<span>{{ $t('general.confirm') }}</span>
</button>
<button class="scaling-btn bg-opacity-100" type="button" @click="useRandomName">
<span>{{ $t('title.generateRandomName') }}</span>
</button>
</div>
</a-form>
</div>
</a-modal>
</template>
<style scoped lang="scss">
.nc-attachment-rename-modal {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
}
</style>

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

@ -60,6 +60,7 @@ const {
selectedImage, selectedImage,
isReadonly, isReadonly,
storedFiles, storedFiles,
getAttachmentUrl,
} = useProvideAttachmentCell(updateModelValue) } = useProvideAttachmentCell(updateModelValue)
watch( watch(
@ -97,10 +98,19 @@ const { isOverDropZone } = useDropZone(currentCellRef as any, onDrop)
/** on new value, reparse our stored attachments */ /** on new value, reparse our stored attachments */
watch( watch(
() => modelValue, () => modelValue,
(nextModel) => { async (nextModel) => {
if (nextModel) { if (nextModel) {
try { try {
const nextAttachments = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean) let nextAttachments = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean)
// reconstruct the url
// See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details
nextAttachments = await Promise.all(
nextAttachments.map(async (attachment: any) => ({
...attachment,
url: await getAttachmentUrl(attachment),
})),
)
if (isPublic.value && isForm.value) { if (isPublic.value && isForm.value) {
storedFiles.value = nextAttachments storedFiles.value = nextAttachments

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

@ -1,3 +1,5 @@
import type { AttachmentType } from 'nocodb-sdk'
import RenameFile from './RenameFile.vue'
import { import {
ColumnInj, ColumnInj,
EditModeInj, EditModeInj,
@ -24,13 +26,6 @@ import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box'
import MdiFileExcelOutline from '~icons/mdi/file-excel-outline' import MdiFileExcelOutline from '~icons/mdi/file-excel-outline'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file' import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
interface AttachmentProps extends File {
data?: any
file: File
title: string
mimetype: string
}
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => { (updateModelValue: (data: string | Record<string, any>[]) => void) => {
const isReadonly = inject(ReadonlyInj, ref(false)) const isReadonly = inject(ReadonlyInj, ref(false))
@ -46,12 +41,13 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const editEnabled = inject(EditModeInj, ref(false)) const editEnabled = inject(EditModeInj, ref(false))
/** keep user selected File object */ /** keep user selected File object */
const storedFiles = ref<AttachmentProps[]>([]) const storedFiles = ref<AttachmentType[]>([])
const attachments = ref<File[]>([]) const attachments = ref<AttachmentType[]>([])
const modalVisible = ref(false) const modalVisible = ref(false)
/** for image carousel */
const selectedImage = ref() const selectedImage = ref()
const { project } = useProject() const { project } = useProject()
@ -60,17 +56,37 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const { files, open } = useFileDialog() const { files, open } = useFileDialog()
const { appInfo } = useGlobal()
const { t } = useI18n() const { t } = useI18n()
const defaultAttachmentMeta = {
...(appInfo.value.ee && {
// Maximum Number of Attachments per cell
maxNumberOfAttachments: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 50) || 50,
// Maximum File Size per file
maxAttachmentSize: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 20) || 20,
supportedAttachmentMimeTypes: ['*'],
}),
}
/** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */
const visibleItems = computed<any[]>(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value))
/** for bulk download */
const selectedVisibleItems = ref<boolean[]>(Array.from({ length: visibleItems.value.length }, () => false))
/** remove a file from our stored attachments (either locally stored or saved ones) */ /** remove a file from our stored attachments (either locally stored or saved ones) */
function removeFile(i: number) { function removeFile(i: number) {
if (isPublic.value) { if (isPublic.value) {
storedFiles.value.splice(i, 1) storedFiles.value.splice(i, 1)
attachments.value.splice(i, 1) attachments.value.splice(i, 1)
selectedVisibleItems.value.splice(i, 1)
updateModelValue(storedFiles.value) updateModelValue(storedFiles.value)
} else { } else {
attachments.value.splice(i, 1) attachments.value.splice(i, 1)
selectedVisibleItems.value.splice(i, 1)
updateModelValue(JSON.stringify(attachments.value)) updateModelValue(JSON.stringify(attachments.value))
} }
@ -80,12 +96,58 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
async function onFileSelect(selectedFiles: FileList | File[]) { async function onFileSelect(selectedFiles: FileList | File[]) {
if (!selectedFiles.length) return if (!selectedFiles.length) return
const attachmentMeta = {
...defaultAttachmentMeta,
...(typeof column.value?.meta === 'string' ? JSON.parse(column.value.meta) : column.value?.meta),
}
const newAttachments = []
const files: File[] = []
for (const file of selectedFiles) {
if (appInfo.value.ee) {
// verify number of files
if (visibleItems.value.length + selectedFiles.length > attachmentMeta.maxNumberOfAttachments) {
message.error(
`You can only upload at most ${attachmentMeta.maxNumberOfAttachments} file${
attachmentMeta.maxNumberOfAttachments > 1 ? 's' : ''
} to this cell.`,
)
return
}
// verify file size
if (file.size > attachmentMeta.maxAttachmentSize * 1024 * 1024) {
message.error(`The size of ${file.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.`)
continue
}
// verify mime type
if (
!attachmentMeta.supportedAttachmentMimeTypes.includes('*') &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(file.type) &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(file.type.split('/')[0])
) {
message.error(`${file.name} has the mime type ${file.type} which is not allowed in this column.`)
continue
}
}
files.push(file)
}
if (isPublic.value && isForm.value) { if (isPublic.value && isForm.value) {
const newFiles = await Promise.all<AttachmentProps>( const newFiles = await Promise.all<AttachmentType>(
Array.from(selectedFiles).map( Array.from(files).map(
(file) => (file) =>
new Promise<AttachmentProps>((resolve) => { new Promise<AttachmentType>((resolve) => {
const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type } const res: { file: File; title: string; mimetype: string; data?: any } = {
...file,
file,
title: file.name,
mimetype: file.type,
}
if (isImage(file.name, (<any>file).mimetype ?? file.type)) { if (isImage(file.name, (<any>file).mimetype ?? file.type)) {
const reader = new FileReader() const reader = new FileReader()
@ -107,35 +169,47 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}), }),
), ),
) )
attachments.value = [...attachments.value, ...newFiles] attachments.value = [...attachments.value, ...newFiles]
return updateModelValue(attachments.value) return updateModelValue(attachments.value)
} }
const newAttachments = [] try {
const data = await api.storage.upload(
for (const file of selectedFiles) { {
try { path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'),
const data = await api.storage.upload( },
{ {
path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'), files,
}, json: '{}',
{ },
files: file, )
json: '{}', newAttachments.push(...data)
}, } catch (e: any) {
) message.error(e.message || t('msg.error.internalError'))
newAttachments.push(...data)
} catch (e: any) {
message.error(e.message || t('msg.error.internalError'))
}
} }
updateModelValue(JSON.stringify([...attachments.value, ...newAttachments])) updateModelValue(JSON.stringify([...attachments.value, ...newAttachments]))
} }
async function renameFile(attachment: AttachmentType, idx: number) {
return new Promise<boolean>((resolve) => {
const { close } = useDialog(RenameFile, {
title: attachment.title,
onRename: (newTitle: string) => {
attachments.value[idx].title = newTitle
updateModelValue(JSON.stringify(attachments.value))
close()
resolve(true)
},
onCancel: () => {
close()
resolve(true)
},
})
})
}
/** save files on drop */ /** save files on drop */
async function onDrop(droppedFiles: File[] | null) { async function onDrop(droppedFiles: File[] | null) {
if (droppedFiles) { if (droppedFiles) {
@ -144,11 +218,41 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
} }
} }
/** bulk download selected files */
async function bulkDownloadFiles() {
await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadFile(visibleItems.value[i]))))
selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false)
}
/** download a file */ /** download a file */
async function downloadFile(item: Record<string, any>) { async function downloadFile(item: AttachmentType) {
;(await import('file-saver')).saveAs(item.url || item.data, item.title) ;(await import('file-saver')).saveAs(item.url || item.data, item.title)
} }
/** construct the attachment url
* See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details
* */
async function getAttachmentUrl(item: AttachmentType) {
const path = item?.path
// if path doesn't exist, use `item.url`
if (path) {
// try ${appInfo.value.ncSiteUrl}/${item.path} first
const url = `${appInfo.value.ncSiteUrl}/${item.path}`
try {
const res = await fetch(url)
if (res.ok) {
// use `url` if it is accessible
return Promise.resolve(url)
}
} catch {
// for some cases, `url` is not accessible as expected
// do nothing here
}
}
// if it fails, use the original url
return Promise.resolve(item.url)
}
const FileIcon = (icon: string) => { const FileIcon = (icon: string) => {
switch (icon) { switch (icon) {
case 'mdi-pdf-box': case 'mdi-pdf-box':
@ -164,9 +268,6 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
} }
} }
/** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */
const visibleItems = computed<any[]>(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value))
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles)) watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles))
return { return {
@ -185,10 +286,15 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
modalVisible, modalVisible,
FileIcon, FileIcon,
removeFile, removeFile,
renameFile,
downloadFile, downloadFile,
updateModelValue, updateModelValue,
selectedImage, selectedImage,
selectedVisibleItems,
storedFiles, storedFiles,
bulkDownloadFiles,
defaultAttachmentMeta,
getAttachmentUrl,
} }
}, },
'useAttachmentCell', 'useAttachmentCell',

132
packages/nc-gui/components/smartsheet/column/AttachmentOptions.vue

@ -0,0 +1,132 @@
<script setup lang="ts">
import type { TreeProps } from 'ant-design-vue'
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import { fileMimeTypeList, fileMimeTypes } from './utils'
import { useGlobal, useVModel } from '#imports'
const props = defineProps<{
value: any
}>()
const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const validators = {}
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
const { appInfo } = useGlobal()
const searchValue = ref<string>('')
setAdditionalValidations({
...validators,
})
// set default value
vModel.value.meta = {
...(appInfo.value.ee && {
// Maximum Number of Attachments per cell
maxNumberOfAttachments: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 50) || 50,
// Maximum File Size per file
maxAttachmentSize: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 20) || 20,
// allow all mime types by default
supportedAttachmentMimeTypes: ['*'],
}),
...vModel.value.meta,
}
const expandedKeys = ref<(string | number)[]>([])
const autoExpandParent = ref<boolean>(true)
const allowAllMimeTypeCheckbox = ref(true)
const getParentKey = (key: string | number, tree: TreeProps['treeData']): string | null => {
if (!tree) return null
let parentKey
for (let i = 0; i < tree.length; i++) {
const node = tree[i]
if (node.children) {
if (node.children.some((item) => item.key === key)) {
parentKey = node.key as string
} else if (getParentKey(key, node.children)) {
parentKey = getParentKey(key, node.children) as string
}
}
}
return parentKey as string
}
function allowAllMimeTypeCheckboxOnChange(evt: CheckboxChangeEvent) {
if (evt.target.checked) {
vModel.value.meta.supportedAttachmentMimeTypes = ['*']
} else {
vModel.value.meta.supportedAttachmentMimeTypes = ['application', 'audio', 'image', 'video', 'misc']
}
}
watch(searchValue, (value) => {
expandedKeys.value = fileMimeTypeList
?.map((item: Record<string, any>) => {
if (item.title.includes(value)) {
return getParentKey(item.key, fileMimeTypes)
}
return null
})
.filter((item: any, i: number, self: any[]) => item && self.indexOf(item) === i) as string[]
searchValue.value = value
autoExpandParent.value = true
})
</script>
<template>
<a-row class="my-2" gutter="8">
<a-col :span="12">
<a-form-item v-bind="validateInfos['meta.maxNumberOfAttachments']" label="Max Number of Attachments">
<a-input-number v-model:value="vModel.meta.maxNumberOfAttachments" :min="1" class="!w-full nc-attachment-max-count" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-bind="validateInfos['meta.maxAttachmentSize']" label="Max Attachment Size (MB)">
<a-input-number v-model:value="vModel.meta.maxAttachmentSize" :min="1" class="!w-full nc-attachment-max-size" />
</a-form-item>
</a-col>
<a-col class="mt-4" :span="24">
<a-form-item v-bind="validateInfos['meta.supportedAttachmentMimeTypes']" class="!p-[10px] border-2">
<a-checkbox
v-model:checked="allowAllMimeTypeCheckbox"
class="nc-allow-all-mime-type-checkbox"
name="virtual"
@change="allowAllMimeTypeCheckboxOnChange"
>
Allow All Mime Types
</a-checkbox>
<div v-if="!allowAllMimeTypeCheckbox" class="mt-[5px]">
<a-input-search v-model:value="searchValue" class="mt-[5px] mb-[15px]" placeholder="Search" />
<a-tree
v-model:expanded-keys="expandedKeys"
v-model:checkedKeys="vModel.meta.supportedAttachmentMimeTypes"
checkable
:height="250"
:tree-data="fileMimeTypes"
:auto-expand-parent="autoExpandParent"
class="!bg-gray-50 my-[10px]"
>
<template #title="{ title }">
<span v-if="title.indexOf(searchValue) > -1">
{{ title.substr(0, title.indexOf(searchValue)) }}
<span class="text-primary font-bold">{{ searchValue }}</span>
{{ title.substr(title.indexOf(searchValue) + searchValue.length) }}
</span>
<span v-else>{{ title }}</span>
</template>
</a-tree>
</div>
</a-form-item>
</a-col>
</a-row>
</template>

10
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -14,6 +14,7 @@ import {
uiTypes, uiTypes,
useColumnCreateStoreOrThrow, useColumnCreateStoreOrThrow,
useEventListener, useEventListener,
useGlobal,
useI18n, useI18n,
useMetas, useMetas,
useNuxtApp, useNuxtApp,
@ -38,6 +39,8 @@ const { t } = useI18n()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { appInfo } = useGlobal()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
@ -133,7 +136,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<template> <template>
<div <div
class="w-[400px] bg-gray-50 shadow p-4 overflow-auto border" class="w-[400px] bg-gray-50 shadow p-4 overflow-auto border"
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }" :class="{ '!w-[600px]': formState.uidt === UITypes.Formula, '!w-[500px]': formState.uidt === UITypes.Attachment }"
@click.stop @click.stop
> >
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-testid="add-or-edit-column"> <a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-testid="add-or-edit-column">
@ -212,6 +215,11 @@ useEventListener('keydown', (e: KeyboardEvent) => {
</span> </span>
</a-checkbox> </a-checkbox>
<LazySmartsheetColumnAttachmentOptions
v-if="appInfo.ee && formState.uidt === UITypes.Attachment"
v-model:value="formState"
/>
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" /> <LazySmartsheetColumnAdvancedOptions v-model:value="formState" />
</div> </div>
</Transition> </Transition>

190
packages/nc-gui/components/smartsheet/column/utils.ts

@ -7,3 +7,193 @@ const relationNames = {
export function getRelationName(type: string) { export function getRelationName(type: string) {
return relationNames[type as keyof typeof relationNames] return relationNames[type as keyof typeof relationNames]
} }
// supported mime types
// retrieved from https://github.com/sindresorhus/file-type/blob/main/supported.js#L146
export const fileMimeTypes = [
{
title: 'Application',
key: 'application',
children: [
{ title: 'application/dicom', key: 'application/dicom' },
{ title: 'application/eps', key: 'application/eps' },
{ title: 'application/epub+zip', key: 'application/epub+zip' },
{ title: 'application/gzip', key: 'application/gzip' },
{ title: 'application/mxf', key: 'application/mxf' },
{ title: 'application/ogg', key: 'application/ogg' },
{ title: 'application/pdf', key: 'application/pdf' },
{ title: 'application/pgp-encrypted', key: 'application/pgp-encrypted' },
{ title: 'application/postscript', key: 'application/postscript' },
{ title: 'application/rtf', key: 'application/rtf' },
{ title: 'application/vnd.ms-asf', key: 'application/vnd.ms-asf' },
{ title: 'application/vnd.ms-cab-compressed', key: 'application/vnd.ms-cab-compressed' },
{ title: 'application/vnd.ms-fontobject', key: 'application/vnd.ms-fontobject' },
{ title: 'application/vnd.ms-htmlhelp', key: 'application/vnd.ms-htmlhelp' },
{ title: 'application/vnd.ms-outlook', key: 'application/vnd.ms-outlook' },
{ title: 'application/vnd.oasis.opendocument.presentation', key: 'application/vnd.oasis.opendocument.presentation' },
{ title: 'application/vnd.oasis.opendocument.spreadsheet', key: 'application/vnd.oasis.opendocument.spreadsheet' },
{ title: 'application/vnd.oasis.opendocument.text', key: 'application/vnd.oasis.opendocument.text' },
{
title: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
key: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
},
{
title: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
key: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
{
title: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
key: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
{ title: 'application/vnd.sketchup.skp', key: 'application/vnd.sketchup.skp' },
{ title: 'application/vnd.tcpdump.pcap', key: 'application/vnd.tcpdump.pcap' },
{ title: 'application/wasm', key: 'application/wasm' },
{ title: 'application/x-7z-compressed', key: 'application/x-7z-compressed' },
{ title: 'application/x-apache-arrow', key: 'application/x-apache-arrow' },
{ title: 'application/x-apple-diskimage', key: 'application/x-apple-diskimage' },
{ title: 'application/x-asar', key: 'application/x-asar' },
{ title: 'application/x-blender', key: 'application/x-blender' },
{ title: 'application/x-bzip2', key: 'application/x-bzip2' },
{ title: 'application/x-cfb', key: 'application/x-cfb' },
{ title: 'application/x-compress', key: 'application/x-compress' },
{ title: 'application/x-deb', key: 'application/x-deb' },
{ title: 'application/x-elf', key: 'application/x-elf' },
{ title: 'application/x-esri-shape', key: 'application/x-esri-shape' },
{ title: 'application/x-google-chrome-extension', key: 'application/x-google-chrome-extension' },
{ title: 'application/x-indesign', key: 'application/x-indesign' },
{ title: 'application/x-lzh-compressed', key: 'application/x-lzh-compressed' },
{ title: 'application/x-lzip', key: 'application/x-lzip' },
{ title: 'application/x-mie', key: 'application/x-mie' },
{ title: 'application/x-mobipocket-ebook', key: 'application/x-mobipocket-ebook' },
{ title: 'application/x-msdownload', key: 'application/x-msdownload' },
{ title: 'application/x-nintendo-nes-rom', key: 'application/x-nintendo-nes-rom' },
{ title: 'application/x-rar-compressed', key: 'application/x-rar-compressed' },
{ title: 'application/x-rpm', key: 'application/x-rpm' },
{ title: 'application/x-shockwave-flash', key: 'application/x-shockwave-flash' },
{ title: 'application/x-sqlite3', key: 'application/x-sqlite3' },
{ title: 'application/x-tar', key: 'application/x-tar' },
{ title: 'application/x-unix-archive', key: 'application/x-unix-archive' },
{ title: 'application/x-xpinstall', key: 'application/x-xpinstall' },
{ title: 'application/x-xz', key: 'application/x-xz' },
{ title: 'application/x.apple.alias', key: 'application/x.apple.alias' },
{ title: 'application/x.ms.shortcut', key: 'application/x.ms.shortcut' },
{ title: 'application/xml', key: 'application/xml' },
{ title: 'application/zip', key: 'application/zip' },
{ title: 'application/zstd', key: 'application/zstd' },
],
},
{
title: 'Audio',
key: 'audio',
children: [
{ title: 'audio/aac', key: 'audio/aac' },
{ title: 'audio/aiff', key: 'audio/aiff' },
{ title: 'audio/amr', key: 'audio/amr' },
{ title: 'audio/ape', key: 'audio/ape' },
{ title: 'audio/midi', key: 'audio/midi' },
{ title: 'audio/mp4', key: 'audio/mp4' },
{ title: 'audio/mpeg', key: 'audio/mpeg' },
{ title: 'audio/ogg', key: 'audio/ogg' },
{ title: 'audio/opus', key: 'audio/opus' },
{ title: 'audio/qcelp', key: 'audio/qcelp' },
{ title: 'audio/vnd.dolby.dd-raw', key: 'audio/vnd.dolby.dd-raw' },
{ title: 'audio/vnd.wave', key: 'audio/vnd.wave' },
{ title: 'audio/wavpack', key: 'audio/wavpack' },
{ title: 'audio/x-dsf', key: 'audio/x-dsf' },
{ title: 'audio/x-flac', key: 'audio/x-flac' },
{ title: 'audio/x-it', key: 'audio/x-it' },
{ title: 'audio/x-m4a', key: 'audio/x-m4a' },
{ title: 'audio/x-ms-asf', key: 'audio/x-ms-asf' },
{ title: 'audio/x-musepack', key: 'audio/x-musepack' },
{ title: 'audio/x-s3m', key: 'audio/x-s3m' },
{ title: 'audio/x-voc', key: 'audio/x-voc' },
{ title: 'audio/x-xm', key: 'audio/x-xm' },
],
},
{
title: 'Image',
key: 'image',
children: [
{ title: 'image/apng', key: 'image/apng' },
{ title: 'image/avif', key: 'image/avif' },
{ title: 'image/bmp', key: 'image/bmp' },
{ title: 'image/bpg', key: 'image/bpg' },
{ title: 'image/flif', key: 'image/flif' },
{ title: 'image/gif', key: 'image/gif' },
{ title: 'image/heic', key: 'image/heic' },
{ title: 'image/heic-sequence', key: 'image/heic-sequence' },
{ title: 'image/heif', key: 'image/heif' },
{ title: 'image/heif-sequence', key: 'image/heif-sequence' },
{ title: 'image/icns', key: 'image/icns' },
{ title: 'image/jls', key: 'image/jls' },
{ title: 'image/jp2', key: 'image/jp2' },
{ title: 'image/jpeg', key: 'image/jpeg' },
{ title: 'image/jpm', key: 'image/jpm' },
{ title: 'image/jpx', key: 'image/jpx' },
{ title: 'image/jxl', key: 'image/jxl' },
{ title: 'image/ktx', key: 'image/ktx' },
{ title: 'image/mj2', key: 'image/mj2' },
{ title: 'image/png', key: 'image/png' },
{ title: 'image/tiff', key: 'image/tiff' },
{ title: 'image/vnd.adobe.photoshop', key: 'image/vnd.adobe.photoshop' },
{ title: 'image/vnd.dwg', key: 'image/vnd.dwg' },
{ title: 'image/vnd.ms-photo', key: 'image/vnd.ms-photo' },
{ title: 'image/webp', key: 'image/webp' },
{ title: 'image/x-adobe-dng', key: 'image/x-adobe-dng' },
{ title: 'image/x-canon-cr2', key: 'image/x-canon-cr2' },
{ title: 'image/x-canon-cr3', key: 'image/x-canon-cr3' },
{ title: 'image/x-fujifilm-raf', key: 'image/x-fujifilm-raf' },
{ title: 'image/x-icon', key: 'image/x-icon' },
{ title: 'image/x-nikon-nef', key: 'image/x-nikon-nef' },
{ title: 'image/x-olympus-orf', key: 'image/x-olympus-orf' },
{ title: 'image/x-panasonic-rw2', key: 'image/x-panasonic-rw2' },
{ title: 'image/x-sony-arw', key: 'image/x-sony-arw' },
{ title: 'image/x-xcf', key: 'image/x-xcf' },
],
},
{
title: 'Video',
key: 'video',
children: [
{ title: 'video/3gpp', key: 'video/3gpp' },
{ title: 'video/3gpp2', key: 'video/3gpp2' },
{ title: 'video/MP1S', key: 'video/MP1S' },
{ title: 'video/MP2P', key: 'video/MP2P' },
{ title: 'video/mp2t', key: 'video/mp2t' },
{ title: 'video/mp4', key: 'video/mp4' },
{ title: 'video/mpeg', key: 'video/mpeg' },
{ title: 'video/ogg', key: 'video/ogg' },
{ title: 'video/quicktime', key: 'video/quicktime' },
{ title: 'video/vnd.avi', key: 'video/vnd.avi' },
{ title: 'video/webm', key: 'video/webm' },
{ title: 'video/x-flv', key: 'video/x-flv' },
{ title: 'video/x-m4v', key: 'video/x-m4v' },
{ title: 'video/x-matroska', key: 'video/x-matroska' },
{ title: 'video/x-ms-asf', key: 'video/x-ms-asf' },
],
},
{
title: 'Misc',
key: 'misc',
children: [
{ title: 'model/3mf', key: 'model/3mf' },
{ title: 'model/gltf-binary', key: 'model/gltf-binary' },
{ title: 'model/stl', key: 'model/stl' },
{ title: 'text/calendar', key: 'text/calendar' },
{ title: 'text/vcard', key: 'text/vcard' },
{ title: 'text/plain', key: 'text/plain' },
{ title: 'text/html', key: 'text/html' },
{ title: 'text/xml', key: 'text/xml' },
{ title: 'text/calendar', key: 'text/calendar' },
{ title: 'text/javascript', key: 'text/javascript' },
{ title: 'text/css', key: 'text/css' },
{ title: 'text/csv', key: 'text/csv' },
{ title: 'font/otf', key: 'font/otf' },
{ title: 'font/ttf', key: 'font/ttf' },
{ title: 'font/woff', key: 'font/woff' },
{ title: 'font/woff2', key: 'font/woff2' },
],
},
]
export const fileMimeTypeList = fileMimeTypes.map((o) => o.children).flat(1)

11
packages/nc-gui/components/virtual-cell/QrCode.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQRCode } from '@vueuse/integrations/useQRCode' import { useQRCode } from '@vueuse/integrations/useQRCode'
import { GridType } from 'nocodb-sdk' import type { GridType } from 'nocodb-sdk'
import { ActiveViewInj } from '#imports' import { ActiveViewInj } from '#imports'
const maxNumberOfAllowedCharsForQrValue = 2000 const maxNumberOfAllowedCharsForQrValue = 2000
@ -68,7 +68,14 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = us
<div v-if="tooManyCharsForQrCode" class="text-left text-wrap mt-2 text-[#e65100] text-xs"> <div v-if="tooManyCharsForQrCode" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('labels.qrCodeValueTooLong') }} {{ $t('labels.qrCodeValueTooLong') }}
</div> </div>
<img v-if="showQrCode" class="mx-auto" :style="{ height: rowHeight ? `${rowHeight * 1.4}rem` : `1.4rem` }" :src="qrCode" alt="QR Code" @click="showQrModal" /> <img
v-if="showQrCode"
class="mx-auto"
:style="{ height: rowHeight ? `${rowHeight * 1.4}rem` : `1.4rem` }"
:src="qrCode"
alt="QR Code"
@click="showQrModal"
/>
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs"> <div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.warning.nonEditableFields.computedFieldUnableToClear') }} {{ $t('msg.warning.nonEditableFields.computedFieldUnableToClear') }}
</div> </div>

4
packages/nc-gui/components/virtual-cell/barcode/Barcode.vue

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ComputedRef } from 'vue'
import type { GridType } from 'nocodb-sdk'
import JsBarcodeWrapper from './JsBarcodeWrapper.vue' import JsBarcodeWrapper from './JsBarcodeWrapper.vue'
import { ComputedRef } from 'vue'
import { GridType } from 'nocodb-sdk'
import { ActiveViewInj } from '#imports' import { ActiveViewInj } from '#imports'
const maxNumberOfAllowedCharsForBarcodeValue = 100 const maxNumberOfAllowedCharsForBarcodeValue = 100

2
packages/nc-gui/composables/useGlobal/state.ts

@ -96,6 +96,8 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
teleEnabled: true, teleEnabled: true,
type: 'nocodb', type: 'nocodb',
version: '0.0.0', version: '0.0.0',
ncAttachmentFieldSize: 20,
ncMaxAttachmentsAllowed: 10,
}) })
/** reactive token payload */ /** reactive token payload */

2
packages/nc-gui/composables/useGlobal/types.ts

@ -19,6 +19,8 @@ export interface AppInfo {
type: string type: string
version: string version: string
ee?: boolean ee?: boolean
ncAttachmentFieldSize: number
ncMaxAttachmentsAllowed: number
} }
export interface StoredState { export interface StoredState {

97
packages/nc-gui/composables/useKanbanViewStore.ts

@ -1,5 +1,15 @@
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import type { Api, ColumnType, KanbanType, SelectOptionType, SelectOptionsType, TableType, ViewType } from 'nocodb-sdk' import type {
Api,
AttachmentType,
ColumnType,
KanbanType,
SelectOptionType,
SelectOptionsType,
TableType,
ViewType,
} from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Row } from '~/lib' import type { Row } from '~/lib'
import { import {
IsPublicInj, IsPublicInj,
@ -14,6 +24,8 @@ import {
provide, provide,
ref, ref,
useApi, useApi,
useFieldQuery,
useGlobal,
useI18n, useI18n,
useInjectionState, useInjectionState,
useNuxtApp, useNuxtApp,
@ -43,6 +55,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { $e, $api } = useNuxtApp() const { $e, $api } = useNuxtApp()
const { appInfo } = useGlobal()
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow() const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { sharedView, fetchSharedViewData, fetchSharedViewGroupedData } = useSharedView() const { sharedView, fetchSharedViewData, fetchSharedViewGroupedData } = useSharedView()
@ -53,6 +67,32 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const password = ref<string | null>(null) const password = ref<string | null>(null)
const { search } = useFieldQuery()
const { sqlUis } = useProject()
const sqlUi = ref(meta.value?.base_id ? sqlUis.value[meta.value?.base_id] : Object.values(sqlUis.value)[0])
const xWhere = computed(() => {
let where
const col =
(meta.value as TableType)?.columns?.find(({ id }) => id === search.value.field) ||
(meta.value as TableType)?.columns?.find((v) => v.pv)
if (!col) return
if (!search.value.query.trim()) return
if (['text', 'string'].includes(sqlUi.value.getAbstractType(col)) && col.dt !== 'bigint') {
where = `(${col.title},like,%${search.value.query.trim()}%)`
} else {
where = `(${col.title},eq,${search.value.query.trim()})`
}
return where
})
const attachmentColumns = computed(() =>
(meta.value?.columns as ColumnType[])?.filter((c) => c.uidt === UITypes.Attachment).map((c) => c.title),
)
provide(SharedViewPasswordInj, password) provide(SharedViewPasswordInj, password)
// kanban view meta data // kanban view meta data
@ -102,35 +142,75 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
rowMeta: {}, rowMeta: {},
})) }))
async function getAttachmentUrl(item: AttachmentType) {
const path = item?.path
// if path doesn't exist, use `item.url`
if (path) {
// try ${appInfo.value.ncSiteUrl}/${item.path} first
const url = `${appInfo.value.ncSiteUrl}/${item.path}`
try {
const res = await fetch(url)
if (res.ok) {
// use `url` if it is accessible
return Promise.resolve(url)
}
} catch {
// for some cases, `url` is not accessible as expected
// do nothing here
}
}
// if it fails, use the original url
return Promise.resolve(item.url)
}
async function loadKanbanData() { async function loadKanbanData() {
if ((!project?.value?.id || !meta.value?.id || !viewMeta?.value?.id) && !isPublic.value) return if ((!project?.value?.id || !meta.value?.id || !viewMeta?.value?.id || !groupingFieldColumn?.value?.id) && !isPublic.value)
return
// reset formattedData & countByStack to avoid storing previous data after changing grouping field // reset formattedData & countByStack to avoid storing previous data after changing grouping field
formattedData.value = new Map<string | null, Row[]>() formattedData.value = new Map<string | null, Row[]>()
countByStack.value = new Map<string | null, number>() countByStack.value = new Map<string | null, number>()
let res let groupData
if (isPublic.value) { if (isPublic.value) {
res = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!, { groupData = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!, {
sortsArr: sorts.value, sortsArr: sorts.value,
filtersArr: nestedFilters.value, filtersArr: nestedFilters.value,
}) })
} else { } else {
res = await api.dbViewRow.groupedDataList( groupData = await api.dbViewRow.groupedDataList(
'noco', 'noco',
project.value.id!, project.value.id!,
meta.value!.id!, meta.value!.id!,
viewMeta.value!.id!, viewMeta.value!.id!,
groupingFieldColumn!.value!.id!, groupingFieldColumn!.value!.id!,
{}, { where: xWhere.value },
{}, {},
) )
} }
for (const data of res) { for (const data of groupData) {
const records = []
const key = data.key const key = data.key
formattedData.value.set(key, formatData(data.value.list)) // TODO: optimize
// reconstruct the url
// See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details
for (const record of data.value.list) {
for (const attachmentColumn of attachmentColumns.value) {
const oldAttachment = JSON.parse(record[attachmentColumn!])
const newAttachment = []
for (const attachmentObj of oldAttachment) {
newAttachment.push({
...attachmentObj,
url: await getAttachmentUrl(attachmentObj),
})
}
record[attachmentColumn!] = newAttachment
}
records.push(record)
}
formattedData.value.set(key, formatData(records))
countByStack.value.set(key, data.value.pageInfo.totalRows || 0) countByStack.value.set(key, data.value.pageInfo.totalRows || 0)
} }
} }
@ -144,6 +224,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const response = !isPublic.value const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, { ? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...{ where: xWhere.value },
...params, ...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), ...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),

59
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -1,7 +1,12 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { AppInfo } from '~/composables/useGlobal'
export default function convertCellData(args: { from: UITypes; to: UITypes; value: any }, isMysql = false) { export default function convertCellData(
args: { from: UITypes; to: UITypes; value: any; column: ColumnType; appInfo: AppInfo },
isMysql = false,
) {
const { from, to, value } = args const { from, to, value } = args
if (from === to && ![UITypes.Attachment, UITypes.Date, UITypes.DateTime, UITypes.Time, UITypes.Year].includes(to)) { if (from === to && ![UITypes.Attachment, UITypes.Date, UITypes.DateTime, UITypes.Time, UITypes.Year].includes(to)) {
return value return value
@ -76,7 +81,57 @@ export default function convertCellData(args: { from: UITypes; to: UITypes; valu
if (parsedVal.some((v: any) => v && !(v.url || v.data))) { if (parsedVal.some((v: any) => v && !(v.url || v.data))) {
throw new Error('Invalid attachment data') throw new Error('Invalid attachment data')
} }
return JSON.stringify(parsedVal) // TODO(refactor): duplicate logic in attachment/utils.ts
const defaultAttachmentMeta = {
...(args.appInfo.ee && {
// Maximum Number of Attachments per cell
maxNumberOfAttachments: Math.max(1, +args.appInfo.ncMaxAttachmentsAllowed || 50) || 50,
// Maximum File Size per file
maxAttachmentSize: Math.max(1, +args.appInfo.ncMaxAttachmentsAllowed || 20) || 20,
supportedAttachmentMimeTypes: ['*'],
}),
}
const attachmentMeta = {
...defaultAttachmentMeta,
...(typeof args.column?.meta === 'string' ? JSON.parse(args.column.meta) : args.column?.meta),
}
const attachments = []
for (const attachment of parsedVal) {
if (args.appInfo.ee) {
// verify number of files
if (parsedVal.length > attachmentMeta.maxNumberOfAttachments) {
message.error(
`You can only upload at most ${attachmentMeta.maxNumberOfAttachments} file${
attachmentMeta.maxNumberOfAttachments > 1 ? 's' : ''
} to this cell.`,
)
return
}
// verify file size
if (attachment.size > attachmentMeta.maxAttachmentSize * 1024 * 1024) {
message.error(`The size of ${attachment.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.`)
continue
}
// verify mime type
if (
!attachmentMeta.supportedAttachmentMimeTypes.includes('*') &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(attachment.type) &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(attachment.type.split('/')[0])
) {
message.error(`${attachment.name} has the mime type ${attachment.type} which is not allowed in this column.`)
continue
}
}
attachments.push(attachment)
}
return JSON.stringify(attachments)
} }
case UITypes.LinkToAnotherRecord: case UITypes.LinkToAnotherRecord:
case UITypes.Lookup: case UITypes.Lookup:

7
packages/nc-gui/composables/useMultiSelect/index.ts

@ -17,6 +17,7 @@ import {
unref, unref,
useCopy, useCopy,
useEventListener, useEventListener,
useGlobal,
useI18n, useI18n,
useMetas, useMetas,
useProject, useProject,
@ -47,6 +48,8 @@ export function useMultiSelect(
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { appInfo } = useGlobal()
const { isMysql } = useProject() const { isMysql } = useProject()
let clipboardContext = $ref<{ value: any; uidt: UITypes } | null>(null) let clipboardContext = $ref<{ value: any; uidt: UITypes } | null>(null)
@ -298,6 +301,8 @@ export function useMultiSelect(
value: clipboardContext.value, value: clipboardContext.value,
from: clipboardContext.uidt, from: clipboardContext.uidt,
to: columnObj.uidt as UITypes, to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
}, },
isMysql(meta.value?.base_id), isMysql(meta.value?.base_id),
) )
@ -330,6 +335,8 @@ export function useMultiSelect(
value: clipboardContext.value, value: clipboardContext.value,
from: clipboardContext.uidt, from: clipboardContext.uidt,
to: columnObj.uidt as UITypes, to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
}, },
isMysql(meta.value?.base_id), isMysql(meta.value?.base_id),
) )

3
packages/nc-gui/lang/ar.json

@ -205,7 +205,8 @@
"quickImport": "استيراد سريع", "quickImport": "استيراد سريع",
"advancedSettings": "الإعدادات المتقدمة", "advancedSettings": "الإعدادات المتقدمة",
"codeSnippet": "كتلة برمجية", "codeSnippet": "كتلة برمجية",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/bn_IN.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/cs.json

@ -205,7 +205,8 @@
"quickImport": "Rychlý import", "quickImport": "Rychlý import",
"advancedSettings": "Pokročilá nastavení", "advancedSettings": "Pokročilá nastavení",
"codeSnippet": "Úryvek kódu", "codeSnippet": "Úryvek kódu",
"keyboardShortcut": "Klávesové zkratky" "keyboardShortcut": "Klávesové zkratky",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Vytvořil/a", "createdBy": "Vytvořil/a",

3
packages/nc-gui/lang/da.json

@ -205,7 +205,8 @@
"quickImport": "Hurtig import", "quickImport": "Hurtig import",
"advancedSettings": "Avancerede indstillinger", "advancedSettings": "Avancerede indstillinger",
"codeSnippet": "Kodeuddrag", "codeSnippet": "Kodeuddrag",
"keyboardShortcut": "Tastaturgenveje" "keyboardShortcut": "Tastaturgenveje",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Oprettet af", "createdBy": "Oprettet af",

3
packages/nc-gui/lang/de.json

@ -205,7 +205,8 @@
"quickImport": "Schnell Importieren", "quickImport": "Schnell Importieren",
"advancedSettings": "Erweiterte Einstellungen", "advancedSettings": "Erweiterte Einstellungen",
"codeSnippet": "Code Ausschnitt", "codeSnippet": "Code Ausschnitt",
"keyboardShortcut": "Tastenkürzel" "keyboardShortcut": "Tastenkürzel",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Erstellt von", "createdBy": "Erstellt von",

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

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/es.json

@ -205,7 +205,8 @@
"quickImport": "Importación rápida", "quickImport": "Importación rápida",
"advancedSettings": "Ajustes avanzados", "advancedSettings": "Ajustes avanzados",
"codeSnippet": "Fragmento de código", "codeSnippet": "Fragmento de código",
"keyboardShortcut": "Atajos de teclado" "keyboardShortcut": "Atajos de teclado",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Creado por", "createdBy": "Creado por",

3
packages/nc-gui/lang/eu.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Ezarpen aurreratuak", "advancedSettings": "Ezarpen aurreratuak",
"codeSnippet": "Kode-zatiak", "codeSnippet": "Kode-zatiak",
"keyboardShortcut": "Teklatuko laster-markak" "keyboardShortcut": "Teklatuko laster-markak",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Sortzailea:", "createdBy": "Sortzailea:",

3
packages/nc-gui/lang/fa.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/fi.json

@ -205,7 +205,8 @@
"quickImport": "Pikatuonti", "quickImport": "Pikatuonti",
"advancedSettings": "Lisäasetukset", "advancedSettings": "Lisäasetukset",
"codeSnippet": "Koodinpätkä", "codeSnippet": "Koodinpätkä",
"keyboardShortcut": "Näppäimistön pikanäppäimet" "keyboardShortcut": "Näppäimistön pikanäppäimet",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Luonut", "createdBy": "Luonut",

89
packages/nc-gui/lang/fr.json

@ -100,18 +100,18 @@
"kanban": "Kanban", "kanban": "Kanban",
"calendar": "Calendrier" "calendar": "Calendrier"
}, },
"user": "Membre", "user": "Utilisateur",
"users": "Membres", "users": "Utilisateurs",
"role": "Rôle", "role": "Rôle",
"roles": "Rôles", "roles": "Rôles",
"roleType": { "roleType": {
"owner": "Propriétaire", "owner": "Propriétaire",
"creator": "Créateur·trice", "creator": "Créateur",
"editor": "Éditeur·trice", "editor": "Éditeur",
"commenter": "Commentateur·trice", "commenter": "Commentateur",
"viewer": "Lecture seule", "viewer": "Lecture seule",
"orgLevelCreator": "Créateur·trice au niveau de l'organisation", "orgLevelCreator": "Créateur au niveau de l'organisation",
"orgLevelViewer": "Visualiseur·atrice de niveau d'organisation" "orgLevelViewer": "Visualiseur de niveau d'organisation"
}, },
"sqlVIew": "Vue SQL" "sqlVIew": "Vue SQL"
}, },
@ -124,7 +124,7 @@
"Checkbox": "Case à cocher", "Checkbox": "Case à cocher",
"MultiSelect": "Sélection multiple", "MultiSelect": "Sélection multiple",
"SingleSelect": "Menu déroulant", "SingleSelect": "Menu déroulant",
"Collaborator": "Collaborateur·trice", "Collaborator": "Collaborateur",
"Date": "Date", "Date": "Date",
"Year": "Année", "Year": "Année",
"Time": "Heure", "Time": "Heure",
@ -176,15 +176,15 @@
"personalView": "Vue personnelle", "personalView": "Vue personnelle",
"appStore": "Magasin d'applications", "appStore": "Magasin d'applications",
"teamAndAuth": "Équipe & Authentification", "teamAndAuth": "Équipe & Authentification",
"rolesUserMgmt": "Gestion des membres & rôles", "rolesUserMgmt": "Gestion des utilisateurs & rôles",
"userMgmt": "Gestion des membres", "userMgmt": "Gestion des utilisateurs",
"apiTokenMgmt": "Gestion des jetons API", "apiTokenMgmt": "Gestion des jetons API",
"rolesMgmt": "Gestion des rôles", "rolesMgmt": "Gestion des rôles",
"projMeta": "Métadonnées du projet", "projMeta": "Métadonnées du projet",
"metaMgmt": "Gestion des métadonnées", "metaMgmt": "Gestion des métadonnées",
"metadata": "Métadonnées", "metadata": "Métadonnées",
"exportImportMeta": "Exporter / importer les métadonnées", "exportImportMeta": "Exporter / importer les métadonnées",
"uiACL": "Contrôle d'accès à l'interface membre", "uiACL": "Contrôle d'accès à l'interface utilisateur",
"metaOperations": "Opérations de métadonnées", "metaOperations": "Opérations de métadonnées",
"audit": "Audit", "audit": "Audit",
"auditLogs": "Journal d'audit", "auditLogs": "Journal d'audit",
@ -205,7 +205,8 @@
"quickImport": "Importation rapide", "quickImport": "Importation rapide",
"advancedSettings": "Paramètres avancés", "advancedSettings": "Paramètres avancés",
"codeSnippet": "Extrait de code", "codeSnippet": "Extrait de code",
"keyboardShortcut": "Raccourcis clavier" "keyboardShortcut": "Raccourcis clavier",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Créé par", "createdBy": "Créé par",
@ -224,7 +225,7 @@
"sqliteFile": "Fichier SQLite", "sqliteFile": "Fichier SQLite",
"hostAddress": "Adresse de l'hôte", "hostAddress": "Adresse de l'hôte",
"port": "Numéro de port", "port": "Numéro de port",
"username": "Identifiant", "username": "Utilisateur",
"password": "Mot de passe", "password": "Mot de passe",
"schemaName": "Nom du schéma", "schemaName": "Nom du schéma",
"database": "Base de données", "database": "Base de données",
@ -274,7 +275,7 @@
"followNocodb": "Suivre NocoDB" "followNocodb": "Suivre NocoDB"
}, },
"docReference": "Référence de document", "docReference": "Référence de document",
"selectUserRole": "Sélectionner le rôle", "selectUserRole": "Sélectionner le rôle d'utilisateur",
"childTable": "Table enfant", "childTable": "Table enfant",
"childColumn": "Colonne enfant", "childColumn": "Colonne enfant",
"linkToAnotherRecord": "Lien vers un autre enregistrement", "linkToAnotherRecord": "Lien vers un autre enregistrement",
@ -312,7 +313,7 @@
"signInWithGoogle": "Se connecter avec Google", "signInWithGoogle": "Se connecter avec Google",
"agreeToTos": "En continuant, vous acceptez les Conditions d'Utilisation", "agreeToTos": "En continuant, vous acceptez les Conditions d'Utilisation",
"welcomeToNc": "Bienvenue sur NocoDB !", "welcomeToNc": "Bienvenue sur NocoDB !",
"inviteOnlySignup": "Autoriser l'inscription en utilisant uniquement le lien d'invitation", "inviteOnlySignup": "Autoriser l'inscription en utilisant uniquement l'url d'invitation",
"nextRow": "Rang suivant", "nextRow": "Rang suivant",
"prevRow": "Rang précédent" "prevRow": "Rang précédent"
}, },
@ -359,14 +360,14 @@
"invite": "Inviter", "invite": "Inviter",
"inviteMore": "Inviter plus", "inviteMore": "Inviter plus",
"inviteTeam": "Inviter une équipe", "inviteTeam": "Inviter une équipe",
"inviteUser": "Inviter un membre", "inviteUser": "Inviter un utilisateur",
"inviteToken": "Inviter via un jeton", "inviteToken": "Inviter via un jeton",
"newUser": "Nouveau membre", "newUser": "Nouvel utilisateur",
"editUser": "Modifier le membre", "editUser": "Modifier l'utilisateur",
"deleteUser": "Supprimer le membre du projet", "deleteUser": "Supprimer l'utilisateur du projet",
"resendInvite": "Renvoyer une invitation par courriel", "resendInvite": "Renvoyer une invitation par courriel",
"copyInviteURL": "Copier le lien d'invitation", "copyInviteURL": "Copier l'URL d'invitation",
"copyPasswordResetURL": "Copier le lien de réinitialisation du mot de passe", "copyPasswordResetURL": "Copier l'URL de réinitialisation du mot de passe",
"newRole": "Nouveau rôle", "newRole": "Nouveau rôle",
"reloadRoles": "Actualiser les rôles", "reloadRoles": "Actualiser les rôles",
"nextPage": "Page suivante", "nextPage": "Page suivante",
@ -427,7 +428,7 @@
"editConnJson": "Éditer le JSON de connexion", "editConnJson": "Éditer le JSON de connexion",
"sponsorUs": "Nous Parrainer", "sponsorUs": "Nous Parrainer",
"sendEmail": "ENVOYER UN EMAIL", "sendEmail": "ENVOYER UN EMAIL",
"addUserToProject": "Ajouter un membre au projet", "addUserToProject": "Ajouter un utilisateur au projet",
"getApiSnippet": "Récupérer le Snippet API", "getApiSnippet": "Récupérer le Snippet API",
"clearCell": "Vider la cellule", "clearCell": "Vider la cellule",
"addFilterGroup": "Ajouter un groupe de filtres", "addFilterGroup": "Ajouter un groupe de filtres",
@ -463,7 +464,7 @@
"light": "Jour (^⇧B)" "light": "Jour (^⇧B)"
}, },
"addTable": "Ajouter un nouveau tableau", "addTable": "Ajouter un nouveau tableau",
"inviteMore": "Inviter plus de membres", "inviteMore": "Inviter plus d'utilisateurs",
"toggleNavDraw": "Afficher ou masquer le panneau de navigation", "toggleNavDraw": "Afficher ou masquer le panneau de navigation",
"reloadApiToken": "Recharger les jetons API", "reloadApiToken": "Recharger les jetons API",
"generateNewApiToken": "Générer de nouveaux jetons d'API", "generateNewApiToken": "Générer de nouveaux jetons d'API",
@ -503,7 +504,7 @@
"msg": { "msg": {
"warning": { "warning": {
"barcode": { "barcode": {
"renderError": "Erreur de code-barres - veuillez vérifier la compatibilité entre la donnée d'entrée et le type de code-barres" "renderError": "Erreur de code-barres - veuillez vérifier la compatibiltié entre la donnée d'entrée et le type de code-barres"
}, },
"nonEditableFields": { "nonEditableFields": {
"computedFieldUnableToClear": "Avertissement : Champ calculé - impossible d'effacer le texte", "computedFieldUnableToClear": "Avertissement : Champ calculé - impossible d'effacer le texte",
@ -513,8 +514,8 @@
"info": { "info": {
"pasteNotSupported": "L'opération de collage n'est pas prise en charge sur la cellule active", "pasteNotSupported": "L'opération de collage n'est pas prise en charge sur la cellule active",
"roles": { "roles": {
"orgCreator": "Les créateur·trice·s peuvent créer de nouveaux projets et accéder à tout projet en lecture.", "orgCreator": "Le créateur peut créer de nouveaux projets et accéder à tout projet invité.",
"orgViewer": "Les visualisateur·trice·s ne peuvent pas créer de nouveaux projets mais il peuvent accéder à tout projet en lecture." "orgViewer": "Le visualisateur n'est pas autorisé à créer de nouveaux projets mais il peut accéder à tout projet invité."
}, },
"footerInfo": "Lignes par page", "footerInfo": "Lignes par page",
"upload": "Sélectionner un fichier à téléverser", "upload": "Sélectionner un fichier à téléverser",
@ -534,11 +535,11 @@
"deleteProject": "Voulez-vous supprimer le projet ?", "deleteProject": "Voulez-vous supprimer le projet ?",
"shareBasePrivate": "Générer une base partagée en lecture seule", "shareBasePrivate": "Générer une base partagée en lecture seule",
"shareBasePublic": "Toute personne avec ce lien peut consulter", "shareBasePublic": "Toute personne avec ce lien peut consulter",
"userInviteNoSMTP": "On dirait que vous n'avez pas encore configuré Mailer ! Merci de copier-coller le lien d'invitation ci-dessous et l'envoyer à", "userInviteNoSMTP": "On dirait que vous n'avez pas encore configuré Mailer! Merci de copier-coller le lien d'invitation ci-dessous et l'envoyer à",
"dragDropHide": "Glisser et déposer des champs ici pour les masquer", "dragDropHide": "Glisser et déposer des champs ici pour les masquer",
"formInput": "Entrer le libellé du formulaire", "formInput": "Entrer le libellé du formulaire",
"formHelpText": "Ajouter un texte d'aide", "formHelpText": "Ajouter un texte d'aide",
"onlyCreator": "Visible uniquement pour les créateur·trice·s", "onlyCreator": "Visible uniquement pour les créateurs",
"formDesc": "Ajouter une description au formulaire", "formDesc": "Ajouter une description au formulaire",
"beforeEnablePwd": "Restreindre l’accès à l’aide d’un mot de passe", "beforeEnablePwd": "Restreindre l’accès à l’aide d’un mot de passe",
"afterEnablePwd": "L’accès est restreint par un mot de passe", "afterEnablePwd": "L’accès est restreint par un mot de passe",
@ -554,15 +555,15 @@
"showMessage": "Montrer ce message ", "showMessage": "Montrer ce message ",
"viewNotShared": "La vue actuelle n'est pas partagée!", "viewNotShared": "La vue actuelle n'est pas partagée!",
"showAllViews": "Montrer toutes les vues partagées sur cette table", "showAllViews": "Montrer toutes les vues partagées sur cette table",
"collabView": "Les collaborateur·trice·s avec des autorisations d'édition ou plus peuvent modifier la configuration de la vue.", "collabView": "Les collaborateurs avec des autorisations d'édition ou plus peuvent modifier la configuration de la vue.",
"lockedView": "Personne ne peut éditer la configuration de la vue jusqu'à ce qu'elle soit déverrouillée.", "lockedView": "Personne ne peut éditer la configuration de la vue jusqu'à ce qu'elle soit déverrouillée.",
"personalView": "Seulement vous pouvez modifier la configuration de la vue. Les autres vues personnelles des collaborateurs sont cachées par défaut.", "personalView": "Seulement vous pouvez modifier la configuration de la vue. Les autres vues personnelles des collaborateurs sont cachées par défaut.",
"ownerDesc": "Peut ajouter / supprimer des créateur·trice·s. Et éditer des structures de base de données complètes et des champs.", "ownerDesc": "Peut ajouter / supprimer des créateurs. Et éditer des structures de base de données complètes et des champs.",
"creatorDesc": "Peut éditer complètement la structure de la base de données et les valeurs.", "creatorDesc": "Peut éditer complètement la structure de la base de données et les valeurs.",
"editorDesc": "Peut éditer des lignes mais ne peut pas modifier la structure de la base de données / des champs.", "editorDesc": "Peut éditer des lignes mais ne peut pas modifier la structure de la base de données / des champs.",
"commenterDesc": "Peut voir et commenter les archives mais ne peut rien éditer", "commenterDesc": "Peut voir et commenter les archives mais ne peut rien éditer",
"viewerDesc": "Peut voir les données mais ne peut rien éditer", "viewerDesc": "Peut voir les données mais ne peut rien éditer",
"addUser": "Ajouter un nouveau membre", "addUser": "Ajouter un nouvel utilisateur",
"staticRoleInfo": "Les rôles définis sur le système ne peuvent pas être modifiés", "staticRoleInfo": "Les rôles définis sur le système ne peuvent pas être modifiés",
"exportZip": "Exporter le meta projet dans un fichier Zip et le télécharger.", "exportZip": "Exporter le meta projet dans un fichier Zip et le télécharger.",
"importZip": "Importer le fichier ZIP du meta projet et redémarrer.", "importZip": "Importer le fichier ZIP du meta projet et redémarrer.",
@ -577,16 +578,16 @@
}, },
"sponsor": { "sponsor": {
"header": "Vous pouvez nous aider !", "header": "Vous pouvez nous aider !",
"message": "Nous sommes une petite équipe travaillant à plein temps pour rendre NocoDB open-Source. Nous croyons qu'un outil comme NocoDB devrait être disponible librement à chaque personne résolvant des problèmes sur Internet." "message": "Nous sommes une petite équipe travaillant à plein temps pour rendre NocoDB open-Source. Nous croyons qu'un outil comme NocoDB devrait être disponible librement à chaque solutionneur de problème sur Internet."
}, },
"loginMsg": "Se connecter à NocoDB", "loginMsg": "Se connecter à NocoDB",
"passwordRecovery": { "passwordRecovery": {
"message_1": "Veuillez fournir l'adresse mail que vous avez utilisée lors de votre inscription.", "message_1": "Veuillez fournir l'adresse mail que vous avez utilisée lorsque vous vous êtes inscrit.",
"message_2": "Nous vous enverrons un courriel avec un lien pour réinitialiser votre mot de passe.", "message_2": "Nous vous enverrons un courriel avec un lien pour réinitialiser votre mot de passe.",
"success": "Veuillez vérifier votre email pour réinitialiser le mot de passe" "success": "Veuillez vérifier votre email pour réinitialiser le mot de passe"
}, },
"signUp": { "signUp": {
"superAdmin": "Vous serez « super admin »", "superAdmin": "Vous serez le « super administrateur »",
"alreadyHaveAccount": "Avez-vous déjà un compte ?", "alreadyHaveAccount": "Avez-vous déjà un compte ?",
"workEmail": "Saisir votre adresse mail professionnelle", "workEmail": "Saisir votre adresse mail professionnelle",
"enterPassword": "Saisir votre mot de passe", "enterPassword": "Saisir votre mot de passe",
@ -626,7 +627,7 @@
"noColumnsToUpdate": "Aucune colonne à mettre à jour", "noColumnsToUpdate": "Aucune colonne à mettre à jour",
"tableDeleted": "Tableau supprimé avec succès", "tableDeleted": "Tableau supprimé avec succès",
"generatePublicShareableReadonlyBase": "Génère une base publique partagée en lecture seule", "generatePublicShareableReadonlyBase": "Génère une base publique partagée en lecture seule",
"deleteViewConfirmation": "Voulez-vous vraiment effacer cette vue ?", "deleteViewConfirmation": "Êtes-vous sûr de vouloir effacer cette vue ?",
"deleteTableConfirmation": "Voulez-vous supprimer ce tableau", "deleteTableConfirmation": "Voulez-vous supprimer ce tableau",
"showM2mTables": "Afficher les tables plusieurs à plusieurs", "showM2mTables": "Afficher les tables plusieurs à plusieurs",
"deleteKanbanStackConfirmation": "La suppression de cette pile entraînera également la suppression de l'option de sélection `{stackToBeDeleted}` de la pile `{groupingField}`. Les enregistrements seront déplacés vers la pile non catégorisée.", "deleteKanbanStackConfirmation": "La suppression de cette pile entraînera également la suppression de l'option de sélection `{stackToBeDeleted}` de la pile `{groupingField}`. Les enregistrements seront déplacés vers la pile non catégorisée.",
@ -639,7 +640,7 @@
"invalidChar": "Caractère invalide dans le chemin du dossier.", "invalidChar": "Caractère invalide dans le chemin du dossier.",
"invalidDbCredentials": "Identifiants de base de données invalides.", "invalidDbCredentials": "Identifiants de base de données invalides.",
"unableToConnectToDb": "Connexion impossible à la base de données, merci de vérifier que la base de données est démarrée et accessible.", "unableToConnectToDb": "Connexion impossible à la base de données, merci de vérifier que la base de données est démarrée et accessible.",
"userDoesntHaveSufficientPermission": "Le membre n’existe pas ou n’a pas les permissions suffisantes pour créer le schéma.", "userDoesntHaveSufficientPermission": "L’utilisateur n’existe pas ou n’a pas les permissions suffisantes pour créer le schéma.",
"dbConnectionStatus": "Paramètres de base de données non valides", "dbConnectionStatus": "Paramètres de base de données non valides",
"dbConnectionFailed": "Echec de connexion :", "dbConnectionFailed": "Echec de connexion :",
"signUpRules": { "signUpRules": {
@ -710,12 +711,12 @@
"createView": "Vue créée avec succès", "createView": "Vue créée avec succès",
"formEmailSMTP": "Veuillez activer le plugin SMTP dans le magasin d'applications pour permettre la notification par courriel", "formEmailSMTP": "Veuillez activer le plugin SMTP dans le magasin d'applications pour permettre la notification par courriel",
"collabView": "Vous êtes bien dans la vue collaborative", "collabView": "Vous êtes bien dans la vue collaborative",
"lockedView": "Vous êtes bien dans la vue verrouillée", "lockedView": "Vous êtes bien dans la vue vérouillée",
"futureRelease": "Bientôt disponible !" "futureRelease": "Bientôt disponible !"
}, },
"success": { "success": {
"columnDuplicated": "Colonne dupliquée avec succès", "columnDuplicated": "Colonne dupliquée avec succès",
"updatedUIACL": "Mise à jour réussie de l'ACL de l'interface pour les tables", "updatedUIACL": "Mise à jour réussie de l'ACL de l'interface utilisateur pour les tables",
"pluginUninstalled": "Le plugin a été désinstallé avec succès", "pluginUninstalled": "Le plugin a été désinstallé avec succès",
"pluginSettingsSaved": "Les paramètres du plugin ont été enregistrés avec succès", "pluginSettingsSaved": "Les paramètres du plugin ont été enregistrés avec succès",
"pluginTested": "Testé avec succès les paramètres du plugin", "pluginTested": "Testé avec succès les paramètres du plugin",
@ -725,22 +726,22 @@
"tableDataExported": "Toutes les données du tableau ont été exportées avec succès", "tableDataExported": "Toutes les données du tableau ont été exportées avec succès",
"updated": "Mise à jour réussie", "updated": "Mise à jour réussie",
"sharedViewDeleted": "Vue partagée effacée avec succès", "sharedViewDeleted": "Vue partagée effacée avec succès",
"userDeleted": "Membre supprimé avec succès", "userDeleted": "Utilisateur supprimé avec succès",
"viewRenamed": "Vue renommée avec succès", "viewRenamed": "Vue renommée avec succès",
"tokenGenerated": "Jeton généré avec succès", "tokenGenerated": "Jeton généré avec succès",
"tokenDeleted": "Token supprimé avec succès", "tokenDeleted": "Token supprimé avec succès",
"userAddedToProject": "Le membre a été ajouté avec succès au projet", "userAddedToProject": "L'utilisateur a été ajouté avec succès au projet",
"userAdded": "Le membre a été ajouté avec succès", "userAdded": "L'utilisateur a été ajouté avec succès",
"userDeletedFromProject": "Suppression du membre du projet réussie", "userDeletedFromProject": "Suppression réussie de l'utilisateur du projet",
"inviteEmailSent": "Email d'invitation envoyé avec succès", "inviteEmailSent": "Email d'invitation envoyé avec succès",
"inviteURLCopied": "URL de l'invitation copiée dans le presse-papiers", "inviteURLCopied": "URL de l'invitation copiée dans le presse-papiers",
"passwordResetURLCopied": "URL de réinitialisation du mot de passe copiée dans le presse-papiers", "passwordResetURLCopied": "URL de réinitialisation du mot de passe copiée dans le presse-papiers",
"shareableURLCopied": "Copie de l'URL de la base partageable dans le presse-papiers !", "shareableURLCopied": "Copie de l'URL de la base partageable dans le presse-papiers !",
"embeddableHTMLCodeCopied": "Copie du code HTML intégrable !", "embeddableHTMLCodeCopied": "Copie du code HTML intégrable !",
"userDetailsUpdated": "Mise à jour réussie des détails du membre", "userDetailsUpdated": "Mise à jour réussie des détails de l'utilisateur",
"tableDataImported": "Données du tableau importées avec succès", "tableDataImported": "Données du tableau importées avec succès",
"webhookUpdated": "Détails du Webhook mis à jour avec succès", "webhookUpdated": "Détails du Webhook mis à jour avec succès",
"webhookDeleted": "Webhook supprimé avec succès", "webhookDeleted": "Crochet supprimé avec succès",
"webhookTested": "Webhook testé avec succès", "webhookTested": "Webhook testé avec succès",
"columnUpdated": "Colonne mise à jour", "columnUpdated": "Colonne mise à jour",
"columnCreated": "Colonne créée", "columnCreated": "Colonne créée",

3
packages/nc-gui/lang/he.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/hi.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/hr.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/id.json

@ -205,7 +205,8 @@
"quickImport": "Impor Cepat", "quickImport": "Impor Cepat",
"advancedSettings": "Pengaturan Lanjutan", "advancedSettings": "Pengaturan Lanjutan",
"codeSnippet": "Cuplikan Kode", "codeSnippet": "Cuplikan Kode",
"keyboardShortcut": "Pintasan Papan Ketik" "keyboardShortcut": "Pintasan Papan Ketik",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Dibuat oleh", "createdBy": "Dibuat oleh",

3
packages/nc-gui/lang/it.json

@ -205,7 +205,8 @@
"quickImport": "Importazione rapida", "quickImport": "Importazione rapida",
"advancedSettings": "Impostazioni Avanzate", "advancedSettings": "Impostazioni Avanzate",
"codeSnippet": "Snippet di codice", "codeSnippet": "Snippet di codice",
"keyboardShortcut": "Scorciatoie da tastiera" "keyboardShortcut": "Scorciatoie da tastiera",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Creato da", "createdBy": "Creato da",

3
packages/nc-gui/lang/ja.json

@ -205,7 +205,8 @@
"quickImport": "クイックインポート", "quickImport": "クイックインポート",
"advancedSettings": "詳細設定", "advancedSettings": "詳細設定",
"codeSnippet": "コードスニペット", "codeSnippet": "コードスニペット",
"keyboardShortcut": "キーボードショートカット" "keyboardShortcut": "キーボードショートカット",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "クリエイティッド バイ", "createdBy": "クリエイティッド バイ",

3
packages/nc-gui/lang/ko.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/lv.json

@ -205,7 +205,8 @@
"quickImport": "Ātrā importēšana", "quickImport": "Ātrā importēšana",
"advancedSettings": "Izvērstie iestatījumi", "advancedSettings": "Izvērstie iestatījumi",
"codeSnippet": "Koda fragments", "codeSnippet": "Koda fragments",
"keyboardShortcut": "Tastatūras saīsnes" "keyboardShortcut": "Tastatūras saīsnes",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Izveidoja", "createdBy": "Izveidoja",

3
packages/nc-gui/lang/nl.json

@ -205,7 +205,8 @@
"quickImport": "Snel importeren", "quickImport": "Snel importeren",
"advancedSettings": "Geavanceerde instellingen", "advancedSettings": "Geavanceerde instellingen",
"codeSnippet": "Codefragment", "codeSnippet": "Codefragment",
"keyboardShortcut": "Sneltoetsen" "keyboardShortcut": "Sneltoetsen",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Gemaakt door", "createdBy": "Gemaakt door",

3
packages/nc-gui/lang/no.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

17
packages/nc-gui/lang/pl.json

@ -205,7 +205,8 @@
"quickImport": "Szybki import", "quickImport": "Szybki import",
"advancedSettings": "Ustawienia zaawansowane", "advancedSettings": "Ustawienia zaawansowane",
"codeSnippet": "Snippet", "codeSnippet": "Snippet",
"keyboardShortcut": "Skróty klawiaturowe" "keyboardShortcut": "Skróty klawiaturowe",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Stworzony przez", "createdBy": "Stworzony przez",
@ -730,22 +731,22 @@
"tokenGenerated": "Token został wygenerowany pomyślnie", "tokenGenerated": "Token został wygenerowany pomyślnie",
"tokenDeleted": "Token usunięty pomyślnie", "tokenDeleted": "Token usunięty pomyślnie",
"userAddedToProject": "Pomyślnie dodano użytkownika do projektu", "userAddedToProject": "Pomyślnie dodano użytkownika do projektu",
"userAdded": "Udało się dodać użytkownika", "userAdded": "Dodano użytkownika",
"userDeletedFromProject": "Użytkownik usunięty z projektu", "userDeletedFromProject": "Użytkownik usunięty z projektu",
"inviteEmailSent": "E-mail z zaproszeniem wysłany pomyślnie", "inviteEmailSent": "E-mail został wysłany pomyślnie",
"inviteURLCopied": "Adres URL zaproszenia skopiowany do schowka", "inviteURLCopied": "Link z zaproszeniem skopiowany do schowka",
"passwordResetURLCopied": "URL resetowania hasła skopiowany do schowka", "passwordResetURLCopied": "URL resetowania hasła skopiowany do schowka",
"shareableURLCopied": "Skopiowano adres URL do schowka!", "shareableURLCopied": "Skopiowano adres URL do schowka!",
"embeddableHTMLCodeCopied": "Skopiowany kod HTML do osadzenia!", "embeddableHTMLCodeCopied": "Skopiowano kod HTML!",
"userDetailsUpdated": "Pomyślnie zaktualizowano dane użytkownika", "userDetailsUpdated": "Pomyślnie zaktualizowano dane użytkownika",
"tableDataImported": "Pomyślnie zaimportowano dane tabeli", "tableDataImported": "Udało się zaimportować dane tabeli",
"webhookUpdated": "Szczegóły webhooka zostały zaktualizowane", "webhookUpdated": "Szczegóły webhooka zostały zaktualizowane",
"webhookDeleted": "Webhook usunięty pomyślnie", "webhookDeleted": "Webhook usunięty pomyślnie",
"webhookTested": "Webhook przetestowany pomyślnie", "webhookTested": "Webhook przetestowany pomyślnie",
"columnUpdated": "Kolumna zaktualizowana", "columnUpdated": "Kolumna zaktualizowana",
"columnCreated": "Kolumna utworzona", "columnCreated": "Kolumna utworzona",
"passwordChanged": "Hasło zostało zmienione. Zaloguj się ponownie.", "passwordChanged": "Hasło zostało pomyślnie zmienione. Proszę zalogować się ponownie.",
"settingsSaved": "Ustawienia zapisane pomyślnie", "settingsSaved": "Zapisano ustawienia",
"roleUpdated": "Rola została pomyślnie zaktualizowana" "roleUpdated": "Rola została pomyślnie zaktualizowana"
} }
} }

3
packages/nc-gui/lang/pt.json

@ -205,7 +205,8 @@
"quickImport": "Importação rápida", "quickImport": "Importação rápida",
"advancedSettings": "Configurações avançadas", "advancedSettings": "Configurações avançadas",
"codeSnippet": "Código Snippet", "codeSnippet": "Código Snippet",
"keyboardShortcut": "Atalhos de teclado" "keyboardShortcut": "Atalhos de teclado",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Criado por", "createdBy": "Criado por",

3
packages/nc-gui/lang/pt_BR.json

@ -205,7 +205,8 @@
"quickImport": "Importação rápida", "quickImport": "Importação rápida",
"advancedSettings": "Configurações avançadas", "advancedSettings": "Configurações avançadas",
"codeSnippet": "Código Snippet", "codeSnippet": "Código Snippet",
"keyboardShortcut": "Atalhos de teclado" "keyboardShortcut": "Atalhos de teclado",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Criado por", "createdBy": "Criado por",

3
packages/nc-gui/lang/ru.json

@ -205,7 +205,8 @@
"quickImport": "Быстрый импорт", "quickImport": "Быстрый импорт",
"advancedSettings": "Расширенные настройки", "advancedSettings": "Расширенные настройки",
"codeSnippet": "Сниппет кода", "codeSnippet": "Сниппет кода",
"keyboardShortcut": "Горячие клавиши" "keyboardShortcut": "Горячие клавиши",
"generateRandomName": "Сгенерировать случайное имя"
}, },
"labels": { "labels": {
"createdBy": "Автор", "createdBy": "Автор",

3
packages/nc-gui/lang/sk.json

@ -205,7 +205,8 @@
"quickImport": "Rýchly import", "quickImport": "Rýchly import",
"advancedSettings": "Rozšírené nastavenia", "advancedSettings": "Rozšírené nastavenia",
"codeSnippet": "Úryvok kódu", "codeSnippet": "Úryvok kódu",
"keyboardShortcut": "Klávesové skratky" "keyboardShortcut": "Klávesové skratky",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Vytvoril", "createdBy": "Vytvoril",

3
packages/nc-gui/lang/sl.json

@ -205,7 +205,8 @@
"quickImport": "Hitri uvoz", "quickImport": "Hitri uvoz",
"advancedSettings": "Napredne nastavitve", "advancedSettings": "Napredne nastavitve",
"codeSnippet": "Odlomek kode", "codeSnippet": "Odlomek kode",
"keyboardShortcut": "Bližnjice na tipkovnici" "keyboardShortcut": "Bližnjice na tipkovnici",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Ustvaril je", "createdBy": "Ustvaril je",

3
packages/nc-gui/lang/sv.json

@ -205,7 +205,8 @@
"quickImport": "Snabb import", "quickImport": "Snabb import",
"advancedSettings": "Avancerade inställningar", "advancedSettings": "Avancerade inställningar",
"codeSnippet": "Kodutdrag", "codeSnippet": "Kodutdrag",
"keyboardShortcut": "Tangentbordsgenvägar" "keyboardShortcut": "Tangentbordsgenvägar",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Skapad av", "createdBy": "Skapad av",

3
packages/nc-gui/lang/th.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet", "codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/tr.json

@ -205,7 +205,8 @@
"quickImport": "Hızlı İçe Aktarma", "quickImport": "Hızlı İçe Aktarma",
"advancedSettings": "Gelişmiş Ayarlar", "advancedSettings": "Gelişmiş Ayarlar",
"codeSnippet": "Kod Parçacığı", "codeSnippet": "Kod Parçacığı",
"keyboardShortcut": "Klavye Kısayolları" "keyboardShortcut": "Klavye Kısayolları",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Tarafından Oluşturuldu", "createdBy": "Tarafından Oluşturuldu",

3
packages/nc-gui/lang/uk.json

@ -205,7 +205,8 @@
"quickImport": "Швидкий імпорт", "quickImport": "Швидкий імпорт",
"advancedSettings": "Додаткові налаштування", "advancedSettings": "Додаткові налаштування",
"codeSnippet": "Фрагмент коду", "codeSnippet": "Фрагмент коду",
"keyboardShortcut": "Комбінації клавіш" "keyboardShortcut": "Комбінації клавіш",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Створено", "createdBy": "Створено",

3
packages/nc-gui/lang/vi.json

@ -205,7 +205,8 @@
"quickImport": "Quick Import", "quickImport": "Quick Import",
"advancedSettings": "Cài đặt Nâng cao", "advancedSettings": "Cài đặt Nâng cao",
"codeSnippet": "Thư viện mã", "codeSnippet": "Thư viện mã",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Đã tạo bởi", "createdBy": "Đã tạo bởi",

3
packages/nc-gui/lang/zh-Hans.json

@ -205,7 +205,8 @@
"quickImport": "快速导入", "quickImport": "快速导入",
"advancedSettings": "高级设置", "advancedSettings": "高级设置",
"codeSnippet": "代码片段", "codeSnippet": "代码片段",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

3
packages/nc-gui/lang/zh-Hant.json

@ -205,7 +205,8 @@
"quickImport": "快速匯入", "quickImport": "快速匯入",
"advancedSettings": "進階設定", "advancedSettings": "進階設定",
"codeSnippet": "程式碼片段", "codeSnippet": "程式碼片段",
"keyboardShortcut": "Keyboard Shortcuts" "keyboardShortcut": "Keyboard Shortcuts",
"generateRandomName": "Generate Random Name"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "Created By",

2
packages/nc-gui/package-lock.json generated

@ -96,7 +96,7 @@
} }
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.101.2", "version": "0.104.2",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",

6
packages/nc-gui/tsconfig.json

@ -19,9 +19,5 @@
"@nuxt/image-edge" "@nuxt/image-edge"
] ]
}, },
"exclude": [ "exclude": ["node_modules", "dist", ".output"]
"node_modules",
"dist",
".output"
]
} }

2
packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts

@ -158,7 +158,7 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
} }
} else if (column.uidt === UITypes.Number) { } else if (column.uidt === UITypes.Number) {
if ( if (
rows.slice(1, this.config.maxRowsToParse).every((v: any) => { rows.slice(1, this.config.maxRowsToParse).some((v: any) => {
return v && v[col] && parseInt(v[col]) !== +v[col] return v && v[col] && parseInt(v[col]) !== +v[col]
}) })
) { ) {

2
packages/nc-lib-gui/package.json

@ -1,6 +1,6 @@
{ {
"name": "nc-lib-gui", "name": "nc-lib-gui",
"version": "0.101.2", "version": "0.104.2",
"description": "NocoDB GUI", "description": "NocoDB GUI",
"author": { "author": {
"name": "NocoDB", "name": "NocoDB",

12
packages/noco-docs/package-lock.json generated

@ -15221,9 +15221,9 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
}, },
"node_modules/ua-parser-js": { "node_modules/ua-parser-js": {
"version": "0.7.32", "version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==", "integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -29177,9 +29177,9 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
}, },
"ua-parser-js": { "ua-parser-js": {
"version": "0.7.32", "version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==" "integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
}, },
"ufo": { "ufo": {
"version": "0.6.11", "version": "0.6.11",

4
packages/nocodb-sdk/package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.101.2", "version": "0.104.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.101.2", "version": "0.104.2",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",

2
packages/nocodb-sdk/package.json

@ -1,6 +1,6 @@
{ {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.101.2", "version": "0.104.2",
"description": "NocoDB SDK", "description": "NocoDB SDK",
"main": "build/main/index.js", "main": "build/main/index.js",
"typings": "build/main/index.d.ts", "typings": "build/main/index.d.ts",

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

@ -470,6 +470,8 @@ export interface AttachmentType {
mimetype?: string; mimetype?: string;
size?: string; size?: string;
icon?: string; icon?: string;
path?: string;
data?: any;
} }
export interface WebhookType { export interface WebhookType {

30
packages/nocodb/docker-compose.yml

@ -19,28 +19,32 @@ services:
# - 3356:3306 # - 3356:3306
# volumes: # volumes:
# - ./mysql-sakila-db:/docker-entrypoint-initdb.d # - ./mysql-sakila-db:/docker-entrypoint-initdb.d
db57: # db57:
image: mysql:5.7 # image: mysql:5.7
# restart: always
# environment:
# MYSQL_ROOT_PASSWORD: password
# ports:
# - 3357:3306
# volumes:
# - ./tests/mysql-sakila-db:/docker-entrypoint-initdb.d
# healthcheck:
# test: "/etc/init.d/mysql status"
# interval: 1s
# retries: 240
db8032:
image: mysql:8.0.32
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: password MYSQL_ROOT_PASSWORD: password
ports: ports:
- 3357:3306 - 3380:3306
volumes: volumes:
- ./tests/mysql-sakila-db:/docker-entrypoint-initdb.d - ./tests/mysql-sakila-db:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: "/etc/init.d/mysql status" test: "/etc/init.d/mysql status"
interval: 1s interval: 1s
retries: 240 retries: 240
# db80:
# image: mysql:8.0
# restart: always
# environment:
# MYSQL_ROOT_PASSWORD: password
# ports:
# - 3380:3306
# volumes:
# - ./mysql-sakila-db:/docker-entrypoint-initdb.d
# maria102: # maria102:
# image: mariadb:10.2 # image: mariadb:10.2
# restart: always # restart: always

32
packages/nocodb/package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "nocodb", "name": "nocodb",
"version": "0.101.2", "version": "0.104.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nocodb", "name": "nocodb",
"version": "0.101.2", "version": "0.104.2",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^5.7.2", "@google-cloud/storage": "^5.7.2",
@ -65,7 +65,7 @@
"mysql2": "^2.2.5", "mysql2": "^2.2.5",
"nanoid": "^3.1.20", "nanoid": "^3.1.20",
"nc-help": "0.2.85", "nc-help": "0.2.85",
"nc-lib-gui": "0.101.2", "nc-lib-gui": "0.104.2",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
@ -153,7 +153,7 @@
} }
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.101.1", "version": "0.104.2",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -4238,9 +4238,9 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
}, },
"node_modules/cookiejar": { "node_modules/cookiejar": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true "dev": true
}, },
"node_modules/copy-concurrently": { "node_modules/copy-concurrently": {
@ -11161,9 +11161,9 @@
} }
}, },
"node_modules/nc-lib-gui": { "node_modules/nc-lib-gui": {
"version": "0.101.2", "version": "0.104.2",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.101.2.tgz", "resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.104.2.tgz",
"integrity": "sha512-mwvSo+pTirZ8XIL3pe9tq2zdOLeigrzdYFHadsJ/xEIx+0U0NNlJglKtnKzr+Zy1wH0LceQCjOZV28v45AtfqQ==", "integrity": "sha512-B4FmViGweFoOieL0mFXdiUWV4GtOQo7XCun2so2So78pJD49yIq6d3oxN8+xiNyGNTvsophcCU9xchV8NLK9/Q==",
"dependencies": { "dependencies": {
"express": "^4.17.1" "express": "^4.17.1"
} }
@ -22301,9 +22301,9 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
}, },
"cookiejar": { "cookiejar": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true "dev": true
}, },
"copy-concurrently": { "copy-concurrently": {
@ -27687,9 +27687,9 @@
} }
}, },
"nc-lib-gui": { "nc-lib-gui": {
"version": "0.101.2", "version": "0.104.2",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.101.2.tgz", "resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.104.2.tgz",
"integrity": "sha512-mwvSo+pTirZ8XIL3pe9tq2zdOLeigrzdYFHadsJ/xEIx+0U0NNlJglKtnKzr+Zy1wH0LceQCjOZV28v45AtfqQ==", "integrity": "sha512-B4FmViGweFoOieL0mFXdiUWV4GtOQo7XCun2so2So78pJD49yIq6d3oxN8+xiNyGNTvsophcCU9xchV8NLK9/Q==",
"requires": { "requires": {
"express": "^4.17.1" "express": "^4.17.1"
} }

4
packages/nocodb/package.json

@ -1,6 +1,6 @@
{ {
"name": "nocodb", "name": "nocodb",
"version": "0.101.2", "version": "0.104.2",
"description": "NocoDB Backend", "description": "NocoDB Backend",
"main": "dist/bundle.js", "main": "dist/bundle.js",
"author": { "author": {
@ -105,7 +105,7 @@
"mysql2": "^2.2.5", "mysql2": "^2.2.5",
"nanoid": "^3.1.20", "nanoid": "^3.1.20",
"nc-help": "0.2.85", "nc-help": "0.2.85",
"nc-lib-gui": "0.101.2", "nc-lib-gui": "0.104.2",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",

2
packages/nocodb/src/lib/Noco.ts

@ -104,7 +104,7 @@ export default class Noco {
constructor() { constructor() {
process.env.PORT = process.env.PORT || '8080'; process.env.PORT = process.env.PORT || '8080';
// todo: move // todo: move
process.env.NC_VERSION = '0100002'; process.env.NC_VERSION = '0101002';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources // if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) { if (process.env.NC_MINIMAL_DBS) {

7
packages/nocodb/src/lib/meta/NcMetaIOImpl.ts

@ -468,6 +468,13 @@ export default class NcMetaIOImpl extends NcMetaIO {
async startTransaction(): Promise<NcMetaIO> { async startTransaction(): Promise<NcMetaIO> {
const trx = await this.connection.transaction(); const trx = await this.connection.transaction();
// todo: Extend transaction class to add our custom properties
Object.assign(trx, {
clientType: this.connection.clientType,
searchPath: (this.connection as any).searchPath,
});
return new NcMetaIOImpl(this.app, this.config, trx); return new NcMetaIOImpl(this.app, this.config, trx);
} }

27
packages/nocodb/src/lib/meta/api/attachmentApis.ts

@ -12,6 +12,7 @@ import { Tele } from 'nc-help';
import extractProjectIdAndAuthenticate from '../helpers/extractProjectIdAndAuthenticate'; import extractProjectIdAndAuthenticate from '../helpers/extractProjectIdAndAuthenticate';
import catchError, { NcError } from '../helpers/catchError'; import catchError, { NcError } from '../helpers/catchError';
import NcPluginMgrv2 from '../helpers/NcPluginMgrv2'; import NcPluginMgrv2 from '../helpers/NcPluginMgrv2';
import Local from '../../v1-legacy/plugins/adapters/storage/Local';
import { NC_ATTACHMENT_FIELD_SIZE } from '../../constants'; import { NC_ATTACHMENT_FIELD_SIZE } from '../../constants';
const isUploadAllowed = async (req: Request, _res: Response, next: any) => { const isUploadAllowed = async (req: Request, _res: Response, next: any) => {
@ -41,7 +42,6 @@ const isUploadAllowed = async (req: Request, _res: Response, next: any) => {
NcError.badRequest('Upload not allowed'); NcError.badRequest('Upload not allowed');
}; };
// const storageAdapter = new Local();
export async function upload(req: Request, res: Response) { export async function upload(req: Request, res: Response) {
const filePath = sanitizeUrlPath( const filePath = sanitizeUrlPath(
req.query?.path?.toString()?.split('/') || [''] req.query?.path?.toString()?.split('/') || ['']
@ -49,23 +49,28 @@ export async function upload(req: Request, res: Response) {
const destPath = path.join('nc', 'uploads', ...filePath); const destPath = path.join('nc', 'uploads', ...filePath);
const storageAdapter = await NcPluginMgrv2.storageAdapter(); const storageAdapter = await NcPluginMgrv2.storageAdapter();
const attachments = await Promise.all( const attachments = await Promise.all(
(req as any).files?.map(async (file) => { (req as any).files?.map(async (file) => {
const fileName = `${nanoid(6)}${path.extname(file.originalname)}`; const fileName = `${nanoid(18)}${path.extname(file.originalname)}`;
let url = await storageAdapter.fileCreate( let url = await storageAdapter.fileCreate(
slash(path.join(destPath, fileName)), slash(path.join(destPath, fileName)),
file file
); );
let attachmentPath;
// if `url` is null, then it is local attachment
if (!url) { if (!url) {
url = `${(req as any).ncSiteUrl}/download/${filePath.join( // then store the attachement path only
'/' // url will be constructued in `useAttachmentCell`
)}/${fileName}`; attachmentPath = `download/${filePath.join('/')}/${fileName}`;
} }
return { return {
url, ...(url ? { url } : {}),
...(attachmentPath ? { path: attachmentPath } : {}),
title: file.originalname, title: file.originalname,
mimetype: file.mimetype, mimetype: file.mimetype,
size: file.size, size: file.size,
@ -86,9 +91,11 @@ export async function uploadViaURL(req: Request, res: Response) {
const destPath = path.join('nc', 'uploads', ...filePath); const destPath = path.join('nc', 'uploads', ...filePath);
const storageAdapter = await NcPluginMgrv2.storageAdapter(); const storageAdapter = await NcPluginMgrv2.storageAdapter();
const attachments = await Promise.all( const attachments = await Promise.all(
req.body?.map?.(async (urlMeta) => { req.body?.map?.(async (urlMeta) => {
const { url, fileName: _fileName } = urlMeta; const { url, fileName: _fileName } = urlMeta;
const fileName = `${nanoid(6)}${_fileName || url.split('/').pop()}`; const fileName = `${nanoid(6)}${_fileName || url.split('/').pop()}`;
let attachmentUrl = await (storageAdapter as any).fileCreateByUrl( let attachmentUrl = await (storageAdapter as any).fileCreateByUrl(
@ -119,12 +126,12 @@ export async function uploadViaURL(req: Request, res: Response) {
export async function fileRead(req, res) { export async function fileRead(req, res) {
try { try {
const storageAdapter = await NcPluginMgrv2.storageAdapter(); // get the local storage adapter to display local attachments
// const type = mimetypes[path.extname(req.s.fileName).slice(1)] || 'text/plain'; const storageAdapter = new Local();
const type = const type =
mimetypes[path.extname(req.params?.[0]).split('/').pop().slice(1)] || mimetypes[path.extname(req.params?.[0]).split('/').pop().slice(1)] ||
'text/plain'; 'text/plain';
// const img = await this.storageAdapter.fileRead(slash(path.join('nc', req.params.projectId, req.params.dbAlias, 'uploads', req.params.fileName)));
const img = await storageAdapter.fileRead( const img = await storageAdapter.fileRead(
slash( slash(
path.join( path.join(
@ -192,6 +199,7 @@ router.post(
catchError(upload), catchError(upload),
] ]
); );
router.post( router.post(
'/api/v1/db/storage/upload-by-url', '/api/v1/db/storage/upload-by-url',
@ -201,6 +209,7 @@ router.post(
catchError(uploadViaURL), catchError(uploadViaURL),
] ]
); );
router.get(/^\/download\/(.+)$/, catchError(fileRead)); router.get(/^\/download\/(.+)$/, catchError(fileRead));
export default router; export default router;

8
packages/nocodb/src/lib/meta/api/dataApis/helpers.ts

@ -160,6 +160,7 @@ async function getDbRows(baseModel, view: View, req: Request) {
dbRow[column.title] = await serializeCellValue({ dbRow[column.title] = await serializeCellValue({
value: row[column.title], value: row[column.title],
column, column,
siteUrl: req['ncSiteUrl'],
}); });
} }
dbRows.push(dbRow); dbRows.push(dbRow);
@ -171,9 +172,11 @@ async function getDbRows(baseModel, view: View, req: Request) {
export async function serializeCellValue({ export async function serializeCellValue({
value, value,
column, column,
siteUrl,
}: { }: {
column?: Column; column?: Column;
value: any; value: any;
siteUrl: string;
}) { }) {
if (!column) { if (!column) {
return value; return value;
@ -192,7 +195,9 @@ export async function serializeCellValue({
return (data || []).map( return (data || []).map(
(attachment) => (attachment) =>
`${encodeURI(attachment.title)}(${encodeURI(attachment.url)})` `${encodeURI(attachment.title)}(${encodeURI(
attachment.path ? `${siteUrl}/${attachment.path}` : attachment.url
)})`
); );
} }
case UITypes.Lookup: case UITypes.Lookup:
@ -205,6 +210,7 @@ export async function serializeCellValue({
serializeCellValue({ serializeCellValue({
value: v, value: v,
column: lookupColumn, column: lookupColumn,
siteUrl,
}) })
) )
) )

3
packages/nocodb/src/lib/meta/api/utilApis.ts

@ -16,6 +16,7 @@ import NcConfigFactory, {
import User from '../../models/User'; import User from '../../models/User';
import catchError from '../helpers/catchError'; import catchError from '../helpers/catchError';
import axios from 'axios'; import axios from 'axios';
import { NC_ATTACHMENT_FIELD_SIZE } from '../../constants';
const versionCache = { const versionCache = {
releaseVersion: null, releaseVersion: null,
@ -55,6 +56,8 @@ export async function appInfo(req: Request, res: Response) {
teleEnabled: !process.env.NC_DISABLE_TELE, teleEnabled: !process.env.NC_DISABLE_TELE,
ncSiteUrl: (req as any).ncSiteUrl, ncSiteUrl: (req as any).ncSiteUrl,
ee: Noco.isEE(), ee: Noco.isEE(),
ncAttachmentFieldSize: NC_ATTACHMENT_FIELD_SIZE,
ncMaxAttachmentsAllowed: +(process.env.NC_MAX_ATTACHMENTS_ALLOWED || 10),
}; };
res.json(result); res.json(result);

18
packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts

@ -79,23 +79,23 @@ export async function validateCondition(filters: Filter[], data: any) {
res = data[field] !== null; res = data[field] !== null;
break; break;
case 'allof': case 'allof':
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) => res = (filter.value?.split(',').map((item) => item.trim()) ?? []).every(
(data[field]?.split(',') ?? []).includes(item) (item) => (data[field]?.split(',') ?? []).includes(item)
); );
break; break;
case 'anyof': case 'anyof':
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) => res = (filter.value?.split(',').map((item) => item.trim()) ?? []).some(
(data[field]?.split(',') ?? []).includes(item) (item) => (data[field]?.split(',') ?? []).includes(item)
); );
break; break;
case 'nallof': case 'nallof':
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) => res = !(
(data[field]?.split(',') ?? []).includes(item) filter.value?.split(',').map((item) => item.trim()) ?? []
); ).every((item) => (data[field]?.split(',') ?? []).includes(item));
break; break;
case 'nanyof': case 'nanyof':
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) => res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).some(
(data[field]?.split(',') ?? []).includes(item) (item) => (data[field]?.split(',') ?? []).includes(item)
); );
break; break;
case 'lt': case 'lt':

2
packages/nocodb/src/lib/models/Base.ts

@ -284,7 +284,7 @@ export default class Base implements BaseType {
[UITypes.Rollup]: 2, [UITypes.Rollup]: 2,
[UITypes.ForeignKey]: 3, [UITypes.ForeignKey]: 3,
[UITypes.LinkToAnotherRecord]: 4, [UITypes.LinkToAnotherRecord]: 4,
} };
for (const model of models) { for (const model of models) {
for (const col of await model.getColumns(ncMeta)) { for (const col of await model.getColumns(ncMeta)) {

2
packages/nocodb/src/lib/models/View.ts

@ -299,7 +299,7 @@ export default class View implements ViewType {
case ViewTypes.GRID: case ViewTypes.GRID:
await GridView.insert( await GridView.insert(
{ {
...(copyFromView?.view as GridView || {}), ...((copyFromView?.view as GridView) || {}),
...(view as GridView), ...(view as GridView),
fk_view_id: view_id, fk_view_id: view_id,
}, },

2
packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts

@ -9,6 +9,7 @@ import ncProjectUpgraderV2_0090000 from './ncProjectUpgraderV2_0090000';
import ncDataTypesUpgrader from './ncDataTypesUpgrader'; import ncDataTypesUpgrader from './ncDataTypesUpgrader';
import ncProjectRolesUpgrader from './ncProjectRolesUpgrader'; import ncProjectRolesUpgrader from './ncProjectRolesUpgrader';
import ncFilterUpgrader from './ncFilterUpgrader'; import ncFilterUpgrader from './ncFilterUpgrader';
import ncAttachmentUpgrader from './ncAttachmentUpgrader';
const log = debug('nc:version-upgrader'); const log = debug('nc:version-upgrader');
import boxen from 'boxen'; import boxen from 'boxen';
@ -37,6 +38,7 @@ export default class NcUpgrader {
{ name: '0098004', handler: ncDataTypesUpgrader }, { name: '0098004', handler: ncDataTypesUpgrader },
{ name: '0098005', handler: ncProjectRolesUpgrader }, { name: '0098005', handler: ncProjectRolesUpgrader },
{ name: '0100002', handler: ncFilterUpgrader }, { name: '0100002', handler: ncFilterUpgrader },
{ name: '0101002', handler: ncAttachmentUpgrader },
]; ];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) { if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return; return;

178
packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts

@ -0,0 +1,178 @@
import { Knex } from 'knex';
import { NcUpgraderCtx } from './NcUpgrader';
import { MetaTable } from '../utils/globals';
import Base from '../models/Base';
import Model from '../models/Model';
import { XKnex } from '../db/sql-data-mapper/index';
import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2';
import { BaseType, UITypes } from 'nocodb-sdk';
// before 0.103.0, an attachment object was like
// [{
// "url": "http://localhost:8080/download/noco/xcdb/Sheet-1/title5/39A410.jpeg",
// "title": "foo.jpeg",
// "mimetype": "image/jpeg",
// "size": 6494
// }]
// in this way, if the base url is changed, the url will be broken
// this upgrader is to convert the existing local attachment object to the following format
// [{
// "url": "http://localhost:8080/download/noco/xcdb/Sheet-1/title5/39A410.jpeg",
// "path": "download/noco/xcdb/Sheet-1/title5/39A410.jpeg",
// "title": "foo.jpeg",
// "mimetype": "image/jpeg",
// "size": 6494
// }]
// the new url will be constructed by `${ncSiteUrl}/${path}` in UI. the old url will be used for fallback
// while other non-local attachments will remain unchanged
function getTnPath(knex: XKnex, tb: Model) {
const schema = (knex as any).searchPath?.();
const clientType = knex.clientType();
if (clientType === 'mssql' && schema) {
return knex.raw('??.??', [schema, tb.table_name]).toQuery();
} else if (clientType === 'snowflake') {
return [
knex.client.config.connection.database,
knex.client.config.connection.schema,
tb.table_name,
].join('.');
} else {
return tb.table_name;
}
}
export default async function ({ ncMeta }: NcUpgraderCtx) {
const bases: BaseType[] = await ncMeta.metaList2(null, null, MetaTable.BASES);
for (const _base of bases) {
const base = new Base(_base);
// skip if the prodect_id is missing
if (!base.project_id) {
continue;
}
const project = await ncMeta.metaGet2(null, null, MetaTable.PROJECT, {
id: base.project_id,
});
// skip if the project is missing
if (!project) {
continue;
}
const isProjectDeleted = project.deleted;
const knex: Knex = base.is_meta
? ncMeta.knexConnection
: NcConnectionMgrv2.get(base);
const models = await base.getModels(ncMeta);
for (const model of models) {
try {
// if the table is missing in database, skip
if (!(await knex.schema.hasTable(getTnPath(knex, model)))) {
continue;
}
const updateRecords = [];
// get all attachment & primary key columns
// and filter out the columns that are missing in database
const columns = await (await Model.get(model.id, ncMeta))
.getColumns(ncMeta)
.then(async (columns) => {
const filteredColumns = [];
for (const column of columns) {
if (column.uidt !== UITypes.Attachment && !column.pk) continue;
if (
!(await knex.schema.hasColumn(
getTnPath(knex, model),
column.column_name
))
)
continue;
filteredColumns.push(column);
}
return filteredColumns;
});
const attachmentColumns = columns
.filter((c) => c.uidt === UITypes.Attachment)
.map((c) => c.column_name);
if (attachmentColumns.length === 0) {
continue;
}
const primaryKeys = columns
.filter((c) => c.pk)
.map((c) => c.column_name);
const records = await knex(getTnPath(knex, model)).select();
for (const record of records) {
for (const attachmentColumn of attachmentColumns) {
let attachmentMeta: Array<{
url: string;
}>;
// if parsing failed ignore the cell
try {
attachmentMeta =
typeof record[attachmentColumn] === 'string'
? JSON.parse(record[attachmentColumn])
: record[attachmentColumn];
} catch {}
// if cell data is not an array, ignore it
if (!Array.isArray(attachmentMeta)) {
continue;
}
if (attachmentMeta) {
const newAttachmentMeta = [];
for (const attachment of attachmentMeta) {
if ('url' in attachment && typeof attachment.url === 'string') {
const match = attachment.url.match(/^(.*)\/download\/(.*)$/);
if (match) {
// e.g. http://localhost:8080/download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
// match[1] = http://localhost:8080
// match[2] = download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
const path = `download/${match[2]}`;
newAttachmentMeta.push({
...attachment,
path,
});
} else {
// keep it as it is
newAttachmentMeta.push(attachment);
}
}
}
const where = primaryKeys
.map((key) => {
return { [key]: record[key] };
})
.reduce((acc, val) => Object.assign(acc, val), {});
updateRecords.push(
await knex(getTnPath(knex, model))
.update({
[attachmentColumn]: JSON.stringify(newAttachmentMeta),
})
.where(where)
);
}
}
}
await Promise.all(updateRecords);
} catch (e) {
// ignore the error related to deleted project
if (!isProjectDeleted) {
throw e;
}
}
}
}
}

6
scripts/sdk/swagger.json

@ -9056,7 +9056,11 @@
}, },
"icon": { "icon": {
"type": "string" "type": "string"
} },
"path": {
"type": "string"
},
"data": {}
} }
}, },
"Webhook": { "Webhook": {

BIN
tests/playwright/fixtures/sampleFiles/Image/1.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
tests/playwright/fixtures/sampleFiles/Image/2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

BIN
tests/playwright/fixtures/sampleFiles/Image/3.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
tests/playwright/fixtures/sampleFiles/Image/4.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
tests/playwright/fixtures/sampleFiles/Image/5.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
tests/playwright/fixtures/sampleFiles/Image/6_bigSize.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

2
tests/playwright/pages/Base.ts

@ -49,7 +49,7 @@ export default abstract class BasePage {
]); ]);
} }
async attachFile({ filePickUIAction, filePath }: { filePickUIAction: Promise<any>; filePath: string }) { async attachFile({ filePickUIAction, filePath }: { filePickUIAction: Promise<any>; filePath: string[] }) {
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting. // It is important to call waitForEvent before click to set up waiting.
this.rootPage.waitForEvent('filechooser'), this.rootPage.waitForEvent('filechooser'),

140
tests/playwright/pages/Dashboard/Grid/Column/Attachment.ts

@ -0,0 +1,140 @@
import { ColumnPageObject } from '.';
import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class AttachmentColumnPageObject extends BasePage {
readonly column: ColumnPageObject;
constructor(column: ColumnPageObject) {
super(column.rootPage);
this.column = column;
}
get() {
return this.column.get();
}
async advanceConfig({
columnTitle,
fileCount,
fileSize,
fileTypesExcludeList,
}: {
columnTitle: string;
fileCount?: number;
fileSize?: number;
fileTypesExcludeList?: string[];
}) {
await this.column.openEdit({ title: columnTitle });
await this.column.editMenuShowMore();
// text box : nc-attachment-max-count
// text box : nc-attachment-max-size
// checkbox : ant-tree-checkbox
// Checkbox order: Application, Audio, Image, Video, Misc
if (fileCount) {
const inputMaxCount = await this.column.get().locator(`.nc-attachment-max-count`);
await inputMaxCount.locator(`input`).fill(fileCount.toString());
}
if (fileSize) {
const inputMaxSize = await this.column.get().locator(`.nc-attachment-max-size`);
await inputMaxSize.locator(`input`).fill(fileSize.toString());
}
if (fileTypesExcludeList) {
// click on nc-allow-all-mime-type-checkbox
const allowAllMimeCheckbox = await this.column.get().locator(`.nc-allow-all-mime-type-checkbox`);
await allowAllMimeCheckbox.click();
const treeList = await this.column.get().locator(`.ant-tree-list`);
const checkboxList = await treeList.locator(`.ant-tree-treenode`);
for (let i = 0; i < fileTypesExcludeList.length; i++) {
const fileType = fileTypesExcludeList[i];
switch (fileType) {
case 'Application':
await checkboxList.nth(0).locator(`.ant-tree-checkbox`).click();
break;
case 'Audio':
await checkboxList.nth(1).locator(`.ant-tree-checkbox`).click();
break;
case 'Image':
await checkboxList.nth(2).locator(`.ant-tree-checkbox`).click();
break;
case 'Video':
await checkboxList.nth(3).locator(`.ant-tree-checkbox`).click();
break;
case 'Misc':
await checkboxList.nth(4).locator(`.ant-tree-checkbox`).click();
break;
default:
break;
}
}
await this.rootPage.waitForTimeout(1000);
}
await this.column.save({ isUpdated: true });
}
// add multiple options at once after column creation is completed
//
async addOptions({ columnTitle, options }: { columnTitle: string; options: string[] }) {
await this.column.openEdit({ title: columnTitle });
for (let i = 0; i < options.length; i++) {
await this.column.get().locator('button:has-text("Add option")').click();
await this.column.get().locator(`[data-testid="select-column-option-input-${i}"]`).click();
await this.column.get().locator(`[data-testid="select-column-option-input-${i}"]`).fill(options[i]);
}
await this.column.save({ isUpdated: true });
}
async editOption({ columnTitle, index, newOption }: { index: number; columnTitle: string; newOption: string }) {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator(`[data-testid="select-column-option-input-${index}"]`).click();
await this.column.get().locator(`[data-testid="select-column-option-input-${index}"]`).fill(newOption);
await this.column.save({ isUpdated: true });
}
async deleteOption({ columnTitle, index }: { index: number; columnTitle: string }) {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click();
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).toHaveClass(/removed/);
await this.column.save({ isUpdated: true });
}
async deleteOptionWithUndo({ columnTitle, index }: { index: number; columnTitle: string }) {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click();
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).toHaveClass(/removed/);
await this.column.get().locator(`svg[data-testid="select-column-option-remove-undo-${index}"]`).click();
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).not.toHaveClass(/removed/);
await this.column.save({ isUpdated: true });
}
async reorderOption({
columnTitle,
sourceOption,
destinationOption,
}: {
columnTitle: string;
sourceOption: string;
destinationOption: string;
}) {
await this.column.openEdit({ title: columnTitle });
await this.column.rootPage.waitForTimeout(150);
await this.column.rootPage.dragAndDrop(
`svg[data-testid="select-option-column-handle-icon-${sourceOption}"]`,
`svg[data-testid="select-option-column-handle-icon-${destinationOption}"]`,
{
force: true,
}
);
await this.column.save({ isUpdated: true });
}
}

7
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -2,15 +2,18 @@ import { expect } from '@playwright/test';
import { GridPage } from '..'; import { GridPage } from '..';
import BasePage from '../../../Base'; import BasePage from '../../../Base';
import { SelectOptionColumnPageObject } from './SelectOptionColumn'; import { SelectOptionColumnPageObject } from './SelectOptionColumn';
import { AttachmentColumnPageObject } from './Attachment';
export class ColumnPageObject extends BasePage { export class ColumnPageObject extends BasePage {
readonly grid: GridPage; readonly grid: GridPage;
readonly selectOption: SelectOptionColumnPageObject; readonly selectOption: SelectOptionColumnPageObject;
readonly attachmentColumnPageObject: AttachmentColumnPageObject;
constructor(grid: GridPage) { constructor(grid: GridPage) {
super(grid.rootPage); super(grid.rootPage);
this.grid = grid; this.grid = grid;
this.selectOption = new SelectOptionColumnPageObject(this); this.selectOption = new SelectOptionColumnPageObject(this);
this.attachmentColumnPageObject = new AttachmentColumnPageObject(this);
} }
get() { get() {
@ -298,6 +301,10 @@ export class ColumnPageObject extends BasePage {
} }
} }
async editMenuShowMore() {
await this.rootPage.locator('.nc-more-options').click();
}
async duplicateColumn({ title, expectedTitle = `${title}_copy` }: { title: string; expectedTitle?: string }) { async duplicateColumn({ title, expectedTitle = `${title}_copy` }: { title: string; expectedTitle?: string }) {
await this.grid.get().locator(`th[data-title="${title}"] .nc-ui-dt-dropdown`).click(); await this.grid.get().locator(`th[data-title="${title}"] .nc-ui-dt-dropdown`).click();
await this.rootPage.locator('li[role="menuitem"]:has-text("Duplicate"):visible').click(); await this.rootPage.locator('li[role="menuitem"]:has-text("Duplicate"):visible').click();

43
tests/playwright/pages/Dashboard/Grid/index.ts

@ -308,18 +308,41 @@ export class GridPage extends BasePage {
} }
async copyWithKeyboard() { async copyWithKeyboard() {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C'); // retry to avoid flakiness, until text is copied to clipboard
await this.verifyToast({ message: 'Copied to clipboard' }); //
let text = '';
return this.getClipboardText(); let retryCount = 5;
while (text === '') {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C');
await this.verifyToast({ message: 'Copied to clipboard' });
text = await this.getClipboardText();
// retry if text is empty till count is reached
retryCount--;
if (0 === retryCount) {
break;
}
}
return text;
} }
async copyWithMouse({ index, columnHeader }: CellProps) { async copyWithMouse({ index, columnHeader }: CellProps) {
await this.cell.get({ index, columnHeader }).click({ button: 'right' }); // retry to avoid flakiness, until text is copied to clipboard
await this.get().page().getByTestId('context-menu-item-copy').click(); //
let text = '';
await this.verifyToast({ message: 'Copied to clipboard' }); let retryCount = 5;
while (text === '') {
return this.getClipboardText(); await this.cell.get({ index, columnHeader }).click({ button: 'right' });
await this.get().page().getByTestId('context-menu-item-copy').click();
await this.verifyToast({ message: 'Copied to clipboard' });
text = await this.getClipboardText();
// retry if text is empty till count is reached
retryCount--;
if (0 === retryCount) {
break;
}
}
return text;
} }
} }

36
tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts

@ -18,14 +18,48 @@ export class AttachmentCellPageObject extends BasePage {
return this.get({ index, columnHeader }).locator('[data-testid="attachment-cell-file-picker-button"]').click(); return this.get({ index, columnHeader }).locator('[data-testid="attachment-cell-file-picker-button"]').click();
} }
async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string }) { // filePath: to attach multiple files, pass an array of file paths
// e.g. ['path/to/file1', 'path/to/file2']
//
async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string[] }) {
const attachFileAction = this.get({ index, columnHeader }) const attachFileAction = this.get({ index, columnHeader })
.locator('[data-testid="attachment-cell-file-picker-button"]') .locator('[data-testid="attachment-cell-file-picker-button"]')
.click(); .click();
return await this.attachFile({ filePickUIAction: attachFileAction, filePath }); return await this.attachFile({ filePickUIAction: attachFileAction, filePath });
} }
async expandModalAddFile({ filePath }: { filePath: string[] }) {
const attachFileAction = this.rootPage
.locator('.ant-modal.nc-attachment-modal.active')
.locator('[data-testid="attachment-expand-file-picker-button"]')
.click();
return await this.attachFile({ filePickUIAction: attachFileAction, filePath });
}
async expandModalOpen({ index, columnHeader }: { index?: number; columnHeader: string }) {
return this.get({ index, columnHeader })
.locator('.nc-cell > .nc-attachment-cell > .group.cursor-pointer')
.last()
.click();
}
async verifyFile({ index, columnHeader }: { index: number; columnHeader: string }) { async verifyFile({ index, columnHeader }: { index: number; columnHeader: string }) {
await expect(await this.get({ index, columnHeader }).locator('.nc-attachment')).toBeVisible(); await expect(await this.get({ index, columnHeader }).locator('.nc-attachment')).toBeVisible();
} }
async verifyFileCount({ index, columnHeader, count }: { index: number; columnHeader: string; count: number }) {
const attachments = await this.get({ index, columnHeader }).locator(
'.nc-cell > .nc-attachment-cell > .flex > .nc-attachment'
);
console.log(await attachments.count());
expect(await attachments.count()).toBe(count);
// attachments should be of count 'count'
// await expect(await attachments.count()).toBe(count);
}
async expandModalClose() {
return this.rootPage.locator('.ant-modal.nc-attachment-modal.active').press('Escape');
}
} }

145
tests/playwright/tests/columnAttachments.spec.ts

@ -2,25 +2,32 @@ import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard'; import { DashboardPage } from '../pages/Dashboard';
import { SharedFormPage } from '../pages/SharedForm'; import { SharedFormPage } from '../pages/SharedForm';
import setup from '../setup'; import setup from '../setup';
import { AccountPage } from '../pages/Account';
import { AccountLicensePage } from '../pages/Account/License';
test.describe('Attachment column', () => { test.describe('Attachment column', () => {
let dashboard: DashboardPage; let dashboard: DashboardPage;
let context: any; let accountLicensePage: AccountLicensePage, accountPage: AccountPage, context: any;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
context = await setup({ page }); context = await setup({ page });
dashboard = new DashboardPage(page, context.project); dashboard = new DashboardPage(page, context.project);
accountPage = new AccountPage(page);
accountLicensePage = new AccountLicensePage(accountPage);
}); });
test('Create and verify atttachent column, verify it in shared form,', async ({ page, context }) => { test('Create and verify attachment column, verify it in shared form,', async ({ page, context }) => {
// run tests slowly
test.slow();
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid.column.create({ await dashboard.grid.column.create({
title: 'testAttach', title: 'testAttach',
type: 'Attachment', type: 'Attachment',
}); });
for (let i = 4; i <= 6; i++) { for (let i = 12; i >= 8; i -= 2) {
const filepath = `${process.cwd()}/fixtures/sampleFiles/${i}.json`; const filepath = [`${process.cwd()}/fixtures/sampleFiles/${i / 2}.json`];
await dashboard.grid.cell.attachment.addFile({ await dashboard.grid.cell.attachment.addFile({
index: i, index: i,
columnHeader: 'testAttach', columnHeader: 'testAttach',
@ -32,12 +39,12 @@ test.describe('Attachment column', () => {
}); });
} }
await dashboard.grid.cell.attachment.addFile({ await dashboard.grid.cell.attachment.addFile({
index: 7, index: 14,
columnHeader: 'testAttach', columnHeader: 'testAttach',
filePath: `${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`, filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
}); });
await dashboard.grid.cell.attachment.verifyFile({ await dashboard.grid.cell.attachment.verifyFile({
index: 7, index: 14,
columnHeader: 'testAttach', columnHeader: 'testAttach',
}); });
@ -60,7 +67,7 @@ test.describe('Attachment column', () => {
}); });
await sharedForm.cell.attachment.addFile({ await sharedForm.cell.attachment.addFile({
columnHeader: 'testAttach', columnHeader: 'testAttach',
filePath: `${process.cwd()}/fixtures/sampleFiles/1.json`, filePath: [`${process.cwd()}/fixtures/sampleFiles/1.json`],
}); });
await sharedForm.submit(); await sharedForm.submit();
await sharedForm.verifySuccessMessage(); await sharedForm.verifySuccessMessage();
@ -83,11 +90,125 @@ test.describe('Attachment column', () => {
const csvArray = csvFileData.split('\r\n'); const csvArray = csvFileData.split('\r\n');
const columns = csvArray[0]; const columns = csvArray[0];
const rows = csvArray.slice(1); const rows = csvArray.slice(1);
const cells = rows[4].split(','); const cells = rows[10].split(',');
await expect(columns).toBe('Country,City List,testAttach'); await expect(columns).toBe('Country,City List,testAttach');
await expect(cells[0]).toBe('Anguilla'); await expect(cells[0]).toBe('Bahrain');
await expect(cells[1]).toBe('South Hill'); await expect(cells[1]).toBe('al-Manama');
await expect(cells[2].includes('4.json(http://localhost:8080/download/')).toBe(true); await expect(cells[2].includes('5.json(http://localhost:8080/download/')).toBe(true);
});
test('Attachment enterprise features,', async ({ page, context }) => {
// configure enterprise key
test.slow();
await accountLicensePage.goto();
await accountLicensePage.saveLicenseKey('1234567890');
await dashboard.goto();
await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid.column.create({
title: 'testAttach',
type: 'Attachment',
});
await dashboard.grid.column.attachmentColumnPageObject.advanceConfig({
columnTitle: 'testAttach',
fileCount: 2,
fileSize: 1,
// allow only image type
fileTypesExcludeList: ['Application', 'Video', 'Audio', 'Misc'],
});
// in-cell, add big file, should get rejected
const bigFile = [`${process.cwd()}/fixtures/sampleFiles/Image/6_bigSize.png`];
await dashboard.grid.cell.attachment.addFile({
index: 1,
columnHeader: 'testAttach',
filePath: bigFile,
});
// The size of ${file.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.
await dashboard.verifyToast({ message: 'The size of 6_bigSize.png exceeds the maximum file size 1 MB.' });
// in-cell, add 2 files, should get accepted
const twoFileArray = [
`${process.cwd()}/fixtures/sampleFiles/Image/1.jpeg`,
`${process.cwd()}/fixtures/sampleFiles/Image/2.png`,
];
await dashboard.grid.cell.attachment.addFile({
index: 1,
columnHeader: 'testAttach',
filePath: twoFileArray,
});
await dashboard.rootPage.waitForTimeout(2000);
await dashboard.grid.cell.attachment.verifyFileCount({
index: 1,
columnHeader: 'testAttach',
count: 2,
});
// add another file, should get rejected
const oneFileArray = [`${process.cwd()}/fixtures/sampleFiles/Image/3.jpeg`];
await dashboard.grid.cell.attachment.addFile({
index: 1,
columnHeader: 'testAttach',
filePath: oneFileArray,
});
// wait for toast 'You can only upload at most 2 files to this cell'
await dashboard.verifyToast({ message: 'You can only upload at most 2 files to this cell' });
// try to upload 3 files in one go, should get rejected
const threeFileArray = [
`${process.cwd()}/fixtures/sampleFiles/Image/1.jpeg`,
`${process.cwd()}/fixtures/sampleFiles/Image/2.png`,
`${process.cwd()}/fixtures/sampleFiles/Image/3.jpeg`,
];
await dashboard.grid.cell.attachment.addFile({
index: 2,
columnHeader: 'testAttach',
filePath: threeFileArray,
});
await dashboard.verifyToast({ message: 'You can only upload at most 2 files to this cell' });
// open expand modal, try to insert file type not supported
// message: ${file.name} has the mime type ${file.type} which is not allowed in this column.
await dashboard.grid.cell.attachment.addFile({
index: 3,
columnHeader: 'testAttach',
filePath: [`${process.cwd()}/fixtures/sampleFiles/1.json`],
});
await dashboard.verifyToast({
message: '1.json has the mime type application/json which is not allowed in this column.',
});
// Expand modal
// open expand modal, try to insert more files
await dashboard.grid.cell.attachment.expandModalOpen({
index: 1,
columnHeader: 'testAttach',
});
await dashboard.grid.cell.attachment.expandModalAddFile({
filePath: oneFileArray,
});
await dashboard.verifyToast({ message: 'You can only upload at most 2 files to this cell' });
// open expand modal, try to insert file type not supported
// message: ${file.name} has the mime type ${file.type} which is not allowed in this column.
await dashboard.grid.cell.attachment.expandModalAddFile({
filePath: [`${process.cwd()}/fixtures/sampleFiles/1.json`],
});
await dashboard.verifyToast({
message: '1.json has the mime type application/json which is not allowed in this column.',
});
// open expand modal, try to insert big file
// message: The size of ${file.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.
await dashboard.grid.cell.attachment.expandModalAddFile({
filePath: bigFile,
});
await dashboard.verifyToast({ message: 'The size of 6_bigSize.png exceeds the maximum file size 1 MB.' });
await dashboard.grid.cell.attachment.expandModalClose();
// wait for timeout
// await dashboard.rootPage.waitForTimeout(20000);
}); });
}); });

Loading…
Cancel
Save