Browse Source

Merge pull request #8367 from nocodb/nc-feat/links-from-existing-cols

Nc feat/links from existing cols
pull/8991/head
Pranav C 4 months ago committed by GitHub
parent
commit
2e66dfdc65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      packages/nc-gui/components/general/DeleteModal.vue
  2. 46
      packages/nc-gui/components/nc/Select.vue
  3. 6
      packages/nc-gui/components/smartsheet/Cell.vue
  4. 6
      packages/nc-gui/components/smartsheet/PlainCell.vue
  5. 1
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  6. 9
      packages/nc-gui/components/smartsheet/column/LinkAdvancedOptions.vue
  7. 235
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  8. 4
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  9. 5
      packages/nc-gui/components/smartsheet/details/Fields.vue
  10. 4
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  11. 2
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  12. 4
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  13. 4
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  14. 28
      packages/nc-gui/components/smartsheet/header/DeleteColumnModal.vue
  15. 17
      packages/nc-gui/components/smartsheet/header/Menu.vue
  16. 6
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  17. 11
      packages/nc-gui/composables/useColumnCreateStore.ts
  18. 2
      packages/nc-gui/composables/useExpandedFormStore.ts
  19. 2
      packages/nc-gui/composables/useViewColumns.ts
  20. 2
      packages/nc-gui/store/sidebar.ts
  21. 2
      packages/nocodb-sdk/src/lib/globals.ts
  22. 60
      packages/nocodb-sdk/src/lib/sqlUi/DatabricksUi.ts
  23. 16
      packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts
  24. 16
      packages/nocodb-sdk/src/lib/sqlUi/MysqlUi.ts
  25. 16
      packages/nocodb-sdk/src/lib/sqlUi/OracleUi.ts
  26. 20
      packages/nocodb-sdk/src/lib/sqlUi/PgUi.ts
  27. 20
      packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts
  28. 20
      packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts
  29. 9
      packages/nocodb/src/controllers/notifications.controller.ts
  30. 2
      packages/nocodb/src/controllers/users/users.controller.ts
  31. 279
      packages/nocodb/src/db/BaseModelSqlv2.ts
  32. 33
      packages/nocodb/src/db/genRollupSelectv2.ts
  33. 54
      packages/nocodb/src/db/generateLookupSelectQuery.ts
  34. 16
      packages/nocodb/src/helpers/catchError.ts
  35. 19
      packages/nocodb/src/helpers/columnHelpers.ts
  36. 3
      packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
  37. 9
      packages/nocodb/src/redis/pubsub-redis.ts
  38. 306
      packages/nocodb/src/services/columns.service.ts
  39. 23
      packages/nocodb/src/services/tables.service.ts
  40. 3
      packages/nocodb/tests/unit/factory/table.ts
  41. 3
      packages/nocodb/tests/unit/rest/index.test.ts
  42. 2
      packages/nocodb/tsconfig.json
  43. 4
      tests/playwright/pages/Dashboard/Details/FieldsPage.ts
  44. 33
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  45. 349
      tests/playwright/tests/db/columns/columnLinkToAnotherRecordAdvanceOptions.spec.ts

8
packages/nc-gui/components/general/DeleteModal.vue

@ -56,7 +56,13 @@ onKeyStroke('Enter', () => {
</div>
<slot name="entity-preview"></slot>
<template v-if="$slots.warning">
<a-alert type="warning" show-icon>
<template #message>
<slot name="warning"></slot>
</template>
</a-alert>
</template>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<NcButton type="secondary" size="small" @click="visible = false">
{{ $t('general.cancel') }}

46
packages/nc-gui/components/nc/Select.vue

@ -1,17 +1,25 @@
<script lang="ts" setup>
const props = defineProps<{
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
size?: 'small' | 'middle' | 'large'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
filterOption?: (input: string, option: any) => boolean
dropdownMatchSelectWidth?: boolean
allowClear?: boolean
loading?: boolean
}>()
import type { iconMap } from '#imports'
const props = withDefaults(
defineProps<{
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
size?: 'small' | 'middle' | 'large'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
filterOption?: (input: string, option: any) => boolean
dropdownMatchSelectWidth?: boolean
allowClear?: boolean
loading?: boolean
suffixIcon?: keyof typeof iconMap
}>(),
{
suffixIcon: 'arrowDown',
},
)
const emits = defineEmits(['update:value', 'change'])
@ -25,15 +33,7 @@ const dropdownClassName = computed(() => {
return className
})
const showSearch = computed(() => props.showSearch)
const filterOption = computed(() => props.filterOption)
const dropdownMatchSelectWidth = computed(() => props.dropdownMatchSelectWidth)
const loading = computed(() => props.loading)
const mode = computed(() => props.mode)
const { showSearch, filterOption, dropdownMatchSelectWidth, loading, mode } = toRefs(props)
const vModel = useVModel(props, 'value', emits)
@ -60,7 +60,7 @@ const onChange = (value: string) => {
>
<template #suffixIcon>
<GeneralLoader v-if="loading" />
<GeneralIcon v-else class="text-gray-800 nc-select-expand-btn" icon="arrowDown" />
<GeneralIcon v-else class="text-gray-800 nc-select-expand-btn" :icon="suffixIcon" />
</template>
<slot />
</a-select>

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

@ -51,7 +51,11 @@ const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUis } = storeToRefs(useBase())
const sqlUi = ref(column.value?.source_id ? sqlUis.value[column.value?.source_id] : Object.values(sqlUis.value)[0])
const sqlUi = ref(
column.value?.source_id && sqlUis.value[column.value?.source_id]
? sqlUis.value[column.value?.source_id]
: Object.values(sqlUis.value)[0],
)
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

6
packages/nc-gui/components/smartsheet/PlainCell.vue

@ -37,7 +37,11 @@ const { basesUser } = storeToRefs(basesStore)
const { isXcdbBase, isMssql, isMysql } = useBase()
const sqlUi = ref(column.value?.source_id ? sqlUis.value[column.value?.source_id] : Object.values(sqlUis.value)[0])
const sqlUi = ref(
column.value?.source_id && sqlUis.value[column.value?.source_id]
? sqlUis.value[column.value?.source_id]
: Object.values(sqlUis.value)[0],
)
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

1
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -363,6 +363,7 @@ const isFullUpdateAllowed = computed(() => {
'min-w-[500px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'overflow-visible': formState.uidt === UITypes.Formula,
'!w-[600px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'min-w-[422px] !w-full': isLinksOrLTAR(formState.uidt),
'shadow-lg shadow-gray-300 border-1 border-gray-200 rounded-xl p-5': !embedMode,
}"
@keydown="handleEscape"

9
packages/nc-gui/components/smartsheet/column/LinkAdvancedOptions.vue

@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
value: any
}>()
</script>
<template>
<span></span>
</template>

235
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -47,9 +47,18 @@ if (!isEdit.value) {
if (!vModel.value.onDelete) vModel.value.onDelete = onUpdateDeleteOptions[0]
if (!vModel.value.virtual) vModel.value.virtual = sqlUi === SqliteUi // appInfo.isCloud || sqlUi === SqliteUi
if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name
} else {
if (!vModel.value.childId)
vModel.custom = {
ref_model_id: vModel.value?.colOptions?.fk_related_model_id,
base_id: meta.value?.base_id,
junc_base_id: meta.value?.base_id,
}
if (!vModel.value.childViewId) vModel.value.childViewId = vModel.value?.colOptions?.fk_target_view_id || null
}
if (!vModel.value.childId) vModel.value.childId = vModel.value?.colOptions?.fk_related_model_id || null
if (!vModel.value.childViewId) vModel.value.childViewId = vModel.value?.colOptions?.fk_target_view_id || null
if (!vModel.value.type) vModel.value.type = vModel.value?.colOptions?.type || 'mm'
const advancedOptions = ref(false)
@ -157,6 +166,41 @@ const handleUpdateRefTable = () => {
updateFieldName()
})
}
const isAdvancedOptionsShownEasterEgg = ref(false)
const cusValidators = {
'custom.column_id': [{ required: true, message: t('general.required') }],
'custom.ref_model_id': [{ required: true, message: t('general.required') }],
'custom.ref_column_id': [{ required: true, message: t('general.required') }],
}
const cusJuncTableValidations = {
'custom.junc_model_id': [{ required: true, message: t('general.required') }],
'custom.junc_column_id': [{ required: true, message: t('general.required') }],
'custom.junc_ref_column_id': [{ required: true, message: t('general.required') }],
}
const onCustomSwitchToggle = () => {
if (vModel.value?.is_custom_link)
setAdditionalValidations({
childId: [],
...cusValidators,
...(vModel.value.type === RelationTypes.MANY_TO_MANY ? cusJuncTableValidations : {}),
})
else
setAdditionalValidations({
childId: [{ required: true, message: t('general.required') }],
})
}
const handleShowAdvanceOptions = () => {
isAdvancedOptionsShownEasterEgg.value = !isAdvancedOptionsShownEasterEgg.value
if (!isAdvancedOptionsShownEasterEgg.value) {
vModel.value.is_custom_link = false
}
}
</script>
<template>
@ -176,7 +220,7 @@ const handleUpdateRefTable = () => {
</span>
{{ $t('title.hasMany') }}
</a-radio>
<a-radio value="oo" data-testid="One to One">
<a-radio value="oo" data-testid="One to One" @dblclick="handleShowAdvanceOptions">
<span class="nc-ltar-icon nc-oo-icon">
<GeneralIcon icon="oneToOneSolid" />
</span>
@ -184,8 +228,23 @@ const handleUpdateRefTable = () => {
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item class="flex w-full nc-ltar-child-table" v-bind="validateInfos.childId">
</div>
<div v-if="isAdvancedOptionsShownEasterEgg && isEeUI">
<a-switch
v-model:checked="vModel.is_custom_link"
:disabled="isEdit"
:is-edit="isEdit"
size="small"
name="Custom"
@change="onCustomSwitchToggle"
/>
<span class="ml-3">Advanced Link</span>
</div>
<div v-if="isEeUI && vModel.is_custom_link">
<LazySmartsheetColumnLinkAdvancedOptions v-model:value="vModel" :is-edit="isEdit" :meta="meta" />
</div>
<template v-else>
<a-form-item class="flex w-full pb-2 nc-ltar-child-table" v-bind="validateInfos.childId">
<a-select
v-model:value="referenceTableChildId"
show-search
@ -211,75 +270,75 @@ const handleUpdateRefTable = () => {
</a-select-option>
</a-select>
</a-form-item>
</template>
<div v-if="isEeUI" class="w-full flex-col">
<div class="flex gap-2 items-center" :class="{ 'mb-2': limitRecToView }">
<a-switch
v-model:checked="limitRecToView"
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
size="small"
:disabled="!vModel.childId"
@change="onLimitRecToViewChange"
></a-switch>
<span
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
class="text-s"
data-testid="nc-limit-record-view"
@click="limitRecToView = !!vModel.childId && !limitRecToView"
>Limit record selection to a view</span
>
</div>
<a-form-item v-if="limitRecToView" class="!pl-8 flex w-full pb-2 mt-4 space-y-2 nc-ltar-child-view">
<NcSelect
v-model:value="vModel.childViewId"
:placeholder="$t('labels.selectView')"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-ltar-child-view"
>
<a-select-option v-for="view of refViews" :key="view.title" :value="view.id">
<div class="flex w-full items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="view" class="text-gray-500" />
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ view.title }}</template>
<span>{{ view.title }}</span>
</NcTooltip>
<template v-if="isEeUI">
<div class="flex gap-2 items-center" :class="{ 'mb-2': limitRecToView }">
<a-switch
v-model:checked="limitRecToView"
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
size="small"
:disabled="!vModel.childId"
@change="onLimitRecToViewChange"
></a-switch>
<span
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
class="text-s"
data-testid="nc-limit-record-view"
@click="limitRecToView = !!vModel.childId && !limitRecToView"
>Limit record selection to a view</span
>
</div>
<a-form-item v-if="limitRecToView" class="!pl-8 flex w-full pb-2 mt-4 space-y-2 nc-ltar-child-view">
<NcSelect
v-model:value="vModel.childViewId"
:placeholder="$t('labels.selectView')"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-ltar-child-view"
>
<a-select-option v-for="view of refViews" :key="view.title" :value="view.id">
<div class="flex w-full items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="view" class="text-gray-500" />
</div>
</a-select-option>
</NcSelect>
</a-form-item>
<div class="mt-4 flex gap-2 items-center" :class="{ 'mb-2': limitRecToCond }">
<a-switch
v-model:checked="limitRecToCond"
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
:disabled="!vModel.childId"
size="small"
></a-switch>
<span
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
data-testid="nc-limit-record-filters"
@click="limitRecToCond = !!vModel.childId && !limitRecToCond"
>
Limit record selection to filters
</span>
</div>
<div v-if="limitRecToCond" class="overflow-auto">
<LazySmartsheetToolbarColumnFilter
ref="filterRef"
v-model="vModel.filters"
class="!pl-8 !p-0 max-w-620px"
:auto-save="false"
:show-loading="false"
:link="true"
:root-meta="meta"
:link-col-id="vModel.id"
/>
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ view.title }}</template>
<span>{{ view.title }}</span>
</NcTooltip>
</div>
</a-select-option>
</NcSelect>
</a-form-item>
<div class="flex gap-2 items-center" :class="{ 'mb-2': limitRecToCond }">
<a-switch
v-model:checked="limitRecToCond"
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
:disabled="!vModel.childId"
size="small"
></a-switch>
<span
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
data-testid="nc-limit-record-filters"
@click="limitRecToCond = !!vModel.childId && !limitRecToCond"
>
Limit record selection to filters
</span>
</div>
</div>
<div v-if="limitRecToCond" class="overflow-auto">
<LazySmartsheetToolbarColumnFilter
ref="filterRef"
v-model="vModel.filters"
class="!pl-8 !p-0 max-w-620px"
:auto-save="false"
:show-loading="false"
:link="true"
:root-meta="meta"
:link-col-id="vModel.id"
/>
</div>
</template>
<template v-if="(!isXcdbBase && !isEdit) || isLinks">
<div>
<NcButton
@ -401,3 +460,41 @@ const handleUpdateRefTable = () => {
@apply h-8.5;
}
</style>
<!-- todo: remove later
<style lang="scss" scoped>
.nc-ltar-relation-type-radio-group {
.nc-ltar-icon {
@apply flex items-center p-1 rounded;
&.nc-mm-icon {
@apply bg-pink-500;
}
&.nc-hm-icon {
@apply bg-orange-500;
}
&.nc-oo-icon {
@apply bg-purple-500;
:deep(svg path) {
@apply stroke-purple-50;
}
}
}
:deep(.ant-radio-wrapper) {
@apply px-3 py-2 flex items-center mr-0;
&:not(:last-child) {
@apply border-b border-gray-200;
}
}
:deep(.ant-radio) {
@apply top-0;
& + span {
@apply flex items-center gap-2;
}
}
}
</style>
-->

