diff --git a/packages/nc-gui/components.d.ts b/packages/nc-gui/components.d.ts index 7809d0a39b..05d859d72c 100644 --- a/packages/nc-gui/components.d.ts +++ b/packages/nc-gui/components.d.ts @@ -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'] diff --git a/packages/nc-gui/components/cell/attachment/Carousel.vue b/packages/nc-gui/components/cell/attachment/Carousel.vue index 63a7c1bb0f..bf0bf3983b 100644 --- a/packages/nc-gui/components/cell/attachment/Carousel.vue +++ b/packages/nc-gui/components/cell/attachment/Carousel.vue @@ -70,13 +70,13 @@ onClickOutside(carouselRef, () => { > @@ -105,6 +105,9 @@ onClickOutside(carouselRef, () => { diff --git a/packages/nc-gui/components/cell/attachment/index.vue b/packages/nc-gui/components/cell/attachment/index.vue index 7a4e99a315..7cf0364f86 100644 --- a/packages/nc-gui/components/cell/attachment/index.vue +++ b/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 diff --git a/packages/nc-gui/components/cell/attachment/utils.ts b/packages/nc-gui/components/cell/attachment/utils.ts index 637d967476..6b202c519c 100644 --- a/packages/nc-gui/components/cell/attachment/utils.ts +++ b/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[]) => 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([]) + const storedFiles = ref([]) - const attachments = ref([]) + const attachments = ref([]) 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(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value)) + + /** for bulk download */ + const selectedVisibleItems = ref(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( - Array.from(selectedFiles).map( + const newFiles = await Promise.all( + Array.from(files).map( (file) => - new Promise((resolve) => { - const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type } + new Promise((resolve) => { + const res: { file: File; title: string; mimetype: string; data?: any } = { + ...file, + file, + title: file.name, + mimetype: file.type, + } if (isImage(file.name, (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, - json: '{}', - }, - ) - - newAttachments.push(...data) - } catch (e: any) { - message.error(e.message || t('msg.error.internalError')) - } + try { + const data = await api.storage.upload( + { + path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'), + }, + { + 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((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) { + 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(() => (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', diff --git a/packages/nc-gui/components/smartsheet/column/AttachmentOptions.vue b/packages/nc-gui/components/smartsheet/column/AttachmentOptions.vue new file mode 100644 index 0000000000..7dd3884cb7 --- /dev/null +++ b/packages/nc-gui/components/smartsheet/column/AttachmentOptions.vue @@ -0,0 +1,132 @@ + + + diff --git a/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue b/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue index 8211e94759..603468b06b 100644 --- a/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue +++ b/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) => {