Browse Source

Nc feat/link record dropdown (#8345)

* feat(nc-gui): link record dropdown setup

* fix(nc-gui): move link record pagination to the bottom

* feat(nc-gui): link record new design

* fix(nc-gui): update bank state subtitle

* fix(nc-gui): small changes

* fix(nc-gui): update skeleton of link records item

* fix(nc-gui): ui break issue

* fix(nc-gui): increase z index of link dropdown overlay

* fix(nc-gui): update link record dropdown header

* fix(nc-gui): update link record dropdown

* fix(nc-gui): new changes

* fix: remove margin

* fix(nc-gui): unlinked records empty state alignment

* fix(nc-gui): make tooltip text size 14px

* fix(nc-gui): reduce subtext font size

* fix(nc-gui): link record item rich text cell alignment issue

* fix(nc-gui): on esc link record dropdown should close

* fix(nc-gui): update link record header and footer height

* fix(nc-gui): add link record dropdown for bt cell

* fix(nc-gui): fix invalid offset issue

* fix(nc-gui): link record z index issue

* fix: PW link unlink corrections

* fix(nc-gui): link record dropdown close on outside issue in expanded form

* fix(nc-gui): close dropdown after adding link in bt cell

* fix(nc-gui): allow close link record dropdown on esc

* fix(nc-gui): add link record dropdown in mm component

* fix(nc-gui): add link record dropdown in hm component

* fix(nc-gui): add link record dropdown in oo component

* fix: auto close for BT

* fix: ltar modal operations

* fix: close link modal

* fix: PW tests (wip)

* test: fix link modal close in share view

* test: fix LTAR in cell handling

* fix(nc-gui): reduce link items skeleton height

* fix(nc-gui): expanded form open issue on clicking bt cell record

* fix(nc-gui): hide back btn if user directly opens record list dropdown

* fix(nc-gui): reset offset when user opens link record dropdwon and switch between linked and unlinked records

* fix(nc-gui): link record item height

* fix(nc-gui): linked record list reload issue on link/unlink

* fix(nc-gui): link record item subtext fields width should be same

* fix(nc-gui): email, url cell truncate text issue

* fix(nc-gui): shared form link record dropdwon ui

* chore(nc-gui): lint

* fix(nc-gu): small changes

* fix(nc-gui): ai review changes

* fix(nc-gui): link record list offset issue

* fix(nc-gui): show plus btn in bt cell even if it is not blank

* fix(nc-gui): barcode visiblility issue in link record dropdown

* fix(nc-gui): do not focus links cell plus btn on close dropdown

* fix(nc-gui): text color

* fix(nc-gui): skip showing qr code null value

* fix(nc-gui): virtual cell margin left issue in link record dropdwon

* fix(nc-gui): link record subtext order should be same as default view col order

* chore(nc-gui): lint

* refactor(nocodb): add default view column order in col meta

* fix(nc-gui): update default view order on reordering column from fields menu

---------

Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>
nc-fix/test-cal
Ramesh Mane 7 months ago committed by GitHub
parent
commit
b4bde7336f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/nc-gui/components/cell/Email.vue
  2. 4
      packages/nc-gui/components/cell/Url.vue
  3. 30
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  4. 4
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  5. 2
      packages/nc-gui/components/tabs/Smartsheet.vue
  6. 99
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  7. 127
      packages/nc-gui/components/virtual-cell/HasMany.vue
  8. 136
      packages/nc-gui/components/virtual-cell/Links.vue
  9. 127
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  10. 93
      packages/nc-gui/components/virtual-cell/OneToOne.vue
  11. 2
      packages/nc-gui/components/virtual-cell/QrCode.vue
  12. 117
      packages/nc-gui/components/virtual-cell/components/Header.vue
  13. 85
      packages/nc-gui/components/virtual-cell/components/LinkRecordDropdown.vue
  14. 416
      packages/nc-gui/components/virtual-cell/components/LinkedItems.vue
  15. 291
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  16. 325
      packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue
  17. 2
      packages/nc-gui/composables/useExpandedFormDetached/index.ts
  18. 22
      packages/nc-gui/composables/useLTARStore.ts
  19. 2
      packages/nc-gui/composables/useSmartsheetStore.ts
  20. 8
      packages/nc-gui/composables/useViewColumns.ts
  21. 2
      packages/nc-gui/lang/en.json
  22. 20
      packages/nocodb/src/models/Column.ts
  23. 13
      packages/nocodb/src/models/Model.ts
  24. 2
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  25. 5
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts
  26. 16
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  27. 11
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  28. 8
      tests/playwright/pages/SharedForm/index.ts
  29. 2
      tests/playwright/tests/db/columns/columnLinkToAnotherRecord.spec.ts

2
packages/nc-gui/components/cell/Email.vue

@ -109,7 +109,7 @@ watch(
<nuxt-link
v-else-if="validEmail"
no-ref
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link"
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link max-w-full"
:href="`mailto:${vModel}`"
target="_blank"
:tabindex="readOnly ? -1 : 0"

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

@ -121,7 +121,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch
no-rel
class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link"
class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link max-w-full"
:to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0"
@ -133,7 +133,7 @@ watch(
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch
no-rel
class="py-1 z-3 w-full h-full text-center !no-underline hover:opacity-75 nc-cell-field-link"
class="py-1 z-3 w-full h-full text-center !no-underline hover:opacity-75 nc-cell-field-link max-w-full"
:to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0"

30
packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts

@ -9,7 +9,7 @@ export const useColumnDrag = ({
tableBodyEl: Ref<HTMLElement | undefined>
gridWrapper: Ref<HTMLElement | undefined>
}) => {
const { eventBus } = useSmartsheetStoreOrThrow()
const { eventBus, isDefaultView, meta } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
const { activeView } = storeToRefs(useViewsStore())
@ -22,6 +22,24 @@ export const useColumnDrag = ({
const dragColPlaceholderDomRef = ref<HTMLElement | null>(null)
const toBeDroppedColId = ref<string | null>(null)
const updateDefaultViewColumnOrder = (columnId: string, order: number) => {
if (!meta.value?.columns) return
const colIndex = meta.value.columns.findIndex((c) => c.id === columnId)
if (colIndex !== -1) {
meta.value.columns[colIndex].meta = { ...(meta.value.columns[colIndex].meta || {}), defaultViewColOrder: order }
meta.value.columns = (meta.value.columns || []).map((c) => {
if (c.id !== columnId) return c
c.meta = { ...(c.meta || {}), defaultViewColOrder: order }
return c
})
}
if (meta.value.columnsById[columnId]) {
meta.value.columnsById[columnId].meta = { ...(meta.value.columnsById[columnId] || {}), defaultViewColOrder: order }
}
}
const reorderColumn = async (colId: string, toColId: string) => {
const toBeReorderedViewCol = gridViewCols.value[colId]
@ -46,12 +64,19 @@ export const useColumnDrag = ({
toBeReorderedViewCol.order = newOrder
if (isDefaultView.value && toBeReorderedViewCol.fk_column_id) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, newOrder)
}
addUndo({
undo: {
fn: async () => {
if (!fields.value) return
toBeReorderedViewCol.order = oldOrder
if (isDefaultView.value) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, oldOrder)
}
await updateGridViewColumn(colId, { order: oldOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
@ -63,6 +88,9 @@ export const useColumnDrag = ({
if (!fields.value) return
toBeReorderedViewCol.order = newOrder
if (isDefaultView.value) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, newOrder)
}
await updateGridViewColumn(colId, { order: newOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)

4
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -54,7 +54,7 @@ const {
toggleFieldVisibility,
} = useViewColumnsOrThrow()
const { eventBus } = useSmartsheetStoreOrThrow()
const { eventBus, isDefaultView } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
@ -127,7 +127,7 @@ const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } },
fields.value.map(async (field, index) => {
if (field.order !== index + 1) {
field.order = index + 1
await saveOrUpdate(field, index, true)
await saveOrUpdate(field, index, true, !!isDefaultView.value)
}
}),
)

2
packages/nc-gui/components/tabs/Smartsheet.vue

@ -21,6 +21,7 @@ import {
provide,
ref,
toRef,
useExpandedFormDetachedProvider,
useMetas,
useProvideCalendarViewStore,
useProvideKanbanViewStore,
@ -83,6 +84,7 @@ provide(
ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')),
)
useExpandedFormDetachedProvider()
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere)

99
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -40,6 +40,8 @@ const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -47,8 +49,6 @@ const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, re
await loadRelatedTableMeta()
const addIcon = computed(() => (cellValue?.value ? 'expand' : 'plus'))
const value = computed(() => {
if (cellValue?.value) {
return cellValue?.value
@ -80,53 +80,70 @@ const belongsToColumn = computed(
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch(listItemsDlg, () => {
isOpen.value = listItemsDlg.value
})
// When isOpen is false, ensure the listItemsDlg is also closed.
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
}
},
{ flush: 'post' },
)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
watch(value, (next) => {
if (next) {
isOpen.value = false
}
})
</script>
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center w-full">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
/>
</template>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
class="flex-none flex group items-center min-w-4"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
icon="plus"
class="flex-none select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>
</div>
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
/>
</template>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
hide-back-btn
/> </template
></LazyVirtualCellComponentsLinkRecordDropdown>
</div>
</template>

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

@ -37,6 +37,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles()
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
@ -85,6 +89,31 @@ const hasManyColumn = computed(
const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
hideBackBtn.value = false
}
const onAttachLinkedRecord = () => {
listItemsDlg.value = false
childListDlg.value = true
}
const openChildList = () => {
if (isUnderLookup.value) return
childListDlg.value = true
listItemsDlg.value = false
isOpen.value = true
hideBackBtn.value = false
}
const openListDlg = () => {
if (isUnderLookup.value) return
listItemsDlg.value = true
childListDlg.value = false
isOpen.value = true
hideBackBtn.value = true
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -95,53 +124,75 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
break
}
})
watch([childListDlg, listItemsDlg], () => {
isOpen.value = childListDlg.value || listItemsDlg.value
})
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
childListDlg.value = false
}
},
{ flush: 'post' },
)
</script>
<template>
<div class="flex items-center gap-1 w-full chips-wrapper">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip
v-for="(cell, i) of cells"
:key="i"
:item="cell.item"
:value="cell.value"
:column="hasManyColumn"
:show-unlink-button="true"
@unlink="unlinkRef(cell.item)"
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center gap-1 w-full chips-wrapper">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip
v-for="(cell, i) of cells"
:key="i"
:item="cell.item"
:value="cell.value"
:column="hasManyColumn"
:show-unlink-button="true"
@unlink="unlinkRef(cell.item)"
/>
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="openChildList"> more... </span>
</template>
</div>
<div v-if="!isUnderLookup && !isSystemColumn(column)" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click.stop="openChildList"
/>
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">
more...
</span>
</template>
<GeneralIcon
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
icon="plus"
class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="openListDlg"
/>
</div>
</div>
<div v-if="!isUnderLookup && !isSystemColumn(column)" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click.stop="childListDlg = true"
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="hasManyColumn"
:hide-back-btn="hideBackBtn"
@attach-linked-record="onAttachLinkedRecord"
/>
<GeneralIcon
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
icon="plus"
class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="listItemsDlg = true"
<LazyVirtualCellComponentsLinkedItems
v-if="childListDlg"
v-model="childListDlg"
:cell-value="localCellValue"
:column="hasManyColumn"
@attach-record="onAttachRecord"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems v-if="listItemsDlg || childListDlg" v-model="listItemsDlg" :column="hasManyColumn" />
<LazyVirtualCellComponentsLinkedItems
v-if="listItemsDlg || childListDlg"
v-model="childListDlg"
:cell-value="localCellValue"
:column="hasManyColumn"
@attach-record="onAttachRecord"
/>
</div>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</template>
<style scoped>

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

@ -25,6 +25,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles()
const { t } = useI18n()
@ -72,12 +76,22 @@ const toatlRecordsLinked = computed(() => {
const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
hideBackBtn.value = false
}
const onAttachLinkedRecord = () => {
listItemsDlg.value = false
childListDlg.value = true
}
const openChildList = () => {
if (isUnderLookup.value) return
childListDlg.value = true
listItemsDlg.value = false
isOpen.value = true
hideBackBtn.value = false
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -85,6 +99,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
case 'Enter':
if (listItemsDlg.value) return
childListDlg.value = true
isOpen.value = true
e.stopPropagation()
break
}
@ -101,69 +116,78 @@ const openListDlg = () => {
if (isUnderLookup.value) return
listItemsDlg.value = true
childListDlg.value = false
isOpen.value = true
hideBackBtn.value = true
}
const plusBtnRef = ref<HTMLElement | null>(null)
const childListDlgRef = ref<HTMLElement | null>(null)
watch([childListDlg], () => {
if (!childListDlg.value) {
childListDlgRef.value?.focus()
}
watch([childListDlg, listItemsDlg], () => {
isOpen.value = childListDlg.value || listItemsDlg.value
})
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
childListDlg.value = false
}
},
{ flush: 'post' },
)
</script>
<template>
<div class="nc-cell-field flex w-full group items-center nc-links-wrapper py-1" @dblclick.stop="openChildList">
<div class="block flex-shrink truncate">
<component
:is="isUnderLookup ? 'span' : 'a'"
ref="childListDlgRef"
v-e="['c:cell:links:modal:open']"
:title="textVal"
class="text-center nc-datatype-link underline-transparent"
:class="{ '!text-gray-300': !textVal }"
:tabindex="readOnly ? -1 : 0"
@click.stop.prevent="openChildList"
@keydown.enter.stop.prevent="openChildList"
>
{{ textVal }}
</component>
</div>
<div class="flex-grow" />
<div
v-if="!isUnderLookup"
ref="plusBtnRef"
:tabindex="readOnly ? -1 : 0"
class="!xs:hidden flex group justify-end group-hover:flex items-center"
@keydown.enter.stop="openListDlg"
>
<MdiPlus
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="openListDlg"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg || childListDlg"
v-model="listItemsDlg"
:column="relatedTableDisplayColumn"
/>
<LazyVirtualCellComponentsLinkedItems
v-if="listItemsDlg || childListDlg"
v-model="childListDlg"
:items="toatlRecordsLinked"
:column="relatedTableDisplayColumn"
:cell-value="localCellValue"
@attach-record="onAttachRecord"
/>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex w-full group items-center">
<div class="block flex-shrink truncate">
<component
:is="isUnderLookup ? 'span' : 'a'"
v-e="['c:cell:links:modal:open']"
:title="textVal"
class="text-center nc-datatype-link underline-transparent"
:class="{ '!text-gray-300': !textVal }"
:tabindex="readOnly ? -1 : 0"
@click.stop.prevent="openChildList"
@keydown.enter.stop.prevent="openChildList"
>
{{ textVal }}
</component>
</div>
<div class="flex-grow" />
<div
v-if="!isUnderLookup"
:tabindex="readOnly ? -1 : 0"
class="!xs:hidden flex group justify-end group-hover:flex items-center"
@keydown.enter.stop="openListDlg"
>
<MdiPlus
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="openListDlg"
/>
</div>
</div>
<template #overlay>
<LazyVirtualCellComponentsLinkedItems
v-if="childListDlg"
v-model="childListDlg"
:items="toatlRecordsLinked"
:column="relatedTableDisplayColumn"
:cell-value="localCellValue"
@attach-record="onAttachRecord"
/>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="relatedTableDisplayColumn"
:hide-back-btn="hideBackBtn"
@attach-linked-record="onAttachLinkedRecord"
/>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</div>
</template>

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

@ -38,6 +38,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles()
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
@ -81,6 +85,31 @@ const unlinkRef = async (rec: Record<string, any>) => {
const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
hideBackBtn.value = false
}
const onAttachLinkedRecord = () => {
listItemsDlg.value = false
childListDlg.value = true
}
const openChildList = () => {
if (isUnderLookup.value) return
childListDlg.value = true
listItemsDlg.value = false
isOpen.value = true
hideBackBtn.value = false
}
const openListDlg = () => {
if (isUnderLookup.value) return
listItemsDlg.value = true
childListDlg.value = false
isOpen.value = true
hideBackBtn.value = true
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -96,53 +125,75 @@ const m2mColumn = computed(
() =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
watch([childListDlg, listItemsDlg], () => {
isOpen.value = childListDlg.value || listItemsDlg.value
})
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
childListDlg.value = false
}
},
{ flush: 'post' },
)
</script>
<template>
<div class="flex items-center gap-1 w-full chips-wrapper">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip
v-for="(cell, i) of cells"
:key="i"
:item="cell.item"
:value="cell.value"
:column="m2mColumn"
:show-unlink-button="true"
@unlink="unlinkRef(cell.item)"
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center gap-1 w-full chips-wrapper">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip
v-for="(cell, i) of cells"
:key="i"
:item="cell.item"
:value="cell.value"
:column="m2mColumn"
:show-unlink-button="true"
@unlink="unlinkRef(cell.item)"
/>
<span v-if="cells?.length === 10" class="caption pointer ml-1 grey--text" @click.stop="openChildList"> more... </span>
</template>
</div>
<div v-if="!isUnderLookup || isForm" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click.stop="openChildList"
/>
<span v-if="cells?.length === 10" class="caption pointer ml-1 grey--text" @click.stop="childListDlg = true">
more...
</span>
</template>
<GeneralIcon
v-if="!readOnly && isUIAllowed('dataEdit')"
icon="plus"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="openListDlg"
/>
</div>
</div>
<div v-if="!isUnderLookup || isForm" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click.stop="childListDlg = true"
<template #overlay>
<LazyVirtualCellComponentsLinkedItems
v-if="childListDlg"
v-model="childListDlg"
:cell-value="localCellValue"
:column="m2mColumn"
@attach-record="onAttachRecord"
/>
<GeneralIcon
v-if="!readOnly && isUIAllowed('dataEdit')"
icon="plus"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="listItemsDlg = true"
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="m2mColumn"
:hide-back-btn="hideBackBtn"
@attach-linked-record="onAttachLinkedRecord"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems v-if="listItemsDlg || childListDlg" v-model="listItemsDlg" :column="m2mColumn" />
<LazyVirtualCellComponentsLinkedItems
v-if="listItemsDlg || childListDlg"
v-model="childListDlg"
:cell-value="localCellValue"
:column="m2mColumn"
@attach-record="onAttachRecord"
/>
</div>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</template>
<style scoped>

93
packages/nc-gui/components/virtual-cell/OneToOne.vue

@ -40,6 +40,8 @@ const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -80,54 +82,63 @@ const belongsToColumn = computed(
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
watch(listItemsDlg, () => {
isOpen.value = listItemsDlg.value
})
// When isOpen is false, ensure the listItemsDlg is also closed.
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
}
},
{ flush: 'post' },
)
</script>
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
/>
</template>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</template>
</div>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
hide-back-btn
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
</div>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</template>
<style scoped lang="scss">

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

@ -9,7 +9,7 @@ const cellValue = inject(CellValueInj)
const isGallery = inject(IsGalleryInj, ref(false))
const qrValue = computed(() => String(cellValue?.value))
const qrValue = computed(() => String(cellValue?.value || ''))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))

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

