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 <nuxt-link
v-else-if="validEmail" v-else-if="validEmail"
no-ref 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}`" :href="`mailto:${vModel}`"
target="_blank" target="_blank"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

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

@ -121,7 +121,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay" v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel 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" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"
@ -133,7 +133,7 @@ watch(
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay" v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel 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" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

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

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

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

@ -54,7 +54,7 @@ const {
toggleFieldVisibility, toggleFieldVisibility,
} = useViewColumnsOrThrow() } = useViewColumnsOrThrow()
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus, isDefaultView } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo() const { addUndo, defineViewScope } = useUndoRedo()
@ -127,7 +127,7 @@ const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } },
fields.value.map(async (field, index) => { fields.value.map(async (field, index) => {
if (field.order !== index + 1) { if (field.order !== index + 1) {
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, provide,
ref, ref,
toRef, toRef,
useExpandedFormDetachedProvider,
useMetas, useMetas,
useProvideCalendarViewStore, useProvideCalendarViewStore,
useProvideKanbanViewStore, useProvideKanbanViewStore,
@ -83,6 +84,7 @@ provide(
ReadonlyInj, ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')), computed(() => !isUIAllowed('dataEdit')),
) )
useExpandedFormDetachedProvider()
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger()) useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere) 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 listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow() const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } = const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -47,8 +49,6 @@ const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, re
await loadRelatedTableMeta() await loadRelatedTableMeta()
const addIcon = computed(() => (cellValue?.value ? 'expand' : 'plus'))
const value = computed(() => { const value = computed(() => {
if (cellValue?.value) { if (cellValue?.value) {
return 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, 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], () => { watch(value, (next) => {
if (!listItemsDlg.value) { if (next) {
plusBtnRef.value?.focus() isOpen.value = false
} }
}) })
</script> </script>
<template> <template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }"> <div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1"> <LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)"> <div class="flex items-center w-full">
<VirtualCellComponentsItemChip <div class="nc-cell-field chips flex items-center flex-1">
:item="value" <template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
:value=" <VirtualCellComponentsItemChip
!Array.isArray(value) && typeof value === 'object' :item="value"
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId] :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" :column="belongsToColumn"
:show-unlink-button="true" hide-back-btn
@unlink="unlinkRef(value)" /> </template
/> ></LazyVirtualCellComponentsLinkRecordDropdown>
</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"
/>
</div> </div>
</template> </template>

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

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

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

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

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

@ -38,6 +38,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false) const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow() const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
@ -81,6 +85,31 @@ const unlinkRef = async (rec: Record<string, any>) => {
const onAttachRecord = () => { const onAttachRecord = () => {
childListDlg.value = false childListDlg.value = false
listItemsDlg.value = true 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) => { 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, 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> </script>
<template> <template>
<div class="flex items-center gap-1 w-full chips-wrapper"> <LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden"> <div class="flex items-center gap-1 w-full chips-wrapper">
<template v-if="cells"> <div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<VirtualCellComponentsItemChip <template v-if="cells">
v-for="(cell, i) of cells" <VirtualCellComponentsItemChip
:key="i" v-for="(cell, i) of cells"
:item="cell.item" :key="i"
:value="cell.value" :item="cell.item"
:column="m2mColumn" :value="cell.value"
:show-unlink-button="true" :column="m2mColumn"
@unlink="unlinkRef(cell.item)" :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"> <GeneralIcon
more... v-if="!readOnly && isUIAllowed('dataEdit')"
</span> icon="plus"
</template> class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="openListDlg"
/>
</div>
</div> </div>
<div v-if="!isUnderLookup || isForm" class="flex justify-end gap-1 min-h-[30px] items-center"> <template #overlay>
<GeneralIcon <LazyVirtualCellComponentsLinkedItems
icon="expand" v-if="childListDlg"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand" v-model="childListDlg"
@click.stop="childListDlg = true" :cell-value="localCellValue"
:column="m2mColumn"
@attach-record="onAttachRecord"
/> />
<LazyVirtualCellComponentsUnLinkedItems
<GeneralIcon v-if="listItemsDlg"
v-if="!readOnly && isUIAllowed('dataEdit')" v-model="listItemsDlg"
icon="plus" :column="m2mColumn"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus" :hide-back-btn="hideBackBtn"
@click.stop="listItemsDlg = true" @attach-linked-record="onAttachLinkedRecord"
/> />
</div> </template>
</LazyVirtualCellComponentsLinkRecordDropdown>
<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> </template>
<style scoped> <style scoped>

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

@ -40,6 +40,8 @@ const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false) const listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow() const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } = 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, 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
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
}) })
// When isOpen is false, ensure the listItemsDlg is also closed.
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
}
},
{ flush: 'post' },
)
</script> </script>
<template> <template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }"> <LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="nc-cell-field chips flex items-center flex-1"> <div class="flex w-full chips-wrapper items-center" :class="{ active }">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)"> <div class="nc-cell-field chips flex items-center flex-1">
<VirtualCellComponentsItemChip <template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
:item="value" <VirtualCellComponentsItemChip
:value=" :item="value"
!Array.isArray(value) && typeof value === 'object' :value="
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId] !Array.isArray(value) && typeof value === 'object'
: value ? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
" : value
:column="belongsToColumn" "
:show-unlink-button="true" :column="belongsToColumn"
@unlink="unlinkRef(value)" :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>
<template #overlay>
<div <LazyVirtualCellComponentsUnLinkedItems
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup" v-if="listItemsDlg"
ref="plusBtnRef" v-model="listItemsDlg"
class="flex justify-end group gap-1 min-h-[30px] items-center" :column="belongsToColumn"
tabindex="0" hide-back-btn
@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> </template>
</LazyVirtualCellComponentsLinkRecordDropdown>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
</div>
</template> </template>
<style scoped lang="scss"> <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 isGallery = inject(IsGalleryInj, ref(false))
const qrValue = computed(() => String(cellValue?.value)) const qrValue = computed(() => String(cellValue?.value || ''))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false)) const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))

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

@ -1,20 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import OnetoOneIcon from '~icons/nc-icons/onetoone' 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' import { iconMap } from '#imports'
const { relation, relatedTableTitle, displayValue, header, tableTitle } = defineProps<{ const {
relation,
relatedTableTitle,
tableTitle,
linkedRecords = 0,
} = defineProps<{
relation: string relation: string
header?: string | null header?: string | null
tableTitle: string tableTitle: string
relatedTableTitle: string relatedTableTitle: string
displayValue?: string displayValue?: string
linkedRecords?: number
}>() }>()
const { isMobileMode } = useGlobal()
const { t } = useI18n() const { t } = useI18n()
const relationMeta = computed(() => { const relationMeta = computed(() => {
@ -52,73 +54,46 @@ const relationMeta = computed(() => {
</script> </script>
<template> <template>
<div class="flex sm:justify-between relative pb-2 items-center"> <div
<div v-if="!isMobileMode" class="flex text-base font-bold justify-start items-center min-w-36"> class="flex-none flex rounded-md gap-1 items-center p-1 max-h-7"
{{ header ?? '' }} :class="{
</div> 'bg-gray-200 text-gray-600': !linkedRecords,
<div class="flex flex-row sm:w-[calc(100%-16rem)] xs:w-full items-center justify-center gap-2 xs:(h-full)"> 'bg-orange-100 text-orange-700': relation === 'hm' && linkedRecords,
<div class="flex sm:justify-end w-[calc(50%-1.5rem)] xs:(w-[calc(50%-1.5rem)] h-full)"> 'bg-pink-100 text-pink-700': relation === 'mm' && linkedRecords,
<div 'bg-blue-100 text-blue-700': relation === 'bt' && linkedRecords,
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" 'bg-purple-100 text-purple-700': relation === 'oo' && linkedRecords,
> }"
<FileIcon class="w-4 h-4 min-w-4" /> >
<span class="truncate"> <NcTooltip class="z-10 flex" placement="bottom">
{{ displayValue }} <template #title>
</span> <div class="p-1">
</div> <h1 class="text-white font-bold">{{ relationMeta.title }}</h1>
</div> <div class="text-white">
<NcTooltip class="flex-shrink-0"> {{ relationMeta.tooltip_desc }}
<template #title> {{ relationMeta.title }} </template> <span class="bg-gray-700 px-2 rounded-md">
<component {{ tableTitle }}
:is="relationMeta.icon" </span>
class="w-7 h-7 p-1 rounded-md" {{ relationMeta.tooltip_desc2 }}
:class="{ <span class="bg-gray-700 px-2 rounded-md">
'!bg-orange-500': relation === 'hm', {{ relatedTableTitle }}
'!bg-pink-500': relation === 'mm', </span>
'!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> </div>
</template> </div>
<InfoIcon class="w-4 h-4" /> </template>
</NcTooltip> <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>
</div> </div>
</template> </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> <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 { import {
ColumnInj, ColumnInj,
IsFormInj, IsFormInj,
@ -30,10 +31,14 @@ const vModel = useVModel(props, 'modelValue', emit)
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const { t } = useI18n()
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isExpandedFormCloseAfterSave = ref(false)
const injectedColumn = inject(ColumnInj, ref()) const injectedColumn = inject(ColumnInj, ref())
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
@ -58,7 +63,7 @@ const {
relatedTableMeta, relatedTableMeta,
link, link,
meta, meta,
headerDisplayValue, row,
resetChildrenListOffsetCount, resetChildrenListOffsetCount,
} = useLTARStoreOrThrow() } = useLTARStoreOrThrow()
@ -68,7 +73,7 @@ watch(
[vModel, isForm], [vModel, isForm],
(nextVal) => { (nextVal) => {
if ((nextVal[0] || nextVal[1]) && !isNew.value) { if ((nextVal[0] || nextVal[1]) && !isNew.value) {
loadChildrenList() loadChildrenList(true)
} }
// reset offset count when closing modal // reset offset count when closing modal
@ -102,20 +107,96 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? []) return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col)) .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 expandedFormDlg = ref(false)
const expandedFormRow = ref({}) 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 colTitle = computed(() => injectedColumn.value?.title || '')
const onClick = (row: Row) => { const onClick = (row: Row) => {
if (readOnly.value) return if (readOnly.value || isForm.value) return
expandedFormRow.value = row expandedFormRow.value = row
expandedFormDlg.value = true 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(() => { const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type return injectedColumn!.value?.colOptions?.type
@ -129,6 +210,9 @@ watch(
) )
watch(expandedFormDlg, () => { watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
}
childrenExcludedOffsetCount.value = 0 childrenExcludedOffsetCount.value = 0
childrenListOffsetCount.value = 0 childrenListOffsetCount.value = 0
}) })
@ -154,6 +238,10 @@ const skeletonCount = computed(() => {
}) })
const totalItemsToShow = computed(() => { const totalItemsToShow = computed(() => {
if (isForm.value || isNew.value) {
return state.value?.[colTitle.value]?.length
}
if (isChildrenLoading.value) { if (isChildrenLoading.value) {
return props.items return props.items
} }
@ -204,6 +292,10 @@ const linkedShortcuts = (e: KeyboardEvent) => {
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', linkedShortcuts) window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
}) })
const childrenListRef = ref<HTMLDivElement>() const childrenListRef = ref<HTMLDivElement>()
@ -226,167 +318,151 @@ const onFilterChange = () => {
</script> </script>
<template> <template>
<NcModal <div class="nc-modal-child-list h-full w-full" :class="{ active: vModel }" @keydown.enter.stop>
v-model:visible="vModel" <div class="flex flex-col h-full">
:body-style="{ 'max-height': '640px', 'height': '85vh' }" <div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
:class="{ active: vModel }" <div v-if="!isForm" class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
:closable="false" <MdiMagnify class="nc-search-icon w-5 h-5" />
:footer="null" <a-input
:width="isForm ? 600 : 800" ref="filterQueryRef"
size="medium" v-model:value="childrenListPagination.query"
wrap-class-name="nc-modal-child-list" :bordered="false"
> placeholder="Search linked records..."
<LazyVirtualCellComponentsHeader class="w-full min-h-4"
v-if="!isForm" size="small"
:display-value="headerDisplayValue" @change="onFilterChange"
:header="$t('activity.linkedRecords')" @keydown.capture.stop="
:linked-records="childrenListCount" (e) => {
:related-table-title="relatedTableMeta?.title" if (e.key === 'Escape') {
:relation="relation" filterQueryRef?.blur()
: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()
} }
} "
" >
> </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> <div ref="childrenListRef" class="flex-1 overflow-auto nc-scrollbar-thin">
<div ref="childrenListRef" class="flex flex-col flex-grow nc-scrollbar-md cursor-pointer pr-1"> <div v-if="isDataExist || isChildrenLoading">
<div v-if="isDataExist || isChildrenLoading" class="mt-2 mb-2"> <div class="cursor-pointer">
<div class="cursor-pointer pr-1"> <template v-if="isChildrenLoading">
<template v-if="isChildrenLoading"> <div
<div v-for="(_x, i) in Array.from({ length: skeletonCount })"
v-for="(_x, i) in Array.from({ length: skeletonCount })" :key="i"
:key="i" class="flex flex-row gap-2 mb-2 transition-all relative !border-gray-200 hover:bg-gray-50"
class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50" >
> <div class="flex items-center">
<a-skeleton-image class="h-24 w-24 !rounded-xl" /> <a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center"> </div>
<a-skeleton-input active class="!w-48 !rounded-xl" size="small" /> <div class="flex flex-col gap-2 flex-grow justify-center">
<div class="flex flex-row gap-6 w-10/12"> <a-skeleton-input active class="h-3 !w-48 !rounded-xl" size="small" />
<div class="flex flex-col gap-0.5"> <div class="flex flex-row gap-6 w-10/12">
<a-skeleton-input active class="!h-4 !w-12" size="small" /> <div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-24" size="small" /> <a-skeleton-input active class="!h-2 !w-12" size="small" />
</div> <a-skeleton-input active class="!h-2 !w-24" size="small" />
<div class="flex flex-col gap-0.5"> </div>
<a-skeleton-input active class="!h-4 !w-12" size="small" /> <div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-24" size="small" /> <a-skeleton-input active class="!h-2 !w-12" size="small" />
</div> <a-skeleton-input active class="!h-2 !w-24" size="small" />
<div class="flex flex-col gap-0.5"> </div>
<a-skeleton-input active class="!h-4 !w-12" size="small" /> <div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-24" size="small" /> <a-skeleton-input active class="!h-2 !w-12" size="small" />
</div> <a-skeleton-input active class="!h-2 !w-24" size="small" />
<div class="flex flex-col gap-0.5"> </div>
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
</div> </div>
</div> </div>
</div> </div>
</div> </template>
</template> <template v-else>
<template v-else> <LazyVirtualCellComponentsListItem
<LazyVirtualCellComponentsListItem v-for="(refRow, id) in childrenList?.list ?? state?.[colTitle] ?? []"
v-for="(refRow, id) in childrenList?.list ?? state?.[colTitle] ?? []" :key="id"
:key="id" :attachment="attachmentCol"
:attachment="attachmentCol" :display-value-type-and-format-prop="displayValueTypeAndFormatProp"
:display-value-type-and-format-prop="displayValueTypeAndFormatProp" :fields="fields"
:fields="fields" :is-linked="childrenList?.list ? isChildrenListLinked[Number.parseInt(id)] : true"
:is-linked="childrenList?.list ? isChildrenListLinked[Number.parseInt(id)] : true" :is-loading="isChildrenListLoading[Number.parseInt(id)]"
:is-loading="isChildrenListLoading[Number.parseInt(id)]" :related-table-display-value-prop="relatedTableDisplayValueProp"
:related-table-display-value-prop="relatedTableDisplayValueProp" :row="refRow"
:row="refRow" data-testid="nc-child-list-item"
data-testid="nc-child-list-item" @link-or-unlink="linkOrUnLink(refRow, id)"
@click="linkOrUnLink(refRow, id)" @expand="onClick(refRow)"
@expand="onClick(refRow)" @keydown.space.prevent.stop="linkOrUnLink(refRow, id)"
@keydown.space.prevent="linkOrUnLink(refRow, id)" @keydown.enter.prevent.stop="() => onClick(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)" />
/> </template>
</template> </div>
</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 }) }}
</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 <NcButton
v-if="!readOnly && childrenListCount < 1" v-if="!readOnly && (childrenListCount < 1 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
v-e="['c:links:link']" v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to" data-testid="nc-child-list-button-link-to"
@click="emit('attachRecord')" size="small"
> @click="emit('attachRecord')"
<div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div> >
</NcButton> <div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div>
</NcButton>
</div>
</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="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">
<div class="flex flex-row justify-between bg-white relative pt-1"> <NcButton
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50"> v-if="!isPublic"
{{ totalItemsToShow || 0 }} {{ !isMobileMode ? $t('objects.records') : '' }} v-e="['c:row-expand:open']"
{{ !isMobileMode && totalItemsToShow !== 0 ? $t('general.are') : '' }} size="small"
{{ $t('general.linked') }} class="!hover:(bg-white text-brand-500)"
</div> type="secondary"
<div v-else class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50"> @click="addNewRecord"
<span class=""> >
{{ state?.[colTitle]?.length || 0 }} {{ $t('objects.records') }} <div class="flex items-center gap-1">
{{ state?.[colTitle]?.length !== 0 ? $t('general.are') : '' }} <MdiPlus v-if="!isMobileMode" class="h-4 w-4" /> {{ $t('activity.newRecord') }}
{{ $t('general.linked') }} </div>
</span> </NcButton>
</div> <NcButton
<div class="!xs:hidden flex absolute -mt-0.75 items-center py-2 justify-center w-full"> v-if="!readOnly && (childrenListCount > 0 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
<NcPagination v-e="['c:links:link']"
v-if="!isNew && childrenList?.pageInfo" data-testid="nc-child-list-button-link-to"
v-model:current="childrenListPagination.page" class="!hover:(bg-white text-brand-500)"
v-model:page-size="childrenListPagination.size" size="small"
:total="+childrenList.pageInfo.totalRows!" type="secondary"
mode="simple" @click="emit('attachRecord')"
/> >
</div> <div class="flex items-center gap-1">
<div class="flex flex-row gap-2"> <GeneralIcon icon="link2" class="!xs:hidden h-4 w-4" />
<NcButton v-if="!isForm" class="nc-close-btn" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton> {{ isMobileMode ? $t('title.linkMore') : $t('title.linkMoreRecords') }}
<NcButton </div>
v-if="!readOnly && childrenListCount > 0" </NcButton>
v-e="['c:links:link']" </div>
data-testid="nc-child-list-button-link-to" <template v-if="!isNew && childrenList?.pageInfo && +childrenList.pageInfo.totalRows! > childrenListPagination.size">
@click="emit('attachRecord')" <div class="flex justify-center items-center">
> <NcPagination
<div class="flex items-center gap-1"> v-model:current="childrenListPagination.page"
<MdiPlus class="!xs:hidden" /> {{ isMobileMode ? $t('title.linkMore') : $t('title.linkMoreRecords') }} v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
mode="simple"
/>
</div> </div>
</NcButton> </template>
</div> </div>
</div> </div>
@ -394,7 +470,15 @@ const onFilterChange = () => {
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:close-after-save="isExpandedFormCloseAfterSave"
:meta="relatedTableMeta" :meta="relatedTableMeta"
:new-record-header="
isExpandedFormCloseAfterSave
? $t('activity.tableNameCreateNewRecord', {
tableName: relatedTableMeta?.title,
})
: undefined
"
:row="{ :row="{
row: expandedFormRow, row: expandedFormRow,
oldRow: expandedFormRow, oldRow: expandedFormRow,
@ -405,11 +489,13 @@ const onFilterChange = () => {
new: true, new: true,
}, },
}" }"
:state="newRowState"
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])" :row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
use-meta-fields use-meta-fields
@created-record="onCreatedRecord"
/> />
</Suspense> </Suspense>
</NcModal> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -420,10 +506,22 @@ const onFilterChange = () => {
:deep(.ant-modal-content) { :deep(.ant-modal-content) {
@apply !p-0; @apply !p-0;
} }
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style> </style>
<style lang="scss"> <style lang="scss">
.nc-modal-child-list > .ant-modal > .ant-modal-content { .nc-dropdown-link-record-search-wrapper {
@apply !p-0; .nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
} }
</style> </style>

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

@ -16,19 +16,23 @@ import {
useVModel, useVModel,
} from '#imports' } from '#imports'
import MaximizeIcon from '~icons/nc-icons/maximize' import MaximizeIcon from '~icons/nc-icons/maximize'
import LinkIcon from '~icons/nc-icons/link'
const props = defineProps<{ const props = withDefaults(
row: any defineProps<{
fields: any[] row: any
attachment: any fields: any[]
relatedTableDisplayValueProp: string attachment: any
displayValueTypeAndFormatProp: { type: string; format: string } relatedTableDisplayValueProp: string
isLoading: boolean displayValueTypeAndFormatProp: { type: string; format: string }
isLinked: boolean isLoading: boolean
}>() isLinked: boolean
}>(),
{
isLoading: false,
},
)
defineEmits(['expand']) defineEmits(['expand', 'linkOrUnlink'])
provide(IsExpandedFormOpenInj, ref(true)) provide(IsExpandedFormOpenInj, ref(true))
@ -88,116 +92,198 @@ const displayValue = computed(() => {
</script> </script>
<template> <template>
<a-card <div class="nc-list-item-wrapper group px-[1px] hover:bg-gray-50 border-y-1 border-gray-200 border-t-transparent">
tabindex="0" <a-card
class="nc-list-item !outline-brand-500 !border-1 group transition-all !rounded-xl relative !mb-2 !border-gray-200 hover:bg-gray-50" tabindex="0"
:class="{ class="nc-list-item !outline-none transition-all relative group-hover:bg-gray-50 cursor-auto"
'!bg-white': isLoading, :class="{
'!border-1': isLinked && !isLoading, '!bg-white': isLoading,
'!cursor-auto !hover:bg-white': readOnly, '!hover:bg-white': readOnly,
}" }"
:body-style="{ padding: 0 }" :body-style="{ padding: '6px 10px !important', borderRadius: 0 }"
:hoverable="false" :hoverable="false"
> >
<div class="flex flex-row items-center justify-start w-full"> <div class="flex items-center gap-3">
<a-carousel v-if="attachment && attachments && attachments.length" autoplay class="!w-24 !h-24 !max-h-24 !max-w-24"> <div v-if="isLoading" class="flex">
<template #customPaging> </template> <MdiLoading class="flex-none w-7 h-7 !text-brand-500 animate-spin" />
<template v-for="(attachmentObj, index) in attachments"> </div>
<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="flex flex-col m-[.75rem] gap-1 flex-grow justify-center overflow-hidden"> <NcTooltip v-else class="z-10 flex">
<div class="flex justify-between xs:gap-x-2"> <template #title> {{ isLinked ? 'Unlink' : 'Link' }}</template>
<span class="font-semibold text-brand-500 nc-display-value xs:(truncate)">
{{ displayValue }} <button
</span> tabindex="-1"
<div class="nc-list-item-link-unlink-btn p-1.5 flex rounded-lg transition-all"
v-if="isLinked && !isLoading"
class="text-brand-500 text-0.875"
:class="{ :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" /> <GeneralIcon :icon="isLinked ? 'minus' : 'plus'" class="flex-none w-4 h-4 !font-extrabold" />
Linked </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> </div>
<MdiLoading <div
v-else-if="isLoading" v-else
:class="{ 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"
'!group-hover:mr-8': fields.length === 0 && !readOnly, >
}" <GeneralIcon class="w-full h-full !text-6xl !leading-10 !text-transparent rounded-lg" icon="fileImage" />
class="w-6 h-6 !text-brand-500 animate-spin" </div>
/> </template>
</div>
<div <div class="flex-1 flex flex-col gap-1 justify-center overflow-hidden">
v-if="fields.length > 0 && !isPublic && !isForm" <div class="flex justify-start">
class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 w-10/12" <span class="font-semibold text-brand-500 nc-display-value truncate leading-[20px]">
> {{ displayValue }}
<div v-for="field in fields" :key="field.id" :class="attachment ? 'sm:w-1/3' : 'sm:w-1/4'"> </span>
<div class="flex flex-col gap-[-1] max-w-72"> </div>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)" <div
class="!scale-60" v-if="fields.length > 0 && !isPublic && !isForm"
:column="field" class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 min-h-5"
:hide-menu="true" >
:hide-icon="true" <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]">
<LazySmartsheetHeaderCell v-else class="!scale-70" :column="field" :hide-menu="true" :hide-icon="true" /> <NcTooltip class="z-10 flex" placement="bottom">
<template #title>
<div v-if="!isRowEmpty(row, field)"> <LazySmartsheetHeaderVirtualCell
<LazySmartsheetVirtualCell v-if="isVirtualCol(field)" v-model="row[field.title]" :row="row" :column="field" /> v-if="isVirtualCol(field)"
<LazySmartsheetCell class="!scale-60 text-gray-100 !text-sm"
v-else :column="field"
v-model="row[field.title]" :hide-menu="true"
class="!text-gray-600 ml-1" />
:column="field" <LazySmartsheetHeaderCell v-else class="!scale-70 text-gray-100 !text-sm" :column="field" :hide-menu="true" />
:edit-enabled="false" </template>
:read-only="true" <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>
<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>
</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>
</div> </a-card>
<NcButton </div>
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>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.slick-list) { :deep(.slick-list) {
@apply rounded-lg; @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>
<style lang="scss"> <style lang="scss">
.nc-list-item { .nc-list-item {
@apply border-1 border-transparent rounded-md;
&:focus-visible {
@apply border-brand-500;
box-shadow: 0 0 0 1px #3366ff;
}
&:hover { &:hover {
.nc-text-area-expand-btn { .nc-text-area-expand-btn {
@apply !hidden; @apply !hidden;
@ -206,13 +292,14 @@ const displayValue = computed(() => {
.long-text-wrapper { .long-text-wrapper {
@apply select-none pointer-events-none; @apply select-none pointer-events-none;
.nc-readonly-rich-text-wrapper { .nc-readonly-rich-text-wrapper {
@apply !min-h-6 !max-h-6; @apply !min-h-5 !max-h-5;
} }
.nc-rich-text-embed { .nc-rich-text-embed {
@apply -mt-0.5;
.nc-textarea-rich-editor { .nc-textarea-rich-editor {
@apply !overflow-hidden; @apply !overflow-hidden;
.ProseMirror { .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, useVModel,
} from '#imports' } 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) const vModel = useVModel(props, 'modelValue', emit)
@ -50,7 +50,6 @@ const {
meta, meta,
unlink, unlink,
row, row,
headerDisplayValue,
resetChildrenExcludedOffsetCount, resetChildrenExcludedOffsetCount,
} = useLTARStoreOrThrow() } = useLTARStoreOrThrow()
@ -100,7 +99,7 @@ watch(
if (!isForm.value) { if (!isForm.value) {
loadChildrenList() loadChildrenList()
} }
loadChildrenExcludedList(rowState.value) loadChildrenExcludedList(rowState.value, true)
} }
if (!nextVal) { if (!nextVal) {
resetChildrenExcludedOffsetCount() resetChildrenExcludedOffsetCount()
@ -157,13 +156,31 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? []) return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col)) .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(() => { const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type 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, () => { watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) { if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false isExpandedFormCloseAfterSave.value = false
@ -253,6 +270,10 @@ watch(childrenExcludedListPagination, () => {
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', linkedShortcuts) window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
}) })
onUnmounted(() => { onUnmounted(() => {
@ -268,154 +289,148 @@ const onFilterChange = () => {
</script> </script>
<template> <template>
<NcModal <div class="nc-modal-link-record h-full w-full overflow-hidden" :class="{ active: vModel }" @keydown.enter.stop>
v-model:visible="vModel" <div class="flex flex-col h-full">
:body-style="{ 'max-height': '640px', 'height': '85vh' }" <div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
:class="{ active: vModel }" <div class="flex-1 gap-2 flex items-center">
:closable="false" <button
:footer="null" v-if="!hideBackBtn"
:width="isForm ? 600 : 800" class="!text-brand-500 hover:!text-brand-700 p-1.5 flex"
wrap-class-name="nc-modal-link-record" @click="emit('attachLinkedRecord')"
>
<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"
> >
<a-skeleton-image class="h-24 w-24 !rounded-xl" /> <GeneralIcon icon="ncArrowLeft" class="flex-none h-4 w-4" />
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center"> </button>
<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-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
<div class="flex flex-col gap-0.5"> <MdiMagnify class="nc-search-icon w-5 h-5" />
<a-skeleton-input active class="!h-4 !w-12" size="small" /> <a-input
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" /> ref="filterQueryRef"
</div> v-model:value="childrenExcludedListPagination.query"
<div class="flex flex-col gap-0.5"> :bordered="false"
<a-skeleton-input active class="!h-4 !w-12" size="small" /> placeholder="Search records to link..."
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" /> class="w-full nc-excluded-search min-h-4"
</div> size="small"
<div class="flex flex-col gap-0.5"> @change="onFilterChange"
<a-skeleton-input active class="!h-4 !w-12" size="small" /> @keydown.capture.stop="
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" /> (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>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-2 flex-grow justify-center">
<a-skeleton-input active class="!h-4 !w-12" size="small" /> <a-skeleton-input active class="h-3 !w-48 !rounded-xl" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" 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> </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> </div>
</template> </template>
<template v-else> <div v-else class="h-full my-auto py-2 flex flex-col gap-3 items-center justify-center text-gray-500">
<LazyVirtualCellComponentsListItem <InboxIcon class="w-16 h-16 mx-auto" />
v-for="(refRow, id) in childrenExcludedList?.list ?? []" <p>
:key="id" {{ $t('msg.thereAreNoRecordsInTable') }}
:attachment="attachmentCol" {{ relatedTableMeta?.title }}
:display-value-type-and-format-prop="displayValueTypeAndFormatProp" </p>
:fields="fields" </div>
: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> </div>
</template> <div class="bg-gray-100 px-3 py-2 rounded-b-md flex items-center justify-between min-h-12">
<div v-else class="my-auto py-2 flex flex-col gap-3 items-center justify-center text-gray-500"> <div class="flex">
<InboxIcon class="w-16 h-16 mx-auto" /> <NcButton
<p> v-if="!isPublic"
{{ $t('msg.thereAreNoRecordsInTable') }} v-e="['c:row-expand:open']"
{{ relatedTableMeta?.title }} size="small"
</p> class="!hover:(bg-white text-brand-500)"
</div> type="secondary"
@click="addNewRecord"
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2"> >
<NcPagination <div class="flex items-center gap-1"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
v-if="childrenExcludedList?.pageInfo" </NcButton>
v-model:current="childrenExcludedListPagination.page" </div>
v-model:page-size="childrenExcludedListPagination.size" <template
:total="+childrenExcludedList?.pageInfo?.totalRows" v-if="
entity-name="links-excluded-list" childrenExcludedList?.pageInfo && +childrenExcludedList?.pageInfo?.totalRows > childrenExcludedListPagination.size
/> "
</div> >
<div v-if="isMobileMode" class="flex items-center">
<div class="mb-2 bg-gray-50 border-gray-50 border-b-2"></div> <NcPagination
v-model:current="childrenExcludedListPagination.page"
<div class="flex flex-row justify-between items-center bg-white relative pt-1"> v-model:page-size="childrenExcludedListPagination.size"
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50 h-9.5"> :total="+childrenExcludedList?.pageInfo?.totalRows"
{{ relation === 'bt' ? (row.row[relatedTableMeta?.title] ? '1' : 0) : childrenListCount ?? 'No' }} entity-name="links-excluded-list"
{{ !isMobileMode ? $t('objects.records') : '' }} {{ !isMobileMode && childrenListCount !== 0 ? 'are' : '' }} />
{{ $t('general.linked') }} </div>
</div> <div v-else class="flex items-center">
<div class="!xs:hidden flex absolute -mt-0.75 items-center py-2 justify-center w-full"> <NcPagination
<NcPagination v-model:current="childrenExcludedListPagination.page"
v-if="childrenExcludedList?.pageInfo" v-model:page-size="childrenExcludedListPagination.size"
v-model:current="childrenExcludedListPagination.page" :total="+childrenExcludedList?.pageInfo?.totalRows"
v-model:page-size="childrenExcludedListPagination.size" entity-name="links-excluded-list"
:total="+childrenExcludedList?.pageInfo?.totalRows" mode="simple"
entity-name="links-excluded-list" />
mode="simple" </div>
/> </template>
</div> </div>
<NcButton class="nc-close-btn ml-auto" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton>
</div> </div>
<Suspense> <Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
@ -446,11 +461,25 @@ const onFilterChange = () => {
@created-record="onCreatedRecord" @created-record="onCreatedRecord"
/> />
</Suspense> </Suspense>
</NcModal> </div>
</template> </template>
<style lang="scss" scoped>
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style>
<style lang="scss"> <style lang="scss">
.nc-modal-link-record > .ant-modal > .ant-modal-content { .nc-dropdown-link-record-search-wrapper {
@apply !p-0; .nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
} }
</style> </style>

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

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

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

@ -12,9 +12,11 @@ import {
IsPublicInj, IsPublicInj,
Modal, Modal,
NOCO, NOCO,
NcErrorType,
SharedViewPasswordInj, SharedViewPasswordInj,
computed, computed,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
extractSdkResponseErrorMsgv2,
inject, inject,
message, message,
parseProp, parseProp,
@ -188,15 +190,16 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return row.value.row[displayValueProp.value] return row.value.row[displayValueProp.value]
}) })
const loadChildrenExcludedList = async (activeState?: any) => { const loadChildrenExcludedList = async (activeState?: any, resetOffset: boolean = false) => {
if (activeState) newRowState.state = activeState if (activeState) newRowState.state = activeState
try { try {
let offset = let offset =
childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
if (offset < 0) { if (offset < 0 || resetOffset) {
offset = 0 offset = 0
childrenExcludedOffsetCount.value = 0 childrenExcludedOffsetCount.value = 0
childrenExcludedListPagination.page = 1
} }
isChildrenExcludedLoading.value = true isChildrenExcludedLoading.value = true
if (isPublic.value) { if (isPublic.value) {
@ -284,27 +287,29 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
} }
} catch (e: any) { } catch (e: any) {
// temporary fix to handle when offset is beyond limit // 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 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 { } finally {
isChildrenExcludedLoading.value = false isChildrenExcludedLoading.value = false
} }
} }
const loadChildrenList = async () => { const loadChildrenList = async (resetOffset: boolean = false) => {
try { try {
isChildrenLoading.value = true isChildrenLoading.value = true
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return
if (!rowId.value || !column.value) return if (!rowId.value || !column.value) return
let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value
if (offset < 0 || resetOffset) {
if (offset < 0) {
offset = 0 offset = 0
childrenListOffsetCount.value = 0 childrenListOffsetCount.value = 0
childrenListPagination.page = 1
} else if (offset >= childrenListCount.value) { } else if (offset >= childrenListCount.value) {
offset = 0 offset = 0
} }
@ -347,6 +352,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenListLinked.value[index] = true isChildrenListLinked.value[index] = true
isChildrenListLoading.value[index] = false isChildrenListLoading.value[index] = false
}) })
if (!childrenListPagination.query) { if (!childrenListPagination.query) {
childrenListCount.value = childrenList.value?.pageInfo.totalRows ?? 0 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 isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP) const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared) const isSharedForm = computed(() => isForm.value && shared)
const isDefaultView = computed(() => view.value?.is_default)
const xWhere = computed(() => { const xWhere = computed(() => {
let where let where
const col = const col =
@ -100,6 +101,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
eventBus, eventBus,
sqlUi, sqlUi,
allFilters, allFilters,
isDefaultView,
} }
}, },
'smartsheet-store', 'smartsheet-store',

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

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

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

@ -1181,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "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( public static async list(
{ {
fk_model_id, fk_model_id,
fk_default_view_id,
}: { }: {
fk_model_id: string; fk_model_id: string;
fk_default_view_id?: string;
}, },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
): Promise<Column[]> { ): Promise<Column[]> {
@ -505,9 +507,27 @@ export default class Column<T = any> implements ColumnType {
order: 'asc', 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) => { columnsList.forEach((column) => {
column.meta = parseMetaProp(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); 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); 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( this.columns = await Column.list(
{ {
fk_model_id: this.id, fk_model_id: this.id,
fk_default_view_id: defaultViewId,
}, },
ncMeta, ncMeta,
); );
@ -391,8 +395,13 @@ export default class Model implements TableType {
} }
if (modelData) { if (modelData) {
const m = new Model(modelData); const m = new Model(modelData);
const columns = await m.getColumns(ncMeta);
await m.getViews(false, 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 }), {}); m.columnsById = columns.reduce((agg, c) => ({ ...agg, [c.id]: c }), {});
return m; return m;
} }

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

@ -84,7 +84,7 @@ export class ExpandedFormPage extends BasePage {
case 'belongsTo': case 'belongsTo':
await field.locator('.nc-virtual-cell').hover(); 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, false);
break; break;
case 'hasMany': case 'hasMany':
case 'manyToMany': 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) // 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('.nc-dropdown-link-record-header').waitFor();
{ {
let isOk = false; let isOk = false;
let count = 0; let count = 0;
@ -54,7 +54,8 @@ export class ChildList extends BasePage {
} }
async close() { 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' }); 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.rootPage.waitForTimeout(100);
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click(); await this.get()
await this.close(); .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() { 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' }); 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); await expect.poll(() => this.rootPage.locator('.ant-card:visible').count()).toBe(count);
// close child list // 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({ await this.waitForResponse({
uiAction: () => 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', requestUrlPathToMatch: '/api/v1/db/data/noco',
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
}); });
await this.rootPage.keyboard.press('Escape'); 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() { 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[]) { async verifyChildList(cardTitle?: string[]) {
@ -69,6 +70,9 @@ export class SharedFormPage extends BasePage {
} }
async selectChildList(cardTitle: string) { 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 // In cell insert
await dashboard.grid.addNewRow({ index: 1, value: '2b' }); await dashboard.grid.addNewRow({ index: 1, value: '2b' });
await dashboard.grid.cell.inCellAdd({ index: 1, columnHeader: 'Sheet1' }); 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({ await dashboard.grid.cell.inCellAdd({
index: 1, index: 1,
columnHeader: 'Sheet1s', columnHeader: 'Sheet1s',

Loading…
Cancel
Save