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. 84
      packages/nc-gui/assets/style/fonts.css
  7. 2
      packages/nc-gui/components.d.ts
  8. 3
      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. 154
      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. 87
      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. 28
      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. 29
      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_ROOT_PASSWORD: password
MYSQL_USER: noco
image: "mysql:5.7"
image: "mysql:8.0.32"
deploy:
resources:
limits:

12
.github/workflows/release-nocodb.yml

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

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

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

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

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

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

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

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

@ -5,10 +5,15 @@
font-weight: 400;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.eot'); /* IE9 Compat Modes */
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.woff2') format('woff2'), /* 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.eot?#iefix')
format('embedded-opentype'),
/* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2')
format('woff2'),
/* 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 */
@ -18,10 +23,15 @@
font-weight: 400;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.eot'); /* IE9 Compat Modes */
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.woff2') format('woff2'), /* 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.eot?#iefix')
format('embedded-opentype'),
/* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2')
format('woff2'),
/* 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 */
}
@ -32,10 +42,15 @@
font-weight: 700;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.eot'); /* IE9 Compat Modes */
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.woff2') format('woff2'), /* 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.eot?#iefix')
format('embedded-opentype'),
/* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2')
format('woff2'),
/* 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 */
}
@ -46,11 +61,17 @@
font-weight: 700;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.eot'); /* IE9 Compat Modes */
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.woff2') format('woff2'), /* 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 */
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.woff2')
format('woff2'),
/* 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 */
@ -60,10 +81,15 @@
font-weight: 900;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.eot'); /* IE9 Compat Modes */
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.woff2') format('woff2'), /* 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.eot?#iefix')
format('embedded-opentype'),
/* IE6-IE8 */ url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900.woff2')
format('woff2'),
/* 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 */
}
@ -74,11 +100,17 @@
font-weight: 900;
src: url('./roboto/roboto-v27-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-900italic.eot'); /* IE9 Compat Modes */
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.woff2') format('woff2'), /* 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 */
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.woff2')
format('woff2'),
/* 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 */

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']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
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']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiChevronUp: typeof import('~icons/mdi/chevron-up')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']

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