@ -1,20 +1,22 @@
<script lang="ts" setup>
import OnetoOneIcon from '~icons/nc-icons/onetoone'
import InfoIcon from '~icons/nc-icons/info'
import FileIcon from '~icons/nc-icons/file'
import { iconMap } from '#imports'
const { relation, relatedTableTitle, displayValue, header, tableTitle } = defineProps<{
const {
relation,
relatedTableTitle,
tableTitle,
linkedRecords = 0,
} = defineProps<{
relation: string
header?: string | null
tableTitle: string
relatedTableTitle: string
displayValue?: string
linkedRecords?: number
}>()
const { isMobileMode } = useGlobal()
const { t } = useI18n()
const relationMeta = computed(() => {
@ -52,73 +54,46 @@ const relationMeta = computed(() => {
</script>
<template>
<div class="flex sm:justify-between relative pb-2 items-center">
<div v-if="!isMobileMode" class="flex text-base font-bold justify-start items-center min-w-36">
{{ header ?? '' }}
</div>
<div class="flex flex-row sm:w-[calc(100%-16rem)] xs:w-full items-center justify-center gap-2 xs:(h-full)">
<div class="flex sm:justify-end w-[calc(50%-1.5rem)] xs:(w-[calc(50%-1.5rem)] h-full)">
<div
class="flex max-w-full xs:w-full flex-shrink-0 xs:(h-full) rounded-md gap-1 text-gray-700 items-center bg-gray-100 px-2 py-1"
>
<FileIcon class="w-4 h-4 min-w-4" />
<span class="truncate">
{{ displayValue }}
</span>
</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 xs:w-[calc(50%-1.5rem)] w-[calc(50%-1.5rem)] xs:justify-start">
<div
class="flex rounded-md max-w-full flex-shrink-0 gap-1 items-center px-2 py-1 xs:w-full overflow-hidden"
: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 min-w-4"
:class="{
'!text-orange-500': relation === 'hm',
'!text-pink-500': relation === 'mm',
'!text-blue-500': relation === 'bt',
}"
/>
<span class="truncate"> {{ relatedTableTitle }} Records </span>
</div>
</div>
</div>
<div v-if="!isMobileMode" class="flex flex-row justify-end w-36">
<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
class="flex-none flex rounded-md gap-1 items-center p-1 max-h-7"
:class="{
'bg-gray-200 text-gray-600': !linkedRecords,
'bg-orange-100 text-orange-700': relation === 'hm' && linkedRecords,
'bg-pink-100 text-pink-700': relation === 'mm' && linkedRecords,
'bg-blue-100 text-blue-700': relation === 'bt' && linkedRecords,
'bg-purple-100 text-purple-700': relation === 'oo' && linkedRecords,
}"
>
<NcTooltip class="z-10 flex" 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>
</template>
<InfoIcon class="w-4 h-4" />
</NcTooltip>
</div>
</template>
<component
:is="relationMeta.icon"
class="flex-none w-5 h-5 p-1 rounded-md"
:class="{
'!bg-orange-500': relation === 'hm',
'!bg-pink-500': relation === 'mm',
'!bg-blue-500': relation === 'bt',
}"
/>
</NcTooltip>
<div class="leading-[20px]">
{{ linkedRecords || 0 }} {{ $t('general.linked') }}
{{ linkedRecords === 1 ? $t('objects.record') : $t('objects.records') }}
</div>
</div>
</template>

85
packages/nc-gui/components/virtual-cell/components/LinkRecordDropdown.vue

@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
isOpen: boolean
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
})
const emits = defineEmits(['update:isOpen'])
const isOpen = useVModel(props, 'isOpen', emits)
const ncLinksDropdownRef = ref<HTMLDivElement>()
const randomClass = `link-records_${Math.floor(Math.random() * 99999)}`
const addOrRemoveClass = (add: boolean = false) => {
const dropdownRoot = ncLinksDropdownRef.value?.parentElement?.parentElement?.parentElement?.parentElement as HTMLElement
if (dropdownRoot) {
if (add) {
dropdownRoot.classList.add('inset-0', 'nc-link-dropdown-root', `nc-root-${randomClass}`)
} else {
dropdownRoot.classList.remove('inset-0', 'nc-link-dropdown-root', `nc-root-${randomClass}`)
}
}
}
watch(
isOpen,
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
const targetEl = e?.target as HTMLElement
if (!targetEl?.classList.contains(`nc-root-${randomClass}`) || targetEl?.closest(`.nc-${randomClass}`)) {
return
}
isOpen.value = false
addOrRemoveClass(false)
})
} else {
addOrRemoveClass(false)
}
},
{ flush: 'post' },
)
watch([ncLinksDropdownRef, isOpen], () => {
if (!ncLinksDropdownRef.value) return
if (isOpen.value) {
addOrRemoveClass(true)
} else {
addOrRemoveClass(false)
}
})
</script>
<template>
<NcDropdown
:visible="isOpen"
placement="bottom"
overlay-class-name="nc-links-dropdown !min-w-[540px]"
:class="`.nc-${randomClass}`"
>
<slot />
<template #overlay>
<div ref="ncLinksDropdownRef" class="h-[412px] w-[540px]" :class="`${randomClass}`">
<slot name="overlay" />
</div>
</template>
</NcDropdown>
</template>
<style lang="scss">
.nc-links-dropdown {
z-index: 1000 !important;
}
.nc-link-dropdown-root {
z-index: 1000;
}
</style>

