Browse Source

Merge pull request #8990 from nocodb/nc-feat/video-player

feat: attachments in browser
pull/9070/head
Anbarasu 4 months ago committed by GitHub
parent
commit
935e9c6ef5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      packages/nc-gui/assets/nc-icons/globe.svg
  2. 89
      packages/nc-gui/assets/nc-icons/google-docs.svg
  3. 5
      packages/nc-gui/assets/nc-icons/play.svg
  4. 9
      packages/nc-gui/components/cell/attachment/AttachFile.vue
  5. 437
      packages/nc-gui/components/cell/attachment/Carousel.vue
  6. 177
      packages/nc-gui/components/cell/attachment/Modal.vue
  7. 7
      packages/nc-gui/components/cell/attachment/Preview/Image.vue
  8. 46
      packages/nc-gui/components/cell/attachment/Preview/MiscOffice.vue
  9. 63
      packages/nc-gui/components/cell/attachment/Preview/Pdf.vue
  10. 55
      packages/nc-gui/components/cell/attachment/Preview/Video.vue
  11. 12
      packages/nc-gui/components/cell/attachment/UploadProviders/Local.vue
  12. 2
      packages/nc-gui/components/cell/attachment/UploadProviders/Url.vue
  13. 271
      packages/nc-gui/components/cell/attachment/index.vue
  14. 17
      packages/nc-gui/components/cell/attachment/utils.ts
  15. 10
      packages/nc-gui/components/dashboard/View.vue
  16. 12
      packages/nc-gui/components/general/DeleteModal.vue
  17. 6
      packages/nc-gui/components/general/FormBanner.vue
  18. 10
      packages/nc-gui/components/general/Overlay.vue
  19. 27
      packages/nc-gui/components/nc/Carousel/Content.vue
  20. 18
      packages/nc-gui/components/nc/Carousel/Item.vue
  21. 26
      packages/nc-gui/components/nc/Carousel/Next.vue
  22. 26
      packages/nc-gui/components/nc/Carousel/Previous.vue
  23. 41
      packages/nc-gui/components/nc/Carousel/index.vue
  24. 24
      packages/nc-gui/components/nc/Carousel/interface.ts
  25. 51
      packages/nc-gui/components/nc/Carousel/useCarousel.ts
  26. 16
      packages/nc-gui/components/nc/Tooltip.vue
  27. 4
      packages/nc-gui/components/smartsheet/Form.vue
  28. 2
      packages/nc-gui/components/smartsheet/Gallery.vue
  29. 2
      packages/nc-gui/components/smartsheet/Kanban.vue
  30. 8
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  31. 2
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  32. 689
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  33. 145
      packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Audits.vue
  34. 488
      packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Comments.vue
  35. 80
      packages/nc-gui/components/smartsheet/expanded-form/Sidebar/index.vue
  36. 8
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  37. 22
      packages/nc-gui/components/smartsheet/grid/Table.vue
  38. 2
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  39. 197
      packages/nc-gui/composables/useExpandedFormStore.ts
  40. 235
      packages/nc-gui/composables/useRowComments.ts
  41. 2
      packages/nc-gui/lang/en.json
  42. 4
      packages/nc-gui/package.json
  43. 5
      packages/nc-gui/plugins/pdf-object.ts
  44. 71
      packages/nc-gui/utils/fileUtils.ts
  45. 8
      packages/nc-gui/utils/iconUtils.ts
  46. 90
      packages/nocodb-sdk/src/lib/Api.ts
  47. 541
      pnpm-lock.yaml
  48. 30
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  49. 1
      tests/playwright/tests/db/columns/columnAttachments.spec.ts
  50. 7
      tests/playwright/tests/db/views/viewForm.spec.ts

12
packages/nc-gui/assets/nc-icons/globe.svg

@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="globe" clip-path="url(#clip0_184_5557)">
<path id="Vector" d="M8.00001 14.6666C11.6819 14.6666 14.6667 11.6819 14.6667 7.99998C14.6667 4.31808 11.6819 1.33331 8.00001 1.33331C4.31811 1.33331 1.33334 4.31808 1.33334 7.99998C1.33334 11.6819 4.31811 14.6666 8.00001 14.6666Z" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M1.33334 8H14.6667" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M8.00001 1.33331C9.66753 3.15888 10.6152 5.528 10.6667 7.99998C10.6152 10.472 9.66753 12.8411 8.00001 14.6666C6.33249 12.8411 5.38484 10.472 5.33334 7.99998C5.38484 5.528 6.33249 3.15888 8.00001 1.33331V1.33331Z" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_5557">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

