Browse Source

Merge pull request #6360 from nocodb/feat/link-revamp

feat: Links Modal Revamp
pull/6415/head
Raju Udava 1 year ago committed by GitHub
parent
commit
7dd3f3d762
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. BIN
      packages/nc-gui/assets/icons/FileIconImageBox.png
  2. 16
      packages/nc-gui/assets/nc-icons/belongsto.svg
  3. 7
      packages/nc-gui/assets/nc-icons/file.svg
  4. 16
      packages/nc-gui/assets/nc-icons/hasmany.svg
  5. 12
      packages/nc-gui/assets/nc-icons/info.svg
  6. 10
      packages/nc-gui/assets/nc-icons/manytomany.svg
  7. 6
      packages/nc-gui/assets/nc-icons/maximize.svg
  8. 8
      packages/nc-gui/assets/nc-icons/multi-file.svg
  9. 5
      packages/nc-gui/assets/nc-icons/onetoone.svg
  10. 45
      packages/nc-gui/assets/style.scss
  11. 3
      packages/nc-gui/components.d.ts
  12. 4
      packages/nc-gui/components/cell/Checkbox.vue
  13. 4
      packages/nc-gui/components/cell/TextArea.vue
  14. 27
      packages/nc-gui/components/smartsheet/Cell.vue
  15. 5
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  16. 6
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  17. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  18. 20
      packages/nc-gui/components/smartsheet/header/Cell.vue
  19. 14
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  20. 4
      packages/nc-gui/components/virtual-cell/HasMany.vue
  21. 25
      packages/nc-gui/components/virtual-cell/Links.vue
  22. 4
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  23. 114
      packages/nc-gui/components/virtual-cell/components/Header.vue
  24. 230
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  25. 130
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  26. 274
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  27. 25
      packages/nc-gui/composables/useExpandedFormStore.ts
  28. 118
      packages/nc-gui/composables/useLTARStore.ts
  29. 48
      packages/nc-gui/windi.config.ts
  30. 28
      packages/nocodb/src/db/BaseModelSqlv2.ts
  31. 9
      packages/nocodb/src/services/data-alias-nested.service.ts
  32. 9
      packages/nocodb/src/services/data-table.service.ts
  33. 9
      packages/nocodb/src/services/datas.service.ts
  34. 14
      packages/nocodb/src/services/public-datas.service.ts
  35. 13
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  36. 38
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts
  37. 21
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  38. 9
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  39. 5
      tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts
  40. 15
      tests/playwright/pages/SharedForm/index.ts
  41. 1
      tests/playwright/tests/db/features/expandedFormUrl.spec.ts
  42. 5
      tests/playwright/tests/db/features/undo-redo.spec.ts
  43. 1
      tests/playwright/tests/db/views/viewForm.spec.ts

BIN
packages/nc-gui/assets/icons/FileIconImageBox.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 6.2 KiB

16
packages/nc-gui/assets/nc-icons/belongsto.svg

@ -0,0 +1,16 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_222_2345)">
<path d="M13 10C11.8954 10 11 9.10457 11 8C11 6.89543 11.8954 6 13 6C14.1046 6 15 6.89543 15 8C15 9.10457 14.1046 10 13 10Z" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10C1.89543 10 1 9.10457 1 8C1 6.89543 1.89543 6 3 6C4.10457 6 5 6.89543 5 8C5 9.10457 4.10457 10 3 10Z" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 15C5.89543 15 5 14.1046 5 13C5 11.8954 5.89543 11 7 11C8.10457 11 9 11.8954 9 13C9 14.1046 8.10457 15 7 15Z" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 5C5.89543 5 5 4.10457 5 3C5 1.89543 5.89543 1 7 1C8.10457 1 9 1.89543 9 3C9 4.10457 8.10457 5 7 5Z" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4L11 6" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M9 12L11 10" stroke="#EDF9FF" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_222_2345">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

7
packages/nc-gui/assets/nc-icons/file.svg

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33341 1.33333H4.00008C3.64646 1.33333 3.30732 1.4738 3.05727 1.72385C2.80722 1.9739 2.66675 2.31304 2.66675 2.66666V13.3333C2.66675 13.6869 2.80722 14.0261 3.05727 14.2761C3.30732 14.5262 3.64646 14.6667 4.00008 14.6667H12.0001C12.3537 14.6667 12.6928 14.5262 12.9429 14.2761C13.1929 14.0261 13.3334 13.6869 13.3334 13.3333V5.33333L9.33341 1.33333Z" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6666 11.3333H5.33325" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6666 8.66667H5.33325" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66659 6H5.99992H5.33325" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33325 1.33333V5.33333H13.3333" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

16
packages/nc-gui/assets/nc-icons/hasmany.svg

@ -0,0 +1,16 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_102_2604)">
<path d="M3 10C4.10457 10 5 9.10457 5 8C5 6.89543 4.10457 6 3 6C1.89543 6 1 6.89543 1 8C1 9.10457 1.89543 10 3 10Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 10C14.1046 10 15 9.10457 15 8C15 6.89543 14.1046 6 13 6C11.8954 6 11 6.89543 11 8C11 9.10457 11.8954 10 13 10Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 15C10.1046 15 11 14.1046 11 13C11 11.8954 10.1046 11 9 11C7.89543 11 7 11.8954 7 13C7 14.1046 7.89543 15 9 15Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 5C10.1046 5 11 4.10457 11 3C11 1.89543 10.1046 1 9 1C7.89543 1 7 1.89543 7 3C7 4.10457 7.89543 5 9 5Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 8L5 8" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 4L5 6" stroke="white" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M7 12L5 10" stroke="white" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_102_2604">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

12
packages/nc-gui/assets/nc-icons/info.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 clip-path="url(#clip0_63_136093)">
<path d="M8.00004 14.6667C11.6819 14.6667 14.6667 11.6819 14.6667 7.99999C14.6667 4.3181 11.6819 1.33333 8.00004 1.33333C4.31814 1.33333 1.33337 4.3181 1.33337 7.99999C1.33337 11.6819 4.31814 14.6667 8.00004 14.6667Z" stroke="#6A7184" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10.6667V8" stroke="#6A7184" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5.33333H8.00667" stroke="#6A7184" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_63_136093">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 786 B

10
packages/nc-gui/assets/nc-icons/manytomany.svg

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6C10.8954 6 10 5.10457 10 4C10 2.89543 10.8954 2 12 2C13.1046 2 14 2.89543 14 4C14 5.10457 13.1046 6 12 6Z" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 14C10.8954 14 10 13.1046 10 12C10 10.8954 10.8954 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14Z" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 14C2.89543 14 2 13.1046 2 12C2 10.8954 2.89543 10 4 10C5.10457 10 6 10.8954 6 12C6 13.1046 5.10457 14 4 14Z" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 6C2.89543 6 2 5.10457 2 4C2 2.89543 2.89543 2 4 2C5.10457 2 6 2.89543 6 4C6 5.10457 5.10457 6 4 6Z" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 10.5L10.5 5.5" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M5.5 5.5L10.5 10.5" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6 4L10 4" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6 12L10 12" stroke="#FFEEFB" stroke-width="1.33333" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

6
packages/nc-gui/assets/nc-icons/maximize.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14H2V10" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 14L6.66667 9.33337" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 2H14V6" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.9999 2L9.33325 6.66667" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 570 B

8
packages/nc-gui/assets/nc-icons/multi-file.svg

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.25 3H5.25C4.91848 3 4.60054 3.12643 4.36612 3.35147C4.1317 3.57652 4 3.88174 4 4.2V13.8C4 14.1183 4.1317 14.4235 4.36612 14.6485C4.60054 14.8736 4.91848 15 5.25 15H12.75C13.0815 15 13.3995 14.8736 13.6339 14.6485C13.8683 14.4235 14 14.1183 14 13.8V6.6L10.25 3Z" stroke="#FC3AC6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 13H3.25C2.91848 13 2.60054 12.8736 2.36612 12.6485C2.1317 12.4235 2 12.1183 2 11.8V2.2C2 1.88174 2.1317 1.57652 2.36612 1.35147C2.60054 1.12643 2.91848 1 3.25 1H8.25L10.5 3" stroke="#FC3AC6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6666 11.3333H6.33325" stroke="#FC3AC6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6666 8.66669H6.33325" stroke="#FC3AC6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.66659 6H6.99992H6.33325" stroke="#FC3AC6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.3333 3.33331V7H13.4999" stroke="#FC3AC6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 10C11.8954 10 11 9.10457 11 8C11 6.89543 11.8954 6 13 6C14.1046 6 15 6.89543 15 8C15 9.10457 14.1046 10 13 10Z" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10C1.89543 10 1 9.10457 1 8C1 6.89543 1.89543 6 3 6C4.10457 6 5 6.89543 5 8C5 9.10457 4.10457 10 3 10Z" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

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