4
packages/nc-gui/components/smartsheet/column/LookupOptions.vue

@ -42,7 +42,9 @@ const refTables = computed(() => {
.map((column) => ({
col: column.colOptions,
column,
...tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id),
...(tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id) ||
metas.value[(column.colOptions as LinkToAnotherRecordType).fk_related_model_id!] ||
{}),
}))
.filter((table) => (table.col as LinkToAnotherRecordType)?.fk_related_model_id === table.id && !table.mm)
return _refTables as Required<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]

5
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -514,7 +514,10 @@ const isColumnValid = (column: TableExplorerColumn) => {
return false
}
if ((column.uidt === UITypes.Links || column.uidt === UITypes.LinkToAnotherRecord) && isNew) {
if (!column.childColumn || !column.childTable || !column.childId) {
if (
(!column.childColumn || !column.childTable || !column.childId) &&
(!column.custom?.ref_model_id || !column.custom?.ref_column_id)
) {
return false
}
}

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

@ -112,9 +112,7 @@ const hiddenFields = computed(() => {
.filter(
(col) =>
!fields.value?.includes(col) &&
(isLocalMode.value && col?.id && fieldsMap.value[col.id]
? fieldsMap.value[col.id]?.initialShow
: true),
(isLocalMode.value && col?.id && fieldsMap.value[col.id] ? fieldsMap.value[col.id]?.initialShow : true),
)
.filter((col) => !isSystemColumn(col))
})

2
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -398,7 +398,7 @@ const bgColor = computed(() => {
@change="findAndLoadSubGroup"
>
<a-collapse-panel
v-for="[i, grp] of Object.entries(vGroup?.children ?? [])"
v-for="[_, grp] of Object.entries(vGroup?.children ?? [])"
:key="`group-panel-${grp.key}`"
class="!border-1 border-gray-300 nc-group rounded-[8px] mb-2"
:style="`background: ${bgColor};`"

4
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -46,10 +46,10 @@ const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const { eventBus } = useSmartsheetStoreOrThrow()
const routeQuery = computed(() => route.value.query as Record<string, string>)
const route = router.currentRoute
const routeQuery = computed(() => route.value.query as Record<string, string>)
const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()

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

@ -109,6 +109,8 @@ export const useColumnDrag = ({
const handleReorderColumn = async () => {
isProcessing.value = true
try {
if (!dragColPlaceholderDomRef.value) return
dragColPlaceholderDomRef.value!.style.left = '0px'
dragColPlaceholderDomRef.value!.style.height = '0px'
await reorderColumn(draggedCol.value!.id!, toBeDroppedColId.value!)
@ -126,6 +128,8 @@ export const useColumnDrag = ({
const dom = document.querySelector('[data-testid="drag-icon-placeholder"]')
if (!dom || !dragColPlaceholderDomRef.value) return
e.dataTransfer.dropEffect = 'none'
e.dataTransfer.effectAllowed = 'none'

28
packages/nc-gui/components/smartsheet/header/DeleteColumnModal.vue

@ -27,6 +27,31 @@ const viewsStore = useViewsStore()
const isLoading = ref(false)
// disable for time being - internal discussion required
/*
const warningMsg = computed(() => {
if (!column?.value) return []
const columns = meta?.value?.columns.filter((c) => {
if (isLinksOrLTAR(c) && c.colOptions) {
return (
(c.colOptions as LinkToAnotherRecordType).fk_parent_column_id === column.value?.id ||
(c.colOptions as LinkToAnotherRecordType).fk_child_column_id === column.value?.id ||
(c.colOptions as LinkToAnotherRecordType).fk_mm_child_column_id === column.value?.id ||
(c.colOptions as LinkToAnotherRecordType).fk_mm_parent_column_id === column.value?.id
)
}
return false
})
if (!columns.length) return null
return `This column is used in following Link column${columns.length > 1 ? 's' : ''}: '${columns
.map((c) => c.title)
.join("', '")}'. Deleting this column will also delete the related Link column${columns.length > 1 ? 's' : ''}.`
}) */
const onDelete = async () => {
if (!column?.value) return
@ -80,5 +105,8 @@ const onDelete = async () => {
</div>
</div>
</template>
<!-- disable for time being - internal discussion required -->
<!-- <template v-if="warningMsg" #warning>{{ warningMsg }}</template> -->
</GeneralDeleteModal>
</template>

17
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -328,7 +328,8 @@ const isDuplicateAllowed = computed(() => {
return (
column?.value &&
!column.value.system &&
((!isMetaReadOnly.value && !isDataReadOnly.value) || readonlyMetaAllowedTypes.includes(column.value?.uidt))
((!isMetaReadOnly.value && !isDataReadOnly.value) || readonlyMetaAllowedTypes.includes(column.value?.uidt)) &&
!column.value.meta?.custom
)
})
const isFilterSupported = computed(
@ -373,6 +374,13 @@ const isColumnEditAllowed = computed(() => {
return false
return true
})
// check if the column is associated as foreign key in any of the link column
const linksAssociated = computed(() => {
return meta.value?.columns?.filter(
(c) => isLinksOrLTAR(c) && [c.colOptions?.fk_child_column_id, c.colOptions?.fk_parent_column_id].includes(column?.value?.id),
)
})
</script>
<template>
@ -398,7 +406,8 @@ const isColumnEditAllowed = computed(() => {
<GeneralSourceRestrictionTooltip message="Field properties cannot be edited." :enabled="!isColumnEditAllowed">
<NcMenuItem
v-if="isUIAllowed('fieldAlter')"
:disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed"
:disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed || linksAssociated.length"
:title="linksAssociated.length ? 'Field is associated with a link column' : undefined"
@click="onEditPress"
>
<div class="nc-column-edit nc-header-menu-item">
@ -408,7 +417,6 @@ const isColumnEditAllowed = computed(() => {
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed">
<NcMenuItem
v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk"
@ -553,8 +561,9 @@ const isColumnEditAllowed = computed(() => {
<GeneralSourceRestrictionTooltip message="Field cannot be deleted." :enabled="!isColumnUpdateAllowed">
<NcMenuItem
v-if="!column?.pv && isUIAllowed('fieldDelete')"
:disabled="!isDeleteAllowed || !isColumnUpdateAllowed"
:disabled="!isDeleteAllowed || !isColumnUpdateAllowed || linksAssociated.length"
class="!hover:bg-red-50"
:title="linksAssociated ? 'Field is associated with a link column' : undefined"
@click="handleDelete"
>
<div

6
packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue

@ -65,7 +65,11 @@ type FilterType = keyof typeof checkTypeFunctions
const { sqlUis } = storeToRefs(useBase())
const sqlUi = ref(column.value?.source_id ? sqlUis.value[column.value?.source_id] : Object.values(sqlUis.value)[0])
const sqlUi = ref(
column.value?.source_id && sqlUis.value[column.value?.source_id]
? sqlUis.value[column.value?.source_id]
: Object.values(sqlUis.value)[0],
)
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

11
packages/nc-gui/composables/useColumnCreateStore.ts

@ -79,6 +79,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const formState = ref<Record<string, any>>({
title: '',
uidt: fromTableExplorer?.value ? defaultType : null,
custom: {},
...clone(column.value || {}),
})
@ -89,6 +90,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined)
formState.value = {
custom: {},
...(!isEdit.value && {
// only take title, column_name and uidt when creating a column
// to avoid the extra props from being taken (e.g. SingleLineText -> LTAR -> SingleLineText)
@ -284,6 +286,9 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
try {
formState.value.table_name = meta.value?.table_name
const refModelId = formState.value.custom?.ref_model_id
// formState.value.title = formState.value.column_name
if (column.value) {
// reset column validation if column is not to be validated
@ -337,7 +342,11 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
/** if LTAR column then force reload related table meta */
if (isLinksOrLTAR(formState.value) && meta.value?.id !== formState.value.childId) {
getMeta(formState.value.childId, true).then(() => {})
if (refModelId) {
getMeta(refModelId, true).then(() => {})
} else {
getMeta(formState.value.childId, true).then(() => {})
}
}
// Column created

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

@ -331,7 +331,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (missingRequiredColumns.size) return
data = await $api.dbTableRow.create('noco', base.value.id as string, meta.value.id, {
data = await $api.dbTableRow.create('noco', meta.value.base_id, meta.value.id, {
...insertObj,
...(ltarState || {}),
})

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

@ -136,7 +136,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
if (isLocalMode.value) {
const fieldById = (fields.value || []).reduce<Record<string, any>>((acc, curr) => {
if (curr.fk_column_id) {
curr.show = curr.initialShow ? true : false
curr.show = !!curr.initialShow
acc[curr.fk_column_id] = curr
}
return acc

2
packages/nc-gui/store/sidebar.ts

@ -1,5 +1,5 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { MAX_WIDTH_FOR_MOBILE_MODE, INITIAL_LEFT_SIDEBAR_WIDTH } from '~/lib/constants'
import { INITIAL_LEFT_SIDEBAR_WIDTH, MAX_WIDTH_FOR_MOBILE_MODE } from '~/lib/constants'
export const useSidebarStore = defineStore('sidebarStore', () => {
const { width } = useWindowSize()

2
packages/nocodb-sdk/src/lib/globals.ts

@ -190,6 +190,8 @@ export enum NcErrorType {
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
BAD_JSON = 'BAD_JSON',
INVALID_PK_VALUE = 'INVALID_PK_VALUE',
COLUMN_ASSOCIATED_WITH_LINK = 'COLUMN_ASSOCIATED_WITH_LINK',
TABLE_ASSOCIATED_WITH_LINK = 'TABLE_ASSOCIATED_WITH_LINK',
}
type Roles = OrgUserRoles | ProjectRoles | WorkspaceUserRoles;

60
packages/nocodb-sdk/src/lib/sqlUi/DatabricksUi.ts

@ -531,7 +531,7 @@ export class DatabricksUi {
}
}
static getDataTypeForUiType(col: { uidt: UITypes; }) {
static getDataTypeForUiType(col: { uidt: UITypes }) {
const colProp: any = {};
switch (col.uidt) {
case 'ID':
@ -667,7 +667,7 @@ export class DatabricksUi {
return colProp;
}
static getDataTypeListForUiType(col: { uidt: UITypes; }, idType?: IDType) {
static getDataTypeListForUiType(col: { uidt: UITypes }, idType?: IDType) {
switch (col.uidt) {
case 'ID':
if (idType === 'AG') {
@ -696,9 +696,7 @@ export class DatabricksUi {
return ['string'];
case 'Checkbox':
return [
'boolean',
];
return ['boolean'];
case 'MultiSelect':
return ['string'];
@ -707,14 +705,10 @@ export class DatabricksUi {
return ['string'];
case 'Year':
return [
'int',
];
return ['int'];
case 'Time':
return [
'string',
];
return ['string'];
case 'PhoneNumber':
case 'Email':
@ -724,32 +718,22 @@ export class DatabricksUi {
return ['string'];
case 'Number':
return [
'int',
];
return ['int'];
case 'Decimal':
return ['decimal', 'float', 'double'];
case 'Currency':
return [
'decimal',
];
return ['decimal'];
case 'Percent':
return [
'decimal',
];
return ['decimal'];
case 'Duration':
return [
'decimal',
];
return ['decimal'];
case 'Rating':
return [
'int',
];
return ['int'];
case 'Formula':
return ['string'];
@ -758,9 +742,7 @@ export class DatabricksUi {
return ['string'];
case 'Count':
return [
'int',
];
return ['int'];
case 'Lookup':
return ['string'];
@ -774,9 +756,7 @@ export class DatabricksUi {
return ['datetime'];
case 'AutoNumber':
return [
'int',
];
return ['int'];
case 'Barcode':
return ['string'];
@ -813,4 +793,20 @@ export class DatabricksUi {
'HOUR',
];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1 === dataType2) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}

16
packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts

@ -968,4 +968,20 @@ export class MssqlUi {
'DATESTR',
];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1 === dataType2) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}

16
packages/nocodb-sdk/src/lib/sqlUi/MysqlUi.ts

@ -1334,4 +1334,20 @@ export class MysqlUi {
static getUnsupportedFnList() {
return ['COUNTA', 'COUNT', 'DATESTR'];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1 === dataType2) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}

16
packages/nocodb-sdk/src/lib/sqlUi/OracleUi.ts

@ -951,6 +951,22 @@ export class OracleUi {
'DATESTR',
];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1 === dataType2) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}
// module.exports = PgUiHelp;

20
packages/nocodb-sdk/src/lib/sqlUi/PgUi.ts

@ -1613,7 +1613,7 @@ export class PgUi {
}
}
static getDataTypeForUiType(col: { uidt: UITypes; }, idType?: IDType) {
static getDataTypeForUiType(col: { uidt: UITypes }, idType?: IDType) {
const colProp: any = {};
switch (col.uidt) {
case 'ID':
@ -1754,7 +1754,7 @@ export class PgUi {
return colProp;
}
static getDataTypeListForUiType(col: { uidt?: UITypes; }, idType: IDType) {
static getDataTypeListForUiType(col: { uidt?: UITypes }, idType: IDType) {
switch (col.uidt) {
case 'ID':
if (idType === 'AG') {
@ -2031,6 +2031,22 @@ export class PgUi {
static getUnsupportedFnList() {
return [];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1?.toLowerCase() === dataType2?.toLowerCase()) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}
// module.exports = PgUiHelp;

20
packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts

@ -707,7 +707,7 @@ export class SnowflakeUi {
}
}
static getDataTypeForUiType(col: { uidt: UITypes; }, idType?: IDType) {
static getDataTypeForUiType(col: { uidt: UITypes }, idType?: IDType) {
const colProp: any = {};
switch (col.uidt) {
case 'ID':
@ -847,7 +847,7 @@ export class SnowflakeUi {
return colProp;
}
static getDataTypeListForUiType(col: { uidt: UITypes; }, idType: IDType) {
static getDataTypeListForUiType(col: { uidt: UITypes }, idType: IDType) {
switch (col.uidt) {
case 'ID':
if (idType === 'AG') {
@ -1034,6 +1034,22 @@ export class SnowflakeUi {
'DATESTR',
];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1 === dataType2) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}
// module.exports = SnowflakeUiHelp;

20
packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts

@ -556,7 +556,7 @@ export class SqliteUi {
}
}
static getDataTypeForUiType(col: { uidt: UITypes; }, idType?: IDType) {
static getDataTypeForUiType(col: { uidt: UITypes }, idType?: IDType) {
const colProp: any = {};
switch (col.uidt) {
case 'ID':
@ -697,7 +697,7 @@ export class SqliteUi {
return colProp;
}
static getDataTypeListForUiType(col: { uidt: UITypes; }, idType?: IDType) {
static getDataTypeListForUiType(col: { uidt: UITypes }, idType?: IDType) {
switch (col.uidt) {
case 'ID':
if (idType === 'AG') {
@ -928,4 +928,20 @@ export class SqliteUi {
'HOUR',
];
}
static isEqual(dataType1: string, dataType2: string) {
if (dataType1 === dataType2) return true;
const abstractType1 = this.getAbstractType({ dt: dataType1 });
const abstractType2 = this.getAbstractType({ dt: dataType2 });
if (
abstractType1 &&
abstractType1 === abstractType2 &&
['integer', 'float'].includes(abstractType1)
)
return true;
return false;
}
}

9
packages/nocodb/src/controllers/notifications.controller.ts

@ -45,7 +45,8 @@ export class NotificationsController {
this.notificationsService.addConnection(req.user.id, res);
let unsubscribeCallback: (keepRedisChannel?: boolean) => Promise<void> = null;
let unsubscribeCallback: (keepRedisChannel?: boolean) => Promise<void> =
null;
if (PubSubRedis.available) {
unsubscribeCallback = await PubSubRedis.subscribe(
@ -57,7 +58,11 @@ export class NotificationsController {
}
res.on('close', async () => {
await this.notificationsService.removeConnection(req.user.id, res, unsubscribeCallback);
await this.notificationsService.removeConnection(
req.user.id,
res,
unsubscribeCallback,
);
});
setTimeout(() => {

2
packages/nocodb/src/controllers/users/users.controller.ts

@ -15,8 +15,6 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { UsersService } from '~/services/users/users.service';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { TenantContext } from '~/decorators/tenant-context.decorator';
import { NcContext, NcRequest } from '~/interface/config';
@Controller()
export class UsersController {

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

@ -1713,13 +1713,13 @@ class BaseModelSqlv2 {
)) as LinkToAnotherRecordColumn
).getParentColumn(this.context);
const parentTable = await parentCol.getModel(this.context);
const childModel = await Model.getBaseModelSQL(this.context, {
const childBaseModel = await Model.getBaseModelSQL(this.context, {
model: childTable,
dbDriver: this.dbDriver,
});
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const qb = this.dbDriver(childTn);
@ -1736,7 +1736,7 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
await childModel.selectObject({
await childBaseModel.selectObject({
qb,
fieldsSet: args.fieldSet,
});
@ -1790,7 +1790,11 @@ class BaseModelSqlv2 {
const parentTable = await parentCol.getModel(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const query = this.dbDriver(childTn)
@ -1945,7 +1949,12 @@ class BaseModelSqlv2 {
)) as LinkToAnotherRecordColumn;
const mmTable = await relColOptions.getMMModel(this.context);
const vtn = this.getTnPath(mmTable);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
id: mmTable.id,
dbDriver: this.dbDriver,
});
const vtn = assocBaseModel.getTnPath(mmTable);
const vcn = (await relColOptions.getMMChildColumn(this.context))
.column_name;
const vrcn = (await relColOptions.getMMParentColumn(this.context))
@ -1980,8 +1989,16 @@ class BaseModelSqlv2 {
).getModel(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
id: parentTable.id,
dbDriver: this.dbDriver,
});
const childBaseModel = await Model.getBaseModelSQL(this.context, {
id: childTable.id,
dbDriver: this.dbDriver,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const rtn = childTn;
const qb = this.dbDriver(rtn)
@ -2096,7 +2113,13 @@ class BaseModelSqlv2 {
)) as LinkToAnotherRecordColumn;
const mmTable = await relColOptions.getMMModel(this.context);
const vtn = this.getTnPath(mmTable);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
model: mmTable,
dbDriver: this.dbDriver,
});
const vtn = assocBaseModel.getTnPath(mmTable);
const vcn = (await relColOptions.getMMChildColumn(this.context))
.column_name;
const vrcn = (await relColOptions.getMMParentColumn(this.context))
@ -2112,7 +2135,12 @@ class BaseModelSqlv2 {
).getModel(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = childTn;
@ -2162,20 +2190,37 @@ class BaseModelSqlv2 {
)) as LinkToAnotherRecordColumn;
const mmTable = await relColOptions.getMMModel(this.context);
const vtn = this.getTnPath(mmTable);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
id: mmTable.id,
dbDriver: this.dbDriver,
});
const vtn = assocBaseModel.getTnPath(mmTable);
const vcn = (await relColOptions.getMMChildColumn(this.context))
.column_name;
const vrcn = (await relColOptions.getMMParentColumn(this.context))
.column_name;
const rcn = (await relColOptions.getParentColumn(this.context)).column_name;
const cn = (await relColOptions.getChildColumn(this.context)).column_name;
const childTable = await (
await relColOptions.getParentColumn(this.context)
).getModel(this.context);
const childModel = await Model.getBaseModelSQL(this.context, {
const parentTable = await (
await relColOptions.getChildColumn(this.context)
).getModel(this.context);
await parentTable.getColumns(this.context);
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
id: parentTable.id,
dbDriver: this.dbDriver,
model: childTable,
});
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
id: childTable.id,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const childView = await relColOptions.getChildView(this.context);
let listArgs: any = {};
if (childView) {
@ -2188,14 +2233,6 @@ class BaseModelSqlv2 {
listArgs = dependencyFields;
}
const parentTable = await (
await relColOptions.getChildColumn(this.context)
).getModel(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = childTn;
const qb = this.dbDriver(rtn).where((qb) =>
@ -2220,7 +2257,7 @@ class BaseModelSqlv2 {
await this.shuffle({ qb });
}
await childModel.selectObject({
await childBaseModel.selectObject({
qb,
fieldsSet: listArgs?.fieldsSet,
viewId: childView?.id,
@ -2248,7 +2285,7 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
const proto = await childModel.getProto();
const proto = await childBaseModel.getProto();
const data = await this.execAndParse(
qb,
await childTable.getColumns(this.context),
@ -2280,16 +2317,20 @@ class BaseModelSqlv2 {
const parentTable = await (
await relColOptions.getParentColumn(this.context)
).getModel(this.context);
const childModel = await Model.getBaseModelSQL(this.context, {
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: parentTable,
});
await parentTable.getColumns(this.context);
const childView = await relColOptions.getChildView(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const tn = childTn;
const rtn = parentTn;
@ -2308,7 +2349,7 @@ class BaseModelSqlv2 {
await this.shuffle({ qb });
}
await childModel.selectObject({ qb });
await childBaseModel.selectObject({ qb });
const aliasColObjMap = await childTable.getAliasColObjMap(this.context);
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
@ -2330,12 +2371,11 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
const proto = await childModel.getProto();
const proto = await childBaseModel.getProto();
const data = await this.execAndParse(
qb,
await childTable.getColumns(this.context),
);
return data.map((c) => {
c.__proto__ = proto;
return c;
@ -2367,7 +2407,12 @@ class BaseModelSqlv2 {
const childView = await relColOptions.getChildView(this.context);
const childTn = this.getTnPath(childTable);
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const tn = childTn;
@ -2530,8 +2575,13 @@ class BaseModelSqlv2 {
await relColOptions.getChildColumn(this.context)
).getModel(this.context);
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: parentTable,
});
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const rtn = parentTn;
const tn = childTn;
@ -2591,9 +2641,16 @@ class BaseModelSqlv2 {
).getModel(this.context);
const childView = await relColOptions.getChildView(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: parentTable,
});
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const rtn = parentTn;
const tn = childTn;
@ -2652,13 +2709,13 @@ class BaseModelSqlv2 {
const childTable = await (
await relColOptions.getChildColumn(this.context)
).getModel(this.context);
const parentModel = await Model.getBaseModelSQL(this.context, {
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: parentTable,
});
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const rtn = parentTn;
const tn = childTn;
@ -2679,7 +2736,7 @@ class BaseModelSqlv2 {
await this.shuffle({ qb });
}
await parentModel.selectObject({ qb });
await parentBaseModel.selectObject({ qb });
const aliasColObjMap = await parentTable.getAliasColObjMap(this.context);
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
@ -2706,7 +2763,7 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
const proto = await parentModel.getProto();
const proto = await parentBaseModel.getProto();
const data = await this.execAndParse(
qb,
await parentTable.getColumns(this.context),
@ -5525,8 +5582,18 @@ class BaseModelSqlv2 {
await childTable.getColumns(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
model: parentTable,
dbDriver: this.dbDriver,
});
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const relatedChildCol = getRelatedLinksColumn(
column,
@ -5582,15 +5649,22 @@ class BaseModelSqlv2 {
const vParentCol = await colOptions.getMMParentColumn(this.context);
const vTable = await colOptions.getMMModel(this.context);
const vTn = this.getTnPath(vTable);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
model: vTable,
dbDriver: this.dbDriver,
});
const vTn = assocBaseModel.getTnPath(vTable);
if (this.isSnowflake || this.isDatabricks) {
const parentPK = this.dbDriver(parentTn)
const parentPK = parentBaseModel
.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(_wherePk(parentTable.primaryKeys, childId))
.first();
const childPK = this.dbDriver(childTn)
const childPK = childBaseModel
.dbDriver(childTn)
.select(childColumn.column_name)
.where(_wherePk(childTable.primaryKeys, rowId))
.first();
@ -5621,11 +5695,13 @@ class BaseModelSqlv2 {
}
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [childId],
cookie,
});
await this.updateLastModified({
baseModel: childBaseModel,
model: childTable,
rowIds: [rowId],
cookie,
@ -5641,14 +5717,20 @@ class BaseModelSqlv2 {
{
const linkedHmRowObj = await this.execAndParse(
this.dbDriver(childTn)
.select(`${childTable.table_name}.${childColumn.column_name}`)
.select(
...new Set(
[childColumn, ...childTable.primaryKeys].map(
(col) => `${childTable.table_name}.${col.column_name}`,
),
),
)
.where(_wherePk(childTable.primaryKeys, childId)),
null,
{ raw: true, first: true },
);
const oldRowId = linkedHmRowObj
? Object.values(linkedHmRowObj)?.[0]
? linkedHmRowObj?.[childTable.primaryKey?.column_name]
: null;
if (oldRowId) {
@ -5699,6 +5781,7 @@ class BaseModelSqlv2 {
await triggerAfterRemoveChild();
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [rowId],
cookie,
@ -5752,14 +5835,20 @@ class BaseModelSqlv2 {
} else {
const linkedHmRowObj = await this.execAndParse(
this.dbDriver(childTn)
.select(childColumn.column_name)
.select(
...new Set(
[childColumn, ...childTable.primaryKeys].map(
(col) => col.column_name,
),
),
)
.where(_wherePk(childTable.primaryKeys, rowId)),
null,
{ raw: true, first: true },
);
const oldChildRowId = linkedHmRowObj
? Object.values(linkedHmRowObj)?.[0]
? linkedHmRowObj[childTable.primaryKeys[0]?.column_name]
: null;
if (oldChildRowId) {
@ -5811,6 +5900,7 @@ class BaseModelSqlv2 {
await triggerAfterRemoveChild();
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [childId],
cookie,
@ -5830,14 +5920,20 @@ class BaseModelSqlv2 {
// 1. check current row is linked with another child
linkedCurrentOoRowObj = await this.execAndParse(
this.dbDriver(childTn)
.select(childColumn.column_name)
.select(
...new Set(
[childColumn, ...childTable.primaryKeys].map(
(col) => col.column_name,
),
),
)
.where(_wherePk(childTable.primaryKeys, rowId)),
null,
{ raw: true, first: true },
);
const oldChildRowId = linkedCurrentOoRowObj
? Object.values(linkedCurrentOoRowObj)?.[0]
? linkedCurrentOoRowObj[childTable.primaryKeys[0]?.column_name]
: null;
if (oldChildRowId) {
@ -5979,14 +6075,20 @@ class BaseModelSqlv2 {
// 2. check current child is linked with another row cell
linkedOoRowObj = await this.execAndParse(
this.dbDriver(childTn)
.select(childColumn.column_name)
.select(
...new Set(
[childColumn, ...childTable.primaryKeys].map(
(col) => `${childTable.table_name}.${col.column_name}`,
),
),
)
.where(_wherePk(childTable.primaryKeys, childId)),
null,
{ raw: true, first: true },
);
const oldRowId = linkedOoRowObj
? Object.values(linkedOoRowObj)?.[0]
? linkedOoRowObj[childTable.primaryKeys[0]?.column_name]
: null;
if (oldRowId) {
const [parentRelatedPkValue, childRelatedPkValue] =
@ -6060,6 +6162,7 @@ class BaseModelSqlv2 {
);
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [childId],
cookie,
@ -6176,8 +6279,18 @@ class BaseModelSqlv2 {
await childTable.getColumns(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
model: parentTable,
dbDriver: this.dbDriver,
});
const childBaseModel = await Model.getBaseModelSQL(this.context, {
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const relatedChildCol = getRelatedLinksColumn(
column,
@ -6210,8 +6323,11 @@ class BaseModelSqlv2 {
const vChildCol = await colOptions.getMMChildColumn(this.context);
const vParentCol = await colOptions.getMMParentColumn(this.context);
const vTable = await colOptions.getMMModel(this.context);
const vTn = this.getTnPath(vTable);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
model: vTable,
dbDriver: this.dbDriver,
});
const vTn = assocBaseModel.getTnPath(vTable);
await this.execAndParse(
this.dbDriver(vTn)
@ -6231,11 +6347,13 @@ class BaseModelSqlv2 {
);
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [childId],
cookie,
});
await this.updateLastModified({
baseModel: childBaseModel,
model: childTable,
rowIds: [rowId],
cookie,
@ -6264,6 +6382,7 @@ class BaseModelSqlv2 {
);
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [rowId],
cookie,
@ -6290,6 +6409,7 @@ class BaseModelSqlv2 {
);
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [childId],
cookie,
@ -6312,6 +6432,7 @@ class BaseModelSqlv2 {
);
await this.updateLastModified({
baseModel: parentBaseModel,
model: parentTable,
rowIds: [childId],
cookie,
@ -6791,7 +6912,7 @@ class BaseModelSqlv2 {
colId: k,
})
.then((col) => {
return col.title;
return col?.title;
})
.catch((e) => {
return Promise.resolve(e);
@ -6806,7 +6927,7 @@ class BaseModelSqlv2 {
colId: col.id,
})
.then((col) => {
return col.title;
return col?.title;
})
.catch((e) => {
return Promise.resolve(e);
@ -7265,8 +7386,18 @@ class BaseModelSqlv2 {
await childTable.getColumns(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const childBaseModel = await Model.getBaseModelSQL(this.context, {
model: childTable,
dbDriver: this.dbDriver,
});
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
model: parentTable,
dbDriver: this.dbDriver,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
let relationType = colOptions.type;
let childIds = _childIds;
@ -7338,7 +7469,12 @@ class BaseModelSqlv2 {
const vParentCol = await colOptions.getMMParentColumn(this.context);
const vTable = await colOptions.getMMModel(this.context);
const vTn = this.getTnPath(vTable);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
model: vTable,
dbDriver: this.dbDriver,
});
const vTn = assocBaseModel.getTnPath(vTable);
let insertData: Record<string, any>[];
@ -7640,8 +7776,18 @@ class BaseModelSqlv2 {
await childTable.getColumns(this.context);
await parentTable.getColumns(this.context);
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const childBaseModel = await Model.getBaseModelSQL(this.context, {
model: childTable,
dbDriver: this.dbDriver,
});
const parentBaseModel = await Model.getBaseModelSQL(this.context, {
model: parentTable,
dbDriver: this.dbDriver,
});
const childTn = childBaseModel.getTnPath(childTable);
const parentTn = parentBaseModel.getTnPath(parentTable);
const relatedChildCol = getRelatedLinksColumn(
column,
@ -7678,6 +7824,11 @@ class BaseModelSqlv2 {
const vParentCol = await colOptions.getMMParentColumn(this.context);
const vTable = await colOptions.getMMModel(this.context);
const assocBaseModel = await Model.getBaseModelSQL(this.context, {
model: vTable,
dbDriver: this.dbDriver,
});
// validate Ids
{
const childRowsQb = this.dbDriver(parentTn).select(
@ -7732,7 +7883,7 @@ class BaseModelSqlv2 {
}
}
const vTn = this.getTnPath(vTable);
const vTn = assocBaseModel.getTnPath(vTable);
const delQb = this.dbDriver(vTn)
.where({
@ -8027,11 +8178,13 @@ class BaseModelSqlv2 {
cookie,
model = this.model,
knex = this.dbDriver,
baseModel = this,
}: {
rowIds: any | any[];
cookie?: { user?: any };
model?: Model;
knex?: XKnex;
baseModel?: BaseModelSqlv2;
}) {
const columns = await model.getColumns(this.context);
@ -8055,7 +8208,7 @@ class BaseModelSqlv2 {
if (Object.keys(updateObject).length === 0) return;
const qb = knex(this.getTnPath(model.table_name)).update(updateObject);
const qb = knex(baseModel.getTnPath(model.table_name)).update(updateObject);
for (const rowId of Array.isArray(rowIds) ? rowIds : [rowIds]) {
qb.orWhere(_wherePk(model.primaryKeys, rowId));

33
packages/nocodb/src/db/genRollupSelectv2.ts

@ -7,6 +7,7 @@ import type {
} from '~/models';
import type { XKnex } from '~/db/CustomKnex';
import type { Knex } from 'knex';
import { Model } from '~/models';
export default async function ({
baseModelSqlv2,
@ -33,11 +34,20 @@ export default async function ({
const parentModel = await parentCol?.getModel(context);
const refTableAlias = `__nc_rollup`;
const parentBaseModel = await Model.getBaseModelSQL(context, {
model: parentModel,
dbDriver: knex,
});
const childBaseModel = await Model.getBaseModelSQL(context, {
model: childModel,
dbDriver: knex,
});
switch (relationColumnOption.type) {
case RelationTypes.HAS_MANY: {
const queryBuilder: any = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
childBaseModel.getTnPath(childModel),
refTableAlias,
]),
)
@ -46,7 +56,7 @@ export default async function ({
)
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
`${alias || parentBaseModel.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
@ -62,7 +72,7 @@ export default async function ({
case RelationTypes.ONE_TO_ONE: {
const qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
childBaseModel.getTnPath(childModel?.table_name),
refTableAlias,
]),
)
@ -71,7 +81,7 @@ export default async function ({
)
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
`${alias || parentBaseModel.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
@ -88,7 +98,10 @@ export default async function ({
const mmModel = await relationColumnOption.getMMModel(context);
const mmChildCol = await relationColumnOption.getMMChildColumn(context);
const mmParentCol = await relationColumnOption.getMMParentColumn(context);
const assocBaseModel = await Model.getBaseModelSQL(context, {
id: mmModel.id,
dbDriver: knex,
});
if (!mmModel) {
return this.dbDriver.raw(`?`, [
NcDataErrorCodes.NC_ERR_MM_MODEL_NOT_FOUND,
@ -97,7 +110,7 @@ export default async function ({
const qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel?.table_name),
parentBaseModel.getTnPath(parentModel?.table_name),
refTableAlias,
]),
)
@ -105,9 +118,9 @@ export default async function ({
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.innerJoin(
baseModelSqlv2.getTnPath(mmModel.table_name),
assocBaseModel.getTnPath(mmModel.table_name),
knex.ref(
`${baseModelSqlv2.getTnPath(mmModel.table_name)}.${
`${assocBaseModel.getTnPath(mmModel.table_name)}.${
mmParentCol.column_name
}`,
),
@ -116,13 +129,13 @@ export default async function ({
)
.where(
knex.ref(
`${baseModelSqlv2.getTnPath(mmModel.table_name)}.${
`${assocBaseModel.getTnPath(mmModel.table_name)}.${
mmChildCol.column_name
}`,
),
'=',
knex.ref(
`${alias || baseModelSqlv2.getTnPath(childModel.table_name)}.${
`${alias || childBaseModel.getTnPath(childModel.table_name)}.${
childCol.column_name
}`,
),

54
packages/nocodb/src/db/generateLookupSelectQuery.ts

@ -1,4 +1,4 @@
import { isVirtualCol, RelationTypes, UITypes } from 'nocodb-sdk';
import { RelationTypes, UITypes } from 'nocodb-sdk';
import type LookupColumn from '../models/LookupColumn';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type {
@ -92,9 +92,14 @@ export default async function generateLookupSelectQuery({
const parentModel = await parentColumn.getModel(context);
await parentModel.getColumns(context);
const parentBaseModel = await Model.getBaseModelSQL(context, {
model: parentModel,
dbDriver: knex,
});
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
parentBaseModel.getTnPath(parentModel.table_name),
alias,
]),
).where(
@ -113,10 +118,14 @@ export default async function generateLookupSelectQuery({
await childModel.getColumns(context);
const parentModel = await parentColumn.getModel(context);
await parentModel.getColumns(context);
const parentBaseModel = await Model.getBaseModelSQL(context, {
model: parentModel,
dbDriver: knex,
});
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
parentBaseModel.getTnPath(childModel.table_name),
alias,
]),
).where(
@ -136,9 +145,14 @@ export default async function generateLookupSelectQuery({
const parentModel = await parentColumn.getModel(context);
await parentModel.getColumns(context);
const parentBaseModel = await Model.getBaseModelSQL(context, {
model: parentModel,
dbDriver: knex,
});
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
parentBaseModel.getTnPath(parentModel.table_name),
alias,
]),
);
@ -149,9 +163,14 @@ export default async function generateLookupSelectQuery({
const mmChildCol = await relation.getMMChildColumn(context);
const mmParentCol = await relation.getMMParentColumn(context);
const associatedBaseModel = await Model.getBaseModelSQL(context, {
model: mmModel,
dbDriver: knex,
});
selectQb
.innerJoin(
baseModelSqlv2.getTnPath(mmModel.table_name, mmTableAlias),
associatedBaseModel.getTnPath(mmModel.table_name, mmTableAlias),
knex.ref(`${mmTableAlias}.${mmParentCol.column_name}`),
'=',
knex.ref(`${alias}.${parentColumn.column_name}`),
@ -217,10 +236,14 @@ export default async function generateLookupSelectQuery({
await childModel.getColumns(context);
const parentModel = await parentColumn.getModel(context);
await parentModel.getColumns(context);
const parentBaseModel = await Model.getBaseModelSQL(context, {
model: parentModel,
dbDriver: knex,
});
selectQb.join(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
parentBaseModel.getTnPath(parentModel.table_name),
nestedAlias,
]),
`${nestedAlias}.${parentColumn.column_name}`,
@ -234,10 +257,14 @@ export default async function generateLookupSelectQuery({
await childModel.getColumns(context);
const parentModel = await parentColumn.getModel(context);
await parentModel.getColumns(context);
const childBaseModel = await Model.getBaseModelSQL(context, {
model: childModel,
dbDriver: knex,
});
selectQb.join(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
childBaseModel.getTnPath(childModel.table_name),
nestedAlias,
]),
`${nestedAlias}.${childColumn.column_name}`,
@ -251,6 +278,10 @@ export default async function generateLookupSelectQuery({
await childModel.getColumns(context);
const parentModel = await parentColumn.getModel(context);
await parentModel.getColumns(context);
const parentBaseModel = await Model.getBaseModelSQL(context, {
model: parentModel,
dbDriver: knex,
});
const mmTableAlias = getAlias();
@ -258,16 +289,21 @@ export default async function generateLookupSelectQuery({
const mmChildCol = await relation.getMMChildColumn(context);
const mmParentCol = await relation.getMMParentColumn(context);
const associatedBaseModel = await Model.getBaseModelSQL(context, {
model: mmModel,
dbDriver: knex,
});
selectQb
.innerJoin(
baseModelSqlv2.getTnPath(mmModel.table_name, mmTableAlias),
associatedBaseModel.getTnPath(mmModel.table_name, mmTableAlias),
knex.ref(`${mmTableAlias}.${mmChildCol.column_name}`),
'=',
knex.ref(`${prevAlias}.${childColumn.column_name}`),
)
.innerJoin(
knex.raw('?? as ??', [
baseModelSqlv2.getTnPath(parentModel.table_name),
parentBaseModel.getTnPath(parentModel.table_name),
nestedAlias,
]),
knex.ref(`${mmTableAlias}.${mmParentCol.column_name}`),

16
packages/nocodb/src/helpers/catchError.ts

@ -558,6 +558,14 @@ const errorHelpers: {
message: 'Invalid JSON in request body',
code: 400,
},
[NcErrorType.COLUMN_ASSOCIATED_WITH_LINK]: {
message: 'Column is associated with a link, please remove the link first',
code: 400,
},
[NcErrorType.TABLE_ASSOCIATED_WITH_LINK]: {
message: 'Table is associated with a link, please remove the link first',
code: 400,
},
};
function generateError(
@ -631,6 +639,14 @@ export class NcError {
});
}
static columnAssociatedWithLink(_id: string, args: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.COLUMN_ASSOCIATED_WITH_LINK, args);
}
static tableAssociatedWithLink(_id: string, args: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.TABLE_ASSOCIATED_WITH_LINK, args);
}
static baseNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.BASE_NOT_FOUND, {
params: id,

19
packages/nocodb/src/helpers/columnHelpers.ts

@ -43,6 +43,8 @@ export async function createHmAndBtColumn(
columnMeta = null,
isLinks = false,
colExtra?: any,
parentColumn?: Column,
isCustom = false,
) {
// save bt column
{
@ -60,7 +62,7 @@ export async function createHmAndBtColumn(
// db_type:
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_parent_column_id: parentColumn?.id || parent.primaryKey.id,
fk_related_model_id: parent.id,
virtual,
// if self referencing treat it as system field to hide from ui
@ -68,6 +70,10 @@ export async function createHmAndBtColumn(
fk_col_name: fkColName,
fk_index_name: fkColName,
...(type === 'bt' ? colExtra : {}),
meta: {
...(colExtra?.meta || {}),
custom: isCustom,
},
});
}
// save hm column
@ -80,6 +86,7 @@ export async function createHmAndBtColumn(
...(columnMeta || {}),
plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title),
custom: isCustom,
};
await Column.insert(context, {
@ -89,7 +96,7 @@ export async function createHmAndBtColumn(
type: 'hm',
fk_target_view_id: childView?.id,
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_parent_column_id: parentColumn?.id || parent.primaryKey.id,
fk_related_model_id: child.id,
virtual,
system: isSystemCol,
@ -128,6 +135,8 @@ export async function createOOColumn(
isSystemCol = false,
columnMeta = null,
colExtra?: any,
parentColumn?: Column,
isCustom = false,
) {
// save bt column
{
@ -144,7 +153,7 @@ export async function createOOColumn(
// Child View ID is given for relation from parent to child. not for child to parent
fk_target_view_id: null,
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_parent_column_id: parentColumn?.id || parent.primaryKey.id,
fk_related_model_id: parent.id,
virtual,
// if self referencing treat it as system field to hide from ui
@ -157,6 +166,7 @@ export async function createOOColumn(
// one-to-one relation is combination of both hm and bt to identify table which have
// foreign key column(similar to bt) we are adding a boolean flag `bt` under meta
bt: true,
custom: isCustom,
},
});
}
@ -176,6 +186,7 @@ export async function createOOColumn(
...(columnMeta || {}),
plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title),
custom: isCustom,
};
await Column.insert(context, {
@ -185,7 +196,7 @@ export async function createOOColumn(
type: 'oo',
fk_target_view_id: childView?.id,
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_parent_column_id: parentColumn?.id || parent.primaryKey.id,
fk_related_model_id: child.id,
virtual,
system: isSystemCol,

3
packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts

@ -15,6 +15,7 @@ import FetchAT from './helpers/fetchAT';
import { importData } from './helpers/readAndProcessData';
import EntityMap from './helpers/EntityMap';
import type { UserType } from 'nocodb-sdk';
import type { AtImportJobData } from '~/interface/Jobs';
import { type Base, Model, Source } from '~/models';
import { sanitizeColumnName } from '~/helpers';
import { AttachmentsService } from '~/services/attachments.service';
@ -31,7 +32,7 @@ import { TablesService } from '~/services/tables.service';
import { ViewColumnsService } from '~/services/view-columns.service';
import { ViewsService } from '~/services/views.service';
import { FormsService } from '~/services/forms.service';
import { AtImportJobData, JOBS_QUEUE, JobTypes } from '~/interface/Jobs';
import { JOBS_QUEUE, JobTypes } from '~/interface/Jobs';
import { GridColumnsService } from '~/services/grid-columns.service';
import { TelemetryService } from '~/services/telemetry.service';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';

9
packages/nocodb/src/redis/pubsub-redis.ts

@ -42,9 +42,9 @@ export class PubSubRedis {
}
/**
*
* @param channel
* @param callback
*
* @param channel
* @param callback
* @returns Returns a callback to unsubscribe
*/
static async subscribe(
@ -74,7 +74,8 @@ export class PubSubRedis {
PubSubRedis.redisSubscriber.on('message', onMessage);
return async (keepRedisChannel = false) => {
// keepRedisChannel is used to keep the channel open for other subscribers
if (!keepRedisChannel) await PubSubRedis.redisSubscriber.unsubscribe(channel);
if (!keepRedisChannel)
await PubSubRedis.redisSubscriber.unsubscribe(channel);
PubSubRedis.redisSubscriber.off('message', onMessage);
};
}

306
packages/nocodb/src/services/columns.service.ts

@ -72,7 +72,7 @@ export enum Altered {
UPDATE_COLUMN = 8,
}
interface ReusableParams {
export interface ReusableParams {
table?: Model;
source?: Source;
base?: Base;
@ -2099,6 +2099,59 @@ export class ColumnsService {
ProjectMgrv2.getSqlMgr(context, { id: source.base_id }, ncMeta),
);
// check column association with any custom links or LTAR
if (!isVirtualCol(column)) {
const columns = await table.getColumns(context, ncMeta);
let link = columns.find((c) => {
return (
isLinksOrLTAR(c.uidt) &&
((c.colOptions as LinkToAnotherRecordColumn)?.fk_child_column_id ===
param.columnId ||
(c.colOptions as LinkToAnotherRecordColumn)?.fk_parent_column_id ===
param.columnId ||
(c.colOptions as LinkToAnotherRecordColumn)
?.fk_mm_child_column_id === param.columnId ||
(c.colOptions as LinkToAnotherRecordColumn)
?.fk_mm_parent_column_id === param.columnId)
);
})?.colOptions as LinkToAnotherRecordColumn;
if (!link) {
link = await ncMeta.metaGet2(
table.fk_workspace_id,
table.base_id,
MetaTable.COL_RELATIONS,
{},
null,
{
_or: [
{ fk_child_column_id: { eq: param.columnId } },
{ fk_parent_column_id: { eq: param.columnId } },
{ fk_mm_child_column_id: { eq: param.columnId } },
{ fk_mm_parent_column_id: { eq: param.columnId } },
],
},
);
}
// if relation found then throw error
if (link) {
const linkCol = await Column.get(
context,
{ colId: link.fk_column_id },
ncMeta,
);
const table = await linkCol.getModel(context, ncMeta);
NcError.columnAssociatedWithLink(column.id, {
customMessage: `Column is associated with Link column '${
linkCol.title || linkCol.column_name
}' (${
table.title || table.table_name
}). Please delete the link column first.`,
});
}
}
/**
* @Note: When using 'falls through to default' cases in a switch statement,
* it is crucial to place them after cases with break statements.
@ -2178,6 +2231,7 @@ export class ColumnsService {
ncMeta,
);
const parentTable = await parentColumn.getModel(context, ncMeta);
const custom = column.meta?.custom;
switch (relationColOpt.type) {
case 'bt':
@ -2192,6 +2246,7 @@ export class ColumnsService {
parentTable,
sqlMgr,
ncMeta,
custom,
});
}
break;
@ -2206,6 +2261,7 @@ export class ColumnsService {
parentTable,
sqlMgr,
ncMeta,
custom,
});
}
break;
@ -2224,37 +2280,39 @@ export class ColumnsService {
ncMeta,
);
await this.deleteHmOrBtRelation(
context,
{
relationColOpt: null,
parentColumn: parentColumn,
childTable: mmTable,
sqlMgr,
parentTable: parentTable,
childColumn: mmParentCol,
source,
ncMeta,
virtual: !!relationColOpt.virtual,
},
true,
);
if (!custom) {
await this.deleteHmOrBtRelation(
context,
{
relationColOpt: null,
parentColumn: parentColumn,
childTable: mmTable,
sqlMgr,
parentTable: parentTable,
childColumn: mmParentCol,
source,
ncMeta,
virtual: !!relationColOpt.virtual,
},
true,
);
await this.deleteHmOrBtRelation(
context,
{
relationColOpt: null,
parentColumn: childColumn,
childTable: mmTable,
sqlMgr,
parentTable: childTable,
childColumn: mmChildCol,
source,
ncMeta,
virtual: !!relationColOpt.virtual,
},
true,
);
await this.deleteHmOrBtRelation(
context,
{
relationColOpt: null,
parentColumn: childColumn,
childTable: mmTable,
sqlMgr,
parentTable: childTable,
childColumn: mmChildCol,
source,
ncMeta,
virtual: !!relationColOpt.virtual,
},
true,
);
}
const columnsInRelatedTable: Column[] = await relationColOpt
.getRelatedTable(context, ncMeta)
.then((m) => m.getColumns(context, ncMeta));
@ -2287,64 +2345,68 @@ export class ColumnsService {
ncMeta,
);
if (mmTable) {
// delete bt columns in m2m table
await mmTable.getColumns(context, ncMeta);
for (const c of mmTable.columns) {
if (!custom) {
if (mmTable) {
// delete bt columns in m2m table
await mmTable.getColumns(context, ncMeta);
for (const c of mmTable.columns) {
if (!isLinksOrLTAR(c.uidt)) continue;
const colOpt =
await c.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (colOpt.type === 'bt') {
await Column.delete(context, c.id, ncMeta);
}
}
}
// delete hm columns in parent table
await parentTable.getColumns(context, ncMeta);
for (const c of parentTable.columns) {
if (!isLinksOrLTAR(c.uidt)) continue;
const colOpt =
await c.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (colOpt.type === 'bt') {
if (
colOpt.fk_related_model_id ===
relationColOpt.fk_mm_model_id
) {
await Column.delete(context, c.id, ncMeta);
}
}
}
// delete hm columns in parent table
await parentTable.getColumns(context, ncMeta);
for (const c of parentTable.columns) {
if (!isLinksOrLTAR(c.uidt)) continue;
const colOpt =
await c.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (
colOpt.fk_related_model_id === relationColOpt.fk_mm_model_id
) {
await Column.delete(context, c.id, ncMeta);
}
}
// delete hm columns in child table
await childTable.getColumns(context, ncMeta);
for (const c of childTable.columns) {
if (!isLinksOrLTAR(c.uidt)) continue;
const colOpt =
await c.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (
colOpt.fk_related_model_id === relationColOpt.fk_mm_model_id
) {
await Column.delete(context, c.id, ncMeta);
// delete hm columns in child table
await childTable.getColumns(context, ncMeta);
for (const c of childTable.columns) {
if (!isLinksOrLTAR(c.uidt)) continue;
const colOpt =
await c.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (
colOpt.fk_related_model_id ===
relationColOpt.fk_mm_model_id
) {
await Column.delete(context, c.id, ncMeta);
}
}
}
if (mmTable) {
// retrieve columns in m2m table again
await mmTable.getColumns(context, ncMeta);
if (mmTable) {
// retrieve columns in m2m table again
await mmTable.getColumns(context, ncMeta);
// ignore deleting table if it has more than 2 columns
// the expected 2 columns would be table1_id & table2_id
if (mmTable.columns.length === 2) {
(mmTable as any).tn = mmTable.table_name;
await sqlMgr.sqlOpPlus(source, 'tableDelete', mmTable);
await mmTable.delete(context, ncMeta);
// ignore deleting table if it has more than 2 columns
// the expected 2 columns would be table1_id & table2_id
if (mmTable.columns.length === 2) {
(mmTable as any).tn = mmTable.table_name;
await sqlMgr.sqlOpPlus(source, 'tableDelete', mmTable);
await mmTable.delete(context, ncMeta);
}
}
}
}
@ -2450,6 +2512,7 @@ export class ColumnsService {
sqlMgr,
ncMeta = Noco.ncMeta,
virtual,
custom = false,
}: {
relationColOpt: LinkToAnotherRecordColumn;
source: Source;
@ -2460,10 +2523,11 @@ export class ColumnsService {
sqlMgr: SqlMgrv2;
ncMeta?: MetaService;
virtual?: boolean;
custom?: boolean;
},
ignoreFkDelete = false,
) => {
if (childTable) {
if (childTable && !custom) {
let foreignKeyName;
// if relationColOpt is not provided, extract it from child table
@ -2514,7 +2578,7 @@ export class ColumnsService {
.then((m) => m.getColumns(context, ncMeta));
const relType = relationColOpt.type === 'bt' ? 'hm' : 'bt';
for (const c of columnsInRelatedTable) {
if (c.uidt !== UITypes.LinkToAnotherRecord) continue;
if (!isLinksOrLTAR(c.uidt)) continue;
const colOpt = await c.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
@ -2532,6 +2596,7 @@ export class ColumnsService {
// delete virtual columns
await Column.delete(context, relationColOpt.fk_column_id, ncMeta);
if (custom) return;
if (!ignoreFkDelete) {
const cTable = await Model.getWithInfo(
context,
@ -2603,6 +2668,7 @@ export class ColumnsService {
sqlMgr,
ncMeta = Noco.ncMeta,
virtual,
custom = false,
}: {
relationColOpt: LinkToAnotherRecordColumn;
source: Source;
@ -2613,50 +2679,55 @@ export class ColumnsService {
sqlMgr: SqlMgrv2;
ncMeta?: MetaService;
virtual?: boolean;
custom?: boolean;
},
ignoreFkDelete = false,
) => {
if (childTable) {
let foreignKeyName;
// if relationColOpt is not provided, extract it from child table
// and get the foreign key name for dropping the foreign key
if (!relationColOpt) {
foreignKeyName = (
(
await childTable.getColumns(context, ncMeta).then(async (cols) => {
for (const col of cols) {
if (col.uidt === UITypes.LinkToAnotherRecord) {
const colOptions =
await col.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (colOptions.fk_related_model_id === parentTable.id) {
return { colOptions };
if (!custom) {
let foreignKeyName;
// if relationColOpt is not provided, extract it from child table
// and get the foreign key name for dropping the foreign key
if (!relationColOpt) {
foreignKeyName = (
(
await childTable
.getColumns(context, ncMeta)
.then(async (cols) => {
for (const col of cols) {
if (col.uidt === UITypes.LinkToAnotherRecord) {
const colOptions =
await col.getColOptions<LinkToAnotherRecordColumn>(
context,
ncMeta,
);
if (colOptions.fk_related_model_id === parentTable.id) {
return { colOptions };
}
}
}
}
}
})
)?.colOptions as LinkToAnotherRecordType
).fk_index_name;
} else {
foreignKeyName = relationColOpt.fk_index_name;
}
})
)?.colOptions as LinkToAnotherRecordType
).fk_index_name;
} else {
foreignKeyName = relationColOpt.fk_index_name;
}
if (!relationColOpt?.virtual && !virtual) {
// Ensure relation deletion is not attempted for virtual relations
try {
// Attempt to delete the foreign key constraint from the database
await sqlMgr.sqlOpPlus(source, 'relationDelete', {
childColumn: childColumn.column_name,
childTable: childTable.table_name,
parentTable: parentTable.table_name,
parentColumn: parentColumn.column_name,
foreignKeyName,
});
} catch (e) {
console.log(e.message);
if (!relationColOpt?.virtual && !virtual) {
// Ensure relation deletion is not attempted for virtual relations
try {
// Attempt to delete the foreign key constraint from the database
await sqlMgr.sqlOpPlus(source, 'relationDelete', {
childColumn: childColumn.column_name,
childTable: childTable.table_name,
parentTable: parentTable.table_name,
parentColumn: parentColumn.column_name,
foreignKeyName,
});
} catch (e) {
console.log(e.message);
}
}
}
}
@ -2685,6 +2756,8 @@ export class ColumnsService {
// delete virtual columns
await Column.delete(context, relationColOpt.fk_column_id, ncMeta);
if (custom) return;
if (!ignoreFkDelete) {
const cTable = await Model.getWithInfo(
context,
@ -2739,7 +2812,6 @@ export class ColumnsService {
};
await sqlMgr.sqlOpPlus(source, 'tableUpdate', tableUpdateBody);
// delete foreign key column
await Column.delete(context, childColumn.id, ncMeta);
}

23
packages/nocodb/src/services/tables.service.ts

@ -37,6 +37,7 @@ import {
getUniqueColumnAliasName,
getUniqueColumnName,
} from '~/helpers/getUniqueName';
import { MetaTable } from '~/utils/globals';
@Injectable()
export class TablesService {
@ -224,6 +225,28 @@ export class TablesService {
NcError.badRequest(
`This is a many to many table for ${tables[0]?.title} (${relColumns[0]?.title}) & ${tables[1]?.title} (${relColumns[1]?.title}). You can disable "Show M2M tables" in base settings to avoid seeing this.`,
);
} else {
// if table is using in custom relation as junction table then delete all the relation
const relations = await Noco.ncMeta.metaList2(
table.fk_workspace_id,
table.base_id,
MetaTable.COL_RELATIONS,
{
condition: {
fk_mm_model_id: table.id,
},
},
);
if (relations.length) {
const relCol = await Column.get(context, {
colId: relations[0].fk_column_id,
});
const relTable = await Model.get(context, relCol.fk_model_id);
NcError.tableAssociatedWithLink(table.id, {
customMessage: `This is a many to many table for '${relTable?.title}' (${relTable?.title}), please delete the column before deleting the table.`,
});
}
}
const base = await Base.getWithInfo(context, table.base_id);

3
packages/nocodb/tests/unit/factory/table.ts

@ -14,7 +14,8 @@ const createTable = async (context, base, args = {}) => {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${base.id}/tables`)
.set('xc-auth', context.token)
.send({ ...defaultValue, ...args });
.send({ ...defaultValue, ...args })
.expect(200);
const table: Model = await Model.get(
{

3
packages/nocodb/tests/unit/rest/index.test.ts

@ -19,11 +19,13 @@ let workspaceTest = () => {};
let ssoTest = () => {};
let cloudOrgTest = () => {};
let bulkAggregationTest = () => {};
let columnTest = () => {};
if (process.env.EE === 'true') {
workspaceTest = require('./tests/ee/workspace.test').default;
ssoTest = require('./tests/ee/sso.test').default;
cloudOrgTest = require('./tests/ee/cloud-org.test').default;
bulkAggregationTest = require('./tests/ee/bulkAggregation.test').default;
columnTest = require('./tests/ee/column.test').default;
}
// import layoutTests from './tests/layout.test';
// import widgetTest from './tests/widget.test';
@ -48,6 +50,7 @@ function restTests() {
readOnlyTest();
aggregationTest();
bulkAggregationTest();
columnTest();
// Enable for dashboard feature
// widgetTest();

2
packages/nocodb/tsconfig.json

@ -41,5 +41,5 @@
]
},
"include": ["src/**/*"],
"exclude": ["src/ee", "src/ee-on-prem"]
"exclude": ["src/ee", "src/ee-on-prem", "src/ee-cloud"]
}

4
tests/playwright/pages/Dashboard/Details/FieldsPage.ts

@ -208,6 +208,10 @@ export class FieldsPage extends BasePage {
break;
case 'Links':
await this.addOrEditColumn.locator('.nc-ltar-relation-type').getByTestId(relationType).click();
// await this.addOrEditColumn
// .locator('.nc-ltar-relation-type >> .ant-radio')
// .nth(relationType === 'Has Many' ? 1 : 0)
// .click();
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).first().fill(childTable);
await this.rootPage

33
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -73,6 +73,8 @@ export class ColumnPageObject extends BasePage {
isDisplayValue = false,
ltarFilters,
ltarView,
custom = false,
refColumn,
}: {
title: string;
type?: string;
@ -92,6 +94,8 @@ export class ColumnPageObject extends BasePage {
isDisplayValue?: boolean;
ltarFilters?: any[];
ltarView?: string;
custom?: boolean;
refColumn?: string;
}) {
if (insertBeforeColumnTitle) {
await this.grid.get().locator(`th[data-title="${insertBeforeColumnTitle}"]`).scrollIntoViewIfNeeded();
@ -197,6 +201,10 @@ export class ColumnPageObject extends BasePage {
await this.rootPage.waitForTimeout(2000);
await this.get().locator('.nc-ltar-relation-type').getByTestId(relationType).click();
// await this.get()
// .locator('.nc-ltar-relation-type >> .ant-radio')
// .nth(relationType === 'Has Many' ? 1 : 0)
// .click();
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable);
await this.rootPage
@ -214,6 +222,31 @@ export class ColumnPageObject extends BasePage {
await this.ltarOption.addFilters(ltarFilters);
}
if (custom) {
// enable advance options
await this.get().locator('.nc-ltar-relation-type >> .ant-radio').nth(1).dblclick();
await this.get().locator('.nc-ltar-relation-type >> .ant-radio').nth(2).dblclick();
await this.get().locator(':has(:has-text("Advanced Link")) > button.ant-switch').click();
// data-testid="custom-link-source-base-id"
// data-testid="custom-link-source-table-id"
// data-testid="custom-link-source-column-id"
// data-testid="custom-link-junction-base-id"
// data-testid="custom-link-junction-table-id"
// data-testid="custom-link-junction-source-column-id"
// data-testid="custom-link-junction-target-column-id"
// data-testid="custom-link-target-base-id"
// data-testid="custom-link-target-table-id"
// data-testid="custom-link-target-column-id"
// select target table and column
// await this.get().get('').nth(2).click();
// select referenced base, column and column
}
break;
case 'User':
break;

349
tests/playwright/tests/db/columns/columnLinkToAnotherRecordAdvanceOptions.spec.ts

@ -0,0 +1,349 @@
import { test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import setup, { unsetup } from '../../../setup';
import { enableQuickRun } from '../../../setup/db';
import { UITypes } from 'nocodb-sdk';
test.describe('LTAR create & update', () => {
// force disabled temporarily
// to be re-visited after advance options menu is finalised
test.skip();
if (enableQuickRun()) test.skip();
let dashboard: DashboardPage;
let context: any;
// todo: Break the test into smaller tests
test.setTimeout(150000);
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.base);
});
test.afterEach(async () => {
await unsetup(context);
});
test('LTAR', async () => {
await dashboard.treeView.createTable({ title: 'Sheet1', baseTitle: context.base.title });
// subsequent table creation fails; hence delay
await dashboard.rootPage.waitForTimeout(1000);
await dashboard.treeView.createTable({ title: 'Sheet2', baseTitle: context.base.title });
await dashboard.grid.column.create({
title: 'Sheet1Id',
type: UITypes.Number,
});
await dashboard.treeView.openTable({ title: 'Sheet1' });
await dashboard.grid.addNewRow({ index: 0, value: '1a' });
await dashboard.grid.addNewRow({ index: 1, value: '1b' });
await dashboard.grid.addNewRow({ index: 2, value: '1c' });
await dashboard.treeView.openTable({ title: 'Sheet2' });
await dashboard.grid.addNewRow({ index: 0, value: '2a' });
await dashboard.grid.addNewRow({ index: 1, value: '2b' });
await dashboard.grid.addNewRow({ index: 2, value: '2c' });
await dashboard.grid.editRow({ index: 0, columnHeader: 'Sheet1Id', value: '1' });
await dashboard.grid.editRow({ index: 1, columnHeader: 'Sheet1Id', value: '1' });
await dashboard.grid.editRow({ index: 2, columnHeader: 'Sheet1Id', value: '1' });
// Create LTAR-HM column
await dashboard.grid.column.create({
title: 'Link1-2hm',
type: UITypes.Links,
childTable: 'Sheet2',
relationType: 'Has Many',
custom: true,
refColumn: 'Sheet1Id',
});
// Sheet2 now has all 3 column categories : HM, BT, MM
// Verify fields and toggle the visibility
await dashboard.grid.toolbar.clickFields();
for (const title of ['Sheet1', 'Sheet1s']) {
await dashboard.grid.toolbar.fields.verify({ title, checked: false });
await dashboard.grid.toolbar.fields.click({ title, isLocallySaved: false });
}
await dashboard.grid.toolbar.clickFields();
// Expanded form insert
await dashboard.grid.footbar.clickAddRecordFromForm();
await dashboard.expandedForm.fillField({
columnTitle: 'Title',
value: '2a',
});
await dashboard.expandedForm.fillField({
columnTitle: 'Sheet1',
value: '1a',
type: 'belongsTo',
});
await dashboard.expandedForm.fillField({
columnTitle: 'Sheet1s',
value: '1a',
type: 'manyToMany',
});
await dashboard.expandedForm.fillField({
columnTitle: 'Link2-1hm',
value: '1a',
type: 'hasMany',
});
await dashboard.expandedForm.save();
// In cell insert
await dashboard.grid.addNewRow({ index: 1, value: '2b' });
await dashboard.grid.cell.inCellAdd({ index: 1, columnHeader: 'Sheet1' });
await dashboard.linkRecord.select('1b');
await dashboard.grid.cell.inCellAdd({
index: 1,
columnHeader: 'Sheet1s',
});
await dashboard.linkRecord.select('1b');
await dashboard.grid.cell.inCellAdd({
index: 1,
columnHeader: 'Link2-1hm',
});
await dashboard.linkRecord.select('1b');
// Expand record insert
await dashboard.grid.addNewRow({ index: 2, value: '2c-temp' });
await dashboard.grid.openExpandedRow({ index: 2 });
await dashboard.expandedForm.fillField({
columnTitle: 'Sheet1',
value: '1c',
type: 'belongsTo',
});
await dashboard.expandedForm.fillField({
columnTitle: 'Sheet1s',
value: '1c',
type: 'manyToMany',
});
await dashboard.expandedForm.fillField({
columnTitle: 'Link2-1hm',
value: '1c',
type: 'hasMany',
});
await dashboard.expandedForm.fillField({
columnTitle: 'Title',
value: '2c',
type: 'text',
});
await dashboard.rootPage.waitForTimeout(1000);
await dashboard.expandedForm.save();
const expected = [
[['1a'], ['1b'], ['1c']],
[['1 Sheet1'], ['1 Sheet1'], ['1 Sheet1']],
[['1 Sheet1'], ['1 Sheet1'], ['1 Sheet1']],
];
const colHeaders = ['Sheet1', 'Sheet1s', 'Link2-1hm'];
// verify LTAR cell values
for (let i = 0; i < expected.length; i++) {
for (let j = 0; j < expected[i].length; j++) {
await dashboard.grid.cell.verifyVirtualCell({
index: j,
columnHeader: colHeaders[i],
count: 1,
value: expected[i][j],
type: i === 0 ? 'bt' : undefined,
options: { singular: 'Sheet1', plural: 'Sheet1s' },
});
}
}
await dashboard.closeTab({ title: 'Sheet2' });
await dashboard.treeView.openTable({ title: 'Sheet1' });
// Verify fields and toggle the visibility
await dashboard.grid.toolbar.clickFields();
await dashboard.grid.toolbar.fields.verify({ title: 'Sheet2', checked: false });
await dashboard.grid.toolbar.fields.click({ title: 'Sheet2', isLocallySaved: false });
await dashboard.grid.toolbar.clickFields();
const expected2 = [
[['1 Sheet2'], ['1 Sheet2'], ['1 Sheet2']],
[['1 Sheet2'], ['1 Sheet2'], ['1 Sheet2']],
[['2a'], ['2b'], ['2c']],
];
const colHeaders2 = ['Link1-2hm', 'Link1-2mm', 'Sheet2'];
// verify LTAR cell values
for (let i = 0; i < expected2.length; i++) {
for (let j = 0; j < expected2[i].length; j++) {
await dashboard.grid.cell.verifyVirtualCell({
index: j,
columnHeader: colHeaders2[i],
count: 1,
value: expected2[i][j],
type: i === 2 ? 'bt' : undefined,
options: { singular: 'Sheet2', plural: 'Sheet2s' },
});
}
}
// Unlink LTAR cells
for (let i = 0; i < expected2.length; i++) {
for (let j = 0; j < expected2[i].length; j++) {
await dashboard.rootPage.waitForTimeout(500);
await dashboard.grid.cell.unlinkVirtualCell({
index: j,
columnHeader: colHeaders2[i],
});
}
}
// delete columns
await dashboard.grid.column.delete({ title: 'Link1-2hm' });
await dashboard.grid.column.delete({ title: 'Link1-2mm' });
await dashboard.grid.column.delete({ title: 'Sheet2' });
// delete table
await dashboard.treeView.deleteTable({ title: 'Sheet1' });
await dashboard.treeView.deleteTable({ title: 'Sheet2' });
});
});
test.describe('Links after edit record', () => {
let dashboard: DashboardPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: false });
dashboard = new DashboardPage(page, context.base);
});
test.afterEach(async () => {
await unsetup(context);
});
async function verifyRow(param: {
index: number;
value: {
Country: string;
formula?: string;
SLT?: string;
Cities: string[];
};
}) {
await dashboard.grid.cell.verify({
index: param.index,
columnHeader: 'Country',
value: param.value.Country,
});
if (param.value.formula) {
await dashboard.grid.cell.verify({
index: param.index,
columnHeader: 'formula',
value: param.value.formula,
});
}
await dashboard.grid.cell.verifyVirtualCell({
index: param.index,
columnHeader: 'Cities',
count: param.value.Cities.length,
options: { singular: 'City', plural: 'Cities' },
});
if (param.value.SLT) {
await dashboard.grid.cell.verify({
index: param.index,
columnHeader: 'SLT',
value: param.value.SLT,
});
}
}
/**
* Scope:
* - Verify LTAR and lookup cell after updating any non-virtual column
* - Verify the formula cell in which the updated cell is referring
* - Verify other non-virtual cells
*
* https://github.com/nocodb/nocodb/issues/4220
*
*/
test('Existing LTAR table verification', async () => {
// open table
await dashboard.treeView.openTable({ title: 'Country' });
await verifyRow({
index: 0,
value: {
Country: 'Afghanistan',
Cities: ['Kabul'],
},
});
await verifyRow({
index: 1,
value: {
Country: 'Algeria',
Cities: ['Batna', 'Bchar', 'Skikda'],
},
});
// create new columns
await dashboard.grid.column.create({
title: 'SLT',
type: 'SingleLineText',
});
await dashboard.grid.column.create({
title: 'formula',
type: 'Formula',
formula: "CONCAT({Country}, ' ', {SLT})",
});
// insert new content into a cell
await dashboard.grid.editRow({
index: 0,
columnHeader: 'SLT',
value: 'test',
});
await verifyRow({
index: 0,
value: {
Country: 'Afghanistan',
Cities: ['Kabul'],
SLT: 'test',
formula: 'Afghanistan test',
},
});
// edit record
await dashboard.grid.editRow({
index: 0,
columnHeader: 'Country',
value: 'Afghanistan2',
});
await verifyRow({
index: 0,
value: {
Country: 'Afghanistan2',
Cities: ['Kabul'],
SLT: 'test',
formula: 'Afghanistan2 test',
},
});
// Delete cell contents and verify
await dashboard.grid.cell.click({ index: 0, columnHeader: 'SLT' });
// trigger delete button key
await dashboard.rootPage.keyboard.press('Delete');
// Verify other non-virtual cells
await verifyRow({
index: 0,
value: {
Country: 'Afghanistan2',
Cities: ['Kabul'],
SLT: '',
formula: 'Afghanistan2',
},
});
});
});
Loading…
Cancel
Save