Files
nocodb/packages/nc-gui/components/virtual-cell/Lookup.vue
2026-04-09 06:34:53 +00:00

643 lines
21 KiB
Vue

<script lang="ts" setup>
import { type ColumnType, type LinkToAnotherRecordType, type LookupType, isBtLikeV2Junction, isMMOrMMLike } from 'nocodb-sdk'
import { FormulaDataTypes, RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
const { getMeta, getMetaByKey } = useMetas()
const { isMobileMode } = useGlobal()
const column = inject(ColumnInj, ref())
const row = inject(RowInj)!
const parentMeta = inject(MetaInj, ref())
const cellValue = inject(CellValueInj, ref())
const isGroupByLabel = inject(IsGroupByLabelInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const cellClickHook = inject(CellClickHookInj, null)
const onDivDataCellEventHook = inject(OnDivDataCellEventHookInj, null)
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const clientMousePosition = inject(ClientMousePositionInj, reactive(clientMousePositionDefaultValue))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const cellEventHook = inject(CellEventHookInj, null)
// Change the row height of the child cell under lookup
// Other wise things like text will can take multi line tag
const providedHeightRef = ref(1) as any
const rowHeight = inject(RowHeightInj, ref(1) as any)
provide(RowHeightInj, providedHeightRef)
const dropdownInitialHeight = ref(0)
// Helper to get the correct base ID for related table (handles cross-base links)
const getRelatedBaseId = (col: ColumnType | undefined) => {
if (!col) return parentMeta.value?.base_id
return (col.colOptions as any)?.fk_related_base_id || parentMeta.value?.base_id
}
const relationColumn = computed(() => {
if (column.value?.fk_model_id && parentMeta.value?.base_id) {
return getMetaByKey(parentMeta.value.base_id, column.value.fk_model_id)?.columns?.find(
(c: ColumnType) => c.id === (column.value?.colOptions as LookupType)?.fk_relation_column_id,
)
}
return undefined
})
watch(
column,
async (newColumn) => {
if (!newColumn?.fk_model_id || getMetaByKey(parentMeta.value?.base_id, newColumn?.fk_model_id)) return
if (parentMeta.value?.base_id) await getMeta(parentMeta.value.base_id, newColumn.fk_model_id)
},
{ immediate: true },
)
watch(
relationColumn,
async (relationCol: { colOptions: LinkToAnotherRecordType }) => {
if (relationCol && relationCol.colOptions) {
const relatedBaseId = getRelatedBaseId(relationCol as ColumnType)
if (relatedBaseId) await getMeta(relatedBaseId, relationCol.colOptions.fk_related_model_id!)
}
},
{ immediate: true },
)
const lookupTableMeta = computed<Record<string, any> | undefined>(() => {
if (relationColumn.value && relationColumn.value?.colOptions) {
const relatedBaseId = getRelatedBaseId(relationColumn.value as ColumnType)
if (relatedBaseId) return getMetaByKey(relatedBaseId, relationColumn.value.colOptions.fk_related_model_id!)
}
return undefined
})
const lookupColumn = computed(
() =>
lookupTableMeta.value?.columns?.find((c: any) => c.id === (column.value?.colOptions as LookupType)?.fk_lookup_column_id) as
| ColumnType
| undefined,
)
watch(
[lookupColumn, rowHeight],
() => {
if (lookupColumn.value && !isAttachment(lookupColumn.value)) {
providedHeightRef.value = 1
} else {
providedHeightRef.value = rowHeight.value
}
},
{
immediate: true,
},
)
const arrValue = computed(() => {
if (
lookupColumn.value?.uidt === UITypes.Checkbox &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(relationColumn.value?.colOptions?.type)
) {
const hasLink = !!row?.value?.row?.[relationColumn.value?.title]
if (!cellValue.value && !hasLink) return []
return (ncIsArray(cellValue.value) ? cellValue.value : [cellValue.value]).map(getCheckBoxValue)
}
if (!cellValue.value) return []
// if lookup column is Attachment and relation type is Belongs/OneToOne to wrap the value in an array
// since the attachment component expects an array or JSON string array
if (lookupColumn.value?.uidt === UITypes.Attachment) {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(relationColumn.value?.colOptions?.type)) {
return ncIsArray(cellValue.value) ? cellValue.value : [cellValue.value]
}
if (
ncIsArray(cellValue.value) &&
cellValue.value.every((v) => {
if (ncIsNull(v)) return true
if (ncIsArray(v)) {
return !v.length || ncIsObject(v[0])
}
return false
})
) {
return cellValue.value
.filter((v) => v !== null)
.reduce((acc, v) => {
acc.push(...v)
return acc
}, [])
}
}
// TODO: We are filtering null as cell value can be null. Find the root cause and fix it
if (Array.isArray(cellValue.value)) return cellValue.value.filter((v) => v !== null)
return [cellValue.value]
})
provide(MetaInj, lookupTableMeta)
provide(IsUnderLookupInj, ref(true))
provide(CellUrlDisableOverlayInj, ref(true))
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activateShowEditNonEditableFieldWarning } =
useShowNotEditableWarning()
const search = ref('')
const searchableUITypes = [
UITypes.ID,
UITypes.SingleLineText,
UITypes.LongText,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Year,
UITypes.PhoneNumber,
UITypes.Email,
UITypes.Number,
UITypes.Decimal,
UITypes.Currency,
UITypes.Percent,
UITypes.Duration,
UITypes.Rating,
UITypes.Formula,
]
const isSearchable = computed(() => {
if (!lookupColumn.value) return false
return searchableUITypes.includes(lookupColumn.value.uidt! as UITypes)
})
const disableDropdown = computed(() => {
if (!lookupColumn.value) return true
if (arrValue.value.length < 2) return true
return false
})
const filteredArrValues = computed(() => {
return arrValue.value.filter((val) => {
return searchCompare(val, search.value)
})
})
const dropdownVisible = ref(false)
const triggerRef = ref<HTMLDivElement | null>(null)
const randomClass = `lookup-${Math.floor(Math.random() * 99999)}`
const cell = computed(() => triggerRef.value?.closest('td, .nc-data-cell'))
const dropdownOverlayRef = ref<HTMLInputElement | null>(null)
const active = inject(ActiveCellInj, ref(false))
function toggleDropdown(e: Event) {
if (e.type !== 'click') return
if (isExpandedForm.value || isForm.value || active.value) {
dropdownVisible.value = !dropdownVisible.value
}
}
const onCellEvent = (event?: Event) => {
if (!(event instanceof KeyboardEvent) || !event.target || isActiveInputElementExist(event)) return
if (isExpandCellKey(event)) {
dropdownVisible.value = !dropdownVisible.value
return true
}
}
onMounted(() => {
onClickOutside(cell.value, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return
dropdownVisible.value = false
})
onDivDataCellEventHook?.on(toggleDropdown)
cellClickHook?.on(toggleDropdown)
cellEventHook?.on(onCellEvent)
if (isUnderLookup.value || !isCanvasInjected || !clientMousePosition || isExpandedForm.value || !isGrid.value) return
if (onCellEvent(canvasCellEventData.event)) return
dropdownVisible.value = true
})
onUnmounted(() => {
onDivDataCellEventHook?.off(toggleDropdown)
cellClickHook?.off(toggleDropdown)
cellEventHook?.off(onCellEvent)
})
watch(dropdownVisible, (val) => {
setTimeout(() => {
if (val && dropdownOverlayRef.value) {
if (!isMobileMode.value) {
dropdownOverlayRef.value?.querySelector<HTMLInputElement>('.lookup-search-input input')?.focus()
}
if (dropdownOverlayRef.value.clientHeight) {
dropdownInitialHeight.value = dropdownOverlayRef.value.clientHeight
}
}
}, 200)
})
useSelectedCellKeydownListener(active, (e) => {
switch (e.key) {
case 'Enter':
dropdownVisible.value = true
e.stopPropagation()
break
}
})
const smartsheetCellClass = computed(() => {
const isAttachmentColumn = isAttachment(lookupColumn.value!)
return [
`${
[UITypes.MultiSelect, UITypes.SingleSelect, UITypes.User].includes(lookupColumn.value!.uidt! as UITypes)
? 'pl-2'
: !isAttachmentColumn
? 'px-1'
: ''
}`,
{
'min-h-0 min-w-0': isAttachmentColumn,
'!w-auto ': !isAttachmentColumn,
},
]
})
const cellHeight = computed(() =>
isGroupByLabel.value || (lookupColumn.value && isAttachment(lookupColumn.value))
? undefined
: rowHeight.value
? `${rowHeight.value === 1 ? rowHeightInPx['1'] - 4 : rowHeightInPx[`${rowHeight.value}`] - (isGrid.value ? 17 : 0)}px`
: `2.85rem`,
)
const handleCloseDropdown = (e: MouseEvent) => {
if (e.target && e.target.closest('.nc-attachment-item')) {
e.stopPropagation()
dropdownVisible.value = false
}
}
const badgedVirtualColumns = [UITypes.Rollup, UITypes.Formula]
const isBadgedVirtualColumn = computed(() => badgedVirtualColumns.includes(lookupColumn.value?.uidt as UITypes))
// Detect formula URL lookups:
// 1. Formula using URL() function — value contains URI::() pattern (detected via replaceUrlsWithLink)
// 2. Formula referencing a URL field like {URL} — parsed_tree.referencedColumn.uidt is URL
const isFormulaUrlLookup = computed(() => {
if (lookupColumn.value?.uidt !== UITypes.Formula) return false
const colOptions = lookupColumn.value?.colOptions as Record<string, any>
// Case 2: formula references a URL column (e.g. {URL_field})
if (colOptions?.parsed_tree?.referencedColumn?.uidt === UITypes.URL) return true
// Case 1: formula uses URL() function — values contain URI::() pattern
if (colOptions?.parsed_tree?.dataType === FormulaDataTypes.STRING) {
return arrValue.value.some((v: any) => ncIsString(v) && typeof replaceUrlsWithLink(v) === 'string')
}
return false
})
const extensionConfig = inject(ExtensionConfigInj, ref({ isPageDesignerPreviewPanel: false }))
const { getPossibleAttachmentSrc } = useAttachment()
const attachmentUrl = computed(() => getPossibleAttachmentSrc(arrValue.value[0])?.[0] ?? '')
</script>
<template>
<img
v-if="extensionConfig.isPageDesignerPreviewPanel && attachmentUrl"
:src="attachmentUrl"
class="object-contain h-full w-full"
/>
<NcDropdown
v-else
:disabled="disableDropdown"
:trigger="[]"
:visible="!disableDropdown && dropdownVisible"
:auto-close="false"
:overlay-class-name="`!min-w-[300px] nc-links-dropdown ${dropdownVisible ? 'active' : ''}`"
>
<div
ref="triggerRef"
class="nc-cell-field h-full w-full nc-lookup-cell"
tabindex="-1"
:style="{
height: cellHeight,
}"
@dblclick="activateShowEditNonEditableFieldWarning"
>
<div
class="h-full w-full overflow-hidden"
:class="{
'nc-cell-lookup-scroll': rowHeight === 1,
'flex gap-1': !(lookupColumn && isAttachment(lookupColumn) && arrValue[0] && ncIsObject(arrValue[0])),
}"
@click="handleCloseDropdown"
>
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn) && !isBadgedVirtualColumn" class="flex h-full virtual-lookup-cells">
<!-- If non-belongs-to and non-one-to-one LTAR column then pass the array value, else iterate and render -->
<template
v-if="
lookupColumn.uidt !== UITypes.LinkToAnotherRecord ||
(lookupColumn.uidt === UITypes.LinkToAnotherRecord &&
(isBtLikeV2Junction(lookupColumn) ||
(!isMMOrMMLike(lookupColumn) &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(lookupColumn.colOptions.type))))
"
>
<LazySmartsheetVirtualCell
v-for="(v, i) of arrValue"
:key="i"
:edit-enabled="false"
:model-value="v"
:column="lookupColumn"
:read-only="true"
/>
</template>
<LazySmartsheetVirtualCell
v-else
:edit-enabled="false"
:read-only="true"
:model-value="arrValue"
:column="lookupColumn"
/>
</div>
<!-- Render normal cell -->
<template v-else>
<div v-if="isAttachment(lookupColumn) && arrValue[0] && ncIsObject(arrValue[0])">
<LazySmartsheetCell :model-value="arrValue" :column="lookupColumn" :edit-enabled="false" :read-only="true" />
</div>
<!-- For attachment cell avoid adding chip style -->
<template v-else>
<div
class="max-h-full max-w-full w-full nc-cell-lookup-scroll !overflow-x-hidden"
:class="{
'nc-scrollbar-thin ': rowHeight !== 1 && !isAttachment(lookupColumn),
}"
>
<div
class="flex gap-1.5 w-full h-full"
:class="{
'flex-wrap': rowHeight !== 1 && !isAttachment(lookupColumn),
'!overflow-hidden nc-cell-lookup-scroll': rowHeight === 1 || isAttachment(lookupColumn),
'items-center': rowHeight === 1,
'items-start': rowHeight !== 1,
'py-[3px]': !isAttachment(lookupColumn),
}"
>
<div
v-for="(v, i) of arrValue"
:key="i"
class="flex-none"
:class="{
'bg-nc-bg-default rounded-full': !isAttachment(lookupColumn),
'border-nc-border-gray-medium rounded border-1 max-w-full': ![
UITypes.Attachment,
UITypes.MultiSelect,
UITypes.SingleSelect,
UITypes.User,
UITypes.CreatedBy,
UITypes.LastModifiedBy,
].includes(lookupColumn.uidt),
'min-h-0 min-w-0': isAttachment(lookupColumn),
}"
>
<LazySmartsheetVirtualCell
v-if="lookupColumn.uidt === UITypes.Rollup || isFormulaUrlLookup"
:edit-enabled="false"
:read-only="true"
:model-value="v"
:column="lookupColumn"
:class="isFormulaUrlLookup ? 'px-2 nc-formula-url-lookup' : 'px-2'"
/>
<LazySmartsheetCell
v-else
:model-value="v"
:column="lookupColumn"
:edit-enabled="false"
:virtual="true"
:read-only="true"
:class="smartsheetCellClass"
/>
</div>
</div>
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.info.computedFieldEditWarning') }}
</div>
<div v-if="showClearNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.info.computedFieldDeleteWarning') }}
</div>
</div>
</template>
</template>
</template>
</div>
</div>
<template #overlay>
<div
v-if="lookupColumn"
ref="dropdownOverlayRef"
class="w-[300px] max-h-[320px] flex flex-col rounded-sm lookup-dropdown outline-none"
:class="[randomClass]"
:style="{
minHeight: dropdownInitialHeight ? `${dropdownInitialHeight}px` : undefined,
}"
tabindex="0"
@keydown.esc="dropdownVisible = false"
>
<a-input v-if="isSearchable" v-model:value="search" :placeholder="$t('general.search')" class="lookup-search-input">
<template #prefix>
<GeneralIcon icon="search" class="text-nc-content-gray-muted" />
</template>
</a-input>
<div class="flex flex-wrap gap-2 items-start overflow-y-auto px-3 py-2">
<div v-if="search && !filteredArrValues.length" class="px-2 text-gray-500 flex flex-col items-center gap-6 text-center">
<img
src="~assets/img/placeholder/no-search-result-found.png"
class="!w-[164px] flex-none"
alt="No search results found"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
<template v-else-if="isVirtualCol(lookupColumn) && !isBadgedVirtualColumn">
<!-- If non-belongs-to and non-one-to-one LTAR column then pass the array value, else iterate and render -->
<template
v-if="
lookupColumn.uidt !== UITypes.LinkToAnotherRecord ||
(lookupColumn.uidt === UITypes.LinkToAnotherRecord &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(lookupColumn.colOptions.type))
"
>
<LazySmartsheetVirtualCell
v-for="(v, i) of filteredArrValues"
:key="i"
:edit-enabled="false"
:model-value="v"
:column="lookupColumn"
:read-only="true"
/>
</template>
</template>
<template v-else>
<div
v-if="isAttachment(lookupColumn) && arrValue[0] && ncIsObject(arrValue[0])"
class="nc-lookup-attachment-wrapper"
@click="handleCloseDropdown"
>
<LazySmartsheetCell :model-value="arrValue" :column="lookupColumn" :edit-enabled="false" :read-only="true" />
</div>
<!-- For attachment cell avoid adding chip style -->
<template v-else>
<div
v-for="(v, i) of filteredArrValues"
:key="i"
class="flex-none"
:class="{
'bg-nc-bg-default rounded-full': !isAttachment(lookupColumn),
'border-nc-border-gray-medium rounded border-1 max-w-full': ![
UITypes.Attachment,
UITypes.MultiSelect,
UITypes.SingleSelect,
UITypes.User,
UITypes.CreatedBy,
UITypes.LastModifiedBy,
].includes(lookupColumn.uidt),
'min-h-0 min-w-0': isAttachment(lookupColumn),
}"
@click="handleCloseDropdown"
>
<LazySmartsheetVirtualCell
v-if="lookupColumn.uidt === UITypes.Rollup || isFormulaUrlLookup"
:edit-enabled="false"
:read-only="true"
:model-value="v"
:column="lookupColumn"
:class="isFormulaUrlLookup ? 'px-2 nc-formula-url-lookup' : 'px-2'"
/>
<LazySmartsheetCell
v-else
:model-value="v"
:column="lookupColumn"
:edit-enabled="false"
:virtual="true"
:read-only="true"
:class="smartsheetCellClass"
/>
</div>
</template>
</template>
</div>
</div>
</template>
</NcDropdown>
</template>
<style lang="scss">
.nc-cell-lookup-scroll {
&::-webkit-scrollbar-thumb {
@apply bg-transparent;
}
}
.nc-cell-lookup-scroll:hover {
&::-webkit-scrollbar-thumb {
@apply bg-nc-bg-gray-medium;
}
}
.nc-lookup-cell .nc-text-area-clamped-text {
@apply !mr-1;
}
.nc-formula-url-lookup .nc-cell-field,
.nc-formula-url-lookup .nc-cell-field-link {
@apply !py-0;
}
.nc-lookup-cell {
&:has(.nc-cell-attachment) {
height: auto !important;
}
.nc-cell-checkbox {
@apply children:pl-0;
& > div {
@apply !h-auto;
}
}
.nc-cell-url {
a {
@apply !py-0;
}
}
.nc-attachment-image {
@apply !hover:cursor-pointer;
}
}
.lookup-dropdown {
.nc-cell-field > span {
@apply !text-[13px];
}
.lookup-search-input {
// order matters hence using vanilla css
border: none;
border-bottom: 1px solid;
@apply !shadow-none px-3 py-[6.5px] rounded-[14px_14px_0_0] !border-nc-border-gray-medium;
input::placeholder {
@apply !text-nc-content-gray-muted;
}
.ant-input-prefix {
@apply mr-2;
}
}
.nc-lookup-attachment-wrapper {
.nc-attachment-cell > div:first-of-type {
@apply !h-auto justify-start pr-6;
.nc-attachment-image {
@apply !hover:cursor-pointer;
}
}
}
}
</style>