@ -542,3 +542,48 @@ a {
input[type='number'] {
@apply !outline-none !ring-0 !border-0;
}
.ant-pagination {
@apply !flex !flex-row !gap-1;
}
.ant-pagination .ant-pagination-prev {
@apply !border-1 !rounded-md !border-gray-200 transform scale-95;
}
.ant-pagination .ant-pagination-item {
@apply !bg-white !rounded-md !border-1 !scale-110 !border-gray-200 !flex !items-center !justify-center;
}
.ant-pagination .ant-pagination-item a {
@apply !no-underline !text-gray-700;
}
.ant-pagination .ant-pagination-item-active a {
@apply !text-brand-500;
}
.ant-pagination .ant-pagination-next {
@apply !border-1 !rounded-md !border-gray-200 scale-95;
}
.ant-pagination .ant-pagination-item-active {
@apply !bg-brand-50 !text-brand-500 !border-0 !scale-110;
}
.ant-pagination .ant-pagination-item-link {
@apply !flex !items-center !justify-center;
}
.ant-pagination .ant-pagination-jump-next-custom-icon .ant-pagination-item-link {
@apply !block;
}
.ant-pagination .ant-pagination-jump-prev-custom-icon .ant-pagination-item-link {
@apply !block;
}
.ant-card-body {
@apply !p-2;
}
.ant-pagination .ant-pagination-item-link-icon {
@apply !block !py-1.5;
}

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

@ -129,12 +129,15 @@ declare module '@vue/runtime-core' {
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiEye: typeof import('~icons/mdi/eye')['default']
MdiFileDocumentMultipleOutline: typeof import('~icons/mdi/file-document-multiple-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
MdiLoading: typeof import('~icons/mdi/loading')['default']
MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMapMarkerOutline: typeof import('~icons/mdi/map-marker-outline')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']

4
packages/nc-gui/components/cell/Checkbox.vue

@ -78,7 +78,7 @@ useSelectedCellKeyupListener(active, (e) => {
<template>
<div
class="flex cursor-pointer w-full h-full py-1"
class="flex cursor-pointer w-full h-full"
:class="{
'justify-center': !isForm,
'w-full': isForm,
@ -92,7 +92,7 @@ useSelectedCellKeyupListener(active, (e) => {
:class="{ '!ml-[-8px]': readOnly, 'w-full justify-start': isEditColumnMenu }"
@click="onClick(true)"
>
<div class="p-1" :class="{ 'bg-gray-100 rounded-full ': !vModel }">
<div :class="{ 'bg-gray-100 rounded-full ': !vModel }">
<Transition name="layout" mode="out-in" :duration="100">
<component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"

4
packages/nc-gui/components/cell/TextArea.vue

@ -10,7 +10,7 @@ import {
iconMap,
inject,
useVModel,
ReadonlyInj
ReadonlyInj,
} from '#imports'
const props = defineProps<{
@ -26,7 +26,7 @@ const editEnabled = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const rowHeight = inject(RowHeightInj, ref(1 as const))
const { showNull } = useGlobal()

27
packages/nc-gui/components/smartsheet/Cell.vue

@ -6,6 +6,7 @@ import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsLockedInj,
IsPublicInj,
@ -90,6 +91,8 @@ const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isEditColumnMenu = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUis } = storeToRefs(useProject())
@ -179,9 +182,6 @@ function initIntersectionObserver() {
})
}
const numberInputAlignment = computed(() => {
return isEditColumnMenu.value ? 'left' : 'right'
})
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
@ -200,9 +200,12 @@ onUnmounted(() => {
class="nc-cell w-full h-full relative"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !props.virtual && !isForm },
{ 'nc-grid-numeric-cell': isGrid && !isForm && isNumericField },
{ 'h-[40px]': !props.editEnabled && isForm && !isSurveyForm && !isAttachment(column) && !props.virtual },
{
'text-brand-500': isPrimary(column) && !props.virtual && !isForm,
'nc-grid-numeric-cell-right': isGrid && isNumericField && !isEditColumnMenu && !isForm && !isExpandedFormOpen,
'h-[40px]': !props.editEnabled && isForm && !isSurveyForm && !isAttachment(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
},
]"
@keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
@ -258,10 +261,16 @@ onUnmounted(() => {
</template>
<style scoped lang="scss">
.nc-grid-numeric-cell {
text-align: v-bind(numberInputAlignment);
.nc-grid-numeric-cell-left {
text-align: left;
:deep(input) {
text-align: left;
}
}
.nc-grid-numeric-cell-right {
text-align: right;
:deep(input) {
text-align: v-bind(numberInputAlignment);
text-align: right;
}
}
</style>

5
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -4,6 +4,7 @@ import {
ActiveCellInj,
CellValueInj,
ColumnInj,
IsExpandedFormOpenInj,
IsFormInj,
IsGridInj,
NavigateDir,
@ -48,6 +49,8 @@ const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir)
@ -91,7 +94,7 @@ onUnmounted(() => {
<div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center"
:class="{ 'text-right justify-end': isGrid && !isForm && isRollup(column) }"
:class="{ 'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm }"
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
>

6
packages/nc-gui/components/smartsheet/expanded-form/Header.vue

@ -20,7 +20,7 @@ const route = useRoute()
const { meta, isSqlView } = useSmartsheetStoreOrThrow()
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow } = useExpandedFormStoreOrThrow()
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow, deleteRowById } = useExpandedFormStoreOrThrow()
const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
@ -72,8 +72,6 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const showDeleteRowModal = ref(false)
const { deleteRowById } = useViewData(meta, ref(props.view))
const onDeleteRowClick = () => {
showDeleteRowModal.value = true
}
@ -81,7 +79,7 @@ const onDeleteRowClick = () => {
const onConfirmDeleteRowClick = async () => {
showDeleteRowModal.value = false
await deleteRowById(primaryKey.value)
reloadTrigger.trigger()
await reloadTrigger.trigger()
emit('cancel')
message.success('Row deleted')
}

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

@ -114,8 +114,6 @@ if (props.rowId) {
useProvideSmartsheetStore(ref({}) as Ref<ViewType>, meta)
provide(IsFormInj, ref(true))
watch(
state,
() => {

20
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -1,11 +1,12 @@
<script setup lang="ts">
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { ColumnInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
import { ColumnInj, IsExpandedFormOpenInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
interface Props {
column: ColumnType
required?: boolean | number
hideMenu?: boolean
hideIcon?: boolean
}
const props = defineProps<Props>()
@ -14,6 +15,8 @@ const hideMenu = toRef(props, 'hideMenu')
const isForm = inject(IsFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isDropDownOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
@ -39,10 +42,15 @@ const closeAddColumnDropdown = () => {
}
const openHeaderMenu = () => {
if (!isForm.value && isUIAllowed('edit-column')) {
if (!isForm.value && !isExpandedForm.value && isUIAllowed('edit-column')) {
editColumnDropdown.value = true
}
}
const openDropDown = () => {
if (isForm.value || isExpandedForm.value || !isUIAllowed('edit-column')) return
isDropDownOpen.value = !isDropDownOpen.value
}
</script>
<template>
@ -50,10 +58,10 @@ const openHeaderMenu = () => {
class="flex items-center w-full text-xs text-gray-500 font-weight-medium"
:class="{ 'h-full': column, '!text-gray-400': isKanban }"
@dblclick="openHeaderMenu"
@click.right="isDropDownOpen = !isDropDownOpen"
@click.right="openDropDown"
@click="isDropDownOpen = false"
>
<SmartsheetHeaderCellIcon v-if="column" />
<SmartsheetHeaderCellIcon v-if="column && !props.hideIcon" />
<div
v-if="column"
class="name pl-1 !truncate"
@ -69,7 +77,7 @@ const openHeaderMenu = () => {
<template v-if="!hideMenu">
<div class="flex-1" />
<LazySmartsheetHeaderMenu
v-if="!isForm && isUIAllowed('edit-column')"
v-if="!isForm && !isExpandedForm && isUIAllowed('edit-column')"
v-model:is-open="isDropDownOpen"
@add-column="addField"
@edit="openHeaderMenu"
@ -103,7 +111,7 @@ const openHeaderMenu = () => {
<style scoped>
.name {
max-width: calc(100% - 40px);
max-width: calc(100% - 10px);
word-break: break-all;
}
</style>

14
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -22,7 +22,7 @@ import {
useUIPermission,
} from '#imports'
const props = defineProps<{ column: ColumnType; hideMenu?: boolean; required?: boolean | number }>()
const props = defineProps<{ column: ColumnType; hideMenu?: boolean; required?: boolean | number; hideIcon?: boolean }>()
const { t } = useI18n()
@ -44,6 +44,8 @@ const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const colOptions = computed(() => column.value?.colOptions)
const tableTile = computed(() => meta?.value?.title)
@ -126,13 +128,17 @@ const closeAddColumnDropdown = () => {
:class="{ 'h-full': column }"
@click.right="isDropDownOpen = !isDropDownOpen"
>
<LazySmartsheetHeaderVirtualCellIcon v-if="column" />
<LazySmartsheetHeaderVirtualCellIcon v-if="column && !props.hideIcon" />
<a-tooltip placement="bottom">
<template #title>
{{ tooltipMsg }}
</template>
<span class="name pl-1" :class="{ 'truncate': !isForm, 'whitespace-pre-line': isForm }" :title="column.title">
<span
class="name pl-1"
:class="{ 'truncate': !isForm || !isExpandedForm, 'whitespace-pre-line': isForm || isExpandedForm }"
:title="column.title"
>
{{ column.title }}
</span>
</a-tooltip>
@ -143,7 +149,7 @@ const closeAddColumnDropdown = () => {
<div class="flex-1" />
<LazySmartsheetHeaderMenu
v-if="!isForm && isUIAllowed('edit-column')"
v-if="!isForm && isUIAllowed('edit-column') && !isExpandedForm"
v-model:is-open="isDropDownOpen"
:virtual="true"
@add-column="addField"

4
packages/nc-gui/components/virtual-cell/HasMany.vue

@ -103,7 +103,6 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
<template>
<div class="flex items-center gap-1 w-full chips-wrapper">
<template v-if="!isForm">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip
@ -130,13 +129,12 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
/>
<GeneralIcon
v-if="!readOnly && isUIAllowed('xcDatatableEditable')"
v-if="(!readOnly && isUIAllowed('xcDatatableEditable')) || isForm"
icon="plus"
class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="listItemsDlg = true"
/>
</div>
</template>
<LazyVirtualCellComponentsListItems v-model="listItemsDlg" :column="hasManyColumn" />

25
packages/nc-gui/components/virtual-cell/Links.vue

@ -21,6 +21,8 @@ const isLocked = inject(IsLockedInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const colTitle = computed(() => column.value?.title || '')
const listItemsDlg = ref(false)
const childListDlg = ref(false)
@ -43,10 +45,16 @@ const relatedTableDisplayColumn = computed(
loadRelatedTableMeta()
const textVal = computed(() => {
if (isForm?.value) {
return state.value?.[colTitle.value]?.length
? `${+state.value?.[colTitle.value]?.length} records Linked`
: 'No records linked'
}
const parsedValue = +value?.value || 0
if (!parsedValue) {
return 'Empty'
return 'No records linked'
} else if (parsedValue === 1) {
return `1 ${column.value?.meta?.singular || 'Link'}`
} else {
@ -84,14 +92,13 @@ const localCellValue = computed<any[]>(() => {
</script>
<template>
<div class="flex w-full items-center nc-links-wrapper" @dblclick.stop="openChildList">
<template v-if="!isForm">
<div class="flex w-full group items-center nc-links-wrapper" @dblclick.stop="openChildList">
<div class="block flex-shrink truncate">
<component
:is="isLocked || isUnderLookup ? 'span' : 'a'"
:title="textVal"
class="text-center pl-3 nc-datatype-link underline-transparent"
:class="{ '!text-gray-300': !value }"
:class="{ '!text-gray-300': !textVal }"
@click.stop.prevent="openChildList"
>
{{ textVal }}
@ -99,15 +106,13 @@ const localCellValue = computed<any[]>(() => {
</div>
<div class="flex-grow" />
<div v-if="!isLocked && !isUnderLookup" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
v-if="!readOnly && isUIAllowed('xcDatatableEditable')"
icon="plus"
class="nc-icon-transition select-none !text-xxl nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus hover:text-shadow-md"
<div v-if="!isLocked && !isUnderLookup" class="flex justify-end hidden group-hover:flex items-center">
<MdiPlus
v-if="(!readOnly && isUIAllowed('xcDatatableEditable')) || isForm"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus"
@click.stop="listItemsDlg = true"
/>
</div>
</template>
<LazyVirtualCellComponentsListItems v-model="listItemsDlg" :column="relatedTableDisplayColumn" />

4
packages/nc-gui/components/virtual-cell/ManyToMany.vue

@ -105,7 +105,6 @@ const m2mColumn = computed(
<template>
<div class="flex items-center gap-1 w-full chips-wrapper">
<template v-if="!isForm">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip
@ -124,7 +123,7 @@ const m2mColumn = computed(
</template>
</div>
<div v-if="!isLocked && !isUnderLookup" class="flex justify-end gap-1 min-h-[30px] items-center">
<div v-if="(!isLocked && !isUnderLookup) || isForm" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@ -138,7 +137,6 @@ const m2mColumn = computed(
@click.stop="listItemsDlg = true"
/>
</div>
</template>
<LazyVirtualCellComponentsListItems v-model="listItemsDlg" :column="m2mColumn" />

114
packages/nc-gui/components/virtual-cell/components/Header.vue

@ -0,0 +1,114 @@
<script lang="ts" setup>
import HasManyIcon from '~icons/nc-icons/hasmany'
import ManytoManyIcon from '~icons/nc-icons/manytomany'
import OnetoOneIcon from '~icons/nc-icons/onetoone'
import BelongsToIcon from '~icons/nc-icons/belongsto'
import InfoIcon from '~icons/nc-icons/info'
import FileIcon from '~icons/nc-icons/file'
const { relation, relatedTableTitle, displayValue, showHeader, tableTitle } = defineProps<{
relation: string
showHeader?: boolean
tableTitle: string
relatedTableTitle: string
displayValue?: string
}>()
const relationMeta = computed(() => {
if (relation === 'hm') {
return {
title: 'Has Many Relation',
icon: HasManyIcon,
tooltip_desc: 'A single record from table ',
tooltip_desc2: ' can be linked with a multiple records from table ',
}
} else if (relation === 'mm') {
return {
title: 'Many to Many Relation',
icon: ManytoManyIcon,
tooltip_desc: 'Multiple records from table ',
tooltip_desc2: ' can be linked with multiple records from table ',
}
} else if (relation === 'bt') {
return {
title: 'Belongs to Relation',
icon: BelongsToIcon,
tooltip_desc: 'A single record from table ',
tooltip_desc2: ' can be linked with a record from table ',
}
} else {
return {
title: 'One to One Relation',
icon: OnetoOneIcon,
tooltip_desc: 'A single record from table ',
tooltip_desc2: ' can be linked with a single record from table ',
}
}
})
</script>
<template>
<div class="flex justify-between relative pb-2 items-center">
<h2 class="text-md font-semibold">
{{ showHeader ? 'Linked Records' : '' }}
</h2>
<div class="grid grid-cols-[1fr,auto,1fr] justify-center items-center gap-2 absolute inset-0 m-auto">
<div class="flex justify-end">
<div class="flex flex-shrink-0 rounded-md gap-1 text-brand-500 items-center bg-gray-100 px-2 py-1">
<FileIcon class="w-4 h-4" />
{{ displayValue }}
</div>
</div>
<NcTooltip class="flex-shrink-0">
<template #title> {{ relationMeta.title }} </template>
<component
:is="relationMeta.icon"
class="w-7 h-7 p-1 rounded-md"
:class="{
'!bg-orange-500': relation === 'hm',
'!bg-pink-500': relation === 'mm',
'!bg-blue-500': relation === 'bt',
}"
/>
</NcTooltip>
<div class="flex justify-start">
<div
class="flex rounded-md flex-shrink-0 gap-1 items-center px-2 py-1"
:class="{
'!bg-orange-50 !text-orange-500': relation === 'hm',
'!bg-pink-50 !text-pink-500': relation === 'mm',
'!bg-blue-50 !text-blue-500': relation === 'bt',
}"
>
<MdiFileDocumentMultipleOutline
class="w-4 h-4"
:class="{
'!text-orange-500': relation === 'hm',
'!text-pink-500': relation === 'mm',
'!text-blue-500': relation === 'bt',
}"
/>
{{ relatedTableTitle }} Records
</div>
</div>
</div>
<NcTooltip class="z-10" placement="bottom">
<template #title>
<div class="p-1">
<h1 class="text-white font-bold">{{ relationMeta.title }}</h1>
<div class="text-white">
{{ relationMeta.tooltip_desc }}
<span class="bg-gray-700 px-2 rounded-md">
{{ tableTitle }}
</span>
{{ relationMeta.tooltip_desc2 }}
<span class="bg-gray-700 px-2 rounded-md">
{{ relatedTableTitle }}
</span>
</div>
</div>
</template>
<InfoIcon class="w-4 h-4" />
</NcTooltip>
</div>
</template>

230
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -1,16 +1,16 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import { type ColumnType, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import type { Row } from '#imports'
import InboxIcon from '~icons/nc-icons/inbox'
import {
ColumnInj,
IsFormInj,
IsPublicInj,
Modal,
ReadonlyInj,
computed,
h,
iconMap,
inject,
isPrimary,
ref,
useLTARStoreOrThrow,
useSmartsheetRowStoreOrThrow,
@ -33,15 +33,21 @@ const readonly = inject(ReadonlyInj, ref(false))
const {
childrenList,
deleteRelatedRow,
childrenListCount,
loadChildrenList,
childrenListPagination,
relatedTableDisplayValueProp,
unlink,
isChildrenListLoading,
isChildrenListLinked,
relatedTableMeta,
row,
link,
meta,
displayValueProp,
} = useLTARStoreOrThrow()
const { isNew, state, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { isNew, state, removeLTARRef, addLTARRef } = useSmartsheetRowStoreOrThrow()
watch(
[vModel, isForm],
@ -53,153 +59,201 @@ watch(
{ immediate: true },
)
const unlinkRow = async (row: Record<string, any>) => {
const unlinkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) {
await removeLTARRef(row, injectedColumn?.value as ColumnType)
} else {
await unlink(row)
await loadChildrenList()
await unlink(row, {}, false, id)
}
}
const unlinkIfNewRow = async (row: Record<string, any>) => {
const linkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) {
await removeLTARRef(row, injectedColumn?.value as ColumnType)
await addLTARRef(row, injectedColumn?.value as ColumnType)
} else {
await link(row, {}, false, id)
}
}
const container = computed(() =>
isForm.value
? h('div', {
class: 'w-full p-2',
})
: Modal,
)
const attachmentCol = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? []).filter((col) => isAttachment(col))[0]
})
const isFocused = ref(false)
const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
.slice(0, 4)
})
const expandedFormDlg = ref(false)
const expandedFormRow = ref()
const expandedFormRow = ref({})
const colTitle = computed(() => injectedColumn.value?.title || '')
/** reload children list whenever cell value changes and list is visible */
const onClick = (row: Row) => {
if (readonly.value) return
expandedFormRow.value = row
expandedFormDlg.value = true
}
const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type
})
watch(
() => props.cellValue,
() => {
if (!isNew.value && vModel.value) loadChildrenList()
if (isNew.value) loadChildrenList()
},
)
const onClick = (row: Row) => {
if (readonly.value) return
expandedFormRow.value = row
expandedFormDlg.value = true
}
watch(expandedFormDlg, () => {
loadChildrenList()
})
</script>
<template>
<component
:is="container"
<a-modal
v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null"
title="Child list"
:body-style="{ padding: 0 }"
:closable="false"
:width="isForm ? 600 : 800"
:body-style="{ 'padding': 0, 'margin': 0, 'min-height': isForm ? '300px' : '500px' }"
wrap-class-name="nc-modal-child-list"
>
<div class="py-6 nc-scrollbar-md">
<div class="flex mb-4 items-center gap-2 px-12">
<component
:is="iconMap.reload"
<LazyVirtualCellComponentsHeader
v-if="!isForm"
class="cursor-pointer text-gray-500"
data-testid="nc-child-list-reload"
@click="loadChildrenList"
:relation="relation"
:linked-records="childrenListCount"
:table-title="meta?.title"
:related-table-title="relatedTableMeta?.title"
:display-value="row.row[displayValueProp]"
/>
<div v-if="!isForm" class="m-4 bg-gray-50 border-gray-50 border-b-2"></div>
<a-button v-if="!readonly" type="primary" ghost data-testid="nc-child-list-button-link-to" @click="emit('attachRecord')">
<div class="flex items-center gap-1">
<component :is="iconMap.link" type="primary" />
Link to '
<GeneralTableIcon :meta="relatedTableMeta" class="-mx-1 w-5" />
{{ relatedTableMeta.title }}'
<div v-if="!isForm" class="flex mt-2 mb-2 items-center gap-2">
<div
class="flex items-center border-1 p-1 rounded-md w-full border-gray-200"
:class="{ '!border-primary': childrenListPagination.query.length !== 0 || isFocused }"
>
<MdiMagnify class="w-5 h-5 ml-2" />
<a-input
ref="filterQueryRef"
v-model:value="childrenListPagination.query"
:placeholder="`Search in ${relatedTableMeta?.title}`"
class="w-full !rounded-md"
size="small"
:bordered="false"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.capture.stop
>
</a-input>
</div>
</a-button>
</div>
<template v-if="(isNew && state?.[colTitle]?.length) || childrenList?.pageInfo?.totalRows">
<div class="nc-scrollbar-md">
<div class="flex flex-col">
<div class="px-12 cursor-pointer">
<a-card
v-for="(row, i) of childrenList?.list ?? state?.[colTitle] ?? []"
:key="i"
class="nc-nested-list-item !my-2 hover:(!bg-gray-200/50 shadow-md)"
@click="onClick(row)"
<div class="mt-2 mb-2">
<div
:class="{
'h-[420px]': !isForm,
'h-[250px]': isForm,
}"
class="overflow-scroll nc-scrollbar-md cursor-pointer pr-1"
>
<div class="flex items-center">
<div class="flex-1 overflow-hidden min-w-0">
<VirtualCellComponentsItemChip
:border="false"
:item="row"
:value="row[relatedTableDisplayValueProp]"
:column="props.column"
/>
</div>
<div v-if="!readonly" class="flex gap-2">
<component
:is="iconMap.linkRemove"
class="!text-base text-grey hover:(!text-red-500) cursor-pointer nc-icon-transition"
data-testid="nc-child-list-icon-unlink"
@click.stop="unlinkRow(row)"
/>
<component
:is="iconMap.delete"
v-if="!readonly && !isPublic"
class="!text-base text-grey hover:(!text-red-500) cursor-pointer nc-icon-transition"
data-testid="nc-child-list-icon-delete"
@click.stop="deleteRelatedRow(row, unlinkIfNewRow)"
<LazyVirtualCellComponentsListItem
v-for="(refRow, id) in childrenList?.list ?? state?.[colTitle] ?? []"
:key="id"
:row="refRow"
:fields="fields"
data-testid="nc-child-list-item"
:attachment="attachmentCol"
:related-table-display-value-prop="relatedTableDisplayValueProp"
:is-linked="childrenList?.list ? isChildrenListLinked[Number.parseInt(id)] : true"
:is-loading="isChildrenListLoading[Number.parseInt(id)]"
@expand="onClick(refRow)"
@click="
() => {
if (isPublic && !isForm) return
isNew
? unlinkRow(refRow, Number.parseInt(id))
: isChildrenListLinked[Number.parseInt(id)]
? unlinkRow(refRow, Number.parseInt(id))
: linkRow(refRow, Number.parseInt(id))
}
"
/>
</div>
</div>
</a-card>
</template>
<div
v-else
:class="{
'h-[420px]': !isForm,
'h-[250px]': isForm,
}"
class="pt-1 flex flex-col items-center justify-center text-gray-500"
>
<InboxIcon class="w-16 h-16 mx-auto" />
<p>There are no records in table</p>
</div>
<div class="my-2 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex flex-row justify-between bg-white relative pt-1">
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-brand-500 bg-brand-50">
{{ childrenListCount || 0 }} records {{ childrenListCount !== 0 ? 'are' : '' }} linked
</div>
<div v-else class="flex items-center justify-center px-2 rounded-md text-brand-500 bg-brand-50">
{{ state?.[colTitle]?.length || 0 }} records {{ state?.[colTitle]?.length !== 0 ? 'are' : '' }} linked
</div>
<div class="flex justify-center mt-6">
<div class="flex absolute items-center py-2 justify-center w-full">
<a-pagination
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
:show-size-changer="false"
class="mt-2 mx-auto"
size="small"
:total="+childrenList?.pageInfo.totalRows"
hide-on-single-page
show-less-items
/>
</div>
</template>
<div v-else class="ml-12 text-gray-500">No Links</div>
<div class="flex flex-row gap-1">
<NcButton v-if="!readonly" type="ghost" data-testid="nc-child-list-button-link-to" @click="emit('attachRecord')">
<MdiPlus /> Link more records
</NcButton>
<NcButton v-if="!isForm" type="primary" class="nc-close-btn" @click="vModel = false"> Close </NcButton>
</div>
</div>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="{ row: expandedFormRow, oldRow: expandedFormRow, rowMeta: {} }"
:meta="relatedTableMeta"
load-row
:row="{
row: expandedFormRow,
oldRow: expandedFormRow,
rowMeta:
Object.keys(expandedFormRow).length > 0
? {}
: {
new: true,
},
}"
use-meta-fields
/>
</Suspense>
</component>
</a-modal>
</template>
<style scoped lang="scss">
:deep(.ant-pagination-item a) {
line-height: 21px !important;
}
:deep(.nc-nested-list-item .ant-card-body) {
@apply !px-1 !py-0;
}

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

@ -0,0 +1,130 @@
<script lang="ts" setup>
import { isVirtualCol } from 'nocodb-sdk'
import { IsFormInj, isImage, useAttachment } from '#imports'
import MaximizeIcon from '~icons/nc-icons/maximize'
const { row, fields, relatedTableDisplayValueProp, isLoading, isLinked, attachment } = defineProps<{
row: any
fields: any[]
attachment: any
relatedTableDisplayValueProp: string
isLoading: boolean
isLinked: boolean
}>()
defineEmits(['expand'])
provide(IsExpandedFormOpenInj, ref(true))
const isForm = inject(IsFormInj, ref(false))
provide(RowHeightInj, ref(1 as const))
const isPublic = inject(IsPublicInj, ref(false))
const { getPossibleAttachmentSrc } = useAttachment()
interface Attachment {
url: string
title: string
type: string
mimetype: string
}
const attachments: Attachment[] = computed(() => {
try {
if (attachment && row[attachment.title]) {
return typeof row[attachment.title] === 'string' ? JSON.parse(row[attachment.title]) : row[attachment.title]
}
return []
} catch (e) {
return []
}
})
</script>
<template>
<a-card
class="!border-1 group transition-all !rounded-xl relative !mb-4 !border-gray-200 hover:bg-gray-50"
:class="{
'!bg-white !border-blue-500': isLoading,
'!border-brand-500 !bg-brand-50 !border-2 !hover:bg-brand-50 !hover:border-brand-500': isLinked,
'!hover:border-gray-400': !isLinked,
}"
:body-style="{ padding: 0 }"
:hoverable="false"
>
<div class="flex flex-row items-center gap-2 w-full">
<a-carousel
v-if="attachment && attachments && attachments.length"
autoplay
class="!w-24 border-1 bg-white border-gray-200 !rounded-md !h-24"
>
<template #customPaging> </template>
<template v-for="(attachmen, index) in attachments">
<LazyCellAttachmentImage
v-if="isImage(attachmen.title, attachmen.mimetype ?? attachmen.type)"
:key="`carousel-${attachmen.title}-${index}`"
class="!h-24 !w-24 object-cover !rounded-md"
:srcs="getPossibleAttachmentSrc(attachmen)"
/>
</template>
</a-carousel>
<div v-else-if="attachment" class="h-24 w-24 w-full !flex flex-row items-center justify-center">
<img class="object-contain h-24 w-24" src="~assets/icons/FileIconImageBox.png" />
</div>
<div class="flex flex-col gap-1 flex-grow justify-center">
<div class="flex justify-between">
<span class="font-bold text-gray-800 ml-1 nc-display-value"> {{ row[relatedTableDisplayValueProp] }} </span>
<MdiCheck
v-if="isLinked"
:class="{
'!group-hover:mr-8': fields.length === 0,
}"
class="w-6 h-6 !text-brand-500"
/>
<MdiLoading
v-else-if="isLoading"
:class="{
'!group-hover:mr-8': fields.length === 0,
}"
class="w-6 h-6 !text-brand-500 animate-spin"
/>
</div>
<div v-if="fields.length > 0 && !isPublic && !isForm" class="flex flex-row gap-4 w-10/12">
<div v-for="field in fields" :key="field.id" :class="attachment ? 'w-1/3' : 'w-1/4'">
<div class="flex flex-col gap-[-1] max-w-72">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
class="!scale-60"
:column="field"
:hide-menu="true"
:hide-icon="true"
/>
<LazySmartsheetHeaderCell v-else class="!scale-70" :column="field" :hide-menu="true" :hide-icon="true" />
<LazySmartsheetVirtualCell v-if="isVirtualCol(field)" v-model="row[field.title]" :row="row" :column="field" />
<LazySmartsheetCell
v-else
v-model="row[field.title]"
class="!text-gray-600 ml-1"
:column="field"
:edit-enabled="false"
:read-only="true"
/>
</div>
</div>
</div>
</div>
</div>
<NcButton
v-if="!isForm && !isPublic"
type="text"
size="lg"
class="!px-2 nc-expand-item !group-hover:block !hidden !absolute right-1 bottom-1"
@click.stop="$emit('expand', row)"
>
<MaximizeIcon class="w-4 h-4" />
</NcButton>
</a-card>
</template>

274
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -1,19 +1,15 @@
<script lang="ts" setup>
import type { Card } from 'ant-design-vue'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import InboxIcon from '~icons/nc-icons/inbox'
import {
ColumnInj,
Empty,
IsPublicInj,
SaveRowInj,
computed,
iconMap,
inject,
isDrawerExist,
ref,
useLTARStoreOrThrow,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useVModel,
} from '#imports'
@ -30,37 +26,48 @@ const filterQueryRef = ref()
const {
childrenExcludedList,
isChildrenExcludedListLinked,
isChildrenExcludedListLoading,
displayValueProp,
childrenListCount,
loadChildrenExcludedList,
loadChildrenList,
childrenExcludedListPagination,
relatedTableDisplayValueProp,
link,
relatedTableMeta,
meta,
unlink,
row,
} = useLTARStoreOrThrow()
const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow()
const { addLTARRef, isNew, removeLTARRef, state: rowState } = useSmartsheetRowStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const saveRow = inject(SaveRowInj, () => {})
const isForm = inject(IsFormInj, ref(false))
const selectedRowIndex = ref(0)
const saveRow = inject(SaveRowInj, () => {})
const isAltKeyDown = ref(false)
const isFocused = ref(false)
const linkRow = async (row: Record<string, any>) => {
childrenExcludedList.value?.list?.splice(selectedRowIndex.value, 1)
const linkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) {
addLTARRef(row, injectedColumn?.value as ColumnType)
isChildrenExcludedListLinked.value[id] = true
saveRow!()
} else {
await link(row)
await link(row, {}, false, id)
}
if (isAltKeyDown.value) {
if (!isNew.value) loadChildrenExcludedList()
}
const unlinkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) {
removeLTARRef(row, injectedColumn?.value as ColumnType)
isChildrenExcludedListLinked.value[id] = false
saveRow!()
} else {
vModel.value = false
await unlink(row, {}, false, id)
}
}
@ -70,13 +77,17 @@ watch(vModel, (nextVal, prevVal) => {
/** reset query and limit */
childrenExcludedListPagination.query = ''
childrenExcludedListPagination.page = 1
loadChildrenExcludedList()
selectedRowIndex.value = 0
if (!isForm.value) {
loadChildrenList()
}
loadChildrenExcludedList(rowState.value)
}
})
const expandedFormDlg = ref(false)
const expandedFormRow = ref({})
/** populate initial state for a new row which is parent/child of current record */
const newRowState = computed(() => {
if (isNew.value) return {}
@ -111,80 +122,22 @@ const newRowState = computed(() => {
}
})
// if it's an existing record close the list
// after new record creation since it's already linking while creating
watch(expandedFormDlg, (nexVal) => {
if (!nexVal && !isNew.value) vModel.value = false
const attachmentCol = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? []).filter((col) => isAttachment(col))[0]
})
useSelectedCellKeyupListener(vModel, (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
e.stopPropagation()
e.preventDefault()
if (childrenExcludedListPagination.page > 1) childrenExcludedListPagination.page--
break
case 'ArrowRight':
e.stopPropagation()
e.preventDefault()
if (
childrenExcludedList.value?.pageInfo &&
childrenExcludedListPagination.page <
(childrenExcludedList.value.pageInfo.totalRows || 1) / childrenExcludedListPagination.size
)
childrenExcludedListPagination.page++
break
case 'ArrowUp':
selectedRowIndex.value = Math.max(0, selectedRowIndex.value - 1)
e.stopPropagation()
e.preventDefault()
break
case 'ArrowDown':
selectedRowIndex.value = Math.min(childrenExcludedList.value?.list?.length - 1, selectedRowIndex.value + 1)
e.stopPropagation()
e.preventDefault()
break
case 'Enter':
{
const selectedRow = childrenExcludedList.value?.list?.[selectedRowIndex.value]
if (selectedRow) {
linkRow(selectedRow)
e.stopPropagation()
e.preventDefault()
}
}
break
default: {
const el = filterQueryRef.value?.$el
if (el && !isDrawerExist()) {
filterQueryRef.value.$el.focus()
}
}
}
const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
.slice(0, 4)
})
const activeRow = (vNode?: InstanceType<typeof Card>) => {
vNode?.$el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
// set variable to true when alt key is pressed
const keyDownHandler = (e: KeyboardEvent) => {
isAltKeyDown.value = e.altKey
}
// set variable to false when key is released
const keyUpHandler = (e: KeyboardEvent) => {
isAltKeyDown.value = e.altKey
}
const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type
})
// add event listeners when vModel is true and remove when false
watch(vModel, (nextVal) => {
if (nextVal) {
document.addEventListener('keydown', keyDownHandler)
document.addEventListener('keyup', keyUpHandler)
} else {
document.removeEventListener('keydown', keyDownHandler)
document.removeEventListener('keyup', keyUpHandler)
}
watch(expandedFormDlg, () => {
loadChildrenExcludedList(rowState.value)
})
</script>
@ -193,95 +146,132 @@ watch(vModel, (nextVal) => {
v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null"
:title="$t('activity.linkRecord')"
:body-style="{ padding: 0 }"
:width="isForm ? 600 : 800"
:closable="false"
:body-style="{ 'padding': 0, 'margin': 0, 'min-height': '500px' }"
wrap-class-name="nc-modal-link-record"
>
<div class="h-[min(max(calc(100vh_-_300px)_,350px),540px)] flex flex-col py-6">
<div class="flex mb-4 items-center gap-2 px-12">
<LazyVirtualCellComponentsHeader
v-if="!isForm"
:relation="relation"
:table-title="meta?.title"
:related-table-title="relatedTableMeta?.title"
:display-value="row.row[displayValueProp]"
/>
<div class="m-4 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex mt-2 mb-2 items-center gap-2">
<div
class="flex items-center border-1 p-1 rounded-md w-full border-gray-200"
:class="{ '!border-primary': childrenExcludedListPagination.query.length !== 0 || isFocused }"
>
<MdiMagnify class="w-5 h-5 ml-2" />
<a-input
ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query"
:placeholder="$t('placeholder.filterQuery')"
class="max-w-[200px]"
:placeholder="`Search in ${relatedTableMeta?.title}`"
class="w-full !rounded-md nc-excluded-search"
size="small"
:bordered="false"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.capture.stop
/>
>
</a-input>
</div>
<div class="flex-1" />
<component :is="iconMap.reload" class="cursor-pointer text-gray-500 nc-reload" @click="loadChildrenExcludedList" />
<!-- Add new record -->
<a-button v-if="!isPublic" type="primary" size="small" @click="expandedFormDlg = true">
{{ $t('activity.addNewRecord') }}
</a-button>
<NcButton
v-if="!isPublic"
type="ghost"
size="xl"
class="!text-brand-500"
@click="
() => {
expandedFormRow = {}
expandedFormDlg = true
}
"
>
<MdiPlus class="w-4 h-4" />
New Record
</NcButton>
</div>
<template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div class="flex-1 overflow-auto min-h-0 scrollbar-thin-dull px-12">
<a-card
v-for="(refRow, i) in childrenExcludedList?.list ?? []"
:key="i"
:ref="selectedRowIndex === i ? activeRow : null"
class="nc-nested-list-item !my-2 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group"
:class="{ 'nc-selected-row': selectedRowIndex === i }"
@click="linkRow(refRow)"
>
<VirtualCellComponentsItemChip
:item="refRow"
:value="refRow[relatedTableDisplayValueProp]"
:column="props.column"
:show-unlink-button="false"
:border="false"
readonly
<div class="pb-2 pt-1">
<div class="h-[420px] overflow-scroll nc-scrollbar-md pr-1 cursor-pointer">
<LazyVirtualCellComponentsListItem
v-for="(refRow, id) in childrenExcludedList?.list ?? []"
:key="id"
data-testid="nc-excluded-list-item"
:row="refRow"
:fields="fields"
:attachment="attachmentCol"
:related-table-display-value-prop="relatedTableDisplayValueProp"
:is-loading="isChildrenExcludedListLoading[Number.parseInt(id)]"
:is-linked="isChildrenExcludedListLinked[Number.parseInt(id)]"
@expand="
() => {
expandedFormRow = refRow
expandedFormDlg = true
}
"
@click="
() => {
if (isChildrenExcludedListLinked[Number.parseInt(id)]) unlinkRow(refRow, Number.parseInt(id))
else linkRow(refRow, Number.parseInt(id))
}
"
/>
</a-card>
</div>
</div>
</template>
<div v-else class="py-2 h-[420px] flex flex-col items-center justify-center text-gray-500">
<InboxIcon class="w-16 h-16 mx-auto" />
<p>There are no records in table</p>
</div>
<div class="my-2 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex justify-center mt-6">
<div class="flex flex-row justify-between bg-white relative pt-1">
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-brand-500 bg-brand-50">
{{ relation === 'bt' ? (row.row[relatedTableMeta?.title] ? '1' : 0) : childrenListCount ?? 'No' }} records
{{ childrenListCount !== 0 ? 'are' : '' }} linked
</div>
<div class="flex absolute items-center py-2 justify-center w-full">
<a-pagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
class="mt-2 !text-xs"
size="small"
:total="+childrenExcludedList.pageInfo.totalRows"
:show-size-changer="false"
class="mt-2 mx-auto"
size="small"
hide-on-single-page
show-less-items
/>
</div>
<div class="text-xs text-gray-400 text-center px-2 mt-4 pb-0">
* Use <kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd>Click</kbd> to select multiple records
<NcButton class="nc-close-btn ml-auto" @click="vModel = false"> Close </NcButton>
</div>
</template>
<a-empty v-else class="my-10" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormDlg"
v-model="expandedFormDlg"
:meta="relatedTableMeta"
:row="{ row: {}, oldRow: {}, rowMeta: { new: true } }"
:row="{
row: expandedFormRow,
oldRow: {},
rowMeta:
Object.keys(expandedFormRow).length > 0
? {}
: {
new: true,
},
}"
:state="newRowState"
use-meta-fields
/>
</Suspense>
</div>
</a-modal>
</template>
<style scoped>
:deep(.ant-pagination-item a) {
line-height: 21px !important;
}
:deep(.nc-selected-row) {
@apply !ring;
}
:deep(.nc-nested-list-item .ant-card-body) {
@apply !px-1 !py-0;
}
</style>

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

@ -287,7 +287,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
NOCO,
// todo: project_id missing on view type
(project?.value?.id || (sharedView.value?.view as any)?.project_id) as string,
meta.value.id,
meta.value.id as string,
encodeURIComponent(rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])),
{
getHiddenColumn: true,
@ -301,6 +301,28 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
})
}
const deleteRowById = async (rowId?: string) => {
try {
const res: { message?: string[] } | number = await $api.dbTableRow.delete(
NOCO,
project.value.id as string,
meta.value.id as string,
encodeURIComponent(rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])),
)
if (res.message) {
message.info(
`Row delete failed: ${`Unable to delete row with ID ${rowId} because of the following:
\n${res.message.join('\n')}.\n
Clear the data first & try again`})}`,
)
return false
}
} catch (e: any) {
message.error(`${t('msg.error.deleteFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
}
const updateComment = async (auditId: string, audit: Partial<AuditType>) => {
return await $api.utils.commentUpdate(auditId, audit)
}
@ -317,6 +339,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
isYou,
commentsDrawer,
row,
deleteRowById,
displayValue,
save,
changedColumns,

118
packages/nc-gui/composables/useLTARStore.ts

@ -64,6 +64,20 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
size: 10,
})
const isChildrenListLoading = ref<Array<boolean>>([])
const isChildrenListLinked = ref<Array<boolean>>([])
const isChildrenExcludedListLoading = ref<Array<boolean>>([])
const isChildrenExcludedListLinked = ref<Array<boolean>>([])
const newRowState = reactive({
state: null,
})
const childrenListCount = ref(0)
const { t } = useI18n()
const isPublic: Ref<boolean> = inject(IsPublicInj, ref(false))
@ -110,7 +124,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return (meta.value?.columns?.find((c: Required<ColumnType>) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title
})
const loadChildrenExcludedList = async () => {
const loadChildrenExcludedList = async (activeState?: any) => {
if (activeState) newRowState.state = activeState
try {
if (isPublic.value) {
const router = useRouter()
@ -148,7 +163,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
where:
childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value],
// fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value],
} as any,
)
} else {
@ -169,6 +184,32 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
} as any,
)
}
childrenExcludedList.value?.list.forEach((row: Record<string, any>, index: number) => {
isChildrenExcludedListLinked.value[index] = false
isChildrenExcludedListLoading.value[index] = false
})
if (childrenExcludedList.value?.list && activeState && activeState[column.value.title]) {
// Mark out exact same objects in activeState[column.value.title] as Linked
// compare all keys and values
childrenExcludedList.value.list.forEach((row: any, index: number) => {
const found = activeState[column.value.title].find((a: any) => {
let isSame = true
for (const key in a) {
if (a[key] !== row[key]) {
isSame = false
}
}
return isSame
})
if (found) {
isChildrenExcludedListLinked.value[index] = true
}
})
}
} catch (e: any) {
message.error(`${t('msg.error.failedToLoadList')}: ${await extractSdkResponseErrorMsg(e)}`)
}
@ -177,13 +218,13 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenList = async () => {
try {
if (colOptions.value.type === 'bt') return
if (!rowId.value || !column.value) return
if (isPublic.value) {
childrenList.value = await $api.public.dataNestedList(
sharedView.value?.uuid as string,
encodeURIComponent(rowId.value),
colOptions.value.type as 'mm' | 'hm',
column?.value?.id,
column.value.id,
{
limit: String(childrenListPagination.size),
offset: String(childrenListPagination.size * (childrenListPagination.page - 1)),
@ -212,6 +253,13 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
} as any,
)
}
childrenList.value?.list.forEach((row: Record<string, any>, index: number) => {
isChildrenListLinked.value[index] = true
isChildrenListLoading.value[index] = false
})
if (!childrenListPagination.query) {
childrenListCount.value = childrenList.value?.pageInfo.totalRows ?? 0
}
} catch (e: any) {
message.error(`${t('msg.error.failedToLoadChildrenList')}: ${await extractSdkResponseErrorMsg(e)}`)
}
@ -254,7 +302,12 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
})
}
const unlink = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}, undo = false) => {
const unlink = async (
row: Record<string, any>,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
undo = false,
index: number, // Index is For Loading and Linked State of Row
) => {
// const column = meta.columns.find(c => c.id === this.column.colOptions.fk_child_column_id);
// todo: handle if new record
// if (this.isNew) {
@ -270,6 +323,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
// }
try {
// todo: audit
isChildrenExcludedListLoading.value[index] = true
isChildrenListLoading.value[index] = true
await $api.dbTableRow.nestedRemove(
NOCO,
project.value.id as string,
@ -283,25 +338,38 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
if (!undo) {
addUndo({
redo: {
fn: (row: Record<string, any>) => unlink(row, {}, true),
fn: (row: Record<string, any>) => unlink(row, {}, true, index),
args: [clone(row)],
},
undo: {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fn: (row: Record<string, any>) => link(row, {}, true),
fn: (row: Record<string, any>) => link(row, {}, true, index),
args: [clone(row)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
isChildrenExcludedListLinked.value[index] = false
isChildrenListLinked.value[index] = false
if (colOptions.value.type !== 'bt') {
childrenListCount.value = childrenListCount.value - 1
}
} catch (e: any) {
message.error(`${t('msg.error.unlinkFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
} finally {
isChildrenExcludedListLoading.value[index] = false
isChildrenListLoading.value[index] = false
}
reloadData?.(false)
}
const link = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}, undo = false) => {
const link = async (
row: Record<string, any>,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
undo = false,
index: number,
) => {
// todo: handle new record
// const pid = this._extractRowId(parent, this.parentMeta);
// const id = this._extractRowId(this.row, this.meta);
@ -316,6 +384,9 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
// return;
// }
try {
isChildrenExcludedListLoading.value[index] = true
isChildrenListLoading.value[index] = true
await $api.dbTableRow.nestedAdd(
NOCO,
project.value.id as string,
@ -325,23 +396,34 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
column?.value?.id,
encodeURIComponent(getRelatedTableRowId(row) as string) as string,
)
await loadChildrenList()
// await loadChildrenList()
if (!undo) {
addUndo({
redo: {
fn: (row: Record<string, any>) => link(row, {}, true),
fn: (row: Record<string, any>) => link(row, {}, true, index),
args: [clone(row)],
},
undo: {
fn: (row: Record<string, any>) => unlink(row, {}, true),
fn: (row: Record<string, any>) => unlink(row, {}, true, index),
args: [clone(row)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
isChildrenExcludedListLinked.value[index] = true
isChildrenListLinked.value[index] = true
if (colOptions.value.type !== 'bt') {
childrenListCount.value = childrenListCount.value + 1
} else {
isChildrenExcludedListLinked.value = Array(childrenExcludedList.value?.list.length).fill(false)
isChildrenExcludedListLinked.value[index] = true
}
} catch (e: any) {
message.error(`Linking failed: ${await extractSdkResponseErrorMsg(e)}`)
} finally {
isChildrenExcludedListLoading.value[index] = false
isChildrenListLoading.value[index] = false
}
reloadData?.(false)
@ -349,19 +431,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
// watchers
watch(childrenExcludedListPagination, async () => {
await loadChildrenExcludedList()
await loadChildrenExcludedList(newRowState.state)
})
watch(childrenListPagination, async () => {
await loadChildrenList()
})
watch(childrenList, async () => {
childrenList.value?.list.forEach((row: Record<string, any>, index: number) => {
isChildrenListLinked.value[index] = true
isChildrenListLoading.value[index] = false
})
})
return {
relatedTableMeta,
loadRelatedTableMeta,
relatedTableDisplayValueProp,
childrenExcludedList,
childrenList,
childrenListCount,
rowId,
childrenExcludedListPagination,
childrenListPagination,
@ -371,6 +461,10 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
link,
loadChildrenExcludedList,
loadChildrenList,
isChildrenExcludedListLinked,
isChildrenListLinked,
isChildrenListLoading,
isChildrenExcludedListLoading,
row,
deleteRelatedRow,
getRelatedTableRowId,

48
packages/nc-gui/windi.config.ts

@ -112,6 +112,54 @@ export default defineConfig({
800: '#B23830',
900: '#7D2721',
},
pink: {
50: '#FFEEFB',
100: '#FED8F4',
200: '#FEB0E8',
300: '#FD89DD',
400: '#FD61D1',
500: '#FC3AC6',
600: '#CA2E9E',
700: '#972377',
800: '#65174F',
900: '#320C28',
},
orange: {
50: '#FFF5EF',
100: '#FEE6D6',
200: '#FDCDAD',
300: '#FCB483',
400: '#FB9B5A',
500: '#FA8231',
600: '#E1752C',
700: '#C86827',
800: '#964E1D',
900: '#4B270F',
},
purple: {
50: '#F3ECFA',
100: '#E5D4F5',
200: '#CBA8EB',
300: '#B17DE1',
400: '#9751D7',
500: '#7D26CD',
600: '#641EA4',
700: '#4B177B',
800: '#320F52',
900: '#190829',
},
blue: {
50: '#EDF9FF',
100: '#D7F2FF',
200: '#AFE5FF',
300: '#86D9FF',
400: '#5ECCFF',
500: '#36BFFF',
600: '#2B99CC',
700: '#207399',
800: '#164C66',
900: '#0B2633',
},
primary: 'rgba(var(--color-primary), var(--tw-bg-opacity))',
accent: 'rgba(var(--color-accent), var(--tw-bg-opacity))',
dark: colors.dark,

28
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -841,9 +841,10 @@ class BaseModelSqlv2 {
}
}
async hmListCount({ colId, id }) {
async hmListCount({ colId, id }, args) {
try {
// const { cn } = this.hasManyRelations.find(({ tn }) => tn === child) || {};
const { where } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
@ -867,10 +868,13 @@ class BaseModelSqlv2 {
this.dbDriver(parentTn)
.select(parentCol.column_name)
.where(_wherePk(parentTable.primaryKeys, id)),
)
.first();
const { count } = await query;
return count;
);
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, query);
return (await query.first())?.count;
} catch (e) {
console.log(e);
throw e;
@ -1072,7 +1076,9 @@ class BaseModelSqlv2 {
return parentIds.map((id) => gs?.[id]?.[0] || []);
}
public async mmListCount({ colId, parentId }) {
public async mmListCount({ colId, parentId }, args) {
const { where } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
@ -1106,12 +1112,12 @@ class BaseModelSqlv2 {
.select(cn)
// .where(parentTable.primaryKey.cn, id)
.where(_wherePk(parentTable.primaryKeys, parentId)),
)
.first();
const { count } = await qb;
);
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
return count;
await conditionV2(this, filterObj, qb);
return (await qb.first())?.count;
}
// todo: naming & optimizing

9
packages/nocodb/src/services/data-alias-nested.service.ts

@ -47,10 +47,13 @@ export class DataAliasNestedService {
},
param.query as any,
);
const count: any = await baseModel.mmListCount({
const count: any = await baseModel.mmListCount(
{
colId: column.id,
parentId: param.rowId,
});
},
param.query,
);
return new PagedResponseImpl(data, {
count,
@ -220,7 +223,7 @@ export class DataAliasNestedService {
const count = await baseModel.hmListCount({
colId: column.id,
id: param.rowId,
});
}, param.query);
return new PagedResponseImpl(data, {
count,

9
packages/nocodb/src/services/data-table.service.ts

@ -287,10 +287,13 @@ export class DataTableService {
},
listArgs as any,
);
count = (await baseModel.mmListCount({
count = (await baseModel.mmListCount(
{
colId: column.id,
parentId: param.rowId,
})) as number;
},
param.query,
)) as number;
} else if (colOptions.type === RelationTypes.HAS_MANY) {
data = await baseModel.hmList(
{
@ -302,7 +305,7 @@ export class DataTableService {
count = (await baseModel.hmListCount({
colId: column.id,
id: param.rowId,
})) as number;
}, param.query)) as number;
} else {
data = await baseModel.btRead(
{

9
packages/nocodb/src/services/datas.service.ts

@ -407,10 +407,13 @@ export class DatasService {
)
)?.[key];
const count: any = await baseModel.mmListCount({
const count: any = await baseModel.mmListCount(
{
colId: param.colId,
parentId: param.rowId,
});
},
param.query,
);
return new PagedResponseImpl(data, {
count,
@ -650,7 +653,7 @@ export class DatasService {
const count = await baseModel.hmListCount({
colId: param.colId,
id: param.rowId,
});
}, param.query);
return new PagedResponseImpl(data, {
totalRows: count,

14
packages/nocodb/src/services/public-datas.service.ts

@ -473,10 +473,13 @@ export class PublicDatasService {
)
)?.[key];
const count: any = await baseModel.mmListCount({
const count: any = await baseModel.mmListCount(
{
colId: param.columnId,
parentId: param.rowId,
});
},
param.query,
);
return new PagedResponseImpl(data, { ...param.query, count });
}
@ -543,10 +546,13 @@ export class PublicDatasService {
)
)?.[key];
const count = await baseModel.hmListCount({
const count = await baseModel.hmListCount(
{
colId: param.columnId,
id: param.rowId,
});
},
param.query,
);
return new PagedResponseImpl(data, { ...param.query, count });
}

13
tests/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -79,7 +79,6 @@ export class ExpandedFormPage extends BasePage {
async fillField({ columnTitle, value, type = 'text' }: { columnTitle: string; value: string; type?: string }) {
const field = this.get().locator(`[data-testid="nc-expand-col-${columnTitle}"]`);
await field.hover();
switch (type) {
case 'text':
await field.locator('input').fill(value);
@ -93,12 +92,14 @@ export class ExpandedFormPage extends BasePage {
break;
}
case 'belongsTo':
await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click();
await this.dashboard.linkRecord.select(value);
break;
case 'hasMany':
case 'manyToMany':
await field.locator(`[data-testid="nc-child-list-button-link-to"]`).click();
await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click();
await this.dashboard.linkRecord.select(value);
break;
case 'dateTime':
@ -157,8 +158,12 @@ export class ExpandedFormPage extends BasePage {
}
async openChildCard(param: { column: string; title: string }) {
const childList = this.get().locator(`[data-testid="nc-expand-col-${param.column}"]`);
await childList.locator(`.ant-card:has-text("${param.title}")`).click();
const childField = this.get().locator(`[data-testid="nc-expand-col-${param.column}"]`);
await childField.locator('.nc-datatype-link').click();
const card = await this.rootPage.locator(`.ant-card:has-text("${param.title}")`);
await card.hover();
await card.locator(`.nc-expand-item`).click();
}
async verifyCount({ count }: { count: number }) {

38
tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts

@ -19,37 +19,47 @@ export class ChildList extends BasePage {
// title: Child list
// button: Link to 'City'
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Child list`);
expect(await this.get().locator(`text=/Link to '.*${linkField}'/i`).isVisible()).toBeTruthy();
expect(await this.get().locator(`[data-testid="nc-child-list-reload"]`).isVisible()).toBeTruthy();
// child list body validation (card count, card title)
const cardCount = cardTitle.length;
await this.get().locator('.ant-modal-content').waitFor();
{
const childList = this.get().locator(`.ant-card`);
let isOk = false;
let count = 0;
let childList;
while (!isOk && count < 5) {
try {
childList = this.get().getByTestId('nc-child-list-item');
const childCards = await childList.count();
expect(childCards).toEqual(cardCount);
if (childCards === cardCount) {
isOk = true;
}
} catch (e) {
await this.rootPage.waitForTimeout(100);
} finally {
count++;
}
}
expect(childList).toBeDefined();
for (let i = 0; i < cardCount; i++) {
await childList.nth(i).locator('.name').waitFor({ state: 'visible' });
await childList.nth(i).locator('.name').scrollIntoViewIfNeeded();
await childList.nth(i).locator('.nc-display-value').waitFor({ state: 'visible' });
await childList.nth(i).locator('.nc-display-value').scrollIntoViewIfNeeded();
await this.rootPage.waitForTimeout(100);
expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]);
// icon: unlink
// icon: delete
expect(await childList.nth(i).locator(`[data-testid="nc-child-list-icon-unlink"]`).isVisible()).toBeTruthy();
expect(await childList.nth(i).locator(`[data-testid="nc-child-list-icon-delete"]`).isVisible()).toBeTruthy();
expect(await childList.nth(i).locator('.nc-display-value').textContent()).toContain(cardTitle[i]);
}
}
}
async close() {
await this.get().locator(`.ant-modal-close-x`).click();
await this.get().locator(`.nc-close-btn`).click();
await this.get().waitFor({ state: 'hidden' });
}
async openLinkRecord({ linkTableTitle }: { linkTableTitle: string }) {
const openActions = () => this.get().locator(`text=/Link to '.*${linkTableTitle}'/i`).click();
const openActions = () => this.get().getByTestId('nc-child-list-button-link-to').click();
await this.waitForResponse({
requestUrlPathToMatch: '/exclude',
httpMethodsToMatch: ['GET'],

21
tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts

@ -11,27 +11,25 @@ export class LinkRecord extends BasePage {
}
async verify(cardTitle?: string[]) {
await this.dashboard.get().locator('.nc-modal-link-record').waitFor();
await this.dashboard.get().locator('.nc-modal-link-record').last().waitFor();
const linkRecord = this.get();
// DOM element validation
// title: Link Record
// button: Add new record
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Link record`);
expect(await linkRecord.locator(`button:has-text("Add new record")`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`.nc-reload`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`button:has-text("New record")`).isVisible()).toBeTruthy();
// placeholder: Filter query
expect(await linkRecord.locator(`[placeholder="Filter query"]`).isVisible()).toBeTruthy();
expect(await linkRecord.locator('.nc-excluded-search').isVisible()).toBeTruthy();
{
const childList = linkRecord.locator(`.ant-card`);
const childList = linkRecord.getByTestId(`nc-excluded-list-item`);
const childCards = await childList.count();
expect(childCards).toEqual(cardTitle.length);
for (let i = 0; i < cardTitle.length; i++) {
await childList.nth(i).locator('.name').scrollIntoViewIfNeeded();
await childList.nth(i).locator('.name').waitFor({ state: 'visible' });
expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]);
await childList.nth(i).locator('.nc-display-value').scrollIntoViewIfNeeded();
await childList.nth(i).locator('.nc-display-value').waitFor({ state: 'visible' });
expect(await childList.nth(i).locator('.nc-display-value').textContent()).toContain(cardTitle[i]);
}
}
}
@ -39,11 +37,12 @@ export class LinkRecord extends BasePage {
async select(cardTitle: string) {
await this.rootPage.waitForTimeout(100);
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
await this.close();
}
async close() {
await this.get().locator(`.ant-modal-close-x`).click();
await this.get().waitFor({ state: 'hidden' });
await this.get().locator('.nc-close-btn').last().click();
await this.get().last().waitFor({ state: 'hidden' });
}
get() {

9
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -345,7 +345,7 @@ export class CellPageObject extends BasePage {
expect(await childList.count()).toBe(count);
// close child list
await this.rootPage.locator('.nc-modal-child-list').locator('button.ant-modal-close:visible').click();
await this.rootPage.locator('.nc-modal-child-list').locator('.nc-close-btn').last().click();
}
}
}
@ -358,13 +358,18 @@ export class CellPageObject extends BasePage {
if (!isLink) {
await cell.click();
await cell.locator('.nc-icon.unlink-icon').click();
// await cell.click();
}
// For HM/MM columns
else {
await cell.locator('.nc-datatype-link').click();
await this.waitForResponse({
uiAction: () => this.rootPage.locator(`[data-testid="nc-child-list-icon-unlink"]`).first().click(),
uiAction: async () =>
this.rootPage
.locator(`[data-testid="nc-child-list-item"]`)
.nth((await this.rootPage.locator(`[data-testid="nc-child-list-item"]`).count()) - 1)
.click(),
requestUrlPathToMatch: '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
});

5
tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts

@ -102,6 +102,11 @@ export class ToolbarSortPage extends BasePage {
await this.get().locator(`button:has-text("Add Sort Option")`).click();
}
await this.rootPage
.locator('.nc-sort-create-modal')
.locator('.nc-sort-column-search-item', { hasText: title })
.scrollIntoViewIfNeeded();
// select column
const selectColumn = async () =>
await this.rootPage

15
tests/playwright/pages/SharedForm/index.ts

@ -31,7 +31,13 @@ export class SharedFormPage extends BasePage {
}
async clickLinkToChildList() {
await this.get().locator('button[data-testid="nc-child-list-button-link-to"]').click();
await this.get().locator('.nc-virtual-cell').hover();
await this.get().locator('.nc-action-icon').click({ force: true });
//await this.get().locator('button[data-testid="nc-child-list-button-link-to"]').click();
}
async closeLinkToChildList() {
await this.get().locator('.nc-close-btn').click();
}
async verifyChildList(cardTitle?: string[]) {
@ -42,14 +48,13 @@ export class SharedFormPage extends BasePage {
// title: Link Record
// button: Add new record
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Link record`);
//await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Link record`);
// add new record option is not available for shared form
expect(await linkRecord.locator(`button:has-text("Add new record")`).isVisible()).toBeFalsy();
expect(await linkRecord.locator(`button:has-text("Link more records")`).isVisible()).toBeFalsy();
expect(await linkRecord.locator(`.nc-reload`).isVisible()).toBeTruthy();
// placeholder: Filter query
expect(await linkRecord.locator(`[placeholder="Filter query"]`).isVisible()).toBeTruthy();
expect(await linkRecord.locator('.nc-excluded-search').isVisible()).toBeTruthy();
{
const childList = linkRecord.locator(`.ant-card`);

1
tests/playwright/tests/db/features/expandedFormUrl.spec.ts

@ -122,6 +122,7 @@ test.describe('Expanded form URL', () => {
// close child card
await dashboard.expandedForm.close();
await dashboard.childList.close();
await dashboard.expandedForm.verify({
header: 'Afghanistan',
url: 'rowId=1',

5
tests/playwright/tests/db/features/undo-redo.spec.ts

@ -609,7 +609,6 @@ test.describe('Undo Redo - LTAR', () => {
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Mumbai');
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Delhi');
@ -617,8 +616,8 @@ test.describe('Undo Redo - LTAR', () => {
await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' });
await verifyRecords([]);
await undo({ page, values: ['Delhi'] });
await undo({ page, values: ['Mumbai', 'Delhi'] });
await undo({ page, values: ['Mumbai'] });
await undo({ page, values: ['Delhi', 'Mumbai'] });
await undo({ page, values: ['Mumbai'] });
await undo({ page, values: [] });
});

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

@ -363,6 +363,7 @@ test.describe('Form view with LTAR', () => {
await sharedForm.verifyChildList(['Atlanta', 'Pune', 'London', 'Sydney']);
await sharedForm.selectChildList('Atlanta');
await sharedForm.closeLinkToChildList();
await sharedForm.submit();
await sharedForm.verifySuccessMessage();

Loading…
Cancel
Save