89
packages/nc-gui/assets/nc-icons/google-docs.svg

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="47px" height="65px" viewBox="0 0 47 65" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 54.1 (76490) - https://sketchapp.com -->
<title>Docs-icon</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-1"></path>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-3"></path>
<linearGradient x1="50.0053945%" y1="8.58610612%" x2="50.0053945%" y2="100.013939%" id="linearGradient-5">
<stop stop-color="#1A237E" stop-opacity="0.2" offset="0%"></stop>
<stop stop-color="#1A237E" stop-opacity="0.02" offset="100%"></stop>
</linearGradient>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-6"></path>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-8"></path>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-10"></path>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-12"></path>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="path-14"></path>
<radialGradient cx="3.16804688%" cy="2.71744318%" fx="3.16804688%" fy="2.71744318%" r="161.248516%" gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.723077),translate(-0.031680,-0.027174)" id="radialGradient-16">
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"></stop>
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%"></stop>
</radialGradient>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Consumer-Apps-Docs-Large-VD-R8" transform="translate(-451.000000, -463.000000)">
<g id="Hero" transform="translate(0.000000, 63.000000)">
<g id="Personal" transform="translate(277.000000, 309.000000)">
<g id="Docs-icon" transform="translate(174.000000, 91.000000)">
<g id="Group">
<g id="Clipped">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L36.71875,10.3409091 L29.375,0 Z" id="Path" fill="#4285F4" fill-rule="nonzero" mask="url(#mask-2)"></path>
</g>
<g id="Clipped">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<g id="SVGID_1_"></g>
<polygon id="Path" fill="url(#linearGradient-5)" fill-rule="nonzero" mask="url(#mask-4)" points="30.6638281 16.4309659 47 32.8582386 47 17.7272727"></polygon>
</g>
<g id="Clipped">
<mask id="mask-7" fill="white">
<use xlink:href="#path-6"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M11.75,47.2727273 L35.25,47.2727273 L35.25,44.3181818 L11.75,44.3181818 L11.75,47.2727273 Z M11.75,53.1818182 L29.375,53.1818182 L29.375,50.2272727 L11.75,50.2272727 L11.75,53.1818182 Z M11.75,32.5 L11.75,35.4545455 L35.25,35.4545455 L35.25,32.5 L11.75,32.5 Z M11.75,41.3636364 L35.25,41.3636364 L35.25,38.4090909 L11.75,38.4090909 L11.75,41.3636364 Z" id="Shape" fill="#F1F1F1" fill-rule="nonzero" mask="url(#mask-7)"></path>
</g>
<g id="Clipped">
<mask id="mask-9" fill="white">
<use xlink:href="#path-8"></use>
</mask>
<g id="SVGID_1_"></g>
<g id="Group" mask="url(#mask-9)">
<g transform="translate(26.437500, -2.954545)">
<path d="M2.9375,2.95454545 L2.9375,16.25 C2.9375,18.6985795 4.90929688,20.6818182 7.34375,20.6818182 L20.5625,20.6818182 L2.9375,2.95454545 Z" id="Path" fill="#A1C2FA" fill-rule="nonzero"></path>
</g>
</g>
</g>
<g id="Clipped">
<mask id="mask-11" fill="white">
<use xlink:href="#path-10"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,4.80113636 C0,2.36363636 1.9828125,0.369318182 4.40625,0.369318182 L29.375,0.369318182 L29.375,0 L4.40625,0 Z" id="Path" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-11)"></path>
</g>
<g id="Clipped">
<mask id="mask-13" fill="white">
<use xlink:href="#path-12"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M42.59375,64.6306818 L4.40625,64.6306818 C1.9828125,64.6306818 0,62.6363636 0,60.1988636 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,60.1988636 C47,62.6363636 45.0171875,64.6306818 42.59375,64.6306818 Z" id="Path" fill-opacity="0.2" fill="#1A237E" fill-rule="nonzero" mask="url(#mask-13)"></path>
</g>
<g id="Clipped">
<mask id="mask-15" fill="white">
<use xlink:href="#path-14"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M33.78125,17.7272727 C31.3467969,17.7272727 29.375,15.7440341 29.375,13.2954545 L29.375,13.6647727 C29.375,16.1133523 31.3467969,18.0965909 33.78125,18.0965909 L47,18.0965909 L47,17.7272727 L33.78125,17.7272727 Z" id="Path" fill-opacity="0.1" fill="#1A237E" fill-rule="nonzero" mask="url(#mask-15)"></path>
</g>
</g>
<path d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z" id="Path" fill="url(#radialGradient-16)" fill-rule="nonzero"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

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

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="play">
<path id="Vector" d="M3.33331 2L12.6666 8L3.33331 14V2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 273 B

9
packages/nc-gui/components/cell/attachment/AttachFile.vue

@ -47,10 +47,11 @@ watch(activeMenu, (newVal, oldValue) => {
@keydown.esc="dialogShow = false"
>
<div class="flex h-full flex-row">
<div style="border-top-left-radius: 1rem; border-bottom-left-radius: 1rem" class="px-2 !-full flex-grow bg-gray-100">
<NcMenu class="!h-full !bg-gray-100">
<div style="border-top-left-radius: 1rem; border-bottom-left-radius: 1rem" class="px-2 !-full flex-grow bg-gray-50">
<NcMenu class="!h-full !bg-gray-50">
<NcMenuItem
key="local"
class="!hover:bg-gray-200 !hover:text-gray-800 rounded-md"
:class="{
'active-menu': activeMenu === 'local',
}"
@ -64,6 +65,7 @@ watch(activeMenu, (newVal, oldValue) => {
<NcMenuItem
v-if="!isPublic"
key="url"
class="!hover:bg-gray-200 !hover:text-gray-800 rounded-md"
:class="{
'active-menu': activeMenu === 'url',
}"
@ -76,6 +78,7 @@ watch(activeMenu, (newVal, oldValue) => {
</NcMenuItem>
<NcMenuItem
key="webcam"
class="!hover:bg-gray-200 !hover:text-gray-800 rounded-md"
:class="{
'active-menu': activeMenu === 'webcam',
}"
@ -115,7 +118,7 @@ watch(activeMenu, (newVal, oldValue) => {
<style lang="scss">
.nc-modal-attachment-create {
.active-menu {
@apply !bg-gray-200 font-sembold text-brand-500 rounded-md;
@apply bg-brand-50 font-sembold text-brand-500 rounded-md;
}
}

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

@ -1,156 +1,379 @@
<script lang="ts" setup>
import { onKeyDown } from '@vueuse/core'
import type { CarouselApi } from '../../nc/Carousel/interface'
import { useAttachmentCell } from './utils'
import { isOffice } from '~/utils/fileUtils'
const { selectedImage, visibleItems, downloadAttachment } = useAttachmentCell()!
const { selectedFile, visibleItems, downloadAttachment, removeFile, renameFile, isPublic, isReadonly, isRenameModalOpen } =
useAttachmentCell()!
const carouselRef = ref()
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const container = ref()
const { isSharedForm } = useSmartsheetStoreOrThrow()
const imageItems = computed(() => visibleItems.value.filter((item) => isImage(item.title, item.mimetype)))
/*
const openComments = ref(false)
*/
const { isUIAllowed } = useRoles()
const container = ref<HTMLElement | null>(null)
const emblaMainApi: CarouselApi = ref()
const emblaThumbnailApi: CarouselApi = ref()
const selectedIndex = ref()
const filetoDelete = reactive({
title: '',
i: 0,
})
const isModalOpen = ref(false)
function onRemoveFileClick(title: any, i: number) {
isModalOpen.value = true
filetoDelete.title = title
filetoDelete.i = i
}
const handleFileDelete = (i: number) => {
removeFile(i)
isModalOpen.value = false
filetoDelete.i = 0
filetoDelete.title = ''
}
const { getPossibleAttachmentSrc } = useAttachment()
/** navigate to previous image on button click */
onKeyDown(
(e) => ['Left', 'ArrowLeft', 'A'].includes(e.key),
() => {
if (carouselRef.value) carouselRef.value.prev()
},
)
useEventListener(container, 'click', (e) => {
const target = e.target as HTMLElement
if (!target.closest('.keep-open') && !target.closest('.nc-button') && !target.closest('img') && !target.closest('video')) {
selectedFile.value = false
}
})
const onThumbClick = (index: number) => {
if (!emblaMainApi.value || !emblaThumbnailApi.value) return
/** navigate to next image on button click */
onKeyDown(
(e) => ['Right', 'ArrowRight', 'D'].includes(e.key),
() => {
if (carouselRef.value) carouselRef.value.next()
},
)
emblaMainApi.value.scrollTo(index)
emblaThumbnailApi.value.scrollTo(index)
}
const onSelect = () => {
if (!emblaMainApi.value || !emblaThumbnailApi.value) return
/** set our selected image when slide changes */
function onSlideChange(index: number) {
selectedImage.value = imageItems.value[index]
const newSnap = emblaMainApi.value.selectedScrollSnap()
selectedIndex.value = newSnap
selectedFile.value = visibleItems.value[newSnap]
emblaThumbnailApi.value.scrollTo(newSnap)
}
/** set our carousel ref and move to initial slide */
const setCarouselRef = (el: Element) => {
carouselRef.value = el
const goPrev = () => {
if (!emblaMainApi.value || !emblaThumbnailApi.value) return
carouselRef.value?.goTo(
imageItems.value.findIndex((item) => item === selectedImage.value),
true,
)
emblaMainApi.value.scrollPrev()
emblaThumbnailApi.value.scrollPrev()
}
/** close overlay view when clicking outside of image */
useEventListener(container, 'click', (e) => {
if (!(e.target as HTMLElement)?.closest('.keep-open') && !(e.target as HTMLElement)?.closest('img')) {
selectedImage.value = false
const goNext = () => {
if (!emblaMainApi.value || !emblaThumbnailApi.value) return
emblaMainApi.value.scrollNext()
emblaThumbnailApi.value.scrollNext()
}
// When the carousel is initialized, we set the selected index to the index of the selected file
// and scroll to that index. We only need to do this once, so we use watchOnce.
watchOnce(emblaMainApi, async (emblaMainApi) => {
if (!emblaMainApi) return
// The focus is set to the container so that the keyboard navigation works
container.value?.focus()
emblaThumbnailApi.value?.on('reInit', onSelect)
emblaMainApi.on('select', onSelect)
await nextTick(() => {
if (!selectedIndex.value) {
const newIndex = visibleItems.value.findIndex((item) => {
if (selectedFile.value?.path) return item?.path === selectedFile.value.path
if (selectedFile.value?.url) return item?.url === selectedFile.value.url
return selectedFile.value?.title === item?.title
})
selectedIndex.value = newIndex
emblaMainApi.scrollTo(newIndex)
}
})
})
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
})
function onKeyDown(event: KeyboardEvent) {
if (isRenameModalOpen.value) return
const prevKey = ['ArrowLeft', 'Left', 'a', 'A']
const nextKey = ['ArrowRight', 'Right', 'd', 'D']
if (prevKey.includes(event.key)) {
event.preventDefault()
emblaMainApi.value?.scrollPrev()
return
}
if (nextKey.includes(event.key)) {
event.preventDefault()
emblaMainApi.value?.scrollNext()
}
}
/* const toggleComment = () => {
openComments.value = !openComments.value
}
onMounted(() => {
if (!isPublic.value && !isExpandedFormOpen.value && isUIAllowed('commentList')) {
const { loadComments } = useRowCommentsOrThrow()
loadComments()
}
})
*/
const initEmblaApi = (val: any) => {
emblaMainApi.value = val
}
</script>
<template>
<GeneralOverlay v-model="selectedImage" :z-index="1001" class="bg-gray-500 bg-opacity-50">
<template v-if="selectedImage">
<div ref="container" class="overflow-hidden p-12 text-center relative xs:h-screen">
<div class="text-white group absolute top-5 right-5">
<component
:is="iconMap.closeCircle"
class="group-hover:text-red-500 cursor-pointer text-4xl"
@click.stop="selectedImage = false"
<GeneralOverlay v-model="selectedFile" transition :z-index="isExpandedFormOpen ? 1000 : 50" class="bg-black bg-opacity-90">
<div class="flex w-full h-full">
<div
v-if="selectedFile"
ref="container"
class="flex w-full overflow-hidden justify-center text-center relative h-screen items-center"
>
<NcButton
class="top-5 !absolute cursor-pointer !z-30 !hover:bg-transparent left-5"
size="xsmall"
type="text"
@click.stop="selectedFile = false"
>
<component :is="iconMap.close" class="text-white" />
</NcButton>
<div
class="keep-open select-none absolute top-5 pointer-events-none inset-x-0 mx-auto group flex items-center justify-center leading-8 inline-block text-center rounded shadow"
>
<h3
style="width: max-content"
class="hover:underline pointer-events-auto font-semibold cursor-pointer text-white"
@click.stop="downloadAttachment(selectedFile)"
>
{{ selectedFile && selectedFile.title }}
</h3>
</div>
<NcCarousel class="!absolute inset-y-16 inset-x-24 keep-open flex justify-center items-center" @init-api="initEmblaApi">
<NcCarouselContent>
<NcCarouselItem v-for="(item, index) in visibleItems" :key="index">
<div v-if="selectedIndex === index" class="justify-center w-full h-full flex items-center">
<LazyCellAttachmentPreviewImage
v-if="isImage(item.title, item.mimeType)"
class="nc-attachment-img-wrapper"
object-fit="contain"
:alt="item.title"
:srcs="getPossibleAttachmentSrc(item)"
/>
<LazyCellAttachmentPreviewVideo
v-else-if="isVideo(item.title, item.mimeType)"
class="flex items-center w-full"
:mime-type="item.mimeType"
:title="item.title"
:src="getPossibleAttachmentSrc(item)"
/>
<LazyCellAttachmentPreviewPdf
v-else-if="isPdf(item.title, item.mimeType)"
class="keep-open"
:src="getPossibleAttachmentSrc(item)"
/>
<LazyCellAttachmentPreviewMiscOffice
v-else-if="isOffice(item.title, item.mimeType)"
class="keep-open"
:src="getPossibleAttachmentSrc(item)"
/>
<div v-else class="bg-white h-full flex flex-col justify-center rounded-md gap-1 items-center w-full">
<component :is="iconMap.file" class="text-gray-600 w-20 h-20" />
<div class="text-gray-800 text-sm">{{ item.title }}</div>
</div>
</div>
</NcCarouselItem>
</NcCarouselContent>
</NcCarousel>
<div
v-if="emblaMainApi?.canScrollPrev()"
:key="selectedIndex"
class="left-2 carousel-navigation keep-open"
@click="goPrev"
>
<component :is="iconMap.arrowLeft" class="text-7xl" />
</div>
<div
v-if="emblaMainApi?.canScrollNext()"
:key="selectedIndex"
class="right-2 carousel-navigation keep-open"
@click="goNext"
>
<component :is="iconMap.arrowRight" class="text-7xl" />
</div>
<!-- <div v-if="isUIAllowed('commentList') && !isExpandedFormOpen" class="absolute top-2 right-2">
<NcButton class="!hover:bg-transparent" type="text" size="small" @click="toggleComment">
<div class="flex gap-1 text-white justify-center items-center">
Comments
<GeneralIcon icon="messageCircle" />
</div>
</NcButton>
</div> -->
<div class="text-white absolute right-2 top-2 cursor-pointer"></div>
<div class="absolute w-full !bottom-2 max-h-18 z-30 flex items-center justify-center">
<NcCarousel class="absolute max-w-sm" @init-api="(val) => (emblaThumbnailApi = val)">
<NcCarouselContent class="!flex !gap-2">
<NcCarouselItem
v-for="(item, index) in visibleItems"
:key="index"
:class="{
'!opacity-100': index === selectedIndex,
'!basis-1/4': visibleItems.length >= 4,
'!basis-1/3': visibleItems.length === 3,
'!basis-1/2': visibleItems.length === 2,
}"
class="px-2 keep-open opacity-50 cursor-pointer"
@click="onThumbClick(index)"
>
<div class="flex items-center justify-center">
<LazyCellAttachmentPreviewImage
v-if="isImage(item.title, item.mimeType)"
class="nc-attachment-img-wrapper h-12"
object-fit="contain"
:alt="item.title"
:srcs="getPossibleAttachmentSrc(item)"
/>
<div
class="keep-open select-none group hover:(ring-1 ring-accent) ring-opacity-100 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow"
@click.stop="downloadAttachment(selectedImage)"
v-else-if="isVideo(item.title, item.mimeType)"
class="h-full flex items-center h-6 justify-center rounded-md px-2 py-1 border-1 border-gray-200"
>
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3>
<GeneralIcon class="text-white" icon="play" />
</div>
<a-carousel
v-if="!!selectedImage"
:ref="setCarouselRef"
dots-class="slick-dots slick-thumb"
:after-change="onSlideChange"
arrows
<div
v-else-if="isPdf(item.title, item.mimeType)"
class="h-full flex items-center h-6 justify-center rounded-md px-2 py-1 border-1 border-gray-200"
>
<template #prevArrow>
<div class="custom-slick-arrow left-2 z-1 keep-open">
<MaterialSymbolsArrowCircleLeftRounded class="rounded-full" />
<GeneralIcon class="text-white" icon="pdfFile" />
</div>
</template>
<template #nextArrow>
<div class="custom-slick-arrow !right-2 z-1 keep-open">
<MaterialSymbolsArrowCircleRightRounded class="rounded-full" />
<div v-else class="h-full flex items-center h-6 justify-center rounded-md px-2 py-1 border-1 border-gray-200">
<GeneralIcon class="text-white" icon="file" />
</div>
</div>
</NcCarouselItem>
</NcCarouselContent>
</NcCarousel>
</div>
</template>
<template #customPaging="props">
<div class="cursor-pointer h-full nc-attachment-img-wrapper">
<LazyCellAttachmentImage
class="!block m-auto h-full w-full"
:alt="imageItems[props.i].title || `#${props.i}`"
:srcs="getPossibleAttachmentSrc(imageItems[props.i])"
<div class="absolute keep-open right-2 z-30 bottom-3 transition-all gap-3 transition-ease-in-out !h-6 flex items-center">
<NcTooltip
v-if="!isSharedForm || (!isReadonly && isUIAllowed('dataEdit') && !isPublic)"
color="light"
placement="bottom"
>
<template #title> {{ $t('title.renameFile') }} </template>
<NcButton
size="xsmall"
class="nc-attachment-rename !hover:text-gray-400 !hover:bg-transparent !text-white"
type="text"
@click="renameFile(selectedFile, selectedIndex, true)"
>
<component :is="iconMap.rename" class="!hover:text-gray-400" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isReadonly" color="light" placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<NcButton
class="!hover:bg-transparent !text-white"
size="xsmall"
type="text"
@click="downloadAttachment(selectedFile)"
>
<component :is="iconMap.download" class="!hover:text-gray-400" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isReadonly" color="light" placement="bottom">
<template #title> {{ $t('title.removeFile') }} </template>
<NcButton
class="!hover:bg-transparent !text-white"
size="xsmall"
type="text"
@click="onRemoveFileClick(selectedFile.title, selectedIndex)"
>
<component
:is="iconMap.delete"
v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)"
class="!hover:text-gray-400"
/>
</NcButton>
</NcTooltip>
</div>
</template>
<div v-for="(item, idx) of imageItems" :key="idx">
<LazyCellAttachmentImage :srcs="getPossibleAttachmentSrc(item)" class="max-w-70vw max-h-70vh" />
<GeneralDeleteModal v-model:visible="isModalOpen" entity-name="File" :on-delete="() => handleFileDelete(filetoDelete.i)">
<template #entity-preview>
<span>
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="file" class="nc-view-icon"></GeneralIcon>
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ filetoDelete.title }}
</div>
</a-carousel>
</div>
</span>
</template>
</GeneralDeleteModal>
</div>
<!-- <div v-if="openComments && isUIAllowed('commentList') && !isExpandedFormOpen" class="bg-white w-88 min-w-88 max-w-88">
<LazySmartsheetExpandedFormSidebarComments />
</div> -->
</div>
</GeneralOverlay>
</template>
<style scoped>
.ant-carousel :deep(.custom-slick-arrow .nc-icon):hover {
@apply !bg-white;
}
.ant-carousel :deep(.slick-dots) {
@apply relative mt-4;
}
.ant-carousel :deep(.slick-slide) {
@apply w-full;
}
.ant-carousel :deep(.slick-slide img) {
@apply border-1 m-auto;
}
.ant-carousel :deep(.slick-thumb) {
@apply bottom-2;
}
.ant-carousel :deep(.slick-thumb li) {
@apply w-[60px] h-[45px];
<style scoped lang="scss">
.carousel-navigation {
@apply absolute text-gray-400 hover:text-white cursor-pointer text-white h-full flex items-center inset-y-0 my-0;
}
</style>
.ant-carousel :deep(.slick-thumb li img) {
@apply w-full h-full block;
filter: grayscale(100%);
<style lang="scss">
.nc-attachment-carousel {
@apply w-max;
}
.ant-carousel :deep(.slick-thumb li.slick-active img) {
filter: grayscale(0%);
}
.carousel-container {
@apply !w-full flex items-center h-full;
.ant-carousel :deep(.slick-arrow.custom-slick-arrow) {
@apply text-4xl text-white hover:text-primary active:text-accent opacity-100 cursor-pointer z-1;
.embla__container {
@apply items-center h-full w-full;
}
.ant-carousel :deep(.custom-slick-arrow:before) {
display: none;
}
.ant-carousel :deep(.custom-slick-arrow:hover) {
opacity: 0.5;
}
.nc-attachment-img-wrapper {
width: fit-content !important;
}
</style>

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

@ -18,7 +18,7 @@ const {
onDrop,
downloadAttachment,
updateModelValue,
selectedImage,
selectedFile,
selectedVisibleItems,
bulkDownloadAttachments,
renameFile,
@ -42,10 +42,10 @@ onKeyDown('Escape', () => {
})
function onClick(item: Record<string, any>) {
selectedImage.value = item
selectedFile.value = item
modalVisible.value = false
const stopHandle = watch(selectedImage, (nextImage) => {
const stopHandle = watch(selectedFile, (nextImage) => {
if (!nextImage) {
setTimeout(() => {
modalVisible.value = true
@ -82,19 +82,28 @@ const handleFileDelete = (i: number) => {
</script>
<template>
<a-modal
<NcModal
v-model:visible="modalVisible"
wrap-class-name="nc-modal-attachment-expand-cell"
class="nc-attachment-modal"
:class="{ active: modalVisible }"
width="80%"
:footer="null"
wrap-class-name="nc-modal-attachment-expand-cell"
>
<template #title>
<div class="flex gap-4">
<div class="flex justify-between pb-6 gap-4">
<div class="font-semibold text-xl">{{ column?.title }}</div>
<div class="flex items-center gap-2">
<NcButton v-if="selectedVisibleItems.length > 0" size="small" @click="bulkDownloadAttachments">
<div class="flex gap-2 items-center">
<GeneralIcon icon="download" />
{{ $t('activity.bulkDownload') }}
</div>
</NcButton>
<NcButton
v-if="isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)"
class="nc-attach-file group"
size="small"
data-testid="attachment-expand-file-picker-button"
@click="open"
>
@ -104,87 +113,101 @@ const handleFileDelete = (i: number) => {
</div>
</NcButton>
<div class="flex items-center gap-2">
{{ $t('labels.viewingAttachmentsOf') }}
<div class="font-semibold underline">{{ column?.title }}</div>
</div>
<div
v-if="selectedVisibleItems.includes(true) && selectedVisibleItems.length > 1"
class="flex flex-1 items-center gap-3 justify-end mr-[30px]"
>
<NcButton type="primary" class="nc-attachment-download-all" @click="bulkDownloadAttachments">
{{ $t('activity.bulkDownload') }}
<NcButton type="secondary" size="small" @click="modalVisible = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
</div>
</template>
<div ref="dropZoneRef" tabindex="0">
<template v-if="isSharedForm || (!readOnly && !dragging)">
<general-overlay
v-model="isOverDropZone"
inline
class="text-white ring ring-accent ring-opacity-100 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
<div ref="dropZoneRef" tabindex="0" class="relative">
<div
v-if="isSharedForm || (!readOnly && !dragging && isOverDropZone)"
class="text-white absolute inset-0 bg-white flex flex-col items-center justify-center gap-2 border-dashed border-1 border-gray-700"
>
<MaterialSymbolsFileCopyOutline class="text-accent" height="35" width="35" />
<div class="text-white text-3xl">{{ $t('labels.dropHere') }}</div>
</general-overlay>
</template>
<div class="text-gray-800 text-3xl">{{ $t('labels.dropHere') }}</div>
</div>
<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 group gap-1">
<a-card class="nc-attachment-item group">
<div
ref="sortableRef"
:class="{ dragging }"
class="grid max-h-140 overflow-auto nc-scrollbar-md md:grid-cols-3 xl:grid-cols-5 gap-y-8 gap-x-4 relative"
>
<div
v-for="(item, i) in visibleItems"
:key="`${item?.title}-${i}`"
class="nc-attachment-item group gap-1 flex border-1 rounded-md border-gray-200 flex-col relative"
>
<NcCheckbox
v-model:checked="selectedVisibleItems[i]"
class="nc-attachment-checkbox absolute top-2 left-2 group-hover:(opacity-100)"
:class="{ '!opacity-100': selectedVisibleItems[i] }"
/>
<div
:class="[dragging ? 'cursor-move' : 'cursor-pointer']"
class="nc-attachment h-full w-full flex items-center justify-center overflow-hidden"
:class="{
'cursor-move': dragging,
'cursor-pointer': !dragging,
}"
class="nc-attachment h-full flex justify-center items-center overflow-hidden"
>
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
v-if="isImage(item.title, item.mimetype)"
:srcs="getPossibleAttachmentSrc(item)"
class="max-h-full h-64 m-auto justify-center"
object-fit="cover"
class="!w-full object-cover !m-0 rounded-t-[5px] justify-center"
@click.stop="onClick(item)"
/>
<component
:is="FileIcon(item.icon)"
v-else-if="item.icon"
height="150"
width="150"
@click.stop="openAttachment(item)"
/>
<component :is="FileIcon(item.icon)" v-else-if="item.icon" :height="45" :width="45" @click.stop="onClick(item)" />
<IcOutlineInsertDriveFile v-else height="150" width="150" @click.stop="openAttachment(item)" />
<IcOutlineInsertDriveFile v-else :height="45" :width="45" @click.stop="onClick(item)" />
</div>
</a-card>
<div class="relative flex" :title="item.title">
<div class="flex-auto truncate line-height-4">
<div class="relative px-1 pb-1 items-center flex" :title="item.title">
<NcTooltip
show-on-truncate-only
class="flex-auto truncate w-full text-[12px] items-center text-gray-700 text-sm line-height-4"
>
{{ item.title }}
</div>
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-6 flex items-center bg-white">
<template #title>
{{ item.title }}
</template>
</NcTooltip>
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-5 gap-0.5 flex items-center bg-white">
<NcTooltip placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<NcButton class="!text-gray-500" size="xsmall" type="text" @click="downloadAttachment(item)">
<component :is="iconMap.download" />
<NcButton
class="!p-0 !w-5 !h-5 text-gray-500 !min-w-[fit-content]"
size="xsmall"
type="text"
@click="downloadAttachment(item)"
>
<component :is="iconMap.download" class="!text-xs h-13px w-13px" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<NcButton size="xsmall" class="nc-attachment-rename !text-gray-500" type="text" @click="renameFile(item, i)">
<component :is="iconMap.rename" />
<NcButton
size="xsmall"
class="!p-0 nc-attachment-rename !h-5 !w-5 !text-gray-500 !min-w-[fit-content] gap-2"
type="text"
@click="renameFile(item, i)"
>
<component :is="iconMap.rename" class="text-xs h-13px w-13px" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!readOnly" placement="bottom">
<NcTooltip v-if="isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.removeFile') }} </template>
<NcButton class="!text-red-500" size="xsmall" type="text" @click="onRemoveFileClick(item.title, i)">
<component :is="iconMap.delete" v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)" />
<NcButton
class="!p-0 !h-4 !w-4 !text-red-500 nc-attachment-remove !min-w-[fit-content]"
size="xsmall"
type="text"
@click="onRemoveFileClick(item.title, i)"
>
<component :is="iconMap.delete" class="text-xs h-13px w-13px" />
</NcButton>
</NcTooltip>
</div>
@ -200,6 +223,7 @@ const handleFileDelete = (i: number) => {
</div>
</div>
</div>
<GeneralDeleteModal v-model:visible="isModalOpen" entity-name="File" :on-delete="() => handleFileDelete(filetoDelete.i)">
<template #entity-preview>
<span>
@ -215,48 +239,19 @@ const handleFileDelete = (i: number) => {
</span>
</template>
</GeneralDeleteModal>
</a-modal>
</NcModal>
</template>
<style lang="scss">
.hide-ui {
@apply h-0 w-0 overflow-hidden whitespace-nowrap;
// When the parent with class 'group' is hovered
@apply h-0 w-0 overflow-x-hidden whitespace-nowrap;
.group:hover & {
@apply h-auto w-auto overflow-visible whitespace-normal;
}
}
.nc-attachment-modal {
.nc-attachment-item {
@apply !h-2/3 !min-h-[200px] flex items-center justify-center relative;
@supports (-moz-appearance: none) {
@apply hover:border-0;
}
&::after {
@apply pointer-events-none rounded absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out;
content: '';
}
@supports (-moz-appearance: none) {
&:hover::after {
@apply ring shadow;
}
&:active::after {
@apply ring ring-accent ring-opacity-100 shadow;
}
}
}
.ant-card-body {
@apply !p-2 w-full h-full;
}
.ant-modal-body {
@apply !p-0;
@apply h-[200px] max-h-[200px] flex relative;
}
.dragging {
@ -264,5 +259,9 @@ const handleFileDelete = (i: number) => {
@apply !pointer-events-none;
}
}
.nc-checkbox > .ant-checkbox {
box-shadow: none !important;
}
}
</style>

7
packages/nc-gui/components/cell/attachment/Image.vue → packages/nc-gui/components/cell/attachment/Preview/Image.vue

@ -2,6 +2,7 @@
interface Props {
srcs: string[]
alt?: string
objectFit?: string
}
const props = defineProps<Props>()
@ -14,8 +15,12 @@ const onError = () => index.value++
<template>
<LazyNuxtImg
v-if="index < props.srcs.length"
class="m-auto h-full max-h-full w-auto nc-attachment-image object-cover"
:class="{
'!object-contain': props.objectFit === 'contain',
}"
class="m-auto h-full max-h-full w-auto object-cover nc-attachment-image"
:src="props.srcs[index]"
loading="lazy"
:alt="props?.alt || ''"
placeholder
quality="75"

46
packages/nc-gui/components/cell/attachment/Preview/MiscOffice.vue

@ -0,0 +1,46 @@
<script setup lang="ts">
interface Props {
src: string[]
class?: string
}
const props = defineProps<Props>()
const currentIndex = ref(0)
const handleError = () => {
if (currentIndex.value < props.src.length - 1) {
currentIndex.value = currentIndex.value + 1
} else {
currentIndex.value = -1
}
}
const openMethod = ref<'google' | undefined>()
</script>
<template>
<div v-if="!openMethod" :class="props.class" class="flex flex-col text-white gap-2 items-center justify-center">
<GeneralIcon class="w-28 h-28" icon="pdfFile" />
<NcButton type="secondary" @click="openMethod = 'google'">
<div class="flex items-center gap-1">
<GeneralIcon class="w-4 h-4" icon="googleDocs" />
Open with Google Docs
</div>
</NcButton>
</div>
<iframe
v-else-if="openMethod === 'google'"
:class="props.class"
:src="`https://docs.google.com/viewer?url=${encodeURIComponent(src[currentIndex])}&embedded=true`"
width="100%"
height="100%"
frameborder="0"
@error="handleError"
></iframe>
</template>
<style scoped lang="scss"></style>

63
packages/nc-gui/components/cell/attachment/Preview/Pdf.vue

@ -0,0 +1,63 @@
<script setup lang="ts">
interface Props {
src: string[]
class?: string
}
const props = defineProps<Props>()
const currentIndex = ref(0)
const handleError = () => {
if (currentIndex.value < props.src.length - 1) {
currentIndex.value = currentIndex.value + 1
} else {
currentIndex.value = -1
}
}
const openMethod = ref<'browser' | 'google' | undefined>()
</script>
<template>
<div v-if="!openMethod" :class="props.class" class="flex flex-col text-white gap-2 items-center justify-center">
<GeneralIcon class="w-28 h-28" icon="pdfFile" />
<div class="flex items-center justify-center gap-2">
<NcButton class="!w-52" type="secondary" @click="openMethod = 'browser'">
<div class="flex items-center gap-1">
<GeneralIcon icon="globe" />
Open in browser
</div>
</NcButton>
<NcButton type="secondary" class="!w-52" @click="openMethod = 'google'">
<div class="flex items-center gap-1">
<GeneralIcon class="w-4 h-4" icon="googleDocs" />
Open with Google Docs
</div>
</NcButton>
</div>
</div>
<pdf-object
v-if="openMethod === 'browser'"
:class="props.class"
:url="src[currentIndex]"
class="w-full h-full"
@error="handleError"
/>
<iframe
v-else-if="openMethod === 'google'"
:class="props.class"
type="application/pdf"
:src="`https://docs.google.com/viewer?url=${encodeURIComponent(src[currentIndex])}&embedded=true`"
width="100%"
height="100%"
frameborder="0"
@error="handleError"
></iframe>
</template>
<style scoped lang="scss"></style>

55
packages/nc-gui/components/cell/attachment/Preview/Video.vue

@ -0,0 +1,55 @@
<script setup lang="ts">
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
interface Props {
src?: string[]
mimeType?: string
class?: string
title?: string
}
const props = withDefaults(defineProps<Props>(), {
class: '',
})
const emit = defineEmits<Emits>()
interface Emits {
(event: 'init', player: any): void
}
const videoPlayer = ref<HTMLElement>()
const player = ref()
onMounted(() => {
if (!videoPlayer.value) return
player.value = new Plyr(videoPlayer.value, {
previewThumbnails: {},
})
emit('init', player.value)
})
onBeforeUnmount(() => {
if (player.value) {
player.value.destroy()
}
})
</script>
<template>
<video
ref="videoPlayer"
controls
playsinline
:class="{
[props.class]: props.class,
}"
class="videoplayer !min-w-128 !min-h-72 w-full"
>
<source v-for="(source, id) in props.src" :key="id" :src="source" :type="mimeType" />
</video>
</template>
<style scoped lang="scss"></style>

12
packages/nc-gui/components/cell/attachment/UploadProviders/Local.vue

@ -106,20 +106,20 @@ onBeforeUnmount(() => {
ref="dropZoneRef"
:class="{
'border-brand-500': isOverDropZone,
'border-dashed border-1': !tempFiles.length,
'border-dashed border-2': !tempFiles.length,
}"
data-testid="attachment-drop-zone"
:style="`height: ${tempFiles.length > 0 ? '324px' : '100%'}`"
class="flex flex-col items-center justify-center h-full w-full flex-grow-1 rounded-lg"
@click="tempFiles.length > 0 ? () => {} : open()"
>
<div v-if="!tempFiles.length" class="flex cursor-pointer items-center justify-center flex-col gap-2">
<div v-if="!tempFiles.length" class="flex cursor-pointer items-center justify-center flex-col gap-4">
<template v-if="!isOverDropZone">
<component :is="iconMap.upload" class="w-5 h-5" />
<component :is="iconMap.upload" class="w-8 h-8 text-gray-500" />
<h1>
{{ $t('labels.clickTo') }}
<span class="font-semibold"> {{ $t('labels.browseFiles') }} </span>
<span class="font-semibold text-brand-500"> {{ $t('labels.browseFiles') }} </span>
{{ $t('general.or') }}
<span class="font-semibold"> {{ $t('labels.dragFilesHere') }} </span>
@ -127,7 +127,7 @@ onBeforeUnmount(() => {
</h1>
</template>
<template v-if="isOverDropZone">
<component :is="iconMap.upload" class="w-5 text-brand-500 h-5" />
<component :is="iconMap.upload" class="w-8 text-brand-500 h-8" />
<h1 class="text-brand-500 font-bold">{{ $t('labels.dropHere') }}</h1>
</template>
</div>
@ -165,7 +165,7 @@ onBeforeUnmount(() => {
</div>
</template>
</div>
<div v-if="tempFiles.length" class="flex gap-2 pt-1 bg-white w-full items-center justify-end">
<div v-if="tempFiles.length" class="flex gap-2 pt-2 bg-white w-full items-center justify-end">
<NcButton :disabled="isLoading" type="secondary" size="small" @click="closeMenu">
{{ $t('labels.cancel') }}
</NcButton>

2
packages/nc-gui/components/cell/attachment/UploadProviders/Url.vue

@ -38,7 +38,7 @@ const deleteAttachment = (index: number) => {
tempAttachments.value.splice(index, 1)
}
const isValidUrl = ref(true)
const isValidUrl = ref(false)
const errorMessage = ref('')

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

@ -38,20 +38,22 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()!
const { isMobileMode } = useGlobal()
const { getPossibleAttachmentSrc, openAttachment: _openAttachment } = useAttachment()
const { getPossibleAttachmentSrc } = useAttachment()
const {
isPublic,
isForm,
column,
modalRendered,
downloadAttachment,
renameFile,
modalVisible,
attachments,
visibleItems,
onDrop,
isLoading,
FileIcon,
selectedImage,
selectedFile,
isReadonly,
storedFiles,
removeFile,
@ -61,7 +63,13 @@ const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, is
const active = inject(ActiveCellInj, ref(false))
const { state: rowState } = useSmartsheetRowStoreOrThrow()
const { state: rowState, row } = useSmartsheetRowStoreOrThrow()
const meta = inject(MetaInj, ref())
if (!isPublic.value && !isForm.value && meta.value) {
useProvideRowComments(meta, row)
}
const { isOverDropZone } = useDropZone(currentCellRef as any, onDrop)
@ -121,7 +129,8 @@ watch(
const isNewAttachmentModalOpen = ref(false)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
if (e.key === 'Enter' && !isReadonly.value) {
if (modalVisible.value) return
if (e.key === 'Enter' && !isReadonly.value && !selectedFile.value) {
if (isNewAttachmentModalOpen.value) return
e.stopPropagation()
if (!modalVisible.value && !isMobileMode.value) {
@ -146,16 +155,6 @@ const open = (e: Event) => {
openAttachmentModal()
}
const openAttachment = (item: any) => {
if (isMobileMode.value && !isExpandedForm.value) {
isExpandedForm.value = true
return
}
_openAttachment(item)
}
const onExpand = () => {
if (isMobileMode.value) return
@ -163,12 +162,12 @@ const onExpand = () => {
modalVisible.value = true
}
const onImageClick = (item: any) => {
const onFileClick = (item: any) => {
if (isMobileMode.value && !isExpandedForm.value) return
if (!isMobileMode.value && (isGallery.value || isKanban.value) && !isExpandedForm.value) return
selectedImage.value = item
selectedFile.value = item
}
const keydownEnter = (e: KeyboardEvent) => {
@ -206,80 +205,161 @@ const handleFileDelete = (i: number) => {
</script>
<template>
<div v-if="isExpandedForm || isForm" class="form-attachment-cell">
<NcButton data-testid="attachment-cell-file-picker-button" type="secondary" size="small" @click="open">
<div class="flex items-center !text-xs gap-1 justify-center">
<MaterialSymbolsAttachFile class="text-gray-500 text-tiny" />
<span class="text-[10px]">
{{ $t('activity.addFiles') }}
</span>
</div>
</NcButton>
<div v-if="visibleItems.length > 0" class="grid mt-2 gap-2 grid-cols-2">
<div
v-for="(item, i) in visibleItems"
:key="`${item?.title}-${i}`"
class="nc-attachment-item group gap-2 flex border-1 rounded-md border-gray-200 flex-col relative"
>
<div
:class="[dragging ? 'cursor-move' : 'cursor-pointer']"
class="nc-attachment h-full flex justify-center items-center overflow-hidden"
>
<LazyCellAttachmentPreviewImage
v-if="isImage(item.title, item.mimetype)"
:srcs="getPossibleAttachmentSrc(item)"
object-fit="cover"
class="!w-full !h-42 object-cover !m-0 rounded-t-[5px] justify-center"
@click="selectedFile = item"
/>
<component :is="FileIcon(item.icon)" v-else-if="item.icon" :height="45" :width="45" @click="selectedFile = item" />
<IcOutlineInsertDriveFile v-else :height="45" :width="45" @click="selectedFile = item" />
</div>
<div class="relative px-1 flex" :title="item.title">
<NcTooltip show-on-truncate-only class="flex-auto truncate w-full text-[13px] items-center text-sm line-height-4">
{{ item.title }}
<template #title>
{{ item.title }}
</template>
</NcTooltip>
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-5 gap-0.5 pb-2 flex items-center bg-white">
<NcTooltip placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<NcButton
class="!p-0 !w-5 !h-5 text-gray-500 !min-w-[fit-content]"
size="xsmall"
type="text"
@click="downloadAttachment(item)"
>
<component :is="iconMap.download" class="!text-xs h-13px w-13px" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isSharedForm || (!isReadonly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<NcButton
size="xsmall"
class="!p-0 nc-attachment-rename !h-5 !w-5 !text-gray-500 !min-w-[fit-content]"
type="text"
@click="renameFile(item, i)"
>
<component :is="iconMap.rename" class="text-xs h-13px w-13px" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isReadonly" placement="bottom">
<template #title> {{ $t('title.removeFile') }} </template>
<NcButton
class="!p-0 !h-5 !w-5 !text-red-500 nc-attachment-remove !min-w-[fit-content]"
size="xsmall"
type="text"
@click="onRemoveFileClick(item.title, i)"
>
<component
:is="iconMap.delete"
v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)"
class="text-xs h-13px w-13px"
/>
</NcButton>
</NcTooltip>
</div>
</div>
</div>
</div>
</div>
<div
v-else
ref="attachmentCellRef"
:style="{
height:
isForm || isExpandedForm
? undefined
: `max(${!rowHeight || rowHeight === 1 ? rowHeightInPx['1'] - 10 : rowHeightInPx[`${rowHeight}`] - 18}px, ${
height: `max(${!rowHeight || rowHeight === 1 ? rowHeightInPx['1'] - 10 : rowHeightInPx[`${rowHeight}`] - 18}px, ${
isGrid ? '22px' : '32px'
})`,
}"
class="nc-attachment-cell relative flex color-transition gap-2 flex items-center w-full xs:(min-h-12 max-h-32)"
:class="{ 'justify-center': !active, 'justify-between': active, 'px-2': isExpandedForm }"
class="nc-attachment-cell relative group flex color-transition gap-2 flex items-center w-full xs:(min-h-12 max-h-32)"
:class="{ 'justify-center': !active, 'justify-between': active }"
>
<LazyCellAttachmentCarousel />
<LazyCellAttachmentCarousel v-if="selectedFile" />
<template v-if="isSharedForm || (!isReadonly && !dragging && !!currentCellRef)">
<template v-if="!isReadonly && !dragging && !!currentCellRef">
<general-overlay
v-model="isOverDropZone"
inline
:target="currentCellRef"
data-rec="true"
class="nc-attachment-cell-dropzone text-white text-lg ring ring-accent ring-opacity-100 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
class="nc-attachment-cell-dropzone text-white text-lg bg-gray-600/75 flex text-sm items-center justify-center gap-2"
>
<MaterialSymbolsFileCopyOutline class="text-accent" />
<MaterialSymbolsFileCopyOutline />
{{ $t('labels.dropHere') }}
</general-overlay>
</template>
<div
v-if="!isReadonly"
v-if="!isReadonly && active && !visibleItems.length"
:class="{ 'sm:(mx-auto px-4) xs:(w-full min-w-8)': !visibleItems.length }"
class="group cursor-pointer py-1 flex gap-1 items-center rounded border-none shadow-sm hover:(bg-primary bg-opacity-10)"
data-testid="attachment-cell-file-picker-button"
class="group cursor-pointer py-1 flex gap-1 items-center rounded border-none"
tabindex="0"
@click="open"
@keydown.enter="keydownEnter"
@keydown.space="keydownSpace"
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<NcTooltip placement="bottom" class="xs:w-full">
<template #title
><span data-rec="true">{{ $t('activity.attachmentDrop') }} </span></template
>
<template #title>
<span data-rec="true">{{ $t('activity.attachmentDrop') }} </span>
</template>
<div
v-if="active || !visibleItems.length || (isForm && visibleItems.length)"
class="flex items-center gap-1 xs:(w-full min-w-12 h-7 justify-center)"
<NcButton
type="secondary"
size="xsmall"
data-testid="attachment-cell-file-picker-button"
class="!px-2 !h-5.5 !min-w-[fit-content]"
@click.stop="open"
>
<div class="flex items-center !text-xs gap-1 justify-center">
<MaterialSymbolsAttachFile class="text-gray-500 text-tiny" />
<div
v-if="!visibleItems.length"
data-rec="true"
class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-tiny xs:(justify-center rounded-lg text-sm)"
>
<span class="text-[10px]">
{{ $t('activity.addFiles') }}
</span>
</div>
</div>
</NcButton>
</NcTooltip>
</div>
<div v-else class="flex" />
<template v-if="visibleItems.length">
<template v-if="visibleItems.length > 0">
<div
ref="sortableRef"
:class="{
'justify-center': !isExpandedForm && !isGallery && !isKanban,
'py-1': rowHeight === 1 && !isForm && !isExpandedForm,
'py-1.5 !gap-4 ': rowHeight !== 1 || isForm || isExpandedForm,
'justify-center': !isGallery && !isKanban,
'py-1': rowHeight === 1,
'py-1.5 !gap-4 ': rowHeight !== 1,
}"
class="nc-attachment-wrapper flex cursor-pointer w-full items-center flex-wrap gap-3 nc-scrollbar-thin mt-0 items-start px-[1px]"
:style="{
maxHeight: isForm || isExpandedForm ? undefined : `max(100%, ${isGrid ? '22px' : '32px'})`,
maxHeight: `max(100%, ${isGrid ? '22px' : '32px'})`,
}"
>
<template v-for="(item, i) of visibleItems" :key="item.url || item.title">
@ -290,10 +370,9 @@ const handleFileDelete = (i: number) => {
<div v-if="isImage(item.title, item.mimetype ?? item.type)">
<div
class="nc-attachment flex items-center flex-col flex-wrap justify-center flex-auto"
:class="{ '!w-30': isForm || isExpandedForm }"
@click="() => onImageClick(item)"
@click="() => onFileClick(item)"
>
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
:alt="item.title || `#${i}`"
class="rounded"
:class="{
@ -301,7 +380,7 @@ const handleFileDelete = (i: number) => {
'h-4.5': isGrid && (!rowHeight || rowHeight === 1),
'h-8': rowHeight === 2,
'h-16.8': rowHeight === 4,
'h-20.8': rowHeight === 6 || isForm || isExpandedForm,
'h-20.8': rowHeight === 6,
}"
:srcs="getPossibleAttachmentSrc(item)"
/>
@ -315,47 +394,65 @@ const handleFileDelete = (i: number) => {
'h-4.5': isGrid && (!rowHeight || rowHeight === 1),
'h-8': rowHeight === 2,
'h-16.8': rowHeight === 4,
'h-20.8 !w-30': rowHeight === 6 || isForm || isExpandedForm,
'ml-2': active,
'h-20.8 !w-30': rowHeight === 6,
}"
@click="openAttachment(item)"
@click="onFileClick(item)"
>
<component :is="FileIcon(item.icon)" v-if="item.icon" :class="{ 'h-13 w-13': isForm || isExpandedForm }" />
<component :is="FileIcon(item.icon)" v-if="item.icon" />
<IcOutlineInsertDriveFile v-else :class="{ 'h-13 w-13': isForm || isExpandedForm }" />
<IcOutlineInsertDriveFile v-else />
</div>
<a-tooltip v-if="!isReadonly && (isForm || isExpandedForm)">
<template #title> {{ $t('title.removeFile') }} </template>
<component
:is="iconMap.closeCircle"
v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)"
class="nc-attachment-remove"
@click.stop="onRemoveFileClick(item.title, i)"
/>
</a-tooltip>
</NcTooltip>
</template>
</div>
<div
v-if="active || (isForm && visibleItems.length)"
class="xs:hidden group cursor-pointer flex gap-1 items-center rounded border-none p-1"
<NcTooltip
placement="bottom"
class="nc-action-icon !absolute hidden right-0 nc-text-area-expand-btn !group-hover:block z-3"
:class="{
'top-0': isGrid && !(!rowHeight || rowHeight === 1),
'top-1': !isGrid,
}"
:style="isGrid && (!rowHeight || rowHeight === 1) ? { top: '50%', transform: 'translateY(-50%)' } : undefined"
>
<template #title>{{ $t('activity.viewAttachment') }}</template>
<NcButton
type="secondary"
size="xsmall"
data-testid="attachment-cell-file-picker-button"
class="!p-0 !w-5 !h-5 !min-w-[fit-content]"
@click.stop="onExpand"
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<NcTooltip v-else placement="bottom" class="flex">
<template #title> {{ $t('activity.viewAttachment') }}</template>
<component :is="iconMap.expand" v-else class="transform group-hover:(!text-grey-800) text-gray-700 text-xs" />
</NcButton>
</NcTooltip>
<NcButton type="text" size="xsmall" @click.stop="onExpand">
<component :is="iconMap.expand" />
<NcTooltip
placement="bottom"
class="nc-action-icon !absolute hidden left-0 nc-text-area-expand-btn !group-hover:block z-3"
:class="{
'top-0': isGrid && !(!rowHeight || rowHeight === 1),
'top-1': !isGrid,
}"
:style="isGrid && (!rowHeight || rowHeight === 1) ? { top: '50%', transform: 'translateY(-50%)' } : undefined"
>
<template #title>{{ $t('activity.addFiles') }}</template>
<NcButton
type="secondary"
size="xsmall"
data-testid="attachment-cell-file-picker-button"
class="!p-0 !w-5 !h-5 !min-w-[fit-content]"
@click.stop="open"
>
<MaterialSymbolsAttachFile class="text-gray-500 text-tiny group-hover:(!text-grey-800) text-gray-700" />
</NcButton>
</NcTooltip>
</div>
</template>
<LazyCellAttachmentModal v-if="modalRendered" />
</div>
<LazyGeneralDeleteModal
v-if="isForm || isExpandedForm"
v-model:visible="isConfirmModalOpen"
@ -376,11 +473,20 @@ const handleFileDelete = (i: number) => {
</span>
</template>
</LazyGeneralDeleteModal>
</div>
<LazyCellAttachmentAttachFile v-if="isNewAttachmentModalOpen" v-model:value="isNewAttachmentModalOpen" />
</template>
<style lang="scss">
.nc-data-cell {
&:has(.form-attachment-cell) {
@apply !border-none;
box-shadow: none !important;
}
.nc-cell-attachment {
@apply !border-none;
}
}
.nc-cell {
.nc-attachment-cell {
.nc-attachment {
@ -400,15 +506,6 @@ const handleFileDelete = (i: number) => {
}
.nc-attachment-item {
@apply relative;
.nc-attachment-remove {
@apply absolute right-0.8 top-0.8 rounded hidden p-0.5 bg-white text-lg leading-none;
box-shadow: 0px 0px 4px #bbb;
}
&:hover .nc-attachment-remove {
@apply block;
}
}
}
</style>

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

@ -42,7 +42,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const modalVisible = ref(false)
/** for image carousel */
const selectedImage = ref()
const selectedFile = ref()
const videoStream = ref<MediaStream | null>(null)
@ -56,6 +56,8 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
reset: true,
})
const isRenameModalOpen = ref(false)
const { appInfo } = useGlobal()
const defaultAttachmentMeta = {
@ -264,18 +266,26 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
}
async function renameFile(attachment: AttachmentType, idx: number) {
async function renameFile(attachment: AttachmentType, idx: number, updateSelectedFile?: boolean) {
return new Promise<boolean>((resolve) => {
isRenameModalOpen.value = true
const { close } = useDialog(RenameFile, {
title: attachment.title,
onRename: (newTitle: string) => {
attachments.value[idx].title = newTitle
updateModelValue(JSON.stringify(attachments.value))
close()
if (updateSelectedFile) {
selectedFile.value = { ...attachment, title: newTitle }
}
isRenameModalOpen.value = false
resolve(true)
},
onCancel: () => {
close()
isRenameModalOpen.value = false
resolve(true)
},
})
@ -414,7 +424,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
renameFile,
downloadAttachment,
updateModelValue,
selectedImage,
selectedFile,
uploadViaUrl,
selectedVisibleItems,
storedFiles,
@ -424,6 +434,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
stopCamera,
videoStream,
permissionGranted,
isRenameModalOpen,
}
},
'useAttachmentCell',

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

@ -47,6 +47,11 @@ watch(currentSidebarSize, () => {
const sidebarWidth = computed(() => (isMobileMode.value ? viewportWidth.value : sideBarSize.value.old))
const remToPx = (rem: number) => {
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
return rem * fontSize
}
const normalizedWidth = computed(() => {
const maxSize = remToPx(viewportWidth.value <= 1560 ? 20 : 35)
const minSize = remToPx(16)
@ -147,11 +152,6 @@ onMounted(() => {
handleSidebarOpenOnMobileForNonViews()
})
function remToPx(rem: number) {
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
return rem * fontSize
}
function onResize(widthPercent: any) {
if (isMobileMode.value) return

12
packages/nc-gui/components/general/DeleteModal.vue

@ -11,6 +11,8 @@ const visible = useVModel(props, 'visible', emits)
const isLoading = ref(false)
const modalRef = ref<HTMLElement>()
const { t } = useI18n()
const deleteLabel = computed(() => props.deleteLabel ?? t('general.delete'))
@ -40,11 +42,19 @@ onKeyStroke('Enter', () => {
onDelete()
})
watch(visible, (value) => {
if (value) {
setTimeout(() => {
modalRef.value?.focus()
}, 100)
}
})
</script>
<template>
<GeneralModal v-model:visible="visible" size="small" centered>
<div class="flex flex-col p-6">
<div ref="modalRef" class="flex flex-col p-6">
<div class="flex flex-row pb-2 mb-3 font-medium text-lg text-gray-800">{{ deleteLabel }} {{ props.entityName }}</div>
<div class="mb-3 text-gray-800">

6
packages/nc-gui/components/general/FormBanner.vue

@ -19,7 +19,11 @@ const getBannerImageSrc = computed(() => {
:class="!bannerImageUrl ? 'shadow-sm' : ''"
:style="{ aspectRatio: 4 / 1 }"
>
<LazyCellAttachmentImage v-if="bannerImageUrl" :srcs="getBannerImageSrc" class="nc-form-banner-image object-cover w-full" />
<LazyCellAttachmentPreviewImage
v-if="bannerImageUrl"
:srcs="getBannerImageSrc"
class="nc-form-banner-image object-cover w-full"
/>
<div v-else class="h-full flex items-stretch justify-between bg-white">
<div class="flex -mt-1">
<img src="~assets/img/form-banner-left.png" alt="form-banner-left'" />

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

@ -46,13 +46,7 @@ export default {
<template>
<teleport :disabled="teleportDisabled || (inline && !target)" :to="target || 'body'">
<Transition :name="transition ? 'fade' : undefined" mode="out-in">
<div
v-show="!!vModel"
v-bind="$attrs"
:style="{ zIndex }"
:class="[inline ? 'absolute' : 'fixed']"
class="top-0 left-0 bottom-0 right-0"
>
<div v-show="!!vModel" v-bind="$attrs" :style="{ zIndex }" :class="[inline ? 'absolute' : 'fixed']" class="inset-0">
<slot :is-open="vModel" />
</div>
</Transition>
@ -62,7 +56,7 @@ export default {
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
transition: opacity 0.8s;
}
.fade-enter,

27
packages/nc-gui/components/nc/Carousel/Content.vue

@ -0,0 +1,27 @@
<script setup lang="ts">
import { useCarousel } from './useCarousel'
import type { WithClassAsProps } from './interface'
const props = defineProps<WithClassAsProps>()
const { carouselRef, orientation } = useCarousel()
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<div ref="carouselRef" class="overflow-hidden carousel-container">
<div
:class="{
'flex-col': orientation === 'vertical',
[props.class]: props.class,
}"
class="flex embla__container"
v-bind="$attrs"
>
<slot />
</div>
</div>
</template>

18
packages/nc-gui/components/nc/Carousel/Item.vue

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { WithClassAsProps } from './interface'
const props = defineProps<WithClassAsProps>()
</script>
<template>
<div
role="group"
aria-roledescription="slide"
:class="{
[props.class]: props.class,
}"
class="min-w-0 shrink-0 flex justify-center items-center embla__slide h-full grow-0 basis-full"
>
<slot />
</div>
</template>

26
packages/nc-gui/components/nc/Carousel/Next.vue

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useCarousel } from './useCarousel'
import type { WithClassAsProps } from './interface'
const props = defineProps<WithClassAsProps>()
const { orientation, canScrollNext, scrollNext } = useCarousel()
</script>
<template>
<NcButton
:disabled="!canScrollNext"
type="secondary"
size="xsmall"
:class="{
'rotate-90': orientation === 'vertical',
[props.class]: props.class,
}"
class="absolute"
@click="scrollNext"
>
<slot>
<GeneralIcon icon="arrowRight" class="h-4 w-4 text-current" />
</slot>
</NcButton>
</template>

26
packages/nc-gui/components/nc/Carousel/Previous.vue

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useCarousel } from './useCarousel'
import type { WithClassAsProps } from './interface'
const props = defineProps<WithClassAsProps>()
const { orientation, canScrollPrev, scrollPrev } = useCarousel()
</script>
<template>
<NcButton
:disabled="!canScrollPrev"
type="secondary"
size="xsmall"
:class="{
'rotate-90': orientation === 'vertical',
[props.class]: props.class,
}"
class="!absolute"
@click="scrollPrev"
>
<slot>
<GeneralIcon icon="arrowLeft" class="h-4 w-4 text-current" />
</slot>
</NcButton>
</template>

41
packages/nc-gui/components/nc/Carousel/index.vue

@ -0,0 +1,41 @@
<script setup lang="ts">
import { useProvideCarousel } from './useCarousel'
import type { CarouselEmits, CarouselProps, WithClassAsProps } from './interface'
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
orientation: 'horizontal',
})
const emits = defineEmits<CarouselEmits>()
const carouselArgs = useProvideCarousel(props, emits)
defineExpose(carouselArgs)
</script>
<template>
<div
:class="{
[props.class]: props.class,
}"
class="relative embla !focus-visible:outline-none"
role="region"
aria-roledescription="carousel"
tabindex="0"
>
<slot v-bind="carouselArgs" />
</div>
</template>
<style lang="scss">
.embla {
overflow: hidden;
}
.embla__container {
display: flex;
}
.embla__slide {
position: relative;
flex: 0 0 100%;
}
</style>

24
packages/nc-gui/components/nc/Carousel/interface.ts

@ -0,0 +1,24 @@
import type { HTMLAttributes, UnwrapRef } from 'vue'
import type useEmblaCarousel from 'embla-carousel-vue'
import type { EmblaCarouselVueType } from 'embla-carousel-vue'
export type CarouselApi = EmblaCarouselVueType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>
export interface CarouselProps {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
}
export interface CarouselEmits {
(e: 'init-api', payload: UnwrapRefCarouselApi): void
}
export interface WithClassAsProps {
class?: HTMLAttributes['class']
}

51
packages/nc-gui/components/nc/Carousel/useCarousel.ts

@ -0,0 +1,51 @@
import emblaCarouselVue from 'embla-carousel-vue'
import type { UnwrapRefCarouselApi as CarouselApi, CarouselEmits, CarouselProps } from './interface'
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
({ opts, orientation, plugins }: CarouselProps, emits: CarouselEmits) => {
const [emblaNode, emblaApi] = emblaCarouselVue(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
)
function scrollPrev() {
emblaApi.value?.scrollPrev()
}
function scrollNext() {
emblaApi.value?.scrollNext()
}
const canScrollNext = ref(false)
const canScrollPrev = ref(false)
function onSelect(api: CarouselApi) {
canScrollNext.value = api?.canScrollNext() || false
canScrollPrev.value = api?.canScrollPrev() || false
}
onMounted(() => {
if (!emblaApi.value) return
emblaApi.value?.on('init', onSelect)
emblaApi.value?.on('reInit', onSelect)
emblaApi.value?.on('select', onSelect)
emits('init-api', emblaApi.value)
})
return { carouselRef: emblaNode, carouselApi: emblaApi, canScrollPrev, canScrollNext, scrollPrev, scrollNext, orientation }
},
)
function useCarousel() {
const carouselState = useInjectCarousel()
if (!carouselState) throw new Error('useCarousel must be used within a <Carousel />')
return carouselState
}
export { useCarousel, useProvideCarousel }

16
packages/nc-gui/components/nc/Tooltip.vue

@ -8,6 +8,7 @@ interface Props {
modifierKey?: string
tooltipStyle?: CSSProperties
// force disable tooltip
color?: 'dark' | 'light'
disabled?: boolean
placement?: TooltipPlacement | undefined
showOnTruncateOnly?: boolean
@ -28,6 +29,8 @@ const hideOnClick = computed(() => props.hideOnClick)
const placement = computed(() => props.placement ?? 'top')
const wrapChild = computed(() => props.wrapChild ?? 'div')
const color = computed(() => (props.color ? props.color : 'dark'))
const el = ref()
const showTooltip = controlledRef(false, {
@ -117,7 +120,7 @@ const onClick = () => {
<template>
<a-tooltip
v-model:visible="showTooltip"
:overlay-class-name="`nc-tooltip ${showTooltip ? 'visible' : 'hidden'} ${overlayClassName}`"
:overlay-class-name="`nc-tooltip-${color} ${showTooltip ? 'visible' : 'hidden'} ${overlayClassName}`"
:overlay-style="tooltipStyle"
:overlay-inner-style="overlayInnerStyle"
arrow-point-at-center
@ -139,7 +142,7 @@ const onClick = () => {
.nc-tooltip.hidden {
@apply invisible;
}
.nc-tooltip {
.nc-tooltip-dark {
.ant-tooltip-inner {
@apply !px-2 !py-1 !rounded-lg !bg-gray-800;
}
@ -147,4 +150,13 @@ const onClick = () => {
@apply !bg-gray-800;
}
}
.nc-tooltip-light {
.ant-tooltip-inner {
@apply !px-2 !py-1 !text-gray-800 !rounded-lg !bg-gray-200;
}
.ant-tooltip-arrow-content {
@apply !bg-gray-200;
}
}
</style>

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

@ -872,7 +872,7 @@ useEventListener(
"
style="transition: all 0.3s ease-in"
>
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
v-if="formViewData.logo_url"
:key="formViewData.logo_url?.path"
:srcs="getFormLogoSrc"
@ -1687,8 +1687,10 @@ useEventListener(
.nc-input {
@apply appearance-none w-full;
&:not(.layout-list) {
&:not(.nc-cell-attachment) {
@apply !bg-white rounded-lg border-solid border-1 border-gray-200 !focus-within:border-brand-500;
}
}
&.layout-list {
@apply h-auto !pl-0 !py-1;
}

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

@ -299,7 +299,7 @@ watch(
</template>
<template v-for="(attachment, index) in attachments(record)">
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`"
class="h-52"

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

@ -785,7 +785,7 @@ const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stac
</template>
<template v-for="attachment in attachments(record)">
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="attachment.path"
class="h-52"

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

@ -55,14 +55,6 @@ const isDayInPagedMonth = (date: dayjs.Dayjs) => {
return date.month() === selectedMonth.value.month()
}
const getDayIndex = (date: dayjs.Dayjs) => {
let dayIndex = date.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
return dayIndex
}
const dragElement = ref<HTMLElement | null>(null)
const draggingId = ref<string | null>(null)

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

@ -582,7 +582,7 @@ onClickOutside(searchRef, toggleSearch)
</template>
<template v-for="(attachment, index) in attachments(record)">
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`"
class="h-10 !w-10 !object-contain"

689
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -1,689 +0,0 @@
<script setup lang="ts">
import { type AuditType, type CommentType, ProjectRoles } from 'nocodb-sdk'
const props = defineProps<{
loading: boolean
primaryKey: string | null
}>()
const {
loadComments,
deleteComment,
comments,
resolveComment,
audits,
isAuditLoading,
saveComment: _saveComment,
updateComment,
} = useExpandedFormStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const commentsWrapperEl = ref<HTMLDivElement>()
const commentInputRef = ref<any>()
const comment = ref('')
const { copy } = useClipboard()
const route = useRoute()
const { dashboardUrl } = useDashboard()
const { user, appInfo } = useGlobal()
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const meta = inject(MetaInj, ref())
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : []))
const isExpandedFormLoading = computed(() => props.loading)
const tab = ref<'comments' | 'audits'>('comments')
const { isUIAllowed } = useRoles()
const router = useRouter()
const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
const editCommentValue = ref<CommentType>()
const isEditing = ref<boolean>(false)
const isCommentMode = ref(false)
const hoveredCommentId = ref<null | string>(null)
async function onEditComment() {
if (!isEditing.value || !editCommentValue.value?.comment) return
while (editCommentValue.value.comment.endsWith('<br />') || editCommentValue.value.comment.endsWith('\n')) {
if (editCommentValue.value.comment.endsWith('<br />')) {
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -6)
} else {
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -2)
}
}
isCommentMode.value = true
const tempCom = {
...editCommentValue.value,
}
isEditing.value = false
editCommentValue.value = undefined
await updateComment(tempCom.id!, {
comment: tempCom.comment,
})
loadComments()
}
function onCancel(e: KeyboardEvent) {
if (!isEditing.value) return
e.preventDefault()
e.stopPropagation()
editCommentValue.value = undefined
loadComments()
isEditing.value = false
editCommentValue.value = undefined
}
function editComment(comment: CommentType) {
editCommentValue.value = {
...comment,
}
isEditing.value = true
nextTick(() => {
scrollToComment(comment.id)
})
}
const value = computed({
get() {
return editCommentValue.value?.comment || ''
},
set(val) {
if (!editCommentValue.value) return
editCommentValue.value.comment = val
},
})
function scrollComments() {
if (commentsWrapperEl.value) {
commentsWrapperEl.value.scrollTo({
top: commentsWrapperEl.value.scrollHeight,
behavior: 'smooth',
})
}
}
const saveComment = async () => {
if (!comment.value.trim()) return
while (comment.value.endsWith('<br />') || comment.value.endsWith('\n')) {
if (comment.value.endsWith('<br />')) {
comment.value = comment.value.slice(0, -6)
} else {
comment.value = comment.value.slice(0, -2)
}
}
isCommentMode.value = true
// Optimistic Insert
comments.value = [
...comments.value,
{
id: `temp-${new Date().getTime()}`,
comment: comment.value,
created_at: new Date().toISOString(),
created_by: user.value?.id,
created_by_email: user.value?.email,
created_display_name: user.value?.display_name ?? '',
},
]
const tempCom = comment.value
comment.value = ''
commentInputRef?.value?.setEditorContent('', true)
await nextTick(() => {
scrollComments()
})
try {
await _saveComment(tempCom)
await nextTick(() => {
isExpandedFormCommentMode.value = true
})
scrollComments()
} catch (e) {
console.error(e)
}
}
const copyComment = async (comment: CommentType) => {
await copy(
encodeURI(
`${dashboardUrl?.value}#/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}?rowId=${props.primaryKey}&commentId=${comment.id}`,
),
)
}
function scrollToComment(commentId: string) {
const commentEl = document.querySelector(`.${commentId}`)
if (commentEl) {
commentEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
function scrollToAudit(auditId?: string) {
if (!auditId) return
const auditEl = commentsWrapperEl.value?.querySelector(`.nc-audit-item.${auditId}`)
if (auditEl) {
auditEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
watch(commentsWrapperEl, () => {
setTimeout(() => {
nextTick(() => {
const query = router.currentRoute.value.query
const commentId = query.commentId
if (commentId) {
router.push({
query: {
rowId: query.rowId,
},
})
scrollToComment(commentId as string)
hoveredCommentId.value = commentId as string
onClickOutside(document.querySelector(`.${hoveredCommentId.value}`)! as HTMLDivElement, handleResetHoverEffect)
} else {
scrollComments()
}
})
}, 100)
})
const createdBy = (
comment: CommentType & {
created_display_name?: string
},
) => {
if (comment.created_by === user.value?.id) {
return 'You'
} else if (comment.created_display_name?.trim()) {
return comment.created_display_name || 'Shared source'
} else if (comment.created_by_email) {
return comment.created_by_email
} else {
return 'Shared source'
}
}
const createdByAudit = (
comment: AuditType & {
created_display_name?: string
},
) => {
if (comment.user === user.value?.email) {
return 'You'
} else if (comment.created_display_name?.trim()) {
return comment.created_display_name || 'Shared source'
} else if (comment.user) {
return comment.user
} else {
return 'Shared source'
}
}
const getUserRole = (email: string) => {
const user = baseUsers.value.find((user) => user.email === email)
if (!user) return ProjectRoles.NO_ACCESS
return user.roles || ProjectRoles.NO_ACCESS
}
const editedAt = (comment: CommentType) => {
if (comment.updated_at !== comment.created_at && comment.updated_at) {
const str = timeAgo(comment.updated_at).replace(' ', '_')
return `[(edited)](a~~~###~~~Edited_${str}) `
}
return ''
}
function handleResetHoverEffect() {
if (!hoveredCommentId.value) return
hoveredCommentId.value = null
}
watch(
() => audits.value.length,
(auditCount) => {
nextTick(() => {
setTimeout(() => {
scrollToAudit(audits.value[auditCount - 1]?.id)
}, 100)
})
},
)
</script>
<template>
<div class="flex flex-col bg-white !h-full w-full rounded-br-2xl overflow-hidden">
<NcTabs v-model:activeKey="tab" class="h-full">
<a-tab-pane key="comments" class="w-full h-full">
<template #tab>
<div v-e="['c:row-expand:comment']" class="flex items-center gap-2">
<GeneralIcon icon="messageCircle" class="w-4 h-4" />
<span class="<lg:hidden"> Comments </span>
</div>
</template>
<div
class="h-full"
:class="{
'pb-1': !hasEditPermission,
}"
>
<div v-if="isExpandedFormLoading" class="flex flex-col items-center justify-center w-full h-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else class="flex flex-col h-full">
<div v-if="comments.length === 0" class="flex flex-col my-1 text-center justify-center h-full nc-scrollbar-thin">
<div class="text-center text-3xl text-gray-700">
<GeneralIcon icon="commentHere" />
</div>
<div class="font-medium text-center my-6 text-gray-500">
{{ hasEditPermission ? $t('activity.startCommenting') : $t('activity.noCommentsYet') }}
</div>
</div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin">
<div
v-for="commentItem of comments"
:key="commentItem.id"
:class="`${commentItem.id}`"
class="nc-comment-item"
@mouseover="handleResetHoverEffect"
>
<div
:class="{
'hover:bg-gray-100': editCommentValue?.id !== commentItem!.id,
'nc-hovered-comment bg-gray-100': hoveredCommentId === commentItem!.id
}"
class="group gap-3 overflow-hidden px-3 py-2 transition-colors"
>
<div class="flex items-start justify-between">
<div
class="flex items-start gap-3 flex-1"
:class="{
'w-[calc(100%)] group-hover:w-[calc(100%_-_50px)]': !appInfo.ee,
'w-[calc(100%_-_44px)] group-hover:w-[calc(100%_-_72px)]': appInfo.ee && commentItem.resolved_by,
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_72px)]':
appInfo.ee && !commentItem.resolved_by && hasEditPermission,
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_44px)]':
appInfo.ee && !commentItem.resolved_by && !hasEditPermission,
}"
>
<GeneralUserIcon
:email="commentItem.created_by_email"
:name="commentItem.created_display_name"
class="mt-0.5"
size="medium"
/>
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]">
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]">
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden">
{{ createdBy(commentItem) }}
</div>
<template #overlay>
<div class="bg-white rounded-lg">
<div class="flex items-center gap-4 py-3 px-2">
<GeneralUserIcon
class="!w-8 !h-8 border-1 border-gray-200 rounded-full"
:name="commentItem.created_display_name"
:email="commentItem.created_by_email"
/>
<div class="flex flex-col">
<div class="font-semibold text-gray-800">
{{ createdBy(commentItem) }}
</div>
<div class="text-xs text-gray-600">
{{ commentItem.created_by_email }}
</div>
</div>
</div>
<div
v-if="isUIAllowed('dataEdit')"
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5"
>
Has <RolesBadge size="sm" :border="false" :role="getUserRole(commentItem.created_by_email!)" />
role in base
</div>
</div>
</template>
</NcDropdown>
<div class="text-xs text-gray-500">
{{ timeAgo(commentItem.created_at!) }}
</div>
</div>
</div>
<div class="flex items-center">
<NcDropdown
v-if="!editCommentValue"
class="nc-comment-more-actions !hidden !group-hover:block"
overlay-class-name="!min-w-[160px]"
placement="bottomRight"
>
<NcButton
class="nc-expand-form-more-actions !hover:bg-gray-200 !w-7 !h-7 !bg-transparent"
size="xsmall"
type="text"
>
<GeneralIcon class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem
v-if="user && commentItem.created_by_email === user.email && hasEditPermission"
v-e="['c:comment-expand:comment:edit']"
class="text-gray-700"
@click="editComment(commentItem)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.rename" class="cursor-pointer" />
{{ $t('general.edit') }}
</div>
</NcMenuItem>
<NcMenuItem
v-e="['c:comment-expand:comment:copy']"
class="text-gray-700"
@click="copyComment(commentItem)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.copy" class="cursor-pointer" />
{{ $t('general.copy') }} URL
</div>
</NcMenuItem>
<template v-if="user && commentItem.created_by_email === user.email && hasEditPermission">
<NcDivider />
<NcMenuItem
v-e="['c:row-expand:comment:delete']"
class="!text-red-500 !hover:bg-red-50"
@click="deleteComment(commentItem.id!)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.delete" class="cursor-pointer" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<div v-if="appInfo.ee">
<NcTooltip v-if="!commentItem.resolved_by && hasEditPermission">
<NcButton
class="nc-resolve-comment-btn !w-7 !h-7 !bg-transparent !hover:bg-gray-200 !hidden !group-hover:block"
size="xsmall"
type="text"
@click="resolveComment(commentItem.id!)"
>
<GeneralIcon class="text-md" icon="checkCircle" />
</NcButton>
<template #title>Click to resolve </template>
</NcTooltip>
<NcTooltip v-else-if="commentItem.resolved_by">
<template #title>{{ `Resolved by ${commentItem.resolved_display_name}` }}</template>
<NcButton
class="!h-7 !w-7 !bg-transparent !hover:bg-gray-200 text-semibold"
size="xsmall"
type="text"
@click="resolveComment(commentItem.id!)"
>
<GeneralIcon class="text-md rounded-full bg-[#17803D] text-white" icon="checkFill" />
</NcButton>
</NcTooltip>
</div>
</div>
</div>
<div
:class="{
'mt-3': commentItem.id === editCommentValue?.id,
}"
class="flex-1 flex flex-col gap-1 max-w-[calc(100%)]"
>
<SmartsheetExpandedFormRichComment
v-if="commentItem.id === editCommentValue?.id && hasEditPermission"
v-model:value="value"
autofocus
:hide-options="false"
class="expanded-form-comment-edit-input cursor-text expanded-form-comment-input !py-2 !px-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-white !text-gray-800 !text-small !leading-18px !max-h-[240px]"
data-testid="expanded-form-comment-input"
sync-value-change
@save="onEditComment"
@keydown.esc="onCancel"
@blur="
() => {
editCommentValue = undefined
isEditing = false
}
"
@keydown.enter.exact.prevent="onEditComment"
/>
<div v-else class="space-y-1 pl-9">
<SmartsheetExpandedFormRichComment
:value="`${commentItem.comment} ${editedAt(commentItem)}`"
class="!text-small !leading-18px !text-gray-800 -ml-1"
read-only
sync-value-change
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="hasEditPermission" class="px-3 pb-3 nc-comment-input !rounded-br-2xl gap-2 flex">
<SmartsheetExpandedFormRichComment
ref="commentInputRef"
v-model:value="comment"
:hide-options="false"
placeholder="Comment..."
class="expanded-form-comment-input !py-2 !px-2 cursor-text border-1 rounded-lg w-full bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[240px]"
:autofocus="isExpandedFormCommentMode"
data-testid="expanded-form-comment-input"
@focus="isExpandedFormCommentMode = false"
@keydown.stop
@save="saveComment"
@keydown.enter.exact.prevent="saveComment"
/>
</div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="audits" class="w-full" :disabled="appInfo.ee">
<template #tab>
<NcTooltip v-if="appInfo.ee" class="tab flex-1">
<template #title>{{ $t('title.comingSoon') }}</template>
<div v-e="['c:row-expand:audit']" class="flex items-center gap-2 text-gray-400">
<GeneralIcon icon="audit" class="w-4 h-4" />
<span class="<lg:hidden"> Audits </span>
</div>
</NcTooltip>
<div v-else v-e="['c:row-expand:audit']" class="flex items-center gap-2">
<GeneralIcon icon="audit" class="w-4 h-4" />
<span class="<lg:hidden"> Audits </span>
</div>
</template>
<div class="h-full pb-1">
<div v-if="isExpandedFormLoading || isAuditLoading" class="flex flex-col items-center justify-center w-full h-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin">
<template v-if="audits.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl text-gray-600">
<MdiHistory />
</div>
<div class="font-bold text-center my-1 text-gray-600">See changes to this record</div>
</div>
</template>
<div v-for="audit of audits" :key="audit.id" :class="`${audit.id}`" class="nc-audit-item">
<div class="group gap-3 overflow-hidden px-3 py-2 hover:bg-gray-100">
<div class="flex items-start justify-between">
<div class="flex items-start gap-3 flex-1 w-full">
<GeneralUserIcon
:email="audit.created_by_email"
:name="audit.created_display_name"
class="mt-0.5"
size="medium"
/>
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]">
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]">
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden">
{{ createdByAudit(audit) }}
</div>
<template #overlay>
<div class="bg-white rounded-lg">
<div class="flex items-center gap-4 py-3 px-2">
<GeneralUserIcon
class="!w-8 !h-8 border-1 border-gray-200 rounded-full"
:name="audit.created_display_name"
:email="audit.created_by_email"
/>
<div class="flex flex-col">
<div class="font-semibold text-gray-800">
{{ createdByAudit(audit) }}
</div>
<div class="text-xs text-gray-600">
{{ audit.created_by_email }}
</div>
</div>
</div>
<div
v-if="isUIAllowed('dataEdit')"
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5"
>
Has <RolesBadge size="sm" :border="false" :role="getUserRole(audit.created_by_email!)" />
role in base
</div>
</div>
</template>
</NcDropdown>
<div class="text-xs text-gray-500">
{{ timeAgo(audit.created_at!) }}
</div>
</div>
</div>
</div>
<div v-dompurify-html="audit.details" class="!text-[13px] text-gray-500 !leading-5 !pl-9"></div>
</div>
</div>
</div>
</div>
</a-tab-pane>
</NcTabs>
</div>
</template>
<style lang="scss" scoped>
.tab {
@apply max-w-1/2;
}
.nc-audit-item {
@apply gap-3;
}
.tab .tab-title {
@apply min-w-0 flex justify-center gap-2 font-semibold items-center;
word-break: 'keep-all';
white-space: 'nowrap';
display: 'inline';
}
.text-decoration-line-through {
text-decoration: line-through;
}
:deep(.red.lighten-4) {
@apply bg-red-100 rounded-md line-through;
}
:deep(.green.lighten-4) {
@apply bg-green-100 rounded-md !mr-3;
}
:deep(.ant-tabs) {
@apply !overflow-visible;
.ant-tabs-nav {
@apply px-3 bg-white;
.ant-tabs-nav-list {
@apply w-[99%] mx-auto gap-6;
.ant-tabs-tab {
@apply flex-1 flex items-center justify-center pt-3 pb-2.5;
& + .ant-tabs-tab {
@apply !ml-0;
}
}
}
}
.ant-tabs-content-holder {
.ant-tabs-content {
@apply h-full;
}
}
}
:deep(.expanded-form-comment-input) {
@apply transition-all duration-150 min-h-8;
box-shadow: none;
&:focus,
&:focus-within {
@apply min-h-16 !bg-white border-brand-500;
box-shadow: 0px 0px 0px 2px rgba(51, 102, 255, 0.24);
}
&::placeholder {
@apply !text-gray-400;
}
}
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) {
@apply bg-white;
}
.nc-hovered-comment {
.nc-expand-form-more-actions,
.nc-resolve-comment-btn {
@apply !block;
}
}
</style>

145
packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Audits.vue

@ -0,0 +1,145 @@
<script setup lang="ts">
import { type AuditType, ProjectRoles } from 'nocodb-sdk'
const { user } = useGlobal()
const { isUIAllowed } = useRoles()
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const meta = inject(MetaInj, ref())
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : []))
const { audits, isAuditLoading } = useExpandedFormStoreOrThrow()
const auditsWrapperEl = ref<HTMLElement | null>(null)
function scrollToAudit(auditId?: string) {
if (!auditId) return
const auditEl = auditsWrapperEl.value?.querySelector(`.nc-audit-item.${auditId}`)
if (auditEl) {
auditEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
const createdByAudit = (
comment: AuditType & {
created_display_name?: string
},
) => {
if (comment.user === user.value?.email) {
return 'You'
} else if (comment.created_display_name?.trim()) {
return comment.created_display_name || 'Shared source'
} else if (comment.user) {
return comment.user
} else {
return 'Shared source'
}
}
const getUserRole = (email: string) => {
const user = baseUsers.value.find((user) => user.email === email)
if (!user) return ProjectRoles.NO_ACCESS
return user.roles || ProjectRoles.NO_ACCESS
}
watch(
() => audits.value.length,
(auditCount) => {
nextTick(() => {
setTimeout(() => {
scrollToAudit(audits.value[auditCount - 1]?.id)
}, 100)
})
},
)
</script>
<template>
<div class="h-full pb-1">
<div v-if="isAuditLoading" class="flex flex-col items-center justify-center w-full h-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else ref="auditsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin">
<template v-if="audits.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl text-gray-600">
<MdiHistory />
</div>
<div class="font-bold text-center my-1 text-gray-600">See changes to this record</div>
</div>
</template>
<div v-for="audit of audits" :key="audit.id" :class="`${audit.id}`" class="nc-audit-item">
<div class="group gap-3 overflow-hidden px-3 py-2 hover:bg-gray-100">
<div class="flex items-start justify-between">
<div class="flex items-start gap-3 flex-1 w-full">
<GeneralUserIcon :email="audit.created_by_email" :name="audit.created_display_name" class="mt-0.5" size="medium" />
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]">
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]">
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden">
{{ createdByAudit(audit) }}
</div>
<template #overlay>
<div class="bg-white rounded-lg">
<div class="flex items-center gap-4 py-3 px-2">
<GeneralUserIcon
class="!w-8 !h-8 border-1 border-gray-200 rounded-full"
:name="audit.created_display_name"
:email="audit.created_by_email"
/>
<div class="flex flex-col">
<div class="font-semibold text-gray-800">
{{ createdByAudit(audit) }}
</div>
<div class="text-xs text-gray-600">
{{ audit.created_by_email }}
</div>
</div>
</div>
<div
v-if="isUIAllowed('dataEdit')"
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5"
>
Has <RolesBadge size="sm" :border="false" :role="getUserRole(audit.created_by_email!)" />
role in base
</div>
</div>
</template>
</NcDropdown>
<div class="text-xs text-gray-500">
{{ timeAgo(audit.created_at!) }}
</div>
</div>
</div>
</div>
<div v-dompurify-html="audit.details" class="!text-[13px] text-gray-500 !leading-5 !pl-9"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.red.lighten-4) {
@apply bg-red-100 rounded-md line-through;
}
.nc-audit-item {
@apply gap-3;
}
:deep(.green.lighten-4) {
@apply bg-green-100 rounded-md !mr-3;
}
</style>

488
packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Comments.vue

@ -0,0 +1,488 @@
<script setup lang="ts">
import { type CommentType, ProjectRoles } from 'nocodb-sdk'
const { user, appInfo } = useGlobal()
const { dashboardUrl } = useDashboard()
const { isUIAllowed } = useRoles()
const { copy } = useClipboard()
const route = useRoute()
const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const meta = inject(MetaInj, ref())
const {
deleteComment,
resolveComment,
isCommentsLoading,
comments,
loadComments,
updateComment,
saveComment: _saveComment,
primaryKey,
} = useRowCommentsOrThrow()
const editCommentValue = ref<CommentType>()
const commentsWrapperEl = ref<HTMLDivElement>()
const isEditing = ref<boolean>(false)
const isCommentMode = ref(false)
const hoveredCommentId = ref<null | string>(null)
const commentInputRef = ref<any>()
const comment = ref('')
const router = useRouter()
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : []))
function scrollComments() {
if (commentsWrapperEl.value) {
commentsWrapperEl.value.scrollTo({
top: commentsWrapperEl.value.scrollHeight,
behavior: 'smooth',
})
}
}
const saveComment = async () => {
if (!comment.value.trim()) return
while (comment.value.endsWith('<br />') || comment.value.endsWith('\n')) {
if (comment.value.endsWith('<br />')) {
comment.value = comment.value.slice(0, -6)
} else {
comment.value = comment.value.slice(0, -2)
}
}
isCommentMode.value = true
// Optimistic Insert
comments.value = [
...comments.value,
{
id: `temp-${new Date().getTime()}`,
comment: comment.value,
created_at: new Date().toISOString(),
created_by: user.value?.id,
created_by_email: user.value?.email,
created_display_name: user.value?.display_name ?? '',
},
]
const tempCom = comment.value
comment.value = ''
commentInputRef?.value?.setEditorContent('', true)
await nextTick(() => {
scrollComments()
})
try {
await _saveComment(tempCom)
await nextTick(() => {
isExpandedFormCommentMode.value = true
})
scrollComments()
} catch (e) {
console.error(e)
}
}
const copyComment = async (comment: CommentType) => {
await copy(
encodeURI(
`${dashboardUrl?.value}#/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}?rowId=${primaryKey.value}&commentId=${comment.id}`,
),
)
}
function scrollToComment(commentId: string) {
const commentEl = document.querySelector(`.${commentId}`)
if (commentEl) {
commentEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
function onCancel(e: KeyboardEvent) {
if (!isEditing.value) return
e.preventDefault()
e.stopPropagation()
editCommentValue.value = undefined
loadComments()
isEditing.value = false
editCommentValue.value = undefined
}
function editComment(comment: CommentType) {
editCommentValue.value = {
...comment,
}
isEditing.value = true
nextTick(() => {
scrollToComment(comment.id!)
})
}
const value = computed({
get() {
return editCommentValue.value?.comment || ''
},
set(val) {
if (!editCommentValue.value) return
editCommentValue.value.comment = val
},
})
async function onEditComment() {
if (!isEditing.value || !editCommentValue.value?.comment) return
while (editCommentValue.value.comment.endsWith('<br />') || editCommentValue.value.comment.endsWith('\n')) {
if (editCommentValue.value.comment.endsWith('<br />')) {
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -6)
} else {
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -2)
}
}
isCommentMode.value = true
const tempCom = {
...editCommentValue.value,
}
isEditing.value = false
editCommentValue.value = undefined
await updateComment(tempCom.id!, {
comment: tempCom.comment,
})
loadComments()
}
const createdBy = (
comment: CommentType & {
created_display_name?: string
},
) => {
if (comment.created_by === user.value?.id) {
return 'You'
} else if (comment.created_display_name?.trim()) {
return comment.created_display_name || 'Shared source'
} else if (comment.created_by_email) {
return comment.created_by_email
} else {
return 'Shared source'
}
}
const editedAt = (comment: CommentType) => {
if (comment.updated_at !== comment.created_at && comment.updated_at) {
const str = timeAgo(comment.updated_at).replace(' ', '_')
return `[(edited)](a~~~###~~~Edited_${str}) `
}
return ''
}
function handleResetHoverEffect() {
if (!hoveredCommentId.value) return
hoveredCommentId.value = null
}
watch(commentsWrapperEl, () => {
setTimeout(() => {
nextTick(() => {
const query = router.currentRoute.value.query
const commentId = query.commentId
if (commentId) {
router.push({
query: {
rowId: query.rowId,
},
})
scrollToComment(commentId as string)
hoveredCommentId.value = commentId as string
onClickOutside(document.querySelector(`.${hoveredCommentId.value}`)! as HTMLDivElement, handleResetHoverEffect)
} else {
scrollComments()
}
})
}, 100)
})
const getUserRole = (email: string) => {
const user = baseUsers.value.find((user) => user.email === email)
if (!user) return ProjectRoles.NO_ACCESS
return user.roles || ProjectRoles.NO_ACCESS
}
</script>
<template>
<div
class="h-full"
:class="{
'pb-1': !hasEditPermission,
}"
>
<div v-if="isCommentsLoading" class="flex flex-col items-center justify-center w-full h-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else class="flex flex-col h-full">
<div v-if="comments.length === 0" class="flex flex-col my-1 text-center justify-center h-full nc-scrollbar-thin">
<div class="text-center text-3xl text-gray-700">
<GeneralIcon icon="commentHere" />
</div>
<div class="font-medium text-center my-6 text-gray-500">
{{ hasEditPermission ? $t('activity.startCommenting') : $t('activity.noCommentsYet') }}
</div>
</div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin">
<div
v-for="commentItem of comments"
:key="commentItem.id"
:class="`${commentItem.id}`"
class="nc-comment-item"
@mouseover="handleResetHoverEffect"
>
<div
:class="{
'hover:bg-gray-100': editCommentValue?.id !== commentItem!.id,
'nc-hovered-comment bg-gray-100': hoveredCommentId === commentItem!.id
}"
class="group gap-3 overflow-hidden px-3 py-2 transition-colors"
>
<div class="flex items-start justify-between">
<div
class="flex items-start gap-3 flex-1"
:class="{
'w-[calc(100%)] group-hover:w-[calc(100%_-_50px)]': !appInfo.ee,
'w-[calc(100%_-_44px)] group-hover:w-[calc(100%_-_72px)]': appInfo.ee && commentItem.resolved_by,
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_72px)]':
appInfo.ee && !commentItem.resolved_by && hasEditPermission,
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_44px)]':
appInfo.ee && !commentItem.resolved_by && !hasEditPermission,
}"
>
<GeneralUserIcon
:email="commentItem.created_by_email"
:name="commentItem.created_display_name"
class="mt-0.5"
size="medium"
/>
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]">
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]">
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden">
{{ createdBy(commentItem) }}
</div>
<template #overlay>
<div class="bg-white rounded-lg">
<div class="flex items-center gap-4 py-3 px-2">
<GeneralUserIcon
class="!w-8 !h-8 border-1 border-gray-200 rounded-full"
:name="commentItem.created_display_name"
:email="commentItem.created_by_email"
/>
<div class="flex flex-col">
<div class="font-semibold text-gray-800">
{{ createdBy(commentItem) }}
</div>
<div class="text-xs text-gray-600">
{{ commentItem.created_by_email }}
</div>
</div>
</div>
<div
v-if="isUIAllowed('dataEdit')"
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5"
>
Has <RolesBadge size="sm" :border="false" :role="getUserRole(commentItem.created_by_email!)" />
role in base
</div>
</div>
</template>
</NcDropdown>
<div class="text-xs text-gray-500">
{{ timeAgo(commentItem.created_at!) }}
</div>
</div>
</div>
<div class="flex items-center">
<NcDropdown
v-if="!editCommentValue"
class="nc-comment-more-actions !hidden !group-hover:block"
overlay-class-name="!min-w-[160px]"
placement="bottomRight"
>
<NcButton
class="nc-expand-form-more-actions !hover:bg-gray-200 !w-7 !h-7 !bg-transparent"
size="xsmall"
type="text"
>
<GeneralIcon class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem
v-if="user && commentItem.created_by_email === user.email && hasEditPermission"
v-e="['c:comment-expand:comment:edit']"
class="text-gray-700"
@click="editComment(commentItem)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.rename" class="cursor-pointer" />
{{ $t('general.edit') }}
</div>
</NcMenuItem>
<NcMenuItem v-e="['c:comment-expand:comment:copy']" class="text-gray-700" @click="copyComment(commentItem)">
<div class="flex gap-2 items-center">
<component :is="iconMap.copy" class="cursor-pointer" />
{{ $t('general.copy') }} URL
</div>
</NcMenuItem>
<template v-if="user && commentItem.created_by_email === user.email && hasEditPermission">
<NcDivider />
<NcMenuItem
v-e="['c:row-expand:comment:delete']"
class="!text-red-500 !hover:bg-red-50"
@click="deleteComment(commentItem.id!)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.delete" class="cursor-pointer" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<div v-if="appInfo.ee">
<NcTooltip v-if="!commentItem.resolved_by && hasEditPermission">
<NcButton
class="nc-resolve-comment-btn !w-7 !h-7 !bg-transparent !hover:bg-gray-200 !hidden !group-hover:block"
size="xsmall"
type="text"
@click="resolveComment(commentItem.id!)"
>
<GeneralIcon class="text-md" icon="checkCircle" />
</NcButton>
<template #title>Click to resolve </template>
</NcTooltip>
<NcTooltip v-else-if="commentItem.resolved_by">
<template #title>{{ `Resolved by ${commentItem.resolved_display_name}` }}</template>
<NcButton
class="!h-7 !w-7 !bg-transparent !hover:bg-gray-200 text-semibold"
size="xsmall"
type="text"
@click="resolveComment(commentItem.id!)"
>
<GeneralIcon class="text-md rounded-full bg-[#17803D] text-white" icon="checkFill" />
</NcButton>
</NcTooltip>
</div>
</div>
</div>
<div
:class="{
'mt-3': commentItem.id === editCommentValue?.id,
}"
class="flex-1 flex flex-col gap-1 max-w-[calc(100%)]"
>
<SmartsheetExpandedFormRichComment
v-if="commentItem.id === editCommentValue?.id && hasEditPermission"
v-model:value="value"
autofocus
:hide-options="false"
class="expanded-form-comment-edit-input cursor-text expanded-form-comment-input !py-2 !px-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-white !text-gray-800 !text-small !leading-18px !max-h-[240px]"
data-testid="expanded-form-comment-input"
sync-value-change
@save="onEditComment"
@keydown.esc="onCancel"
@blur="
() => {
editCommentValue = undefined
isEditing = false
}
"
@keydown.enter.exact.prevent="onEditComment"
/>
<div v-else class="space-y-1 pl-9">
<SmartsheetExpandedFormRichComment
:value="`${commentItem.comment} ${editedAt(commentItem)}`"
class="!text-small !leading-18px !text-gray-800 -ml-1"
read-only
sync-value-change
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="hasEditPermission" class="px-3 pb-3 nc-comment-input !rounded-br-2xl gap-2 flex">
<SmartsheetExpandedFormRichComment
ref="commentInputRef"
v-model:value="comment"
:hide-options="false"
placeholder="Comment..."
class="expanded-form-comment-input !py-2 !px-2 cursor-text border-1 rounded-lg w-full bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[240px]"
:autofocus="isExpandedFormCommentMode"
data-testid="expanded-form-comment-input"
@focus="isExpandedFormCommentMode = false"
@keydown.stop
@save="saveComment"
@keydown.enter.exact.prevent="saveComment"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.expanded-form-comment-input) {
@apply transition-all duration-150 min-h-8;
box-shadow: none;
&:focus,
&:focus-within {
@apply min-h-16 !bg-white border-brand-500;
box-shadow: 0px 0px 0px 2px rgba(51, 102, 255, 0.24);
}
&::placeholder {
@apply !text-gray-400;
}
}
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) {
@apply bg-white;
}
.nc-hovered-comment {
.nc-expand-form-more-actions,
.nc-resolve-comment-btn {
@apply !block;
}
}
</style>

80
packages/nc-gui/components/smartsheet/expanded-form/Sidebar/index.vue

@ -0,0 +1,80 @@
<script setup lang="ts">
const { appInfo } = useGlobal()
const tab = ref<'comments' | 'audits'>('comments')
</script>
<template>
<div class="flex flex-col bg-white !h-full w-full rounded-br-2xl overflow-hidden">
<NcTabs v-model:activeKey="tab" class="h-full">
<a-tab-pane key="comments" class="w-full h-full">
<template #tab>
<div v-e="['c:row-expand:comment']" class="flex items-center gap-2">
<GeneralIcon icon="messageCircle" class="w-4 h-4" />
<span class="<lg:hidden"> Comments </span>
</div>
</template>
<SmartsheetExpandedFormSidebarComments />
</a-tab-pane>
<a-tab-pane key="audits" class="w-full" :disabled="appInfo.ee">
<template #tab>
<NcTooltip v-if="appInfo.ee" class="tab flex-1">
<template #title>{{ $t('title.comingSoon') }}</template>
<div v-e="['c:row-expand:audit']" class="flex items-center gap-2 text-gray-400">
<GeneralIcon icon="audit" class="w-4 h-4" />
<span class="<lg:hidden"> Audits </span>
</div>
</NcTooltip>
<div v-else v-e="['c:row-expand:audit']" class="flex items-center gap-2">
<GeneralIcon icon="audit" class="w-4 h-4" />
<span class="<lg:hidden"> Audits </span>
</div>
</template>
<SmartsheetExpandedFormSidebarAudits />
</a-tab-pane>
</NcTabs>
</div>
</template>
<style lang="scss" scoped>
.tab {
@apply max-w-1/2;
}
.tab .tab-title {
@apply min-w-0 flex justify-center gap-2 font-semibold items-center;
word-break: 'keep-all';
white-space: 'nowrap';
display: 'inline';
}
.text-decoration-line-through {
text-decoration: line-through;
}
:deep(.ant-tabs) {
@apply !overflow-visible;
.ant-tabs-nav {
@apply px-3 bg-white;
.ant-tabs-nav-list {
@apply w-[99%] mx-auto gap-6;
.ant-tabs-tab {
@apply flex-1 flex items-center justify-center pt-3 pb-2.5;
& + .ant-tabs-tab {
@apply !ml-0;
}
}
}
}
.ant-tabs-content-holder {
.ant-tabs-content {
@apply h-full;
}
}
}
</style>

8
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -342,7 +342,7 @@ if (isKanban.value) {
provide(IsExpandedFormOpenInj, isExpanded)
const triggerRowLoad = async (rowId?: string) => {
await Promise.allSettled([loadComments(rowId), loadAudits(rowId), _loadRow(rowId)])
await Promise.allSettled([loadComments(rowId, false), loadAudits(rowId), _loadRow(rowId)])
isLoading.value = false
}
@ -972,7 +972,7 @@ export default {
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
class="nc-comments-drawer border-l-1 relative border-gray-200 bg-gray-50 w-1/3 max-w-[340px] min-w-0 h-full xs:hidden rounded-br-2xl"
>
<SmartsheetExpandedFormComments :primary-key="primaryKey" :loading="isLoading" />
<SmartsheetExpandedFormSidebar />
</div>
</div>
</div>
@ -1055,13 +1055,15 @@ export default {
@apply !rounded-lg;
transition: all 0.3s;
&:not(.nc-readonly-div-data-cell):not(.nc-system-field) {
&:not(.nc-readonly-div-data-cell):not(.nc-system-field):not(.nc-attachment-cell) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&:not(:focus-within):hover:not(.nc-readonly-div-data-cell):not(.nc-system-field) {
@apply !border-1;
&:not(.nc-attachment-cell) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24);
}
}
&.nc-readonly-div-data-cell,
&.nc-system-field {

22
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -128,6 +128,7 @@ const {
isViewColumnsLoading: _isViewColumnsLoading,
updateGridViewColumn,
gridViewCols,
metaColumnById,
resizingColOldWith,
} = useViewColumnsOrThrow()
@ -1102,13 +1103,34 @@ const saveOrUpdateRecords = async (
// #Grid Resize
const onresize = (colID: string | undefined, event: any) => {
if (!colID) return
// Set 80px minimum width for attachment cells
if (metaColumnById.value[colID].uidt === UITypes.Attachment) {
const size = event.detail.split('px')[0]
if (+size < 80) {
updateGridViewColumn(colID, { width: '80px' })
} else {
updateGridViewColumn(colID, { width: event.detail })
}
} else {
updateGridViewColumn(colID, { width: event.detail })
}
}
const onXcResizing = (cn: string | undefined, event: any) => {
if (!cn) return
// Set 80px minimum width for attachment cells
if (metaColumnById.value[cn].uidt === UITypes.Attachment) {
const size = event.detail.split('px')[0]
if (+size < 80) {
gridViewCols.value[cn].width = '80px'
} else {
gridViewCols.value[cn].width = `${event.detail}`
}
} else {
gridViewCols.value[cn].width = `${event.detail}`
}
}
const onXcStartResizing = (cn: string | undefined, event: any) => {
if (!cn) return

2
packages/nc-gui/components/virtual-cell/components/ListItem.vue

@ -88,7 +88,7 @@ const displayValue = computed(() => {
<a-carousel autoplay class="!w-11 !h-11 !max-h-11 !max-w-11">
<template #customPaging> </template>
<template v-for="(attachmentObj, index) in attachments">
<LazyCellAttachmentImage
<LazyCellAttachmentPreviewImage
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`"
class="!w-11 !h-11 !max-h-11 !max-w-11object-cover !rounded-l-xl"

197
packages/nc-gui/composables/useExpandedFormStore.ts

@ -1,4 +1,4 @@
import type { AuditType, ColumnType, CommentType, TableType } from 'nocodb-sdk'
import type { AuditType, ColumnType, TableType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import dayjs from 'dayjs'
@ -10,19 +10,8 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const isPublic = inject(IsPublicInj, ref(false))
const comments = ref<
Array<
CommentType & {
created_display_name: string
resolved_display_name?: string
}
>
>([])
const audits = ref<Array<AuditType>>([])
const isCommentsLoading = ref(false)
const isAuditLoading = ref(false)
const commentsDrawer = ref(true)
@ -61,6 +50,9 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { comments, resolveComment, loadComments, updateComment, deleteComment, saveComment, isCommentsLoading } =
useProvideRowComments(meta, row)
const { isUIAllowed } = useRoles()
// getters
@ -101,78 +93,6 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
return extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
})
const loadComments = async (_rowId?: string) => {
if (!isUIAllowed('commentList') || (!row.value && !_rowId)) return
const rowId = _rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
try {
isCommentsLoading.value = true
const res = ((
await $api.utils.commentList({
row_id: rowId,
fk_model_id: meta.value.id as string,
})
).list || []) as Array<
CommentType & {
created_display_name: string
}
>
comments.value = res.map((comment) => {
const user = baseUsers.value.find((u) => u.id === comment.created_by)
const resolvedUser = comment.resolved_by ? baseUsers.value.find((u) => u.id === comment.resolved_by) : null
return {
...comment,
created_display_name: user?.display_name ?? (user?.email ?? '').split('@')[0],
resolved_display_name: resolvedUser ? resolvedUser.display_name ?? resolvedUser.email.split('@')[0] : null,
}
})
} catch (e: unknown) {
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} finally {
isCommentsLoading.value = false
}
}
const deleteComment = async (commentId: string) => {
if (!isUIAllowed('commentDelete')) return
const tempC = comments.value.find((c) => c.id === commentId)
try {
comments.value = comments.value.filter((c) => c.id !== commentId)
await $api.utils.commentDelete(commentId)
// update comment count in rowMeta
Object.assign(row.value, {
...row.value,
rowMeta: {
...row.value.rowMeta,
commentCount: (row.value.rowMeta.commentCount ?? 1) - 1,
},
})
} catch (e: unknown) {
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
comments.value = [...comments.value, tempC]
}
}
const loadAudits = async (_rowId?: string, showLoading: boolean = true) => {
if (!isUIAllowed('auditListRow') || isEeUI || (!row.value && !_rowId)) return
@ -224,84 +144,6 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
}
const resolveComment = async (commentId: string) => {
if (!isUIAllowed('commentResolve')) return
const tempC = comments.value.find((c) => c.id === commentId)
try {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return {
...c,
resolved_by: tempC.resolved_by ? null : $state.user?.value?.id,
resolved_by_email: tempC.resolved_by ? null : $state.user?.value?.email,
resolved_display_name: tempC.resolved_by
? null
: $state.user?.value?.display_name ?? $state.user?.value?.email.split('@')[0],
}
}
return c
})
await $api.utils.commentResolve(commentId)
} catch (e: unknown) {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return tempC
}
return c
})
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
}
const saveComment = async (comment: string) => {
try {
if (!row.value || !comment) {
comments.value = comments.value.filter((c) => !c.id?.startsWith('temp-'))
return
}
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
await $api.utils.commentRow({
fk_model_id: meta.value?.id as string,
row_id: rowId,
comment: `${comment}`.replace(/(<br \/>)+$/g, ''),
})
// Increase Comment Count in rowMeta
Object.assign(row.value, {
rowMeta: {
...row.value.rowMeta,
commentCount: (row.value.rowMeta.commentCount ?? 0) + 1,
},
})
// reloadTrigger?.trigger()
await loadComments()
} catch (e: any) {
comments.value = comments.value.filter((c) => !(c.id ?? '').startsWith('temp-'))
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
$e('a:row-expand:comment')
}
const save = async (
ltarState: Record<string, any> = {},
undo = false,
@ -536,37 +378,6 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
}
const updateComment = async (commentId: string, comment: Partial<CommentType>) => {
const tempEdit = comments.value.find((c) => c.id === commentId)
try {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return {
...c,
...comment,
updated_at: new Date().toISOString(),
}
}
return c
})
await $api.utils.commentUpdate(commentId, comment)
} catch (e: any) {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return tempEdit
}
return c
})
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
}
return {
...rowStore,
loadComments,

235
packages/nc-gui/composables/useRowComments.ts

@ -0,0 +1,235 @@
import type { ColumnType, CommentType, TableType } from 'nocodb-sdk'
const [useProvideRowComments, useRowComments] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => {
const isCommentsLoading = ref(false)
const { isUIAllowed } = useRoles()
const { $e, $state, $api } = useNuxtApp()
const comments = ref<
Array<
CommentType & {
created_display_name: string
resolved_display_name?: string
}
>
>([])
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : []))
const loadComments = async (_rowId?: string, ignoreLoadingIndicator = true) => {
if (!isUIAllowed('commentList') || (!row.value && !_rowId)) return
const rowId = _rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
try {
if (!ignoreLoadingIndicator) isCommentsLoading.value = true
const res = ((
await $api.utils.commentList({
row_id: rowId,
fk_model_id: meta.value.id as string,
})
).list || []) as Array<
CommentType & {
created_display_name: string
}
>
comments.value = res.map((comment) => {
const user = baseUsers.value.find((u) => u.id === comment.created_by)
const resolvedUser = comment.resolved_by ? baseUsers.value.find((u) => u.id === comment.resolved_by) : null
return {
...comment,
created_display_name: user?.display_name ?? (user?.email ?? '').split('@')[0],
resolved_display_name: resolvedUser ? resolvedUser.display_name ?? resolvedUser.email.split('@')[0] : undefined,
}
})
} catch (e: unknown) {
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} finally {
if (!ignoreLoadingIndicator) isCommentsLoading.value = false
}
}
const deleteComment = async (commentId: string) => {
if (!isUIAllowed('commentDelete')) return
const tempC = comments.value.find((c) => c.id === commentId)
if (!tempC) return
try {
comments.value = comments.value.filter((c) => c.id !== commentId)
await $api.utils.commentDelete(commentId)
// update comment count in rowMeta
Object.assign(row.value, {
...row.value,
rowMeta: {
...row.value.rowMeta,
commentCount: (row.value.rowMeta.commentCount ?? 1) - 1,
},
})
} catch (e: unknown) {
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
comments.value = [...comments.value, tempC]
}
}
const resolveComment = async (commentId: string) => {
if (!isUIAllowed('commentResolve')) return
const tempC = comments.value.find((c) => c.id === commentId)
if (!tempC) return
try {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return {
...c,
resolved_by: tempC.resolved_by ? undefined : $state.user?.value?.id,
resolved_by_email: tempC.resolved_by ? undefined : $state.user?.value?.email,
resolved_display_name: tempC.resolved_by
? undefined
: $state.user?.value?.display_name ?? $state.user?.value?.email.split('@')[0],
}
}
return c
})
await $api.utils.commentResolve(commentId, {})
} catch (e: unknown) {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return tempC
}
return c
})
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
}
const saveComment = async (comment: string) => {
try {
if (!row.value || !comment) {
comments.value = comments.value.filter((c) => !c.id?.startsWith('temp-'))
return
}
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
await $api.utils.commentRow({
fk_model_id: meta.value?.id as string,
row_id: rowId,
comment: `${comment}`.replace(/(<br \/>)+$/g, ''),
})
// Increase Comment Count in rowMeta
Object.assign(row.value, {
rowMeta: {
...row.value.rowMeta,
commentCount: (row.value.rowMeta.commentCount ?? 0) + 1,
},
})
// reloadTrigger?.trigger()
await loadComments()
} catch (e: any) {
comments.value = comments.value.filter((c) => !(c.id ?? '').startsWith('temp-'))
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
$e('a:row-expand:comment')
}
const updateComment = async (commentId: string, comment: Partial<CommentType>) => {
const tempEdit = comments.value.find((c) => c.id === commentId)
if (!tempEdit) return
try {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return {
...c,
...comment,
updated_at: new Date().toISOString(),
}
}
return c
})
await $api.utils.commentUpdate(commentId, comment)
} catch (e: any) {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return tempEdit
}
return c
})
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
}
const primaryKey = computed(() => {
return extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
})
return {
comments,
loadComments,
saveComment,
updateComment,
resolveComment,
deleteComment,
isCommentsLoading,
primaryKey,
}
})
export { useProvideRowComments }
export function useRowCommentsOrThrow() {
const rowComments = useRowComments()
if (!rowComments) {
throw new Error('useRowComments is not provided')
}
return rowComments
}

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

@ -391,7 +391,7 @@
"title": {
"webcam": "Webcam",
"uploadViaUrl": "Upload via URL",
"localFiles": "Local Files",
"localFiles": "Local files",
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",

4
packages/nc-gui/package.json

@ -62,6 +62,7 @@
"dagre": "^0.8.5",
"dayjs": "^1.11.11",
"deep-object-diff": "^1.1.9",
"embla-carousel-vue": "^8.1.7",
"emoji-mart-vue-fast": "^15.0.2",
"file-saver": "^2.0.5",
"fuse.js": "^6.6.2",
@ -79,7 +80,10 @@
"nocodb-sdk": "workspace:^",
"papaparse": "^5.4.1",
"parse-github-url": "^1.0.2",
"pdfobject": "^2.3.0",
"pdfobject-vue": "^0.0.4",
"pinia": "^2.1.7",
"plyr": "^3.7.8",
"qrcode": "^1.5.3",
"rfdc": "^1.3.1",
"showdown": "^2.1.0",

5
packages/nc-gui/plugins/pdf-object.ts

@ -0,0 +1,5 @@
import PDFObjectPlugin from 'pdfobject-vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(PDFObjectPlugin)
})

71
packages/nc-gui/utils/fileUtils.ts

@ -15,11 +15,80 @@ const imageExt = [
'heic-sequence',
]
const videoExt = [
'webm',
'mpg',
'mp2',
'mp3',
'mpeg',
'ogg',
'mp4',
'm4v',
'avi',
'wmv',
'mov',
'qt',
'flv',
'mkv',
'3gp',
'3g2',
'vob',
'ts',
]
const officeExt = [
'txt',
'css',
'html',
'php',
'c',
'cpp',
'h',
'hpp',
'js',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'pdf',
'pages',
'ai',
'psd',
'tiff',
'dxf',
'svg',
'eps',
'ps',
'ttf',
'xps',
'zip',
'rar',
'csv',
]
const isAudio = (name: string, mimetype?: string) => {
return name?.toLowerCase().endsWith('.mp3') || mimetype?.startsWith('audio/')
}
const isVideo = (name: string, mimetype?: string) => {
return videoExt.some((e) => name?.toLowerCase().endsWith(`.${e}`)) || mimetype?.startsWith('video/')
}
const isImage = (name: string, mimetype?: string) => {
return imageExt.some((e) => name?.toLowerCase().endsWith(`.${e}`)) || mimetype?.startsWith('image/')
}
export { isImage, imageExt }
const isPdf = (name: string, mimetype?: string) => {
return name?.toLowerCase().endsWith('.pdf') || mimetype?.startsWith('application/pdf')
}
const isOffice = (name: string, _mimetype?: string) => {
return officeExt.some((e) => name?.toLowerCase().endsWith(`.${e}`))
}
export { isImage, imageExt, isVideo, isPdf, isOffice, isAudio }
// Ref : https://stackoverflow.com/a/12002275
// Tested in Mozilla Firefox browser, Chrome

8
packages/nc-gui/utils/iconUtils.ts

@ -40,6 +40,7 @@ import Phishing from '~icons/material-symbols/phishing-outline-rounded'
import MdiAccountGroup from '~icons/mdi/account-group'
import MdiDotsVertical from '~icons/mdi/dots-vertical'
import MdiDotsHorizontal from '~icons/mdi/dots-horizontal'
import MdiPdf from '~icons/mdi/file-pdf'
import PhExcelThin from '~icons/ph/microsoft-excel-logo-light'
import VscodeIconsExcelColored from '~icons/vscode-icons/file-type-excel'
import PhCsvThin from '~icons/ph/file-csv'
@ -204,6 +205,9 @@ import NcMaximize from '~icons/nc-icons/maximize'
import NcMaximizeAll from '~icons/nc-icons/maximize-all'
import NcDrag from '~icons/nc-icons/drag'
import NcRefresh from '~icons/nc-icons/refresh'
import NcPlay from '~icons/nc-icons/play'
import GoogleDocs from '~icons/nc-icons/google-docs'
import NcGlobe from '~icons/nc-icons/globe'
// keep it for reference
// todo: remove it after all icons are migrated
@ -638,6 +642,10 @@ export const iconMap = {
ncDrag: NcDrag,
refresh: NcRefresh,
chevronUpDown: NcChevronUpDown,
play: NcPlay,
googleDocs: GoogleDocs,
pdfFile: MdiPdf,
globe: NcGlobe,
}
export const getMdiIcon = (type: string): any => {

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

@ -9332,6 +9332,55 @@ export class Api<
...params,
}),
/**
* @description Download attachment from a shared view
*
* @tags Public
* @name DataAttachmentDownload
* @summary Get Shared View Attachment
* @request GET:/api/v2/public/shared-view/{sharedViewUuid}/downloadAttachment/{columnId}/{rowId}
* @response `200` `{
\** URL to download the attachment *\
url?: string,
\** Path to download the attachment *\
path?: string,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
dataAttachmentDownload: (
sharedViewUuid: string,
columnId: IdType,
rowId: any,
query?: {
/** URL or Path of the attachment */
urlOrPath?: string;
},
params: RequestParams = {}
) =>
this.request<
{
/** URL to download the attachment */
url?: string;
/** Path to download the attachment */
path?: string;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v2/public/shared-view/${sharedViewUuid}/downloadAttachment/${columnId}/${rowId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description List Shared View Grouped Data
*
@ -11826,6 +11875,47 @@ export class Api<
...params,
}),
/**
* @description Download attachment from a given row
*
* @tags DB Data Table Row
* @name AttachmentDownload
* @summary Download Attachment
* @request GET:/api/v2/downloadAttachment/{modelId}/{columnId}/{rowId}
* @response `200` `{
\** URL to download attachment *\
url?: string,
\** Path to download attachment *\
path?: string,
}` OK
*/
attachmentDownload: (
modelId: string,
columnId: string,
rowId: string,
query?: {
/** URL or Path of the attachment */
urlOrPath?: string;
},
params: RequestParams = {}
) =>
this.request<
{
/** URL to download attachment */
url?: string;
/** Path to download attachment */
path?: string;
},
any
>({
path: `/api/v2/downloadAttachment/${modelId}/${columnId}/${rowId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description Copy links from the one cell and paste them into another cell or delete all records from cell
*

541
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

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

@ -21,9 +21,22 @@ export class AttachmentCellPageObject extends BasePage {
// 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[] }) {
async addFile({
index,
columnHeader,
filePath,
skipElemClick,
}: {
index?: number;
columnHeader: string;
filePath: string[];
skipElemClick?: boolean;
}) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
if (!skipElemClick) {
await this.get({ index, columnHeader }).click({ position: { x: 1, y: 1 } });
}
await this.get({ index, columnHeader }).locator('[data-testid="attachment-cell-file-picker-button"]').click();
@ -39,9 +52,22 @@ export class AttachmentCellPageObject extends BasePage {
await this.rootPage.waitForTimeout(750);
}
async removeFile({ attIndex, index, columnHeader }: { attIndex: number; index?: number; columnHeader: string }) {
async removeFile({
attIndex,
index,
columnHeader,
skipElemClick,
}: {
attIndex: number;
index?: number;
columnHeader: string;
skipElemClick?: boolean;
}) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
if (!skipElemClick) {
await this.get({ index, columnHeader }).click({ position: { x: 1, y: 1 } });
}
await this.get({ index, columnHeader }).locator('.nc-attachment-item').nth(attIndex).hover();
await this.get({ index, columnHeader })

1
tests/playwright/tests/db/columns/columnAttachments.spec.ts

@ -78,6 +78,7 @@ test.describe('Attachment column', () => {
await sharedForm.cell.attachment.addFile({
columnHeader: 'testAttach',
filePath: [`${process.cwd()}/fixtures/sampleFiles/1.json`],
skipElemClick: true,
});
await sharedForm.rootPage.waitForTimeout(1000);

7
tests/playwright/tests/db/views/viewForm.spec.ts

@ -226,6 +226,7 @@ test.describe('Form view', () => {
await sharedForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
skipElemClick: true,
});
await sharedForm.cell.fillText({
columnHeader: 'Title',
@ -1355,6 +1356,7 @@ test.describe('Form view: field validation', () => {
await sharedForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
skipElemClick: true,
});
const attError = await sharedForm.getFormFieldErrors({ title: 'Attachment' });
@ -1364,11 +1366,13 @@ test.describe('Form view: field validation', () => {
await sharedForm.cell.attachment.removeFile({
columnHeader: 'Attachment',
attIndex: 0,
skipElemClick: true,
});
await sharedForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/Image/2.png`],
skipElemClick: true,
});
await attError.verify({ hasError: false });
@ -1401,6 +1405,7 @@ test.describe('Form view: field validation', () => {
await surveyForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
skipElemClick: true,
});
const surveryAttError = await surveyForm.getFormFieldErrors();
@ -1410,11 +1415,13 @@ test.describe('Form view: field validation', () => {
await surveyForm.cell.attachment.removeFile({
columnHeader: 'Attachment',
attIndex: 0,
skipElemClick: true,
});
await surveyForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/Image/2.png`],
skipElemClick: true,
});
await surveryAttError.verify({ hasError: false });

Loading…
Cancel
Save