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'] { input[type='number'] {
@apply !outline-none !ring-0 !border-0; @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'] MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default'] MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiEye: typeof import('~icons/mdi/eye')['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'] MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default'] MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default'] MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default'] MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
MdiLoading: typeof import('~icons/mdi/loading')['default']
MdiLogin: typeof import('~icons/mdi/login')['default'] MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default'] MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMapMarkerOutline: typeof import('~icons/mdi/map-marker-outline')['default'] MdiMapMarkerOutline: typeof import('~icons/mdi/map-marker-outline')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['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> <template>
<div <div
class="flex cursor-pointer w-full h-full py-1" class="flex cursor-pointer w-full h-full"
:class="{ :class="{
'justify-center': !isForm, 'justify-center': !isForm,
'w-full': isForm, 'w-full': isForm,
@ -92,7 +92,7 @@ useSelectedCellKeyupListener(active, (e) => {
:class="{ '!ml-[-8px]': readOnly, 'w-full justify-start': isEditColumnMenu }" :class="{ '!ml-[-8px]': readOnly, 'w-full justify-start': isEditColumnMenu }"
@click="onClick(true)" @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"> <Transition name="layout" mode="out-in" :duration="100">
<component <component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)" :is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"

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

@ -10,7 +10,7 @@ import {
iconMap, iconMap,
inject, inject,
useVModel, useVModel,
ReadonlyInj ReadonlyInj,
} from '#imports' } from '#imports'
const props = defineProps<{ const props = defineProps<{
@ -26,7 +26,7 @@ const editEnabled = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, 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() const { showNull } = useGlobal()

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

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

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

@ -4,6 +4,7 @@ import {
ActiveCellInj, ActiveCellInj,
CellValueInj, CellValueInj,
ColumnInj, ColumnInj,
IsExpandedFormOpenInj,
IsFormInj, IsFormInj,
IsGridInj, IsGridInj,
NavigateDir, NavigateDir,
@ -48,6 +49,8 @@ const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
function onNavigate(dir: NavigateDir, e: KeyboardEvent) { function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir) emit('navigate', dir)
@ -91,7 +94,7 @@ onUnmounted(() => {
<div <div
ref="elementToObserve" ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center" 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.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $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 { 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() const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
@ -72,8 +72,6 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const showDeleteRowModal = ref(false) const showDeleteRowModal = ref(false)
const { deleteRowById } = useViewData(meta, ref(props.view))
const onDeleteRowClick = () => { const onDeleteRowClick = () => {
showDeleteRowModal.value = true showDeleteRowModal.value = true
} }
@ -81,7 +79,7 @@ const onDeleteRowClick = () => {
const onConfirmDeleteRowClick = async () => { const onConfirmDeleteRowClick = async () => {
showDeleteRowModal.value = false showDeleteRowModal.value = false
await deleteRowById(primaryKey.value) await deleteRowById(primaryKey.value)
reloadTrigger.trigger() await reloadTrigger.trigger()
emit('cancel') emit('cancel')
message.success('Row deleted') 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) useProvideSmartsheetStore(ref({}) as Ref<ViewType>, meta)
provide(IsFormInj, ref(true))
watch( watch(
state, state,
() => { () => {

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

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

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

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

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

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

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

@ -105,7 +105,6 @@ const m2mColumn = computed(
<template> <template>
<div class="flex items-center gap-1 w-full chips-wrapper"> <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"> <div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells"> <template v-if="cells">
<VirtualCellComponentsItemChip <VirtualCellComponentsItemChip
@ -124,7 +123,7 @@ const m2mColumn = computed(
</template> </template>
</div> </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 <GeneralIcon
icon="expand" icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-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" @click.stop="listItemsDlg = true"
/> />
</div> </div>
</template>
<LazyVirtualCellComponentsListItems v-model="listItemsDlg" :column="m2mColumn" /> <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> <script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk' import { type ColumnType, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import type { Row } from '#imports' import type { Row } from '#imports'
import InboxIcon from '~icons/nc-icons/inbox'
import { import {
ColumnInj, ColumnInj,
IsFormInj, IsFormInj,
IsPublicInj, IsPublicInj,
Modal,
ReadonlyInj, ReadonlyInj,
computed, computed,
h,
iconMap,
inject, inject,
isPrimary,
ref, ref,
useLTARStoreOrThrow, useLTARStoreOrThrow,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
@ -33,15 +33,21 @@ const readonly = inject(ReadonlyInj, ref(false))
const { const {
childrenList, childrenList,
deleteRelatedRow, childrenListCount,
loadChildrenList, loadChildrenList,
childrenListPagination, childrenListPagination,
relatedTableDisplayValueProp, relatedTableDisplayValueProp,
unlink, unlink,
isChildrenListLoading,
isChildrenListLinked,
relatedTableMeta, relatedTableMeta,
row,
link,
meta,
displayValueProp,
} = useLTARStoreOrThrow() } = useLTARStoreOrThrow()
const { isNew, state, removeLTARRef } = useSmartsheetRowStoreOrThrow() const { isNew, state, removeLTARRef, addLTARRef } = useSmartsheetRowStoreOrThrow()
watch( watch(
[vModel, isForm], [vModel, isForm],
@ -53,153 +59,201 @@ watch(
{ immediate: true }, { immediate: true },
) )
const unlinkRow = async (row: Record<string, any>) => { const unlinkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) { if (isNew.value) {
await removeLTARRef(row, injectedColumn?.value as ColumnType) await removeLTARRef(row, injectedColumn?.value as ColumnType)
} else { } else {
await unlink(row) await unlink(row, {}, false, id)
await loadChildrenList()
} }
} }
const unlinkIfNewRow = async (row: Record<string, any>) => { const linkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) { 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(() => const attachmentCol = computedInject(FieldsInj, (_fields) => {
isForm.value return (relatedTableMeta.value.columns ?? []).filter((col) => isAttachment(col))[0]
? h('div', { })
class: 'w-full p-2',
}) const isFocused = ref(false)
: Modal,
) 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 expandedFormDlg = ref(false)
const expandedFormRow = ref() const expandedFormRow = ref({})
const colTitle = computed(() => injectedColumn.value?.title || '') 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( watch(
() => props.cellValue, () => props.cellValue,
() => { () => {
if (!isNew.value && vModel.value) loadChildrenList() if (isNew.value) loadChildrenList()
}, },
) )
const onClick = (row: Row) => { watch(expandedFormDlg, () => {
if (readonly.value) return loadChildrenList()
expandedFormRow.value = row })
expandedFormDlg.value = true
}
</script> </script>
<template> <template>
<component <a-modal
:is="container"
v-model:visible="vModel" v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null" :footer="null"
title="Child list" :closable="false"
:body-style="{ padding: 0 }" :width="isForm ? 600 : 800"
:body-style="{ 'padding': 0, 'margin': 0, 'min-height': isForm ? '300px' : '500px' }"
wrap-class-name="nc-modal-child-list" wrap-class-name="nc-modal-child-list"
> >
<div class="py-6 nc-scrollbar-md"> <LazyVirtualCellComponentsHeader
<div class="flex mb-4 items-center gap-2 px-12">
<component
:is="iconMap.reload"
v-if="!isForm" v-if="!isForm"
class="cursor-pointer text-gray-500" :relation="relation"
data-testid="nc-child-list-reload" :linked-records="childrenListCount"
@click="loadChildrenList" :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 v-if="!isForm" class="flex mt-2 mb-2 items-center gap-2">
<div class="flex items-center gap-1"> <div
<component :is="iconMap.link" type="primary" /> class="flex items-center border-1 p-1 rounded-md w-full border-gray-200"
Link to ' :class="{ '!border-primary': childrenListPagination.query.length !== 0 || isFocused }"
<GeneralTableIcon :meta="relatedTableMeta" class="-mx-1 w-5" /> >
{{ relatedTableMeta.title }}' <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> </div>
</a-button>
</div> </div>
<template v-if="(isNew && state?.[colTitle]?.length) || childrenList?.pageInfo?.totalRows"> <template v-if="(isNew && state?.[colTitle]?.length) || childrenList?.pageInfo?.totalRows">
<div class="nc-scrollbar-md"> <div class="mt-2 mb-2">
<div class="flex flex-col"> <div
<div class="px-12 cursor-pointer"> :class="{
<a-card 'h-[420px]': !isForm,
v-for="(row, i) of childrenList?.list ?? state?.[colTitle] ?? []" 'h-[250px]': isForm,
:key="i" }"
class="nc-nested-list-item !my-2 hover:(!bg-gray-200/50 shadow-md)" class="overflow-scroll nc-scrollbar-md cursor-pointer pr-1"
@click="onClick(row)"
> >
<div class="flex items-center"> <LazyVirtualCellComponentsListItem
<div class="flex-1 overflow-hidden min-w-0"> v-for="(refRow, id) in childrenList?.list ?? state?.[colTitle] ?? []"
<VirtualCellComponentsItemChip :key="id"
:border="false" :row="refRow"
:item="row" :fields="fields"
:value="row[relatedTableDisplayValueProp]" data-testid="nc-child-list-item"
:column="props.column" :attachment="attachmentCol"
/> :related-table-display-value-prop="relatedTableDisplayValueProp"
</div> :is-linked="childrenList?.list ? isChildrenListLinked[Number.parseInt(id)] : true"
:is-loading="isChildrenListLoading[Number.parseInt(id)]"
<div v-if="!readonly" class="flex gap-2"> @expand="onClick(refRow)"
<component @click="
:is="iconMap.linkRemove" () => {
class="!text-base text-grey hover:(!text-red-500) cursor-pointer nc-icon-transition" if (isPublic && !isForm) return
data-testid="nc-child-list-icon-unlink" isNew
@click.stop="unlinkRow(row)" ? unlinkRow(refRow, Number.parseInt(id))
/> : isChildrenListLinked[Number.parseInt(id)]
<component ? unlinkRow(refRow, Number.parseInt(id))
:is="iconMap.delete" : linkRow(refRow, Number.parseInt(id))
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)"
/> />
</div> </div>
</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>
<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>
<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>
<div class="flex justify-center mt-6"> <div class="flex absolute items-center py-2 justify-center w-full">
<a-pagination <a-pagination
v-if="!isNew && childrenList?.pageInfo" v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page" v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size" v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
:show-size-changer="false"
class="mt-2 mx-auto" class="mt-2 mx-auto"
size="small" size="small"
:total="+childrenList?.pageInfo.totalRows" hide-on-single-page
show-less-items show-less-items
/> />
</div> </div>
</template> <div class="flex flex-row gap-1">
<NcButton v-if="!readonly" type="ghost" data-testid="nc-child-list-button-link-to" @click="emit('attachRecord')">
<div v-else class="ml-12 text-gray-500">No Links</div> <MdiPlus /> Link more records
</NcButton>
<NcButton v-if="!isForm" type="primary" class="nc-close-btn" @click="vModel = false"> Close </NcButton>
</div>
</div> </div>
<Suspense> <Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:row="{ row: expandedFormRow, oldRow: expandedFormRow, rowMeta: {} }"
:meta="relatedTableMeta" :meta="relatedTableMeta"
load-row :row="{
row: expandedFormRow,
oldRow: expandedFormRow,
rowMeta:
Object.keys(expandedFormRow).length > 0
? {}
: {
new: true,
},
}"
use-meta-fields use-meta-fields
/> />
</Suspense> </Suspense>
</component> </a-modal>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
:deep(.ant-pagination-item a) {
line-height: 21px !important;
}
:deep(.nc-nested-list-item .ant-card-body) { :deep(.nc-nested-list-item .ant-card-body) {
@apply !px-1 !py-0; @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> <script lang="ts" setup>
import type { Card } from 'ant-design-vue' import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import InboxIcon from '~icons/nc-icons/inbox'
import { import {
ColumnInj, ColumnInj,
Empty,
IsPublicInj, IsPublicInj,
SaveRowInj, SaveRowInj,
computed, computed,
iconMap,
inject, inject,
isDrawerExist,
ref, ref,
useLTARStoreOrThrow, useLTARStoreOrThrow,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
useVModel, useVModel,
} from '#imports' } from '#imports'
@ -30,37 +26,48 @@ const filterQueryRef = ref()
const { const {
childrenExcludedList, childrenExcludedList,
isChildrenExcludedListLinked,
isChildrenExcludedListLoading,
displayValueProp,
childrenListCount,
loadChildrenExcludedList, loadChildrenExcludedList,
loadChildrenList,
childrenExcludedListPagination, childrenExcludedListPagination,
relatedTableDisplayValueProp, relatedTableDisplayValueProp,
link, link,
relatedTableMeta, relatedTableMeta,
meta, meta,
unlink,
row, row,
} = useLTARStoreOrThrow() } = useLTARStoreOrThrow()
const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow() const { addLTARRef, isNew, removeLTARRef, state: rowState } = useSmartsheetRowStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false)) 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>) => { const linkRow = async (row: Record<string, any>, id: number) => {
childrenExcludedList.value?.list?.splice(selectedRowIndex.value, 1)
if (isNew.value) { if (isNew.value) {
addLTARRef(row, injectedColumn?.value as ColumnType) addLTARRef(row, injectedColumn?.value as ColumnType)
isChildrenExcludedListLinked.value[id] = true
saveRow!() saveRow!()
} else { } 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 { } else {
vModel.value = false await unlink(row, {}, false, id)
} }
} }
@ -70,13 +77,17 @@ watch(vModel, (nextVal, prevVal) => {
/** reset query and limit */ /** reset query and limit */
childrenExcludedListPagination.query = '' childrenExcludedListPagination.query = ''
childrenExcludedListPagination.page = 1 childrenExcludedListPagination.page = 1
loadChildrenExcludedList() if (!isForm.value) {
selectedRowIndex.value = 0 loadChildrenList()
}
loadChildrenExcludedList(rowState.value)
} }
}) })
const expandedFormDlg = ref(false) const expandedFormDlg = ref(false)
const expandedFormRow = ref({})
/** populate initial state for a new row which is parent/child of current record */ /** populate initial state for a new row which is parent/child of current record */
const newRowState = computed(() => { const newRowState = computed(() => {
if (isNew.value) return {} if (isNew.value) return {}
@ -111,80 +122,22 @@ const newRowState = computed(() => {
} }
}) })
// if it's an existing record close the list const attachmentCol = computedInject(FieldsInj, (_fields) => {
// after new record creation since it's already linking while creating return (relatedTableMeta.value.columns ?? []).filter((col) => isAttachment(col))[0]
watch(expandedFormDlg, (nexVal) => {
if (!nexVal && !isNew.value) vModel.value = false
}) })
useSelectedCellKeyupListener(vModel, (e: KeyboardEvent) => { const fields = computedInject(FieldsInj, (_fields) => {
switch (e.key) { return (relatedTableMeta.value.columns ?? [])
case 'ArrowLeft': .filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
e.stopPropagation() .slice(0, 4)
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 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 relation = computed(() => {
const keyUpHandler = (e: KeyboardEvent) => { return injectedColumn!.value?.colOptions?.type
isAltKeyDown.value = e.altKey })
}
// add event listeners when vModel is true and remove when false watch(expandedFormDlg, () => {
watch(vModel, (nextVal) => { loadChildrenExcludedList(rowState.value)
if (nextVal) {
document.addEventListener('keydown', keyDownHandler)
document.addEventListener('keyup', keyUpHandler)
} else {
document.removeEventListener('keydown', keyDownHandler)
document.removeEventListener('keyup', keyUpHandler)
}
}) })
</script> </script>
@ -193,95 +146,132 @@ watch(vModel, (nextVal) => {
v-model:visible="vModel" v-model:visible="vModel"
:class="{ active: vModel }" :class="{ active: vModel }"
:footer="null" :footer="null"
:title="$t('activity.linkRecord')" :width="isForm ? 600 : 800"
:body-style="{ padding: 0 }" :closable="false"
:body-style="{ 'padding': 0, 'margin': 0, 'min-height': '500px' }"
wrap-class-name="nc-modal-link-record" wrap-class-name="nc-modal-link-record"
> >
<div class="h-[min(max(calc(100vh_-_300px)_,350px),540px)] flex flex-col py-6"> <LazyVirtualCellComponentsHeader
<div class="flex mb-4 items-center gap-2 px-12"> 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 <a-input
ref="filterQueryRef" ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query" v-model:value="childrenExcludedListPagination.query"
:placeholder="$t('placeholder.filterQuery')" :placeholder="`Search in ${relatedTableMeta?.title}`"
class="max-w-[200px]" class="w-full !rounded-md nc-excluded-search"
size="small" size="small"
:bordered="false"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.capture.stop @keydown.capture.stop
/> >
</a-input>
</div>
<div class="flex-1" /> <div class="flex-1" />
<component :is="iconMap.reload" class="cursor-pointer text-gray-500 nc-reload" @click="loadChildrenExcludedList" />
<!-- Add new record --> <!-- Add new record -->
<a-button v-if="!isPublic" type="primary" size="small" @click="expandedFormDlg = true"> <NcButton
{{ $t('activity.addNewRecord') }} v-if="!isPublic"
</a-button> type="ghost"
size="xl"
class="!text-brand-500"
@click="
() => {
expandedFormRow = {}
expandedFormDlg = true
}
"
>
<MdiPlus class="w-4 h-4" />
New Record
</NcButton>
</div> </div>
<template v-if="childrenExcludedList?.pageInfo?.totalRows"> <template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div class="flex-1 overflow-auto min-h-0 scrollbar-thin-dull px-12"> <div class="pb-2 pt-1">
<a-card <div class="h-[420px] overflow-scroll nc-scrollbar-md pr-1 cursor-pointer">
v-for="(refRow, i) in childrenExcludedList?.list ?? []" <LazyVirtualCellComponentsListItem
:key="i" v-for="(refRow, id) in childrenExcludedList?.list ?? []"
:ref="selectedRowIndex === i ? activeRow : null" :key="id"
class="nc-nested-list-item !my-2 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group" data-testid="nc-excluded-list-item"
:class="{ 'nc-selected-row': selectedRowIndex === i }" :row="refRow"
@click="linkRow(refRow)" :fields="fields"
> :attachment="attachmentCol"
<VirtualCellComponentsItemChip :related-table-display-value-prop="relatedTableDisplayValueProp"
:item="refRow" :is-loading="isChildrenExcludedListLoading[Number.parseInt(id)]"
:value="refRow[relatedTableDisplayValueProp]" :is-linked="isChildrenExcludedListLinked[Number.parseInt(id)]"
:column="props.column" @expand="
:show-unlink-button="false" () => {
:border="false" expandedFormRow = refRow
readonly expandedFormDlg = true
}
"
@click="
() => {
if (isChildrenExcludedListLinked[Number.parseInt(id)]) unlinkRow(refRow, Number.parseInt(id))
else linkRow(refRow, Number.parseInt(id))
}
"
/> />
</a-card>
</div> </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 <a-pagination
v-if="childrenExcludedList?.pageInfo" v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page" v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size" v-model:page-size="childrenExcludedListPagination.size"
class="mt-2 !text-xs"
size="small"
:total="+childrenExcludedList.pageInfo.totalRows" :total="+childrenExcludedList.pageInfo.totalRows"
:show-size-changer="false"
class="mt-2 mx-auto"
size="small"
hide-on-single-page
show-less-items show-less-items
/> />
</div> </div>
<NcButton class="nc-close-btn ml-auto" @click="vModel = false"> Close </NcButton>
<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
</div> </div>
</template>
<a-empty v-else class="my-10" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
<Suspense> <Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormDlg" v-if="expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:meta="relatedTableMeta" :meta="relatedTableMeta"
:row="{ row: {}, oldRow: {}, rowMeta: { new: true } }" :row="{
row: expandedFormRow,
oldRow: {},
rowMeta:
Object.keys(expandedFormRow).length > 0
? {}
: {
new: true,
},
}"
:state="newRowState" :state="newRowState"
use-meta-fields use-meta-fields
/> />
</Suspense> </Suspense>
</div>
</a-modal> </a-modal>
</template> </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, NOCO,
// todo: project_id missing on view type // todo: project_id missing on view type
(project?.value?.id || (sharedView.value?.view as any)?.project_id) as string, (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[])), encodeURIComponent(rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])),
{ {
getHiddenColumn: true, 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>) => { const updateComment = async (auditId: string, audit: Partial<AuditType>) => {
return await $api.utils.commentUpdate(auditId, audit) return await $api.utils.commentUpdate(auditId, audit)
} }
@ -317,6 +339,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
isYou, isYou,
commentsDrawer, commentsDrawer,
row, row,
deleteRowById,
displayValue, displayValue,
save, save,
changedColumns, changedColumns,

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

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

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

@ -112,6 +112,54 @@ export default defineConfig({
800: '#B23830', 800: '#B23830',
900: '#7D2721', 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))', primary: 'rgba(var(--color-primary), var(--tw-bg-opacity))',
accent: 'rgba(var(--color-accent), var(--tw-bg-opacity))', accent: 'rgba(var(--color-accent), var(--tw-bg-opacity))',
dark: colors.dark, 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 { try {
// const { cn } = this.hasManyRelations.find(({ tn }) => tn === child) || {}; // const { cn } = this.hasManyRelations.find(({ tn }) => tn === child) || {};
const { where } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find( const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId, (c) => c.id === colId,
); );
@ -867,10 +868,13 @@ class BaseModelSqlv2 {
this.dbDriver(parentTn) this.dbDriver(parentTn)
.select(parentCol.column_name) .select(parentCol.column_name)
.where(_wherePk(parentTable.primaryKeys, id)), .where(_wherePk(parentTable.primaryKeys, id)),
) );
.first(); const aliasColObjMap = await childTable.getAliasColObjMap();
const { count } = await query; const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
return count;
await conditionV2(this, filterObj, query);
return (await query.first())?.count;
} catch (e) { } catch (e) {
console.log(e); console.log(e);
throw e; throw e;
@ -1072,7 +1076,9 @@ class BaseModelSqlv2 {
return parentIds.map((id) => gs?.[id]?.[0] || []); 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( const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId, (c) => c.id === colId,
); );
@ -1106,12 +1112,12 @@ class BaseModelSqlv2 {
.select(cn) .select(cn)
// .where(parentTable.primaryKey.cn, id) // .where(parentTable.primaryKey.cn, id)
.where(_wherePk(parentTable.primaryKeys, parentId)), .where(_wherePk(parentTable.primaryKeys, parentId)),
) );
.first(); const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
const { count } = await qb;
return count; await conditionV2(this, filterObj, qb);
return (await qb.first())?.count;
} }
// todo: naming & optimizing // todo: naming & optimizing

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

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

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

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

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

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

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

@ -473,10 +473,13 @@ export class PublicDatasService {
) )
)?.[key]; )?.[key];
const count: any = await baseModel.mmListCount({ const count: any = await baseModel.mmListCount(
{
colId: param.columnId, colId: param.columnId,
parentId: param.rowId, parentId: param.rowId,
}); },
param.query,
);
return new PagedResponseImpl(data, { ...param.query, count }); return new PagedResponseImpl(data, { ...param.query, count });
} }
@ -543,10 +546,13 @@ export class PublicDatasService {
) )
)?.[key]; )?.[key];
const count = await baseModel.hmListCount({ const count = await baseModel.hmListCount(
{
colId: param.columnId, colId: param.columnId,
id: param.rowId, id: param.rowId,
}); },
param.query,
);
return new PagedResponseImpl(data, { ...param.query, count }); 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 }) { async fillField({ columnTitle, value, type = 'text' }: { columnTitle: string; value: string; type?: string }) {
const field = this.get().locator(`[data-testid="nc-expand-col-${columnTitle}"]`); const field = this.get().locator(`[data-testid="nc-expand-col-${columnTitle}"]`);
await field.hover();
switch (type) { switch (type) {
case 'text': case 'text':
await field.locator('input').fill(value); await field.locator('input').fill(value);
@ -93,12 +92,14 @@ export class ExpandedFormPage extends BasePage {
break; break;
} }
case 'belongsTo': case 'belongsTo':
await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click(); await field.locator('.nc-action-icon').click();
await this.dashboard.linkRecord.select(value); await this.dashboard.linkRecord.select(value);
break; break;
case 'hasMany': case 'hasMany':
case 'manyToMany': 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); await this.dashboard.linkRecord.select(value);
break; break;
case 'dateTime': case 'dateTime':
@ -157,8 +158,12 @@ export class ExpandedFormPage extends BasePage {
} }
async openChildCard(param: { column: string; title: string }) { async openChildCard(param: { column: string; title: string }) {
const childList = this.get().locator(`[data-testid="nc-expand-col-${param.column}"]`); const childField = this.get().locator(`[data-testid="nc-expand-col-${param.column}"]`);
await childList.locator(`.ant-card:has-text("${param.title}")`).click(); 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 }) { 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 // title: Child list
// button: Link to 'City' // button: Link to 'City'
// icon: reload // 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) // child list body validation (card count, card title)
const cardCount = cardTitle.length; const cardCount = cardTitle.length;
await this.get().locator('.ant-modal-content').waitFor(); 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(); 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++) { for (let i = 0; i < cardCount; i++) {
await childList.nth(i).locator('.name').waitFor({ state: 'visible' }); await childList.nth(i).locator('.nc-display-value').waitFor({ state: 'visible' });
await childList.nth(i).locator('.name').scrollIntoViewIfNeeded(); await childList.nth(i).locator('.nc-display-value').scrollIntoViewIfNeeded();
await this.rootPage.waitForTimeout(100); await this.rootPage.waitForTimeout(100);
expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]); expect(await childList.nth(i).locator('.nc-display-value').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();
} }
} }
} }
async close() { 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' }); await this.get().waitFor({ state: 'hidden' });
} }
async openLinkRecord({ linkTableTitle }: { linkTableTitle: string }) { 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({ await this.waitForResponse({
requestUrlPathToMatch: '/exclude', requestUrlPathToMatch: '/exclude',
httpMethodsToMatch: ['GET'], 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[]) { 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(); const linkRecord = this.get();
// DOM element validation // DOM element validation
// title: Link Record // title: Link Record
// button: Add new record // button: Add new record
// icon: reload // icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Link record`); expect(await linkRecord.locator(`button:has-text("New record")`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`button:has-text("Add new record")`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`.nc-reload`).isVisible()).toBeTruthy();
// placeholder: Filter query // 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(); const childCards = await childList.count();
expect(childCards).toEqual(cardTitle.length); expect(childCards).toEqual(cardTitle.length);
for (let i = 0; i < cardTitle.length; i++) { for (let i = 0; i < cardTitle.length; i++) {
await childList.nth(i).locator('.name').scrollIntoViewIfNeeded(); await childList.nth(i).locator('.nc-display-value').scrollIntoViewIfNeeded();
await childList.nth(i).locator('.name').waitFor({ state: 'visible' }); await childList.nth(i).locator('.nc-display-value').waitFor({ state: 'visible' });
expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]); 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) { async select(cardTitle: string) {
await this.rootPage.waitForTimeout(100); await this.rootPage.waitForTimeout(100);
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click(); await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
await this.close();
} }
async close() { async close() {
await this.get().locator(`.ant-modal-close-x`).click(); await this.get().locator('.nc-close-btn').last().click();
await this.get().waitFor({ state: 'hidden' }); await this.get().last().waitFor({ state: 'hidden' });
} }
get() { 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); expect(await childList.count()).toBe(count);
// close child list // 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) { if (!isLink) {
await cell.click(); await cell.click();
await cell.locator('.nc-icon.unlink-icon').click(); await cell.locator('.nc-icon.unlink-icon').click();
// await cell.click();
} }
// For HM/MM columns // For HM/MM columns
else { else {
await cell.locator('.nc-datatype-link').click(); await cell.locator('.nc-datatype-link').click();
await this.waitForResponse({ 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/', requestUrlPathToMatch: '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'], 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.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 // select column
const selectColumn = async () => const selectColumn = async () =>
await this.rootPage await this.rootPage

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

@ -31,7 +31,13 @@ export class SharedFormPage extends BasePage {
} }
async clickLinkToChildList() { 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[]) { async verifyChildList(cardTitle?: string[]) {
@ -42,14 +48,13 @@ export class SharedFormPage extends BasePage {
// title: Link Record // title: Link Record
// button: Add new record // button: Add new record
// icon: reload // 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 // 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 // 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.locator(`.ant-card`);

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

@ -122,6 +122,7 @@ test.describe('Expanded form URL', () => {
// close child card // close child card
await dashboard.expandedForm.close(); await dashboard.expandedForm.close();
await dashboard.childList.close();
await dashboard.expandedForm.verify({ await dashboard.expandedForm.verify({
header: 'Afghanistan', header: 'Afghanistan',
url: 'rowId=1', 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 grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Mumbai'); await dashboard.linkRecord.select('Mumbai');
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' }); await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Delhi'); await dashboard.linkRecord.select('Delhi');
@ -617,8 +616,8 @@ test.describe('Undo Redo - LTAR', () => {
await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' }); await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' });
await verifyRecords([]); await verifyRecords([]);
await undo({ page, values: ['Delhi'] }); await undo({ page, values: ['Mumbai'] });
await undo({ page, values: ['Mumbai', 'Delhi'] }); await undo({ page, values: ['Delhi', 'Mumbai'] });
await undo({ page, values: ['Mumbai'] }); await undo({ page, values: ['Mumbai'] });
await undo({ page, values: [] }); 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.verifyChildList(['Atlanta', 'Pune', 'London', 'Sydney']);
await sharedForm.selectChildList('Atlanta'); await sharedForm.selectChildList('Atlanta');
await sharedForm.closeLinkToChildList();
await sharedForm.submit(); await sharedForm.submit();
await sharedForm.verifySuccessMessage(); await sharedForm.verifySuccessMessage();

Loading…
Cancel
Save