@ -24,7 +24,7 @@ const setLicense = async () => {
try {
await api.orgLicense.set({ key: key })
message.success('License key updated')
await loadAppInfo();
await loadAppInfo()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
@ -32,7 +32,6 @@ const setLicense = async () => {
}
loadLicense()
</script>
<template>

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

@ -19,6 +19,6 @@ onMounted(() => {
<template>
<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>
</template>

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

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

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

@ -20,6 +20,9 @@ const {
downloadFile,
updateModelValue,
selectedImage,
selectedVisibleItems,
bulkDownloadFiles,
renameFile,
} = useAttachmentCell()!
// todo: replace placeholder var
@ -44,15 +47,30 @@ function onClick(item: Record<string, any>) {
selectedImage.value = item
modalVisible.value = false
const stopHandle = watch(selectedImage, (nextImage, _, onCleanup) => {
const stopHandle = watch(selectedImage, (nextImage) => {
if (!nextImage) {
setTimeout(() => {
modalVisible.value = true
}, 50)
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>
@ -71,6 +89,7 @@ function onClick(item: Record<string, any>) {
<div
v-if="isSharedForm || (!readOnly && isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attach-file group"
data-testid="attachment-expand-file-picker-button"
@click="open"
>
<MaterialSymbolsAttachFile class="transform group-hover:(text-accent scale-120)" />
@ -82,6 +101,10 @@ function onClick(item: Record<string, any>) {
Viewing Attachments of
<div class="font-semibold underline">{{ column?.title }}</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>
</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 v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1">
<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">
<template #title> Remove File </template>
<MdiCloseCircle
v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attachment-remove"
@click.stop="removeFile(i)"
@click.stop="onRemoveFileClick(item.title, i)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> Download file </template>
<template #title> Download File </template>
<div class="nc-attachment-download group-hover:(opacity-100)">
<MdiDownload @click.stop="downloadFile(item)" />
</div>
</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
:class="[dragging ? 'cursor-move' : 'cursor-pointer']"
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);
}
.nc-attachment-checkbox {
@apply absolute top-2 left-2;
@apply transition-opacity duration-150 ease-in opacity-0;
}
.nc-attachment-remove {
@apply absolute top-2 right-2 bg-white;
@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,
isReadonly,
storedFiles,
getAttachmentUrl,
} = useProvideAttachmentCell(updateModelValue)
watch(
@ -97,10 +98,19 @@ const { isOverDropZone } = useDropZone(currentCellRef as any, onDrop)
/** on new value, reparse our stored attachments */
watch(
() => modelValue,
(nextModel) => {
async (nextModel) => {
if (nextModel) {
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) {
storedFiles.value = nextAttachments

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

@ -1,3 +1,5 @@
import type { AttachmentType } from 'nocodb-sdk'
import RenameFile from './RenameFile.vue'
import {
ColumnInj,
EditModeInj,
@ -24,13 +26,6 @@ import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box'
import MdiFileExcelOutline from '~icons/mdi/file-excel-outline'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
interface AttachmentProps extends File {
data?: any
file: File
title: string
mimetype: string
}
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => {
const isReadonly = inject(ReadonlyInj, ref(false))
@ -46,12 +41,13 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const editEnabled = inject(EditModeInj, ref(false))
/** 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)
/** for image carousel */
const selectedImage = ref()
const { project } = useProject()
@ -60,17 +56,37 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const { files, open } = useFileDialog()
const { appInfo } = useGlobal()
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) */
function removeFile(i: number) {
if (isPublic.value) {
storedFiles.value.splice(i, 1)
attachments.value.splice(i, 1)
selectedVisibleItems.value.splice(i, 1)
updateModelValue(storedFiles.value)
} else {
attachments.value.splice(i, 1)
selectedVisibleItems.value.splice(i, 1)
updateModelValue(JSON.stringify(attachments.value))
}
@ -80,12 +96,58 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
async function onFileSelect(selectedFiles: FileList | File[]) {
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) {
const newFiles = await Promise.all<AttachmentProps>(
Array.from(selectedFiles).map(
const newFiles = await Promise.all<AttachmentType>(
Array.from(files).map(
(file) =>
new Promise<AttachmentProps>((resolve) => {
const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type }
new Promise<AttachmentType>((resolve) => {
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)) {
const reader = new FileReader()
@ -107,35 +169,47 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}),
),
)
attachments.value = [...attachments.value, ...newFiles]
return updateModelValue(attachments.value)
}
const newAttachments = []
for (const file of selectedFiles) {
try {
const data = await api.storage.upload(
{
path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'),
},
{
files: file,
files,
json: '{}',
},
)
newAttachments.push(...data)
} catch (e: any) {
message.error(e.message || t('msg.error.internalError'))
}
}
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 */
async function onDrop(droppedFiles: File[] | null) {
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 */
async function downloadFile(item: Record<string, any>) {
async function downloadFile(item: AttachmentType) {
;(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) => {
switch (icon) {
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))
return {
@ -185,10 +286,15 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
modalVisible,
FileIcon,
removeFile,
renameFile,
downloadFile,
updateModelValue,
selectedImage,
selectedVisibleItems,
storedFiles,
bulkDownloadFiles,
defaultAttachmentMeta,
getAttachmentUrl,
}
},
'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,
useColumnCreateStoreOrThrow,
useEventListener,
useGlobal,
useI18n,
useMetas,
useNuxtApp,
@ -38,6 +39,8 @@ const { t } = useI18n()
const { $e } = useNuxtApp()
const { appInfo } = useGlobal()
const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false))
@ -133,7 +136,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<template>
<div
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
>
<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>
</a-checkbox>
<LazySmartsheetColumnAttachmentOptions
v-if="appInfo.ee && formState.uidt === UITypes.Attachment"
v-model:value="formState"
/>
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" />
</div>
</Transition>

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

@ -7,3 +7,193 @@ const relationNames = {
export function getRelationName(type: string) {
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">
import { useQRCode } from '@vueuse/integrations/useQRCode'
import { GridType } from 'nocodb-sdk'
import type { GridType } from 'nocodb-sdk'
import { ActiveViewInj } from '#imports'
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">
{{ $t('labels.qrCodeValueTooLong') }}
</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">
{{ $t('msg.warning.nonEditableFields.computedFieldUnableToClear') }}
</div>

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

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ComputedRef } from 'vue'
import type { GridType } from 'nocodb-sdk'
import JsBarcodeWrapper from './JsBarcodeWrapper.vue'
import { ComputedRef } from 'vue'
import { GridType } from 'nocodb-sdk'
import { ActiveViewInj } from '#imports'
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,
type: 'nocodb',
version: '0.0.0',
ncAttachmentFieldSize: 20,
ncMaxAttachmentsAllowed: 10,
})
/** reactive token payload */

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

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

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

@ -1,5 +1,15 @@
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 {
IsPublicInj,
@ -14,6 +24,8 @@ import {
provide,
ref,
useApi,
useFieldQuery,
useGlobal,
useI18n,
useInjectionState,
useNuxtApp,
@ -43,6 +55,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { $e, $api } = useNuxtApp()
const { appInfo } = useGlobal()
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { sharedView, fetchSharedViewData, fetchSharedViewGroupedData } = useSharedView()
@ -53,6 +67,32 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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)
// kanban view meta data
@ -102,35 +142,75 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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() {
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
formattedData.value = new Map<string | null, Row[]>()
countByStack.value = new Map<string | null, number>()
let res
let groupData
if (isPublic.value) {
res = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!, {
groupData = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!, {
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
})
} else {
res = await api.dbViewRow.groupedDataList(
groupData = await api.dbViewRow.groupedDataList(
'noco',
project.value.id!,
meta.value!.id!,
viewMeta.value!.id!,
groupingFieldColumn!.value!.id!,
{},
{ where: xWhere.value },
{},
)
}
for (const data of res) {
for (const data of groupData) {
const records = []
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)
}
}
@ -144,6 +224,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...{ where: xWhere.value },
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.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 type { ColumnType } 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
if (from === to && ![UITypes.Attachment, UITypes.Date, UITypes.DateTime, UITypes.Time, UITypes.Year].includes(to)) {
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))) {
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.Lookup:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -100,18 +100,18 @@
"kanban": "Kanban",
"calendar": "Calendrier"
},
"user": "Membre",
"users": "Membres",
"user": "Utilisateur",
"users": "Utilisateurs",
"role": "Rôle",
"roles": "Rôles",
"roleType": {
"owner": "Propriétaire",
"creator": "Créateur·trice",
"editor": "Éditeur·trice",
"commenter": "Commentateur·trice",
"creator": "Créateur",
"editor": "Éditeur",
"commenter": "Commentateur",
"viewer": "Lecture seule",
"orgLevelCreator": "Créateur·trice au niveau de l'organisation",
"orgLevelViewer": "Visualiseur·atrice de niveau d'organisation"
"orgLevelCreator": "Créateur au niveau de l'organisation",
"orgLevelViewer": "Visualiseur de niveau d'organisation"
},
"sqlVIew": "Vue SQL"
},
@ -124,7 +124,7 @@
"Checkbox": "Case à cocher",
"MultiSelect": "Sélection multiple",
"SingleSelect": "Menu déroulant",
"Collaborator": "Collaborateur·trice",
"Collaborator": "Collaborateur",
"Date": "Date",
"Year": "Année",
"Time": "Heure",
@ -176,15 +176,15 @@
"personalView": "Vue personnelle",
"appStore": "Magasin d'applications",
"teamAndAuth": "Équipe & Authentification",
"rolesUserMgmt": "Gestion des membres & rôles",
"userMgmt": "Gestion des membres",
"rolesUserMgmt": "Gestion des utilisateurs & rôles",
"userMgmt": "Gestion des utilisateurs",
"apiTokenMgmt": "Gestion des jetons API",
"rolesMgmt": "Gestion des rôles",
"projMeta": "Métadonnées du projet",
"metaMgmt": "Gestion des métadonnées",
"metadata": "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",
"audit": "Audit",
"auditLogs": "Journal d'audit",
@ -205,7 +205,8 @@
"quickImport": "Importation rapide",
"advancedSettings": "Paramètres avancés",
"codeSnippet": "Extrait de code",
"keyboardShortcut": "Raccourcis clavier"
"keyboardShortcut": "Raccourcis clavier",
"generateRandomName": "Generate Random Name"
},
"labels": {
"createdBy": "Créé par",
@ -224,7 +225,7 @@
"sqliteFile": "Fichier SQLite",
"hostAddress": "Adresse de l'hôte",
"port": "Numéro de port",
"username": "Identifiant",
"username": "Utilisateur",
"password": "Mot de passe",
"schemaName": "Nom du schéma",
"database": "Base de données",
@ -274,7 +275,7 @@
"followNocodb": "Suivre NocoDB"
},
"docReference": "Référence de document",
"selectUserRole": "Sélectionner le rôle",
"selectUserRole": "Sélectionner le rôle d'utilisateur",
"childTable": "Table enfant",
"childColumn": "Colonne enfant",
"linkToAnotherRecord": "Lien vers un autre enregistrement",
@ -312,7 +313,7 @@
"signInWithGoogle": "Se connecter avec Google",
"agreeToTos": "En continuant, vous acceptez les Conditions d'Utilisation",
"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",
"prevRow": "Rang précédent"
},
@ -359,14 +360,14 @@
"invite": "Inviter",
"inviteMore": "Inviter plus",
"inviteTeam": "Inviter une équipe",
"inviteUser": "Inviter un membre",
"inviteUser": "Inviter un utilisateur",
"inviteToken": "Inviter via un jeton",
"newUser": "Nouveau membre",
"editUser": "Modifier le membre",
"deleteUser": "Supprimer le membre du projet",
"newUser": "Nouvel utilisateur",
"editUser": "Modifier l'utilisateur",
"deleteUser": "Supprimer l'utilisateur du projet",
"resendInvite": "Renvoyer une invitation par courriel",
"copyInviteURL": "Copier le lien d'invitation",
"copyPasswordResetURL": "Copier le lien de réinitialisation du mot de passe",
"copyInviteURL": "Copier l'URL d'invitation",
"copyPasswordResetURL": "Copier l'URL de réinitialisation du mot de passe",
"newRole": "Nouveau rôle",
"reloadRoles": "Actualiser les rôles",
"nextPage": "Page suivante",
@ -427,7 +428,7 @@
"editConnJson": "Éditer le JSON de connexion",
"sponsorUs": "Nous Parrainer",
"sendEmail": "ENVOYER UN EMAIL",
"addUserToProject": "Ajouter un membre au projet",
"addUserToProject": "Ajouter un utilisateur au projet",
"getApiSnippet": "Récupérer le Snippet API",
"clearCell": "Vider la cellule",
"addFilterGroup": "Ajouter un groupe de filtres",
@ -463,7 +464,7 @@
"light": "Jour (^⇧B)"
},
"addTable": "Ajouter un nouveau tableau",
"inviteMore": "Inviter plus de membres",
"inviteMore": "Inviter plus d'utilisateurs",
"toggleNavDraw": "Afficher ou masquer le panneau de navigation",
"reloadApiToken": "Recharger les jetons API",
"generateNewApiToken": "Générer de nouveaux jetons d'API",
@ -503,7 +504,7 @@
"msg": {
"warning": {
"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": {
"computedFieldUnableToClear": "Avertissement : Champ calculé - impossible d'effacer le texte",
@ -513,8 +514,8 @@
"info": {
"pasteNotSupported": "L'opération de collage n'est pas prise en charge sur la cellule active",
"roles": {
"orgCreator": "Les créateur·trice·s peuvent créer de nouveaux projets et accéder à tout projet en lecture.",
"orgViewer": "Les visualisateur·trice·s ne peuvent pas créer de nouveaux projets mais il peuvent accéder à tout projet en lecture."
"orgCreator": "Le créateur peut créer de nouveaux projets et accéder à tout projet invité.",
"orgViewer": "Le visualisateur n'est pas autorisé à créer de nouveaux projets mais il peut accéder à tout projet invité."
},
"footerInfo": "Lignes par page",
"upload": "Sélectionner un fichier à téléverser",
@ -538,7 +539,7 @@
"dragDropHide": "Glisser et déposer des champs ici pour les masquer",
"formInput": "Entrer le libellé du formulaire",
"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",
"beforeEnablePwd": "Restreindre l’accès à l’aide d’un mot de passe",
"afterEnablePwd": "L’accès est restreint par un mot de passe",
@ -554,15 +555,15 @@
"showMessage": "Montrer ce message ",
"viewNotShared": "La vue actuelle n'est pas partagée!",
"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.",
"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.",
"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",
"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",
"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.",
@ -577,16 +578,16 @@
},
"sponsor": {
"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",
"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.",
"success": "Veuillez vérifier votre email pour réinitialiser le mot de passe"
},
"signUp": {
"superAdmin": "Vous serez « super admin »",
"superAdmin": "Vous serez le « super administrateur »",
"alreadyHaveAccount": "Avez-vous déjà un compte ?",
"workEmail": "Saisir votre adresse mail professionnelle",
"enterPassword": "Saisir votre mot de passe",
@ -626,7 +627,7 @@
"noColumnsToUpdate": "Aucune colonne à mettre à jour",
"tableDeleted": "Tableau supprimé avec succès",
"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",
"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.",
@ -639,7 +640,7 @@
"invalidChar": "Caractère invalide dans le chemin du dossier.",
"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.",
"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",
"dbConnectionFailed": "Echec de connexion :",
"signUpRules": {
@ -710,12 +711,12 @@
"createView": "Vue créée avec succès",
"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",
"lockedView": "Vous êtes bien dans la vue verrouillée",
"lockedView": "Vous êtes bien dans la vue vérouillée",
"futureRelease": "Bientôt disponible !"
},
"success": {
"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",
"pluginSettingsSaved": "Les paramètres du plugin ont été enregistrés avec succès",
"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",
"updated": "Mise à jour réussie",
"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",
"tokenGenerated": "Jeton généré avec succès",
"tokenDeleted": "Token supprimé avec succès",
"userAddedToProject": "Le membre a été ajouté avec succès au projet",
"userAdded": "Le membre a été ajouté avec succès",
"userDeletedFromProject": "Suppression du membre du projet réussie",
"userAddedToProject": "L'utilisateur a été ajouté avec succès au projet",
"userAdded": "L'utilisateur a été ajouté avec succès",
"userDeletedFromProject": "Suppression réussie de l'utilisateur du projet",
"inviteEmailSent": "Email d'invitation envoyé avec succès",
"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",
"shareableURLCopied": "Copie de l'URL de la base partageable dans le presse-papiers !",
"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",
"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",
"columnUpdated": "Colonne mise à jour",
"columnCreated": "Colonne créée",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -205,7 +205,8 @@
"quickImport": "Szybki import",
"advancedSettings": "Ustawienia zaawansowane",
"codeSnippet": "Snippet",
"keyboardShortcut": "Skróty klawiaturowe"
"keyboardShortcut": "Skróty klawiaturowe",
"generateRandomName": "Generate Random Name"
},
"labels": {
"createdBy": "Stworzony przez",
@ -730,22 +731,22 @@
"tokenGenerated": "Token został wygenerowany pomyślnie",
"tokenDeleted": "Token usunięty pomyślnie",
"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",
"inviteEmailSent": "E-mail z zaproszeniem wysłany pomyślnie",
"inviteURLCopied": "Adres URL zaproszenia skopiowany do schowka",
"inviteEmailSent": "E-mail został wysłany pomyślnie",
"inviteURLCopied": "Link z zaproszeniem skopiowany do schowka",
"passwordResetURLCopied": "URL resetowania hasła skopiowany 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",
"tableDataImported": "Pomyślnie zaimportowano dane tabeli",
"tableDataImported": "Udało się zaimportować dane tabeli",
"webhookUpdated": "Szczegóły webhooka zostały zaktualizowane",
"webhookDeleted": "Webhook usunięty pomyślnie",
"webhookTested": "Webhook przetestowany pomyślnie",
"columnUpdated": "Kolumna zaktualizowana",
"columnCreated": "Kolumna utworzona",
"passwordChanged": "Hasło zostało zmienione. Zaloguj się ponownie.",
"settingsSaved": "Ustawienia zapisane pomyślnie",
"passwordChanged": "Hasło zostało pomyślnie zmienione. Proszę zalogować się ponownie.",
"settingsSaved": "Zapisano ustawienia",
"roleUpdated": "Rola została pomyślnie zaktualizowana"
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6
packages/nc-gui/tsconfig.json

@ -19,9 +19,5 @@
"@nuxt/image-edge"
]
},
"exclude": [
"node_modules",
"dist",
".output"
]
"exclude": ["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) {
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]
})
) {

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

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

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

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

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

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

2
packages/nocodb-sdk/package.json

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

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

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

28
packages/nocodb/docker-compose.yml

@ -19,28 +19,32 @@ services:
# - 3356:3306
# volumes:
# - ./mysql-sakila-db:/docker-entrypoint-initdb.d
db57:
image: mysql:5.7
# db57:
# 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
environment:
MYSQL_ROOT_PASSWORD: password
ports:
- 3357:3306
- 3380:3306
volumes:
- ./tests/mysql-sakila-db:/docker-entrypoint-initdb.d
healthcheck:
test: "/etc/init.d/mysql status"
interval: 1s
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:
# image: mariadb:10.2
# restart: always

32
packages/nocodb/package-lock.json generated

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

4
packages/nocodb/package.json

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

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

@ -104,7 +104,7 @@ export default class Noco {
constructor() {
process.env.PORT = process.env.PORT || '8080';
// 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 (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> {
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);
}

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

@ -12,6 +12,7 @@ import { Tele } from 'nc-help';
import extractProjectIdAndAuthenticate from '../helpers/extractProjectIdAndAuthenticate';
import catchError, { NcError } from '../helpers/catchError';
import NcPluginMgrv2 from '../helpers/NcPluginMgrv2';
import Local from '../../v1-legacy/plugins/adapters/storage/Local';
import { NC_ATTACHMENT_FIELD_SIZE } from '../../constants';
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');
};
// const storageAdapter = new Local();
export async function upload(req: Request, res: Response) {
const filePath = sanitizeUrlPath(
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 storageAdapter = await NcPluginMgrv2.storageAdapter();
const attachments = await Promise.all(
(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(
slash(path.join(destPath, fileName)),
file
);
let attachmentPath;
// if `url` is null, then it is local attachment
if (!url) {
url = `${(req as any).ncSiteUrl}/download/${filePath.join(
'/'
)}/${fileName}`;
// then store the attachement path only
// url will be constructued in `useAttachmentCell`
attachmentPath = `download/${filePath.join('/')}/${fileName}`;
}
return {
url,
...(url ? { url } : {}),
...(attachmentPath ? { path: attachmentPath } : {}),
title: file.originalname,
mimetype: file.mimetype,
size: file.size,
@ -86,9 +91,11 @@ export async function uploadViaURL(req: Request, res: Response) {
const destPath = path.join('nc', 'uploads', ...filePath);
const storageAdapter = await NcPluginMgrv2.storageAdapter();
const attachments = await Promise.all(
req.body?.map?.(async (urlMeta) => {
const { url, fileName: _fileName } = urlMeta;
const fileName = `${nanoid(6)}${_fileName || url.split('/').pop()}`;
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) {
try {
const storageAdapter = await NcPluginMgrv2.storageAdapter();
// const type = mimetypes[path.extname(req.s.fileName).slice(1)] || 'text/plain';
// get the local storage adapter to display local attachments
const storageAdapter = new Local();
const type =
mimetypes[path.extname(req.params?.[0]).split('/').pop().slice(1)] ||
'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(
slash(
path.join(
@ -192,6 +199,7 @@ router.post(
catchError(upload),
]
);
router.post(
'/api/v1/db/storage/upload-by-url',
@ -201,6 +209,7 @@ router.post(
catchError(uploadViaURL),
]
);
router.get(/^\/download\/(.+)$/, catchError(fileRead));
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({
value: row[column.title],
column,
siteUrl: req['ncSiteUrl'],
});
}
dbRows.push(dbRow);
@ -171,9 +172,11 @@ async function getDbRows(baseModel, view: View, req: Request) {
export async function serializeCellValue({
value,
column,
siteUrl,
}: {
column?: Column;
value: any;
siteUrl: string;
}) {
if (!column) {
return value;
@ -192,7 +195,9 @@ export async function serializeCellValue({
return (data || []).map(
(attachment) =>
`${encodeURI(attachment.title)}(${encodeURI(attachment.url)})`
`${encodeURI(attachment.title)}(${encodeURI(
attachment.path ? `${siteUrl}/${attachment.path}` : attachment.url
)})`
);
}
case UITypes.Lookup:
@ -205,6 +210,7 @@ export async function serializeCellValue({
serializeCellValue({
value: v,
column: lookupColumn,
siteUrl,
})
)
)

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

@ -16,6 +16,7 @@ import NcConfigFactory, {
import User from '../../models/User';
import catchError from '../helpers/catchError';
import axios from 'axios';
import { NC_ATTACHMENT_FIELD_SIZE } from '../../constants';
const versionCache = {
releaseVersion: null,
@ -55,6 +56,8 @@ export async function appInfo(req: Request, res: Response) {
teleEnabled: !process.env.NC_DISABLE_TELE,
ncSiteUrl: (req as any).ncSiteUrl,
ee: Noco.isEE(),
ncAttachmentFieldSize: NC_ATTACHMENT_FIELD_SIZE,
ncMaxAttachmentsAllowed: +(process.env.NC_MAX_ATTACHMENTS_ALLOWED || 10),
};
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;
break;
case 'allof':
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) =>
(data[field]?.split(',') ?? []).includes(item)
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).every(
(item) => (data[field]?.split(',') ?? []).includes(item)
);
break;
case 'anyof':
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) =>
(data[field]?.split(',') ?? []).includes(item)
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).some(
(item) => (data[field]?.split(',') ?? []).includes(item)
);
break;
case 'nallof':
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) =>
(data[field]?.split(',') ?? []).includes(item)
);
res = !(
filter.value?.split(',').map((item) => item.trim()) ?? []
).every((item) => (data[field]?.split(',') ?? []).includes(item));
break;
case 'nanyof':
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) =>
(data[field]?.split(',') ?? []).includes(item)
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).some(
(item) => (data[field]?.split(',') ?? []).includes(item)
);
break;
case 'lt':

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

@ -284,7 +284,7 @@ export default class Base implements BaseType {
[UITypes.Rollup]: 2,
[UITypes.ForeignKey]: 3,
[UITypes.LinkToAnotherRecord]: 4,
}
};
for (const model of models) {
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:
await GridView.insert(
{
...(copyFromView?.view as GridView || {}),
...((copyFromView?.view as GridView) || {}),
...(view as GridView),
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 ncProjectRolesUpgrader from './ncProjectRolesUpgrader';
import ncFilterUpgrader from './ncFilterUpgrader';
import ncAttachmentUpgrader from './ncAttachmentUpgrader';
const log = debug('nc:version-upgrader');
import boxen from 'boxen';
@ -37,6 +38,7 @@ export default class NcUpgrader {
{ name: '0098004', handler: ncDataTypesUpgrader },
{ name: '0098005', handler: ncProjectRolesUpgrader },
{ name: '0100002', handler: ncFilterUpgrader },
{ name: '0101002', handler: ncAttachmentUpgrader },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
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": {
"type": "string"
}
},
"path": {
"type": "string"
},
"data": {}
}
},
"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([
// It is important to call waitForEvent before click to set up waiting.
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 BasePage from '../../../Base';
import { SelectOptionColumnPageObject } from './SelectOptionColumn';
import { AttachmentColumnPageObject } from './Attachment';
export class ColumnPageObject extends BasePage {
readonly grid: GridPage;
readonly selectOption: SelectOptionColumnPageObject;
readonly attachmentColumnPageObject: AttachmentColumnPageObject;
constructor(grid: GridPage) {
super(grid.rootPage);
this.grid = grid;
this.selectOption = new SelectOptionColumnPageObject(this);
this.attachmentColumnPageObject = new AttachmentColumnPageObject(this);
}
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 }) {
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();

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

@ -308,18 +308,41 @@ export class GridPage extends BasePage {
}
async copyWithKeyboard() {
// retry to avoid flakiness, until text is copied to clipboard
//
let text = '';
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();
return this.getClipboardText();
// retry if text is empty till count is reached
retryCount--;
if (0 === retryCount) {
break;
}
}
return text;
}
async copyWithMouse({ index, columnHeader }: CellProps) {
// retry to avoid flakiness, until text is copied to clipboard
//
let text = '';
let retryCount = 5;
while (text === '') {
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();
return 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();
}
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 })
.locator('[data-testid="attachment-cell-file-picker-button"]')
.click();
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 }) {
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 { SharedFormPage } from '../pages/SharedForm';
import setup from '../setup';
import { AccountPage } from '../pages/Account';
import { AccountLicensePage } from '../pages/Account/License';
test.describe('Attachment column', () => {
let dashboard: DashboardPage;
let context: any;
let accountLicensePage: AccountLicensePage, accountPage: AccountPage, context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
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.grid.column.create({
title: 'testAttach',
type: 'Attachment',
});
for (let i = 4; i <= 6; i++) {
const filepath = `${process.cwd()}/fixtures/sampleFiles/${i}.json`;
for (let i = 12; i >= 8; i -= 2) {
const filepath = [`${process.cwd()}/fixtures/sampleFiles/${i / 2}.json`];
await dashboard.grid.cell.attachment.addFile({
index: i,
columnHeader: 'testAttach',
@ -32,12 +39,12 @@ test.describe('Attachment column', () => {
});
}
await dashboard.grid.cell.attachment.addFile({
index: 7,
index: 14,
columnHeader: 'testAttach',
filePath: `${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`,
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
});
await dashboard.grid.cell.attachment.verifyFile({
index: 7,
index: 14,
columnHeader: 'testAttach',
});
@ -60,7 +67,7 @@ test.describe('Attachment column', () => {
});
await sharedForm.cell.attachment.addFile({
columnHeader: 'testAttach',
filePath: `${process.cwd()}/fixtures/sampleFiles/1.json`,
filePath: [`${process.cwd()}/fixtures/sampleFiles/1.json`],
});
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
@ -83,11 +90,125 @@ test.describe('Attachment column', () => {
const csvArray = csvFileData.split('\r\n');
const columns = csvArray[0];
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(cells[0]).toBe('Anguilla');
await expect(cells[1]).toBe('South Hill');
await expect(cells[2].includes('4.json(http://localhost:8080/download/')).toBe(true);
await expect(cells[0]).toBe('Bahrain');
await expect(cells[1]).toBe('al-Manama');
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