Browse Source

Merge pull request #4820 from nocodb/feat/attachments

feat: attachments
pull/4941/head
Raju Udava 2 years ago committed by GitHub
parent
commit
aab21b07b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/nc-gui/components.d.ts
  2. 7
      packages/nc-gui/components/cell/attachment/Carousel.vue
  3. 50
      packages/nc-gui/components/cell/attachment/Modal.vue
  4. 85
      packages/nc-gui/components/cell/attachment/RenameFile.vue
  5. 14
      packages/nc-gui/components/cell/attachment/index.vue
  6. 154
      packages/nc-gui/components/cell/attachment/utils.ts
  7. 132
      packages/nc-gui/components/smartsheet/column/AttachmentOptions.vue
  8. 10
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  9. 190
      packages/nc-gui/components/smartsheet/column/utils.ts
  10. 11
      packages/nc-gui/components/virtual-cell/QrCode.vue
  11. 4
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  12. 2
      packages/nc-gui/composables/useGlobal/state.ts
  13. 2
      packages/nc-gui/composables/useGlobal/types.ts
  14. 68
      packages/nc-gui/composables/useKanbanViewStore.ts
  15. 59
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  16. 7
      packages/nc-gui/composables/useMultiSelect/index.ts
  17. 3
      packages/nc-gui/lang/en.json
  18. 2
      packages/nocodb-sdk/src/lib/Api.ts
  19. 2
      packages/nocodb/src/lib/Noco.ts
  20. 7
      packages/nocodb/src/lib/meta/NcMetaIOImpl.ts
  21. 27
      packages/nocodb/src/lib/meta/api/attachmentApis.ts
  22. 8
      packages/nocodb/src/lib/meta/api/dataApis/helpers.ts
  23. 3
      packages/nocodb/src/lib/meta/api/utilApis.ts
  24. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  25. 112
      packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts
  26. 6
      scripts/sdk/swagger.json
  27. BIN
      tests/playwright/fixtures/sampleFiles/Image/1.jpeg
  28. BIN
      tests/playwright/fixtures/sampleFiles/Image/2.png
  29. BIN
      tests/playwright/fixtures/sampleFiles/Image/3.jpeg
  30. BIN
      tests/playwright/fixtures/sampleFiles/Image/4.jpeg
  31. BIN
      tests/playwright/fixtures/sampleFiles/Image/5.jpeg
  32. BIN
      tests/playwright/fixtures/sampleFiles/Image/6_bigSize.png
  33. 2
      tests/playwright/pages/Base.ts
  34. 140
      tests/playwright/pages/Dashboard/Grid/Column/Attachment.ts
  35. 7
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  36. 36
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  37. 145
      tests/playwright/tests/columnAttachments.spec.ts

1
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']

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 {

68
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,7 @@ import {
provide,
ref,
useApi,
useGlobal,
useI18n,
useInjectionState,
useNuxtApp,
@ -43,6 +54,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { $e, $api } = useNuxtApp()
const { appInfo } = useGlobal()
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { sharedView, fetchSharedViewData, fetchSharedViewGroupedData } = useSharedView()
@ -53,6 +66,10 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const password = ref<string | null>(null)
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,6 +119,27 @@ 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
@ -109,15 +147,15 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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!,
@ -128,9 +166,27 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
)
}
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)
}
}

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/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",

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 {

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);

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;

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

@ -0,0 +1,112 @@
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 { 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]);
} 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: Base[] = await ncMeta.metaList2(null, null, MetaTable.BASES);
for (const base of bases) {
const knex: XKnex = base.is_meta
? ncMeta.knexConnection
: NcConnectionMgrv2.get(base);
const models = await (await Base.get(base.id, ncMeta)).getModels(ncMeta);
for (const model of models) {
const updateRecords = [];
const columns = await (
await Model.get(model.id, ncMeta)
).getColumns(ncMeta);
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([
...primaryKeys,
...attachmentColumns,
]);
for (const record of records) {
for (const attachmentColumn of attachmentColumns) {
const attachmentMeta =
typeof record[attachmentColumn] === 'string'
? JSON.parse(record[attachmentColumn])
: record[attachmentColumn];
if (attachmentMeta) {
const newAttachmentMeta = [];
for (const attachment of attachmentMeta) {
if ('url' in attachment) {
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);
}
}
}

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();

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