416
packages/nc-gui/components/virtual-cell/components/LinkedItems.vue

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { type ColumnType, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import {
ColumnInj,
IsFormInj,
@ -30,10 +31,14 @@ const vModel = useVModel(props, 'modelValue', emit)
const { isMobileMode } = useGlobal()
const { t } = useI18n()
const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isExpandedFormCloseAfterSave = ref(false)
const injectedColumn = inject(ColumnInj, ref())
const readOnly = inject(ReadonlyInj, ref(false))
@ -58,7 +63,7 @@ const {
relatedTableMeta,
link,
meta,
headerDisplayValue,
row,
resetChildrenListOffsetCount,
} = useLTARStoreOrThrow()
@ -68,7 +73,7 @@ watch(
[vModel, isForm],
(nextVal) => {
if ((nextVal[0] || nextVal[1]) && !isNew.value) {
loadChildrenList()
loadChildrenList(true)
}
// reset offset count when closing modal
@ -102,20 +107,96 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
.slice(0, isMobileMode.value ? 1 : 4)
.sort((a, b) => {
if (a.meta?.defaultViewColOrder !== undefined && b.meta?.defaultViewColOrder !== undefined) {
return a.meta.defaultViewColOrder - b.meta.defaultViewColOrder
}
return 0
})
.slice(0, isMobileMode.value ? 1 : 3)
})
const expandedFormDlg = ref(false)
const expandedFormRow = ref({})
/** populate initial state for a new row which is parent/child of current record */
const newRowState = computed(() => {
if (isNew.value) return {}
const colOpt = (injectedColumn?.value as ColumnType)?.colOptions as LinkToAnotherRecordType
const colInRelatedTable: ColumnType | undefined = relatedTableMeta?.value?.columns?.find((col) => {
// Links as for the case of 'mm' we need the 'Links' column
if (!isLinksOrLTAR(col)) return false
const colOpt1 = col?.colOptions as LinkToAnotherRecordType
if (colOpt1?.fk_related_model_id !== meta.value.id) return false
if (colOpt.type === RelationTypes.MANY_TO_MANY && colOpt1?.type === RelationTypes.MANY_TO_MANY) {
return (
colOpt.fk_parent_column_id === colOpt1.fk_child_column_id && colOpt.fk_child_column_id === colOpt1.fk_parent_column_id
)
} else {
return (
colOpt.fk_parent_column_id === colOpt1.fk_parent_column_id && colOpt.fk_child_column_id === colOpt1.fk_child_column_id
)
}
})
if (!colInRelatedTable) return {}
const relatedTableColOpt = colInRelatedTable?.colOptions as LinkToAnotherRecordType
if (!relatedTableColOpt) return {}
if (relatedTableColOpt.type === RelationTypes.BELONGS_TO) {
return {
[colInRelatedTable.title as string]: row?.value?.row,
}
} else {
return {
[colInRelatedTable.title as string]: row?.value && [row.value.row],
}
}
})
const colTitle = computed(() => injectedColumn.value?.title || '')
const onClick = (row: Row) => {
if (readOnly.value) return
if (readOnly.value || isForm.value) return
expandedFormRow.value = row
expandedFormDlg.value = true
}
const addNewRecord = () => {
expandedFormRow.value = {}
expandedFormDlg.value = true
isExpandedFormCloseAfterSave.value = true
}
const onCreatedRecord = (record: any) => {
const msgVNode = h(
'div',
{
class: 'ml-1 inline-flex flex-col gap-1 items-start',
},
[
h(
'span',
{
class: 'font-semibold',
},
t('activity.recordCreatedLinked'),
),
h(
'span',
{
class: 'text-gray-500',
},
t('activity.gotSavedLinkedSuccessfully', {
tableName: relatedTableMeta.value?.title,
recordTitle: record[relatedTableDisplayValueProp.value],
}),
),
],
)
message.success(msgVNode)
}
const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type
@ -129,6 +210,9 @@ watch(
)
watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
}
childrenExcludedOffsetCount.value = 0
childrenListOffsetCount.value = 0
})
@ -154,6 +238,10 @@ const skeletonCount = computed(() => {
})
const totalItemsToShow = computed(() => {
if (isForm.value || isNew.value) {
return state.value?.[colTitle.value]?.length
}
if (isChildrenLoading.value) {
return props.items
}
@ -204,6 +292,10 @@ const linkedShortcuts = (e: KeyboardEvent) => {
onMounted(() => {
window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
})
const childrenListRef = ref<HTMLDivElement>()
@ -226,167 +318,151 @@ const onFilterChange = () => {
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '640px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="false"
:footer="null"
:width="isForm ? 600 : 800"
size="medium"
wrap-class-name="nc-modal-child-list"
>
<LazyVirtualCellComponentsHeader
v-if="!isForm"
:display-value="headerDisplayValue"
:header="$t('activity.linkedRecords')"
:linked-records="childrenListCount"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
<div v-if="!isForm" class="flex mt-2 mb-2 items-center gap-2">
<div class="flex items-center border-1 p-1 rounded-md w-full border-gray-200 !focus-within:border-primary">
<MdiMagnify class="w-5 h-5 ml-2 text-gray-500" />
<a-input
ref="filterQueryRef"
v-model:value="childrenListPagination.query"
:bordered="false"
:placeholder="`Search in ${relatedTableMeta?.title}`"
class="w-full !sm:rounded-md xs:min-h-8 !xs:rounded-xl"
size="small"
@change="onFilterChange"
@keydown.capture.stop="
(e) => {
if (e.key === 'Escape') {
filterQueryRef?.blur()
<div class="nc-modal-child-list h-full w-full" :class="{ active: vModel }" @keydown.enter.stop>
<div class="flex flex-col h-full">
<div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
<div v-if="!isForm" class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
<MdiMagnify class="nc-search-icon w-5 h-5" />
<a-input
ref="filterQueryRef"
v-model:value="childrenListPagination.query"
:bordered="false"
placeholder="Search linked records..."
class="w-full min-h-4"
size="small"
@change="onFilterChange"
@keydown.capture.stop="
(e) => {
if (e.key === 'Escape') {
filterQueryRef?.blur()
}
}
}
"
>
</a-input>
"
>
</a-input>
</div>
<div v-else>&nbsp;</div>
<LazyVirtualCellComponentsHeader
data-testid="nc-link-count-info"
:linked-records="totalItemsToShow"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
</div>
</div>
<div ref="childrenListRef" class="flex flex-col flex-grow nc-scrollbar-md cursor-pointer pr-1">
<div v-if="isDataExist || isChildrenLoading" class="mt-2 mb-2">
<div class="cursor-pointer pr-1">
<template v-if="isChildrenLoading">
<div
v-for="(_x, i) in Array.from({ length: skeletonCount })"
:key="i"
class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50"
>
<a-skeleton-image class="h-24 w-24 !rounded-xl" />
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center">
<a-skeleton-input active class="!w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
<div ref="childrenListRef" class="flex-1 overflow-auto nc-scrollbar-thin">
<div v-if="isDataExist || isChildrenLoading">
<div class="cursor-pointer">
<template v-if="isChildrenLoading">
<div
v-for="(_x, i) in Array.from({ length: skeletonCount })"
:key="i"
class="flex flex-row gap-2 mb-2 transition-all relative !border-gray-200 hover:bg-gray-50"
>
<div class="flex items-center">
<a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
</div>
<div class="flex flex-col gap-2 flex-grow justify-center">
<a-skeleton-input active class="h-3 !w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<LazyVirtualCellComponentsListItem
v-for="(refRow, id) in childrenList?.list ?? state?.[colTitle] ?? []"
:key="id"
:attachment="attachmentCol"
:display-value-type-and-format-prop="displayValueTypeAndFormatProp"
:fields="fields"
:is-linked="childrenList?.list ? isChildrenListLinked[Number.parseInt(id)] : true"
:is-loading="isChildrenListLoading[Number.parseInt(id)]"
:related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow"
data-testid="nc-child-list-item"
@click="linkOrUnLink(refRow, id)"
@expand="onClick(refRow)"
@keydown.space.prevent="linkOrUnLink(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)"
/>
</template>
</div>
</div>
<div v-else class="pt-1 flex flex-col gap-4 my-auto items-center justify-center text-gray-500 text-center">
<img
:alt="$t('msg.clickLinkRecordsToAddLinkFromTable', { tableName: relatedTableMeta?.title })"
class="!w-[18.5rem] flex-none"
src="~assets/img/placeholder/link-records.png"
/>
<div class="text-2xl text-gray-700 font-bold">{{ $t('msg.noLinkedRecords') }}</div>
<div class="text-gray-700">
{{ $t('msg.clickLinkRecordsToAddLinkFromTable', { tableName: relatedTableMeta?.title }) }}
</template>
<template v-else>
<LazyVirtualCellComponentsListItem
v-for="(refRow, id) in childrenList?.list ?? state?.[colTitle] ?? []"
:key="id"
:attachment="attachmentCol"
:display-value-type-and-format-prop="displayValueTypeAndFormatProp"
:fields="fields"
:is-linked="childrenList?.list ? isChildrenListLinked[Number.parseInt(id)] : true"
:is-loading="isChildrenListLoading[Number.parseInt(id)]"
:related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow"
data-testid="nc-child-list-item"
@link-or-unlink="linkOrUnLink(refRow, id)"
@expand="onClick(refRow)"
@keydown.space.prevent.stop="linkOrUnLink(refRow, id)"
@keydown.enter.prevent.stop="() => onClick(refRow, id)"
/>
</template>
</div>
</div>
<div v-else class="h-full flex flex-col gap-2 my-auto items-center justify-center text-gray-500 text-center">
<img
:alt="$t('msg.clickLinkRecordsToAddLinkFromTable')"
class="!w-[158px] flex-none"
src="~assets/img/placeholder/link-records.png"
/>
<div class="text-base text-gray-700 font-bold">{{ $t('msg.noLinkedRecords') }}</div>
<div class="text-gray-700">
{{ $t('msg.clickLinkRecordsToAddLinkFromTable') }}
</div>
<NcButton
v-if="!readOnly && childrenListCount < 1"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div>
</NcButton>
<NcButton
v-if="!readOnly && (childrenListCount < 1 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
size="small"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div>
</NcButton>
</div>
</div>
</div>
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2">
<NcPagination
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
/>
</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-gray-500 bg-brand-50">
{{ totalItemsToShow || 0 }} {{ !isMobileMode ? $t('objects.records') : '' }}
{{ !isMobileMode && totalItemsToShow !== 0 ? $t('general.are') : '' }}
{{ $t('general.linked') }}
</div>
<div v-else class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50">
<span class="">
{{ state?.[colTitle]?.length || 0 }} {{ $t('objects.records') }}
{{ state?.[colTitle]?.length !== 0 ? $t('general.are') : '' }}
{{ $t('general.linked') }}
</span>
</div>
<div class="!xs:hidden flex absolute -mt-0.75 items-center py-2 justify-center w-full">
<NcPagination
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
mode="simple"
/>
</div>
<div class="flex flex-row gap-2">
<NcButton v-if="!isForm" class="nc-close-btn" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton>
<NcButton
v-if="!readOnly && childrenListCount > 0"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1">
<MdiPlus class="!xs:hidden" /> {{ isMobileMode ? $t('title.linkMore') : $t('title.linkMoreRecords') }}
<div class="bg-gray-100 px-3 py-2 rounded-b-md flex items-center justify-between gap-3 min-h-12">
<div class="flex items-center gap-2">
<NcButton
v-if="!isPublic"
v-e="['c:row-expand:open']"
size="small"
class="!hover:(bg-white text-brand-500)"
type="secondary"
@click="addNewRecord"
>
<div class="flex items-center gap-1">
<MdiPlus v-if="!isMobileMode" class="h-4 w-4" /> {{ $t('activity.newRecord') }}
</div>
</NcButton>
<NcButton
v-if="!readOnly && (childrenListCount > 0 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
class="!hover:(bg-white text-brand-500)"
size="small"
type="secondary"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1">
<GeneralIcon icon="link2" class="!xs:hidden h-4 w-4" />
{{ isMobileMode ? $t('title.linkMore') : $t('title.linkMoreRecords') }}
</div>
</NcButton>
</div>
<template v-if="!isNew && childrenList?.pageInfo && +childrenList.pageInfo.totalRows! > childrenListPagination.size">
<div class="flex justify-center items-center">
<NcPagination
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
mode="simple"
/>
</div>
</NcButton>
</template>
</div>
</div>
@ -394,7 +470,15 @@ const onFilterChange = () => {
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:close-after-save="isExpandedFormCloseAfterSave"
:meta="relatedTableMeta"
:new-record-header="
isExpandedFormCloseAfterSave
? $t('activity.tableNameCreateNewRecord', {
tableName: relatedTableMeta?.title,
})
: undefined
"
:row="{
row: expandedFormRow,
oldRow: expandedFormRow,
@ -405,11 +489,13 @@ const onFilterChange = () => {
new: true,
},
}"
:state="newRowState"
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
use-meta-fields
@created-record="onCreatedRecord"
/>
</Suspense>
</NcModal>
</div>
</template>
<style lang="scss" scoped>
@ -420,10 +506,22 @@ const onFilterChange = () => {
:deep(.ant-modal-content) {
@apply !p-0;
}
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style>
<style lang="scss">
.nc-modal-child-list > .ant-modal > .ant-modal-content {
@apply !p-0;
.nc-dropdown-link-record-search-wrapper {
.nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
}
</style>

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

@ -16,19 +16,23 @@ import {
useVModel,
} from '#imports'
import MaximizeIcon from '~icons/nc-icons/maximize'
import LinkIcon from '~icons/nc-icons/link'
const props = defineProps<{
row: any
fields: any[]
attachment: any
relatedTableDisplayValueProp: string
displayValueTypeAndFormatProp: { type: string; format: string }
isLoading: boolean
isLinked: boolean
}>()
const props = withDefaults(
defineProps<{
row: any
fields: any[]
attachment: any
relatedTableDisplayValueProp: string
displayValueTypeAndFormatProp: { type: string; format: string }
isLoading: boolean
isLinked: boolean
}>(),
{
isLoading: false,
},
)
defineEmits(['expand'])
defineEmits(['expand', 'linkOrUnlink'])
provide(IsExpandedFormOpenInj, ref(true))
@ -88,116 +92,198 @@ const displayValue = computed(() => {
</script>
<template>
<a-card
tabindex="0"
class="nc-list-item !outline-brand-500 !border-1 group transition-all !rounded-xl relative !mb-2 !border-gray-200 hover:bg-gray-50"
:class="{
'!bg-white': isLoading,
'!border-1': isLinked && !isLoading,
'!cursor-auto !hover:bg-white': readOnly,
}"
:body-style="{ padding: 0 }"
:hoverable="false"
>
<div class="flex flex-row items-center justify-start w-full">
<a-carousel v-if="attachment && attachments && attachments.length" autoplay class="!w-24 !h-24 !max-h-24 !max-w-24">
<template #customPaging> </template>
<template v-for="(attachmentObj, index) in attachments">
<LazyCellAttachmentImage
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`"
class="!h-24 !w-24 !max-h-24 !max-w-24 object-cover !rounded-l-xl"
:srcs="getPossibleAttachmentSrc(attachmentObj)"
/>
</template>
</a-carousel>
<div
v-else-if="attachment"
class="h-24 w-24 !min-h-24 !min-w-24 !max-h-24 !max-w-24 !flex flex-row items-center !rounded-l-xl justify-center"
>
<GeneralIcon class="w-full h-full !text-6xl !leading-10 !text-transparent rounded-lg" icon="fileImage" />
</div>
<div class="nc-list-item-wrapper group px-[1px] hover:bg-gray-50 border-y-1 border-gray-200 border-t-transparent">
<a-card
tabindex="0"
class="nc-list-item !outline-none transition-all relative group-hover:bg-gray-50 cursor-auto"
:class="{
'!bg-white': isLoading,
'!hover:bg-white': readOnly,
}"
:body-style="{ padding: '6px 10px !important', borderRadius: 0 }"
:hoverable="false"
>
<div class="flex items-center gap-3">
<div v-if="isLoading" class="flex">
<MdiLoading class="flex-none w-7 h-7 !text-brand-500 animate-spin" />
</div>
<div class="flex flex-col m-[.75rem] gap-1 flex-grow justify-center overflow-hidden">
<div class="flex justify-between xs:gap-x-2">
<span class="font-semibold text-brand-500 nc-display-value xs:(truncate)">
{{ displayValue }}
</span>
<div
v-if="isLinked && !isLoading"
class="text-brand-500 text-0.875"
<NcTooltip v-else class="z-10 flex">
<template #title> {{ isLinked ? 'Unlink' : 'Link' }}</template>
<button
tabindex="-1"
class="nc-list-item-link-unlink-btn p-1.5 flex rounded-lg transition-all"
:class="{
'!group-hover:mr-12': fields.length === 0 && !readOnly,
'bg-red-100 text-red-500 hover:bg-red-200': isLinked,
'bg-green-100 text-green-500 hover:bg-green-200': !isLinked,
}"
@click="$emit('linkOrUnlink')"
>
<LinkIcon class="w-4 h-4" />
Linked
<GeneralIcon :icon="isLinked ? 'minus' : 'plus'" class="flex-none w-4 h-4 !font-extrabold" />
</button>
</NcTooltip>
<template v-if="attachment">
<div v-if="attachments && attachments.length">
<a-carousel autoplay class="!w-11 !h-11 !max-h-11 !max-w-11">
<template #customPaging> </template>
<template v-for="(attachmentObj, index) in attachments">
<LazyCellAttachmentImage
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`"
class="!w-11 !h-11 !max-h-11 !max-w-11object-cover !rounded-l-xl"
:srcs="getPossibleAttachmentSrc(attachmentObj)"
/>
</template>
</a-carousel>
</div>
<MdiLoading
v-else-if="isLoading"
:class="{
'!group-hover:mr-8': fields.length === 0 && !readOnly,
}"
class="w-6 h-6 !text-brand-500 animate-spin"
/>
</div>
<div
v-else
class="h-11 w-11 !min-h-11 !min-w-11 !max-h-11 !max-w-11 !flex flex-row items-center !rounded-l-xl justify-center"
>
<GeneralIcon class="w-full h-full !text-6xl !leading-10 !text-transparent rounded-lg" icon="fileImage" />
</div>
</template>
<div
v-if="fields.length > 0 && !isPublic && !isForm"
class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 w-10/12"
>
<div v-for="field in fields" :key="field.id" :class="attachment ? 'sm:w-1/3' : 'sm: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" />
<div v-if="!isRowEmpty(row, field)">
<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 class="flex-1 flex flex-col gap-1 justify-center overflow-hidden">
<div class="flex justify-start">
<span class="font-semibold text-brand-500 nc-display-value truncate leading-[20px]">
{{ displayValue }}
</span>
</div>
<div
v-if="fields.length > 0 && !isPublic && !isForm"
class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 min-h-5"
>
<div v-for="field in fields" :key="field.id" class="sm:(w-1/3 max-w-1/3 overflow-hidden)">
<div v-if="!isRowEmpty(row, field)" class="flex flex-col gap-[-1]">
<NcTooltip class="z-10 flex" placement="bottom">
<template #title>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
class="!scale-60 text-gray-100 !text-sm"
:column="field"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell v-else class="!scale-70 text-gray-100 !text-sm" :column="field" :hide-menu="true" />
</template>
<div class="nc-link-record-cell flex w-full max-w-full">
<LazySmartsheetVirtualCell v-if="isVirtualCol(field)" v-model="row[field.title]" :row="row" :column="field" />
<LazySmartsheetCell
v-else
v-model="row[field.title]"
:column="field"
:edit-enabled="false"
:read-only="true"
/>
</div>
</NcTooltip>
</div>
<div v-else class="flex flex-row w-full h-[1.375rem] pl-1 items-center justify-start">-</div>
<div v-else class="flex flex-row w-full max-w-72 h-5 pl-1 items-center justify-start">-</div>
</div>
</div>
</div>
<div v-if="!isForm && !isPublic && !readOnly" class="flex-none flex items-center w-7">
<button
v-e="['c:row-expand:open']"
:tabindex="-1"
class="z-10 flex items-center justify-center nc-expand-item !group-hover:visible !invisible !h-7 !w-7 transition-all !hover:children:(w-4.5 h-4.5)"
@click.stop="$emit('expand', row)"
>
<MaximizeIcon class="flex-none w-4 h-4 scale-125" />
</button>
</div>
</div>
</div>
<NcButton
v-if="!isForm && !isPublic && !readOnly"
v-e="['c:row-expand:open']"
type="text"
size="medium"
class="!px-2 nc-expand-item !group-hover:block !hidden !border-1 !shadow-sm !border-gray-200 !bg-white !absolute right-3 bottom-3"
:class="{
'!group-hover:right-1.8 !group-hover:bottom-1.7': fields.length === 0,
}"
@click.stop="$emit('expand', row)"
>
<MaximizeIcon class="w-4 h-4" />
</NcButton>
</a-card>
</a-card>
</div>
</template>
<style lang="scss" scoped>
:deep(.slick-list) {
@apply rounded-lg;
}
.nc-list-item-link-unlink-btn {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
.nc-link-record-cell {
:deep(.nc-cell),
:deep(.nc-virtual-cell) {
@apply !text-small !text-gray-600 ml-1;
.nc-cell-field,
.nc-cell-field-link,
input,
textarea {
@apply !text-small !p-0 m-0;
}
&:not(.nc-display-value-cell) {
@apply text-gray-600;
font-weight: 500;
.nc-cell-field,
input,
textarea {
@apply text-gray-600;
font-weight: 500;
}
}
.nc-cell-field,
a.nc-cell-field-link,
input,
textarea {
@apply !p-0 m-0;
}
&.nc-cell-longtext {
@apply leading-[18px];
textarea {
@apply pr-2;
}
.long-text-wrapper {
@apply !min-h-4;
.nc-rich-text-grid {
@apply pl-0 -ml-1;
}
}
}
.ant-picker-input {
@apply text-small leading-4;
font-weight: 500;
input {
@apply text-small leading-4;
font-weight: 500;
}
}
.ant-select:not(.ant-select-customize-input) {
.ant-select-selector {
@apply !border-none flex-nowrap pr-4.5;
}
.ant-select-arrow {
@apply right-[3px];
}
}
}
}
</style>
<style lang="scss">
.nc-list-item {
@apply border-1 border-transparent rounded-md;
&:focus-visible {
@apply border-brand-500;
box-shadow: 0 0 0 1px #3366ff;
}
&:hover {
.nc-text-area-expand-btn {
@apply !hidden;
@ -206,13 +292,14 @@ const displayValue = computed(() => {
.long-text-wrapper {
@apply select-none pointer-events-none;
.nc-readonly-rich-text-wrapper {
@apply !min-h-6 !max-h-6;
@apply !min-h-5 !max-h-5;
}
.nc-rich-text-embed {
@apply -mt-0.5;
.nc-textarea-rich-editor {
@apply !overflow-hidden;
.ProseMirror {
@apply !overflow-hidden line-clamp-1;
@apply !overflow-hidden line-clamp-1 h-[18px] pt-0.4;
}
}
}

325
packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue

@ -14,9 +14,9 @@ import {
useVModel,
} from '#imports'
const props = defineProps<{ modelValue: boolean; column: any }>()
const props = defineProps<{ modelValue: boolean; column: any; hideBackBtn?: boolean }>()
const emit = defineEmits(['update:modelValue', 'addNewRecord'])
const emit = defineEmits(['update:modelValue', 'addNewRecord', 'attachLinkedRecord'])
const vModel = useVModel(props, 'modelValue', emit)
@ -50,7 +50,6 @@ const {
meta,
unlink,
row,
headerDisplayValue,
resetChildrenExcludedOffsetCount,
} = useLTARStoreOrThrow()
@ -100,7 +99,7 @@ watch(
if (!isForm.value) {
loadChildrenList()
}
loadChildrenExcludedList(rowState.value)
loadChildrenExcludedList(rowState.value, true)
}
if (!nextVal) {
resetChildrenExcludedOffsetCount()
@ -157,13 +156,31 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
.slice(0, isMobileMode.value ? 1 : 4)
.sort((a, b) => {
if (a.meta?.defaultViewColOrder !== undefined && b.meta?.defaultViewColOrder !== undefined) {
return a.meta.defaultViewColOrder - b.meta.defaultViewColOrder
}
return 0
})
.slice(0, isMobileMode.value ? 1 : 3)
})
const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type
})
const totalItemsToShow = computed(() => {
if (relation.value === 'bt') {
return row.value.row[relatedTableMeta.value?.title] ? 1 : 0
}
if (isForm.value || isNew.value) {
return rowState.value?.[injectedColumn!.value?.title]?.length ?? 0
}
return childrenListCount.value ?? 0
})
watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
@ -253,6 +270,10 @@ watch(childrenExcludedListPagination, () => {
onMounted(() => {
window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
})
onUnmounted(() => {
@ -268,154 +289,148 @@ const onFilterChange = () => {
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '640px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="false"
:footer="null"
:width="isForm ? 600 : 800"
wrap-class-name="nc-modal-link-record"
>
<LazyVirtualCellComponentsHeader
v-if="!isForm"
:display-value="headerDisplayValue"
:header="$t('activity.addNewLink')"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
<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 !focus-within:border-primary">
<MdiMagnify class="w-5 h-5 ml-2 text-gray-500" />
<a-input
ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query"
:bordered="false"
:placeholder="`${$t('general.searchIn')} ${relatedTableMeta?.title}`"
class="w-full !rounded-md nc-excluded-search xs:min-h-8"
size="small"
@change="onFilterChange"
@keydown.capture.stop="
(e) => {
if (e.key === 'Escape') {
filterQueryRef?.blur()
}
}
"
>
</a-input>
</div>
<div class="flex-1" />
<!-- Add new record -->
<NcButton
v-if="!isPublic"
v-e="['c:row-expand:open']"
:size="isMobileMode ? 'medium' : 'small'"
class="!text-brand-500"
type="secondary"
@click="addNewRecord"
>
<div class="flex items-center gap-1 px-4"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
</NcButton>
</div>
<template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div ref="childrenExcludedListRef" class="overflow-scroll nc-scrollbar-md pr-1 cursor-pointer flex flex-col flex-grow">
<template v-if="isChildrenExcludedLoading">
<div
v-for="(_x, i) in Array.from({ length: 10 })"
:key="i"
class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50"
<div class="nc-modal-link-record h-full w-full overflow-hidden" :class="{ active: vModel }" @keydown.enter.stop>
<div class="flex flex-col h-full">
<div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
<div class="flex-1 gap-2 flex items-center">
<button
v-if="!hideBackBtn"
class="!text-brand-500 hover:!text-brand-700 p-1.5 flex"
@click="emit('attachLinkedRecord')"
>
<a-skeleton-image class="h-24 w-24 !rounded-xl" />
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center">
<a-skeleton-input active class="!xs:w-30 !w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
<GeneralIcon icon="ncArrowLeft" class="flex-none h-4 w-4" />
</button>
<div class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
<MdiMagnify class="nc-search-icon w-5 h-5" />
<a-input
ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query"
:bordered="false"
placeholder="Search records to link..."
class="w-full nc-excluded-search min-h-4"
size="small"
@change="onFilterChange"
@keydown.capture.stop="
(e) => {
if (e.key === 'Escape') {
filterQueryRef?.blur()
}
}
"
>
</a-input>
</div>
</div>
<LazyVirtualCellComponentsHeader
data-testid="nc-link-count-info"
:linked-records="totalItemsToShow"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
</div>
<div class="flex-1 overflow-auto nc-scrollbar-thin">
<template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div ref="childrenExcludedListRef">
<template v-if="isChildrenExcludedLoading">
<div
v-for="(_x, i) in Array.from({ length: 10 })"
:key="i"
class="flex flex-row gap-2 mb-2 transition-all relative !border-gray-200 hover:bg-gray-50"
>
<div class="flex items-center">
<a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
<div class="flex flex-col gap-2 flex-grow justify-center">
<a-skeleton-input active class="h-3 !w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<LazyVirtualCellComponentsListItem
v-for="(refRow, id) in childrenExcludedList?.list ?? []"
:key="id"
:attachment="attachmentCol"
:display-value-type-and-format-prop="displayValueTypeAndFormatProp"
:fields="fields"
:is-linked="isChildrenExcludedListLinked[Number.parseInt(id)]"
:is-loading="isChildrenExcludedListLoading[Number.parseInt(id)]"
:related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow"
data-testid="nc-excluded-list-item"
@link-or-unlink="onClick(refRow, id)"
@expand="
() => {
expandedFormRow = refRow
expandedFormDlg = true
}
"
@keydown.space.prevent.stop="() => onClick(refRow, id)"
@keydown.enter.prevent.stop="() => onClick(refRow, id)"
/>
</template>
</div>
</template>
<template v-else>
<LazyVirtualCellComponentsListItem
v-for="(refRow, id) in childrenExcludedList?.list ?? []"
:key="id"
:attachment="attachmentCol"
:display-value-type-and-format-prop="displayValueTypeAndFormatProp"
:fields="fields"
:is-linked="isChildrenExcludedListLinked[Number.parseInt(id)]"
:is-loading="isChildrenExcludedListLoading[Number.parseInt(id)]"
:related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow"
data-testid="nc-excluded-list-item"
@click="() => onClick(refRow, id)"
@expand="
() => {
expandedFormRow = refRow
expandedFormDlg = true
}
"
@keydown.space.prevent="() => onClick(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)"
/>
</template>
<div v-else class="h-full my-auto py-2 flex flex-col gap-3 items-center justify-center text-gray-500">
<InboxIcon class="w-16 h-16 mx-auto" />
<p>
{{ $t('msg.thereAreNoRecordsInTable') }}
{{ relatedTableMeta?.title }}
</p>
</div>
</div>
</template>
<div v-else class="my-auto py-2 flex flex-col gap-3 items-center justify-center text-gray-500">
<InboxIcon class="w-16 h-16 mx-auto" />
<p>
{{ $t('msg.thereAreNoRecordsInTable') }}
{{ relatedTableMeta?.title }}
</p>
</div>
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2">
<NcPagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows"
entity-name="links-excluded-list"
/>
</div>
<div class="mb-2 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex flex-row justify-between items-center bg-white relative pt-1">
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50 h-9.5">
{{ relation === 'bt' ? (row.row[relatedTableMeta?.title] ? '1' : 0) : childrenListCount ?? 'No' }}
{{ !isMobileMode ? $t('objects.records') : '' }} {{ !isMobileMode && childrenListCount !== 0 ? 'are' : '' }}
{{ $t('general.linked') }}
</div>
<div class="!xs:hidden flex absolute -mt-0.75 items-center py-2 justify-center w-full">
<NcPagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows"
entity-name="links-excluded-list"
mode="simple"
/>
<div class="bg-gray-100 px-3 py-2 rounded-b-md flex items-center justify-between min-h-12">
<div class="flex">
<NcButton
v-if="!isPublic"
v-e="['c:row-expand:open']"
size="small"
class="!hover:(bg-white text-brand-500)"
type="secondary"
@click="addNewRecord"
>
<div class="flex items-center gap-1"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
</NcButton>
</div>
<template
v-if="
childrenExcludedList?.pageInfo && +childrenExcludedList?.pageInfo?.totalRows > childrenExcludedListPagination.size
"
>
<div v-if="isMobileMode" class="flex items-center">
<NcPagination
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows"
entity-name="links-excluded-list"
/>
</div>
<div v-else class="flex items-center">
<NcPagination
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows"
entity-name="links-excluded-list"
mode="simple"
/>
</div>
</template>
</div>
<NcButton class="nc-close-btn ml-auto" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton>
</div>
<Suspense>
<LazySmartsheetExpandedForm
@ -446,11 +461,25 @@ const onFilterChange = () => {
@created-record="onCreatedRecord"
/>
</Suspense>
</NcModal>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style>
<style lang="scss">
.nc-modal-link-record > .ant-modal > .ant-modal-content {
@apply !p-0;
.nc-dropdown-link-record-search-wrapper {
.nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
}
</style>

2
packages/nc-gui/composables/useExpandedFormDetached/index.ts

@ -19,6 +19,8 @@ const [setup, use] = useInjectionState(() => {
return ref<UseExpandedFormDetachedProps[]>([])
})
export { setup as useExpandedFormDetachedProvider }
export function useExpandedFormDetached() {
let states = use()!

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

@ -12,9 +12,11 @@ import {
IsPublicInj,
Modal,
NOCO,
NcErrorType,
SharedViewPasswordInj,
computed,
extractSdkResponseErrorMsg,
extractSdkResponseErrorMsgv2,
inject,
message,
parseProp,
@ -188,15 +190,16 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return row.value.row[displayValueProp.value]
})
const loadChildrenExcludedList = async (activeState?: any) => {
const loadChildrenExcludedList = async (activeState?: any, resetOffset: boolean = false) => {
if (activeState) newRowState.state = activeState
try {
let offset =
childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
if (offset < 0) {
if (offset < 0 || resetOffset) {
offset = 0
childrenExcludedOffsetCount.value = 0
childrenExcludedListPagination.page = 1
}
isChildrenExcludedLoading.value = true
if (isPublic.value) {
@ -284,27 +287,29 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
}
} catch (e: any) {
// temporary fix to handle when offset is beyond limit
if ((await extractSdkResponseErrorMsg(e)) === 'Offset is beyond the total number of records') {
const error = await extractSdkResponseErrorMsgv2(e)
if (error.error === NcErrorType.INVALID_OFFSET_VALUE) {
childrenExcludedListPagination.page = 0
return loadChildrenExcludedList(activeState)
return loadChildrenExcludedList(activeState, true)
}
message.error(`${t('msg.error.failedToLoadList')}: ${await extractSdkResponseErrorMsg(e)}`)
message.error(`${t('msg.error.failedToLoadList')}: ${error.message}`)
} finally {
isChildrenExcludedLoading.value = false
}
}
const loadChildrenList = async () => {
const loadChildrenList = async (resetOffset: boolean = false) => {
try {
isChildrenLoading.value = true
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return
if (!rowId.value || !column.value) return
let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value
if (offset < 0) {
if (offset < 0 || resetOffset) {
offset = 0
childrenListOffsetCount.value = 0
childrenListPagination.page = 1
} else if (offset >= childrenListCount.value) {
offset = 0
}
@ -347,6 +352,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenListLinked.value[index] = true
isChildrenListLoading.value[index] = false
})
if (!childrenListPagination.query) {
childrenListCount.value = childrenList.value?.pageInfo.totalRows ?? 0
}

2
packages/nc-gui/composables/useSmartsheetStore.ts

@ -38,6 +38,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared)
const isDefaultView = computed(() => view.value?.is_default)
const xWhere = computed(() => {
let where
const col =
@ -100,6 +101,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
eventBus,
sqlUi,
allFilters,
isDefaultView,
}
},
'smartsheet-store',

8
packages/nc-gui/composables/useViewColumns.ts

@ -159,7 +159,12 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
$e('a:fields:show-all')
}
const saveOrUpdate = async (field: any, index: number, disableDataReload: boolean = false) => {
const saveOrUpdate = async (
field: any,
index: number,
disableDataReload: boolean = false,
updateDefaultViewColumnOrder: boolean = false,
) => {
if (isLocalMode.value && fields.value) {
fields.value[index] = field
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
@ -168,6 +173,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
...column,
...field,
id: field.fk_column_id,
...(updateDefaultViewColumnOrder ? { meta: { ...parseProp(column.meta), defaultViewColOrder: field.order } } : {}),
}
}
return column

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

@ -1181,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",

20
packages/nocodb/src/models/Column.ts

@ -486,8 +486,10 @@ export default class Column<T = any> implements ColumnType {
public static async list(
{
fk_model_id,
fk_default_view_id,
}: {
fk_model_id: string;
fk_default_view_id?: string;
},
ncMeta = Noco.ncMeta,
): Promise<Column[]> {
@ -505,9 +507,27 @@ export default class Column<T = any> implements ColumnType {
order: 'asc',
},
});
const defaultViewColumns = fk_default_view_id
? await View.getColumns(fk_default_view_id)
: [];
const defaultViewColumnOrderMap = defaultViewColumns.reduce(
(acc, col) => {
acc[col.fk_column_id] = col.order;
return acc;
},
{} as Record<string, number>,
);
columnsList.forEach((column) => {
column.meta = parseMetaProp(column);
if (defaultViewColumns.length) {
column.meta = {
...column.meta,
defaultViewColOrder: defaultViewColumnOrderMap[column.id],
};
}
});
await NocoCache.setList(CacheScope.COLUMN, [fk_model_id], columnsList);

13
packages/nocodb/src/models/Model.ts

@ -65,10 +65,14 @@ export default class Model implements TableType {
Object.assign(this, data);
}
public async getColumns(ncMeta = Noco.ncMeta): Promise<Column[]> {
public async getColumns(
ncMeta = Noco.ncMeta,
defaultViewId = undefined,
): Promise<Column[]> {
this.columns = await Column.list(
{
fk_model_id: this.id,
fk_default_view_id: defaultViewId,
},
ncMeta,
);
@ -391,8 +395,13 @@ export default class Model implements TableType {
}
if (modelData) {
const m = new Model(modelData);
const columns = await m.getColumns(ncMeta);
await m.getViews(false, ncMeta);
const defaultViewId = m.views.find((view) => view.is_default).id;
const columns = await m.getColumns(ncMeta, defaultViewId);
m.columnsById = columns.reduce((agg, c) => ({ ...agg, [c.id]: c }), {});
return m;
}

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

@ -84,7 +84,7 @@ export class ExpandedFormPage extends BasePage {
case 'belongsTo':
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, false);
break;
case 'hasMany':
case 'manyToMany':

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

@ -22,7 +22,7 @@ export class ChildList extends BasePage {
// child list body validation (card count, card title)
const cardCount = cardTitle.length;
await this.get().locator('.ant-modal-content').waitFor();
await this.get().locator('.nc-dropdown-link-record-header').waitFor();
{
let isOk = false;
let count = 0;
@ -54,7 +54,8 @@ export class ChildList extends BasePage {
}
async close() {
await this.get().locator(`.nc-close-btn`).click();
// await this.get().locator(`.nc-close-btn`).click();
await this.rootPage.keyboard.press('Escape');
await this.get().waitFor({ state: 'hidden' });
}

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

@ -33,14 +33,22 @@ export class LinkRecord extends BasePage {
}
}
async select(cardTitle: string) {
async select(cardTitle: string, close = true) {
await this.rootPage.waitForTimeout(100);
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
await this.close();
await this.get()
.locator(`.ant-card:has-text("${cardTitle}"):visible`)
.locator('button.nc-list-item-link-unlink-btn')
.click();
// explicitly close dropdown (auto closes for belongs to)
if (close) {
await this.close();
}
}
async close() {
await this.get().locator('.nc-close-btn').last().click();
await this.get().getByTestId('nc-link-count-info').click();
await this.rootPage.keyboard.press('Escape');
await this.get().last().waitFor({ state: 'hidden' });
}

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

@ -347,7 +347,9 @@ export class CellPageObject extends BasePage {
await expect.poll(() => this.rootPage.locator('.ant-card:visible').count()).toBe(count);
// close child list
await this.rootPage.locator('.nc-modal-child-list').locator('.nc-close-btn').last().click();
// await this.rootPage.locator('.nc-modal-child-list').locator('.nc-close-btn').last().click();
await this.rootPage.locator('.nc-modal-child-list').getByTestId('nc-link-count-info').click();
await this.rootPage.keyboard.press('Escape');
}
}
}
@ -373,12 +375,17 @@ export class CellPageObject extends BasePage {
await this.waitForResponse({
uiAction: () =>
this.rootPage.locator(`[data-testid="nc-child-list-item"]`).last().click({ force: true, timeout: 3000 }),
this.rootPage
.locator(`[data-testid="nc-child-list-item"]`)
.last()
.locator('button.nc-list-item-link-unlink-btn')
.click({ force: true, timeout: 3000 }),
requestUrlPathToMatch: '/api/v1/db/data/noco',
httpMethodsToMatch: ['GET'],
});
await this.rootPage.keyboard.press('Escape');
await this.rootPage.keyboard.press('Escape');
}
}

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

@ -40,7 +40,8 @@ export class SharedFormPage extends BasePage {
}
async closeLinkToChildList() {
await this.get().locator('.nc-close-btn').click();
// await this.get().locator('.nc-close-btn').click();
await this.rootPage.keyboard.press('Escape');
}
async verifyChildList(cardTitle?: string[]) {
@ -69,6 +70,9 @@ export class SharedFormPage extends BasePage {
}
async selectChildList(cardTitle: string) {
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
await this.get()
.locator(`.ant-card:has-text("${cardTitle}"):visible`)
.locator('.nc-list-item-link-unlink-btn')
.click();
}
}

2
tests/playwright/tests/db/columns/columnLinkToAnotherRecord.spec.ts

@ -90,7 +90,7 @@ test.describe('LTAR create & update', () => {
// In cell insert
await dashboard.grid.addNewRow({ index: 1, value: '2b' });
await dashboard.grid.cell.inCellAdd({ index: 1, columnHeader: 'Sheet1' });
await dashboard.linkRecord.select('1b');
await dashboard.linkRecord.select('1b', false);
await dashboard.grid.cell.inCellAdd({
index: 1,
columnHeader: 'Sheet1s',

Loading…
Cancel
Save