Browse Source

Nc feat/links view filter (#8646)

* feat(nocodb): add support for limiting selection to specific views

* test: fix failing tests

* fix: failing playwright tests

* feat: allow updating static view filter from both sides

* fix: remove console logs

* refactor: rename migration name

* fix: corrections in ui and update api

* fix: apply same behaviour for LTAR column(bt)

* refactor: rename view id column in relation to avoid confusion

* fix: option to disable view filter(switch)

* refactor: some minor ui spacing corrections

* fix: avoid setting target view id for bt relation when creating hm relation

* feat: links - record selection based on custom filters

* fix: corrections

* feat: add edit support for conditions

* feat: option to switch between dynamic and static value

* fix: backend corrections

* feat: apis for links filter

* feat: filter api integration with ui

* feat: filter with save and update

* feat: dynamic filter

* feat: shared form filter

* feat: expanded form

* fix: missing imports and corrections

* fix: pass correct column list

* fix: nested filter bug

* fix: corrections in actions and swagger

* fix: missing add button menu

* fix: expanded form bug

* test: playwright test - WIP

* test: playwright - link with filters/view

* chore: lint

* refactor: ui corrections

* fix: remove unnecessary filtering from hm/mm list and count

* fix: filter ui correction

* fix: lable correction

* fix: skip view filter for rollup

* fix: ui corrections

* fix: extract correct column id

* fix: duplicate LTAR - missing target view

* feat: add duplicate support for link with filters/view

* fix: height issue and nested filter creation bug

* fix: pass metadata to nested filter component

* fix: filter on column creation

* fix: filter getting cloned under group

* fix: exclude deleted filters when deciding locked state

* fix: update state when switching to dynamic filter

* fix: unlink view on delete and handle undefined values as null

* fix: filter based on unsaved data

* fix: handle overflow

* fix: multi-field editor - filter UI correction

* fix: duplicate link column with dynamic field ref

* fix: remove virtual column support

* fix: add support to link filter in normal list method

* fix: apply filter on count query

* fix: pass correct column list

* feat: add link filter support in multifield column creation

* feat: add link filter support in multifield column creation

* Merge branch 'develop' into feat/links-view-filter

* fix: dynamic value column export

* fix: review comments

* test: kludge for groupby tests

* fix: extract updated status correctly

* test: try waitFor for links

* test: kludge

* refactor: exclude attachment & rating from dynamic filter and treat float and integer as number

* test: label correction

* refactor: replace try...catch and use if condition

* fix: apply conditions only if enabled

* fix: MFE bugs

* refactor: show radio button active border only when focused

* fix:  proper state handling

* fix: view delete - unlink from link column

* fix: duplicate Link with filter view id

* refactor: column filter section padding

* fix: exclude system columns

* fix: dynamic column filter logic correction

* refactor: cleanup

* test: kludge with delay for groupby test

* refactor: add missing placeholder method

* docs: limit link record selection

* refactor: add missing placeholder method

* chore: lint

---------

Co-authored-by: DarkPhoenix2704 <anbarasun123@gmail.com>
Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>
pull/8653/head
Pranav C 4 months ago committed by GitHub
parent
commit
6624bf5091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 24
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  2. 5
      packages/nc-gui/components/smartsheet/column/LinkOptions.vue
  3. 153
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  4. 12
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  5. 7
      packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue
  6. 2
      packages/nc-gui/components/smartsheet/details/Api.vue
  7. 62
      packages/nc-gui/components/smartsheet/details/Fields.vue
  8. 1
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  9. 1
      packages/nc-gui/components/smartsheet/expanded-form/RichTextOptions.vue
  10. 2
      packages/nc-gui/components/smartsheet/header/Menu.vue
  11. 328
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  12. 6
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  13. 7
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  14. 1
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  15. 1
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  16. 3
      packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue
  17. 18
      packages/nc-gui/composables/useColumnCreateStore.ts
  18. 27
      packages/nc-gui/composables/useLTARStore.ts
  19. 1
      packages/nc-gui/composables/useSharedFormViewStore.ts
  20. 107
      packages/nc-gui/composables/useViewFilters.ts
  21. 3
      packages/nc-gui/lang/en.json
  22. 67
      packages/noco-docs/docs/070.fields/040.field-types/040.links-based/010.links.md
  23. BIN
      packages/noco-docs/static/img/v2/fields/types/limit-by-filter.png
  24. BIN
      packages/noco-docs/static/img/v2/fields/types/limit-by-view.png
  25. BIN
      packages/noco-docs/static/img/v2/fields/types/link-filter-settings-2.png
  26. BIN
      packages/noco-docs/static/img/v2/fields/types/link-filter-settings.png
  27. BIN
      packages/noco-docs/static/img/v2/fields/types/links.png
  28. 2
      packages/nocodb/src/controllers/filters.controller.ts
  29. 11
      packages/nocodb/src/controllers/public-datas.controller.ts
  30. 659
      packages/nocodb/src/db/BaseModelSqlv2.ts
  31. 152
      packages/nocodb/src/db/genRollupSelectv2.ts
  32. 12
      packages/nocodb/src/helpers/columnHelpers.ts
  33. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  34. 26
      packages/nocodb/src/meta/migrations/v2/nc_048_view_links.ts
  35. 26
      packages/nocodb/src/models/Column.ts
  36. 51
      packages/nocodb/src/models/Filter.ts
  37. 19
      packages/nocodb/src/models/LinkToAnotherRecordColumn.ts
  38. 20
      packages/nocodb/src/models/View.ts
  39. 2
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts
  40. 59
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  41. 61
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  42. 3
      packages/nocodb/src/schema/swagger-v2.json
  43. 29
      packages/nocodb/src/schema/swagger.json
  44. 1
      packages/nocodb/src/services/app-hooks/interfaces.ts
  45. 100
      packages/nocodb/src/services/columns.service.ts
  46. 16
      packages/nocodb/src/services/datas.service.ts
  47. 10
      packages/nocodb/src/services/filters.service.ts
  48. 28
      packages/nocodb/src/services/public-datas.service.ts
  49. 2
      tests/playwright/pages/Dashboard/Details/FieldsPage.ts
  50. 18
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  51. 5
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  52. 13
      tests/playwright/pages/Dashboard/Grid/Column/LTARFilterOption.ts
  53. 61
      tests/playwright/pages/Dashboard/Grid/Column/LTAROptionColumn.ts
  54. 19
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  55. 4
      tests/playwright/pages/Dashboard/Grid/Group.ts
  56. 22
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  57. 1
      tests/playwright/tests/db/general/toolbarOperations.spec.ts
  58. 9
      tests/playwright/tests/db/views/viewGridShare.spec.ts

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

@ -192,15 +192,20 @@ onMounted(() => {
...formState.value,
...others,
}
if (colOptions) {
const meta = formState.value.meta || {}
onUidtOrIdTypeChange()
formState.value = {
...formState.value,
colOptions: {
...colOptions,
},
meta,
}
}
} else {
formState.value.filters = undefined
}
// for cases like formula
@ -269,21 +274,16 @@ const submitBtnLabel = computed(() => {
loadingLabel: `${isEdit.value && !props.columnLabel ? t('general.updating') : t('general.saving')} ${columnLabel.value}`,
}
})
const filterOption = (input: string, option: { value: UITypes }) => {
return (
option.value.toLowerCase().includes(input.toLowerCase()) ||
(UITypesName[option.value] && UITypesName[option.value].toLowerCase().includes(input.toLowerCase()))
)
}
</script>
<template>
<div
class="overflow-auto"
class="overflow-auto max-h-[max(80vh,500px)]"
:class="{
'bg-white': !props.fromTableExplorer,
'w-[384px]': !props.embedMode,
'min-w-500px': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'!w-146': isTextArea(formState) && formState.meta?.richMode,
'!w-116 overflow-visible': formState.uidt === UITypes.Formula && !props.embedMode,
'!w-[500px]': formState.uidt === UITypes.Attachment && !props.embedMode && !appInfo.ee,
'!w-[600px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
@ -336,7 +336,6 @@ const filterOption = (input: string, option: { value: UITypes }) => {
class="nc-column-type-input !rounded-lg"
:disabled="isKanban || readOnly || (isEdit && !!onlyNameUpdateOnEditColumns.find((col) => col === formState.uidt))"
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200"
:filter-option="filterOption"
@dropdown-visible-change="onDropdownChange"
@change="onUidtOrIdTypeChange"
@dblclick="showDeprecated = !showDeprecated"
@ -539,6 +538,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
&:not(:hover):not(:focus) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&:hover:not(:focus) {
@apply border-gray-300;
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24);
@ -552,6 +552,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
&:not(:hover):not(:focus-within):not(.shadow-selected) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&:hover:not(:focus-within):not(.shadow-selected) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24);
}
@ -572,9 +573,11 @@ const filterOption = (input: string, option: { value: UITypes }) => {
box-shadow: none;
}
}
&:not(.ant-radio-wrapper-disabled):not(:hover):not(:focus-within):not(.shadow-selected) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&:hover:not(:focus-within):not(.ant-radio-wrapper-disabled) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24);
}
@ -585,6 +588,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
&:not(.ant-select-disabled):hover.ant-select-disabled .ant-select-selector {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&:hover:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector {
@apply border-gray-300;
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24);
@ -636,6 +640,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
.ant-alert-message {
@apply text-sm text-gray-800 font-weight-600;
}
.ant-alert-description {
@apply text-small text-gray-500 font-weight-500;
}
@ -651,6 +656,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
:deep(textarea::placeholder) {
@apply text-gray-500;
}
.nc-column-options-wrapper {
&:empty {
@apply hidden;

5
packages/nc-gui/components/smartsheet/column/LinkOptions.vue

@ -1,4 +1,6 @@
<script setup lang="ts">
import { storeToRefs, useColumnCreateStoreOrThrow, useVModel } from '#imports'
const props = defineProps<{
value: any
}>()
@ -7,6 +9,9 @@ const emit = defineEmits(['update:value'])
const { t } = useI18n()
const viewsStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewsStore)
const vModel = useVModel(props, 'value', emit)
const { validateInfos, setAdditionalValidations } = useColumnCreateStoreOrThrow()

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ModelTypes, MssqlUi, RelationTypes, SqliteUi, UITypes } from 'nocodb-sdk'
import { ModelTypes, MssqlUi, RelationTypes, SqliteUi, UITypes, ViewTypes } from 'nocodb-sdk'
const props = defineProps<{
value: any
@ -14,11 +14,17 @@ const isEdit = toRef(props, 'isEdit')
const meta = inject(MetaInj, ref())
const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi, isXcdbBase } = useColumnCreateStoreOrThrow()
const filterRef = ref()
const { setAdditionalValidations, setPostSaveOrUpdateCbk, validateInfos, onDataTypeChange, sqlUi, isXcdbBase } =
useColumnCreateStoreOrThrow()
const baseStore = useBase()
const { tables } = storeToRefs(baseStore)
const viewsStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewsStore)
const { t } = useI18n()
if (!isEdit.value) {
@ -31,7 +37,6 @@ const onUpdateDeleteOptions = sqlUi === MssqlUi ? ['NO ACTION'] : ['NO ACTION',
if (!isEdit.value) {
if (!vModel.value.parentId) vModel.value.parentId = meta.value?.id
if (!vModel.value.childId) vModel.value.childId = null
if (!vModel.value.childColumn) vModel.value.childColumn = `${meta.value?.table_name}_id`
if (!vModel.value.childTable) vModel.value.childTable = meta.value?.table_name
if (!vModel.value.parentTable) vModel.value.parentTable = vModel.value.rtn || ''
@ -43,6 +48,9 @@ if (!isEdit.value) {
if (!vModel.value.virtual) vModel.value.virtual = sqlUi === SqliteUi // appInfo.isCloud || sqlUi === SqliteUi
if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name
}
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
const advancedOptions = ref(false)
const refTables = computed(() => {
@ -53,10 +61,74 @@ const refTables = computed(() => {
return tables.value.filter((t) => t.type === ModelTypes.TABLE && t.source_id === meta.value?.source_id)
})
const refViews = computed(() => {
if (!vModel.value.childId) return []
const views = viewsByTable.value.get(vModel.value.childId)
return (views || []).filter((v) => v.type !== ViewTypes.FORM)
})
const filterOption = (value: string, option: { key: string }) => option.key.toLowerCase().includes(value.toLowerCase())
const isLinks = computed(() => vModel.value.uidt === UITypes.Links && vModel.value.type !== RelationTypes.ONE_TO_ONE)
const { metas, getMeta } = useMetas()
watch(
() => vModel.value.childId,
async (tableId) => {
if (tableId) {
getMeta(tableId).catch(() => {
// ignore
})
viewsStore
.loadViews({
ignoreLoading: true,
tableId,
})
.catch(() => {
// ignore
})
}
},
{
immediate: true,
},
)
vModel.value.meta = vModel.value.meta || {}
const limitRecToView = ref(!!vModel.value.childViewId)
const limitRecToCond = computed({
get() {
return !!vModel.value.meta?.enableConditions
},
set(value) {
vModel.value.meta = vModel.value.meta || {}
vModel.value.meta.enableConditions = value
},
})
const onLimitRecToViewChange = (value: boolean) => {
if (!value) {
vModel.value.childViewId = null
}
}
provide(
MetaInj,
computed(() => metas.value[vModel.value.childId] || {}),
)
onMounted(() => {
setPostSaveOrUpdateCbk(async ({ colId, column }) => {
await filterRef.value?.applyChanges(colId || column.id, false)
})
})
onUnmounted(() => {
setPostSaveOrUpdateCbk(null)
})
const referenceTableChildId = computed({
get: () => (isEdit.value ? vModel.value?.colOptions?.fk_related_model_id : vModel.value?.childId) ?? null,
set: (value) => {
@ -128,6 +200,74 @@ const linkType = computed({
</a-select-option>
</a-select>
</a-form-item>
<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>
</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>
</div>
</div>
<template v-if="(!isXcdbBase && !isEdit) || isLinks">
<div>
@ -218,19 +358,24 @@ const linkType = computed({
</template>
<style lang="scss" scoped>
:deep(.nc-filter-grid) {
@apply !pr-0;
}
:deep(.nc-ltar-relation-type .ant-radio-group) {
@apply flex justify-between gap-2 children:(flex-1 m-0 px-2 py-1 border-1 border-gray-200 rounded-lg);
.ant-radio-wrapper {
@apply transition-all flex-row-reverse justify-between items-center py-1 pl-1 pr-3;
&.ant-radio-wrapper-checked:not(.ant-radio-wrapper-disabled) {
&.ant-radio-wrapper-checked:not(.ant-radio-wrapper-disabled):focus-within {
@apply border-brand-500;
}
span:not(.ant-radio):not(.nc-ltar-icon) {
@apply flex-1 pl-0 flex items-center gap-2;
}
.ant-radio {
@apply top-0;
}

12
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -22,7 +22,7 @@ const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, isEdit } = useColumnCreateStoreOrThrow()
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
// const { base } = storeToRefs(useBase())
@ -186,7 +186,7 @@ const syncOptions = () => {
return renderA - renderB
})
.map((op) => {
const { status: _s, ...rest } = op
const { index: _i, status: _s, ...rest } = op
return rest
})
}
@ -221,13 +221,7 @@ const removeRenderedOption = (index: number) => {
}
const optionChanged = (changedElement: Option) => {
const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => {
if (o.id !== undefined && changedElement.id !== undefined) {
return o.id === changedElement.id
} else {
return o.index === changedElement.index
}
})
const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => o.id === changedElement.id)
if (changedDefaultOptionIndex !== -1) {
if (vModel.value.uidt === UITypes.SingleSelect) {

7
packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue

@ -13,12 +13,7 @@ const { options } = toRefs(props)
const searchQuery = ref('')
const filteredOptions = computed(
() =>
options.value?.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(UITypesName[c.name] && UITypesName[c.name].toLowerCase().includes(searchQuery.value.toLowerCase())),
) ?? [],
() => options.value?.filter((c) => c.name.toLowerCase().includes(searchQuery.value.toLowerCase())) ?? [],
)
const inputRef = ref()

2
packages/nc-gui/components/smartsheet/details/Api.vue

@ -7,7 +7,7 @@ const { t } = useI18n()
const baseStore = useBase()
const { base } = storeToRefs(baseStore)
const { appInfo, token } = useGlobal()
const { appInfo } = useGlobal()
const meta = inject(MetaInj, ref())

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

@ -1,10 +1,10 @@
<script setup lang="ts">
import { diff } from 'deep-object-diff'
import { message } from 'ant-design-vue'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { ColumnType, FilterType, SelectOptionsType } from 'nocodb-sdk'
import Draggable from 'vuedraggable'
import { onKeyDown, useMagicKeys } from '@vueuse/core'
import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
interface TableExplorerColumn extends ColumnType {
id?: string
@ -271,8 +271,21 @@ const duplicateField = async (field: TableExplorerColumn) => {
duplicateFieldHook.value = fieldPayload as TableExplorerColumn
}
// Check any filter is changed recursively
const checkForFilterChange = (filters: (FilterType & { status?: string })[]) => {
for (const filter of filters) {
if (filter.status) {
return true
}
if (filter.is_group) {
if (checkForFilterChange(filter.children || [])) {
return true
}
}
}
}
// This method is called whenever there is a change in field properties
const onFieldUpdate = (state: TableExplorerColumn) => {
const onFieldUpdate = (state: TableExplorerColumn, skipLinkChecks = false) => {
const col = fields.value.find((col) => compareCols(col, state))
if (!col) return
@ -317,6 +330,17 @@ const onFieldUpdate = (state: TableExplorerColumn) => {
if (field || (field && moveField)) {
field.column = state
} else if (isLinksOrLTAR(state) && !skipLinkChecks) {
if (
['title', 'column_name', 'meta'].some((k) => k in diffs) ||
('childViewId' in diffs && diffs.childViewId !== col.colOptions?.fk_target_view_id) ||
checkForFilterChange(diffs.filters || [])
) {
ops.value.push({
op: 'update',
column: state,
})
}
} else {
ops.value.push({
op: 'update',
@ -415,21 +439,27 @@ const onMove = (_event: { moved: { newIndex: number; oldIndex: number } }) => {
}
if (op) {
onFieldUpdate({
...op.column,
column_order: {
order,
view_id: view.value?.id as string,
onFieldUpdate(
{
...op.column,
column_order: {
order,
view_id: view.value?.id as string,
},
},
})
true,
)
} else {
onFieldUpdate({
...field,
column_order: {
order,
view_id: view.value?.id as string,
onFieldUpdate(
{
...field,
column_order: {
order,
view_id: view.value?.id as string,
},
},
})
true,
)
}
}
@ -961,7 +991,7 @@ watch(
{{ $t('labels.multiField.deletedField') }}
</NcBadge>
<NcBadge
v-else-if="fieldStatus(field) === 'add'"
v-else-if="isColumnValid(field) && fieldStatus(field) === 'add'"
color="orange"
:border="false"
class="bg-green-50 text-green-700"

1
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -10,7 +10,6 @@ const {
deleteComment,
comments,
audits,
isCommentsLoading,
isAuditLoading,
saveComment: _saveComment,
comment: newComment,

1
packages/nc-gui/components/smartsheet/expanded-form/RichTextOptions.vue

@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { Editor } from '@tiptap/vue-3'
import MdiFormatStrikeThrough from '~icons/mdi/format-strikethrough'
interface Props {
editor: Editor | undefined

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

@ -392,7 +392,7 @@ const filterOrGroupByThisField = (event: SmartsheetStoreEvents) => {
<NcMenuItem v-if="!column?.pv" @click="hideOrShowField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
<GeneralLoader v-if="isLoading === 'hideOrShow'" size="regular" />
<component v-else :is="isHiddenCol ? iconMap.eye : iconMap.eyeSlash" class="text-gray-700 !w-4 !h-4" />
<component :is="isHiddenCol ? iconMap.eye : iconMap.eyeSlash" v-else class="text-gray-700 !w-4 !h-4" />
<!-- Hide Field -->
{{ isHiddenCol ? $t('general.showField') : $t('general.hideField') }}
</div>

328
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ColumnType, FilterType } from 'nocodb-sdk'
import { type ColumnType, type FilterType, isCreatedOrLastModifiedTimeCol, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { PlanLimitTypes, UITypes } from 'nocodb-sdk'
interface Props {
@ -8,10 +8,13 @@ interface Props {
autoSave: boolean
hookId?: string
showLoading?: boolean
modelValue?: undefined | Filter[]
modelValue?: FilterType[] | null
webHook?: boolean
link?: boolean
draftFilter?: Partial<FilterType>
isOpen?: boolean
rootMeta?: any
linkColId?: string
}
const props = withDefaults(defineProps<Props>(), {
@ -21,17 +24,21 @@ const props = withDefaults(defineProps<Props>(), {
parentId: undefined,
hookId: undefined,
webHook: false,
link: false,
linkColId: undefined,
})
const emit = defineEmits(['update:filtersLength', 'update:draftFilter', 'update:modelValue'])
const initialModelValue = props.modelValue
const excludedFilterColUidt = [UITypes.QrCode, UITypes.Barcode]
const draftFilter = useVModel(props, 'draftFilter', emit)
const modelValue = useVModel(props, 'modelValue', emit)
const { nestedLevel, parentId, autoSave, hookId, showLoading, webHook } = toRefs(props)
const { nestedLevel, parentId, autoSave, hookId, showLoading, webHook, link, linkColId } = toRefs(props)
const nested = computed(() => nestedLevel.value > 0)
@ -53,6 +60,9 @@ const isPublic = inject(IsPublicInj, ref(false))
const { $e } = useNuxtApp()
const { nestedFilters } = useSmartsheetStoreOrThrow()
const currentFilters = modelValue.value || (!link.value && !webHook.value && nestedFilters.value) || []
const {
filters,
nonDeletedFilters,
@ -73,9 +83,11 @@ const {
parentId,
computed(() => autoSave.value),
() => reloadDataHook.trigger({ shouldShowLoading: showLoading.value, offset: 0 }),
modelValue.value || nestedFilters.value,
!modelValue.value,
currentFilters,
props.nestedLevel > 0,
webHook.value,
link.value,
linkColId,
)
const { getPlanLimit } = useWorkspace()
@ -89,7 +101,12 @@ const isMounted = ref(false)
const columns = computed(() => meta.value?.columns)
const fieldsToFilter = computed(() => (columns.value || []).filter((c) => !excludedFilterColUidt.includes(c.uidt as UITypes)))
const fieldsToFilter = computed(() =>
(columns.value || []).filter((c) => {
if (link.value && isSystemColumn(c) && !c.pk && !isCreatedOrLastModifiedTimeCol(c)) return false
return !excludedFilterColUidt.includes(c.uidt as UITypes)
}),
)
const getColumn = (filter: Filter) => {
// extract looked up column if available
@ -167,6 +184,8 @@ const filterUpdateCondition = (filter: FilterType, i: number) => {
logical: filter.logical_op,
comparison: filter.comparison_op,
comparison_sub_op: filter.comparison_sub_op,
link: !!link.value,
webHook: !!webHook.value,
})
}
@ -174,7 +193,13 @@ watch(
() => activeView.value?.id,
(n, o) => {
// if nested no need to reload since it will get reloaded from parent
if (!nested.value && n !== o && (hookId?.value || !webHook.value)) loadFilters(hookId?.value, webHook.value)
if (!nested.value && n !== o && (hookId?.value || !webHook.value) && (linkColId?.value || !link.value))
loadFilters({
hookId: hookId.value,
isWebhook: webHook.value,
linkColId: unref(linkColId),
isLink: link.value,
})
},
)
@ -194,7 +219,7 @@ const filtersCount = computed(() => {
}, 0)
})
const applyChanges = async (hookId?: string, nested = false, isConditionSupported = true) => {
const applyChanges = async (hookOrColId?: string, nested = false, isConditionSupported = true) => {
// if condition is not supported, delete all filters present
// it's used for bulk webhooks with filters since bulk webhooks don't support conditions at the moment
if (!isConditionSupported) {
@ -203,21 +228,36 @@ const applyChanges = async (hookId?: string, nested = false, isConditionSupporte
await deleteFilter(filters.value[i], i)
}
}
await sync(hookId, nested)
if (link.value) {
if (!hookOrColId && !props.nestedLevel) return
await sync({ linkId: hookOrColId, nested })
} else {
await sync({ hookId: hookOrColId, nested })
}
if (!localNestedFilters.value?.length) return
for (const nestedFilter of localNestedFilters.value) {
if (nestedFilter.parentId) {
await nestedFilter.applyChanges(hookId, true)
await nestedFilter.applyChanges(hookOrColId, true)
}
}
}
const selectFilterField = (filter: Filter, index: number) => {
const col = getColumn(filter)
if (!col) return
// reset dynamic field if the field is changed to virtual column
if (isVirtualCol(col)) {
resetDynamicField(filter, index).catch(() => {
// do nothing
})
} else {
filter.fk_value_col_id = null
}
// when we change the field,
// the corresponding default filter operator needs to be changed as well
// since the existing one may not be supported for the new field
@ -316,7 +356,18 @@ const showFilterInput = (filter: Filter) => {
}
onMounted(async () => {
await Promise.all([loadFilters(hookId?.value, webHook.value), loadBtLookupTypes()])
await Promise.all([
(async () => {
if (!initialModelValue)
await loadFilters({
hookId: hookId?.value,
isWebhook: webHook.value,
linkColId: unref(linkColId),
isLink: link.value,
})
})(),
loadBtLookupTypes(),
])
isMounted.value = true
})
@ -381,34 +432,99 @@ const onLogicalOpUpdate = async (filter: Filter, index: number) => {
// watch for changes in filters and update the modelValue
watch(
filters,
() => {
if (modelValue.value !== filters.value) modelValue.value = filters.value
(value) => {
if (value && value !== modelValue.value) {
modelValue.value = value
}
},
{
immediate: true,
},
)
const addFilterBtnRef = ref()
watchEffect(() => {
if (props.isOpen && !nested.value && addFilterBtnRef.value) {
setTimeout(() => {
addFilterBtnRef.value?.$el?.focus()
}, 10)
}
})
async function resetDynamicField(filter: any, i) {
filter.dynamic = false
filter.fk_value_col_id = null
await saveOrUpdate(filter, i)
}
const { sqlUis } = storeToRefs(useBase())
const sqlUi = meta.value?.source_id ? sqlUis.value[meta.value?.source_id] : Object.values(sqlUis.value)[0]
const isDynamicFilterAllowed = (filter: FilterType) => {
const col = getColumn(filter)
// if virtual column, don't allow dynamic filter
if (isVirtualCol(col)) return false
// disable dynamic filter for certain fields like rating, attachment, etc
if (
[
UITypes.Attachment,
UITypes.Rating,
UITypes.Checkbox,
UITypes.QrCode,
UITypes.Barcode,
UITypes.Collaborator,
UITypes.GeoData,
UITypes.SpecificDBType,
].includes(col.uidt as UITypes)
)
return false
const abstractType = sqlUi.getAbstractType(col)
if (!['integer', 'float', 'text', 'string'].includes(abstractType)) return false
return !filter.comparison_op || ['eq', 'lt', 'gt', 'lte', 'gte', 'like', 'nlike', 'neq'].includes(filter.comparison_op)
}
const dynamicColumns = (filter: FilterType) => {
const filterCol = getColumn(filter)
if (!filterCol) return []
return props.rootMeta?.columns?.filter((c: ColumnType) => {
if (excludedFilterColUidt.includes(c.uidt as UITypes) || isVirtualCol(c) || (isSystemColumn(c) && !c.pk)) {
return false
}
const dynamicColAbstractType = sqlUi.getAbstractType(c)
const filterColAbstractType = sqlUi.getAbstractType(filterCol)
// treat float and integer as number
if ([dynamicColAbstractType, filterColAbstractType].every((type) => ['float', 'integer'].includes(type))) {
return true
}
// treat text and string as string
if ([dynamicColAbstractType, filterColAbstractType].every((type) => ['text', 'string'].includes(type))) {
return true
}
return filterColAbstractType === dynamicColAbstractType
})
}
const changeToDynamic = async (filter, i) => {
filter.dynamic = isDynamicFilterAllowed(filter) && showFilterInput(filter)
await saveOrUpdate(filter, i)
}
</script>
<template>
<div
class="menu-filter-dropdown"
data-testid="nc-filter"
class="menu-filter-dropdown w-min"
:class="{
'max-h-[max(80vh,500px)] min-w-112 py-2 pl-4': !nested,
'w-full ': nested,
'min-w-full': nested,
}"
>
<div v-if="nested" class="flex w-full items-center mb-2">
<div :class="[`nc-filter-logical-op-level-${nestedLevel}`]"><slot name="start"></slot></div>
<div v-if="nested" class="flex min-w-full w-min items-center mb-2">
<div :class="[`nc-filter-logical-op-level-${nestedLevel}`]">
<slot name="start"></slot>
</div>
<div class="flex-grow"></div>
<NcDropdown :trigger="['hover']" overlay-class-name="nc-dropdown-filter-group-sub-menu">
<GeneralIcon icon="plus" class="cursor-pointer" />
@ -443,13 +559,13 @@ watchEffect(() => {
</div>
</NcMenuItem>
<NcMenuItem v-if="!webHook && nestedLevel < 5" @click.stop="addFilterGroup()">
<NcButton v-if="!webHook && nestedLevel < 5" @click.stop="addFilterGroup()">
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plusSquare" />
{{ $t('activity.addFilterGroup') }}
</div>
</NcMenuItem>
</NcButton>
</template>
</NcMenu>
</template>
@ -459,29 +575,35 @@ watchEffect(() => {
</div>
</div>
<div
v-if="filters && filters.length"
v-if="visibleFilters && visibleFilters.length"
ref="wrapperDomRef"
class="flex flex-col gap-y-1.5 nc-filter-grid w-full"
class="flex flex-col gap-y-1.5 nc-filter-grid min-w-full w-min"
:class="{ 'max-h-420px nc-scrollbar-thin nc-filter-top-wrapper pr-4 my-2 py-1': !nested }"
@click.stop
>
<template v-for="(filter, i) in filters" :key="i">
<template v-if="filter.status !== 'delete'">
<template v-if="filter.is_group">
<div class="flex flex-col w-full gap-y-2">
<div class="flex rounded-lg p-2 w-full border-1" :class="[`nc-filter-nested-level-${nestedLevel}`]">
<div class="flex flex-col min-w-full w-min gap-y-2">
<div class="flex rounded-lg p-2 min-w-full w-min border-1" :class="[`nc-filter-nested-level-${nestedLevel}`]">
<LazySmartsheetToolbarColumnFilter
v-if="filter.id || filter.children || !autoSave"
:key="filter.id ?? i"
:key="i"
ref="localNestedFilters"
v-model="filter.children"
:nested-level="nestedLevel + 1"
:parent-id="filter.id"
:auto-save="autoSave"
:web-hook="webHook"
:link="link"
:show-loading="false"
:root-meta="rootMeta"
:link-col-id="linkColId"
>
<template #start>
<span v-if="!i" class="flex items-center nc-filter-where-label ml-1">{{ $t('labels.where') }}</span>
<span v-if="!visibleFilters.indexOf(filter)" class="flex items-center nc-filter-where-label ml-1">{{
$t('labels.where')
}}</span>
<div v-else :key="`${i}nested`" class="flex nc-filter-logical-op">
<NcSelect
v-model:value="filter.logical_op"
@ -513,7 +635,7 @@ watchEffect(() => {
<NcButton
v-if="!filter.readOnly"
:key="i"
v-e="['c:filter:delete']"
v-e="['c:filter:delete', { link: !!link, webHook: !!webHook }]"
type="text"
size="small"
class="nc-filter-item-remove-btn cursor-pointer"
@ -528,14 +650,14 @@ watchEffect(() => {
</template>
<div v-else class="flex flex-row gap-x-0 w-full nc-filter-wrapper" :class="`nc-filter-wrapper-${filter.fk_column_id}`">
<div v-if="!i" class="flex items-center !min-w-18 !max-w-18 pl-3 nc-filter-where-label">
<div v-if="!visibleFilters.indexOf(filter)" class="flex items-center !min-w-18 !max-w-18 pl-3 nc-filter-where-label">
{{ $t('labels.where') }}
</div>
<NcSelect
v-else
v-model:value="filter.logical_op"
v-e="['c:filter:logical-op:select']"
v-e="['c:filter:logical-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="h-full !min-w-18 !max-w-18 capitalize"
hide-details
@ -566,13 +688,14 @@ watchEffect(() => {
class="nc-filter-field-select min-w-32 max-w-32 max-h-8"
:columns="fieldsToFilter"
:disabled="filter.readOnly"
:meta="meta"
@click.stop
@change="selectFilterField(filter, i)"
/>
<NcSelect
v-model:value="filter.comparison_op"
v-e="['c:filter:comparison-op:select']"
v-e="['c:filter:comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-operation-select !min-w-26.75 !max-w-26.75 max-h-8"
:placeholder="$t('labels.operation')"
@ -606,7 +729,7 @@ watchEffect(() => {
<NcSelect
v-else-if="isDateType(types[filter.fk_column_id])"
v-model:value="filter.comparison_sub_op"
v-e="['c:filter:sub-comparison-op:select']"
v-e="['c:filter:sub-comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-sub_operation-select min-w-28"
:class="{
@ -641,29 +764,99 @@ watchEffect(() => {
</a-select-option>
</template>
</NcSelect>
<div class="flex items-center flex-grow">
<div v-if="link && (filter.dynamic || filter.fk_value_col_id)" class="flex-grow">
<SmartsheetToolbarFieldListAutoCompleteDropdown
v-if="showFilterInput(filter)"
v-model="filter.fk_value_col_id"
class="nc-filter-field-select min-w-32 w-full max-h-8"
:columns="dynamicColumns(filter)"
:meta="rootMeta"
@change="saveOrUpdate(filter, i)"
/>
</div>
<a-checkbox
v-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
/>
<SmartsheetToolbarFilterInput
v-if="showFilterInput(filter)"
class="nc-filter-value-select rounded-md min-w-34"
:column="{ ...getColumn(filter), uidt: types[filter.fk_column_id] }"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@click.stop
/>
<div v-else-if="!isDateType(types[filter.fk_column_id])" class="flex-grow"></div>
<template v-else>
<a-checkbox
v-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
/>
<SmartsheetToolbarFilterInput
v-if="showFilterInput(filter)"
class="nc-filter-value-select rounded-md min-w-34"
:column="{ ...getColumn(filter), uidt: types[filter.fk_column_id] }"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@click.stop
/>
<div v-else-if="!isDateType(types[filter.fk_column_id])" class="flex-grow"></div>
</template>
<template v-if="link">
<NcDropdown
class="nc-settings-dropdown h-full flex items-center min-w-0 rounded-lg"
:trigger="['click']"
placement="bottom"
>
<NcButton type="text" size="small">
<GeneralIcon icon="settings" />
</NcButton>
<template #overlay>
<div class="relative overflow-visible min-h-17 w-10">
<div
class="absolute -top-21 flex flex-col min-h-34.5 w-70 p-1.5 bg-white rounded-lg border-1 border-gray-200 justify-start overflow-hidden"
style="box-shadow: 0px 4px 6px -2px rgba(0, 0, 0, 0.06), 0px -12px 16px -4px rgba(0, 0, 0, 0.1)"
:class="{
'-left-32.5': !isAddNewRecordGridMode,
'-left-21.5': isAddNewRecordGridMode,
}"
>
<div
class="px-4 py-3 flex flex-col select-none gap-y-2 cursor-pointer rounded-md hover:bg-gray-100 text-gray-600 nc-new-record-with-grid group"
@click="resetDynamicField(filter, i)"
>
<div class="flex flex-row items-center justify-between w-full">
<div class="flex flex-row items-center justify-start gap-x-3">Static condition</div>
<GeneralIcon
v-if="!filter.dynamic && !filter.fk_value_col_id"
icon="check"
class="w-4 h-4 text-primary"
/>
</div>
<div class="flex flex-row text-xs text-gray-400">Filter based on static value</div>
</div>
<div
v-e="['c:filter:dynamic-filter']"
class="px-4 py-3 flex flex-col select-none gap-y-2 cursor-pointer rounded-md hover:bg-gray-100 text-gray-600 nc-new-record-with-form group"
:class="
isDynamicFilterAllowed(filter) && showFilterInput(filter) ? 'cursor-pointer' : 'cursor-not-allowed'
"
@click="changeToDynamic(filter, i)"
>
<div class="flex flex-row items-center justify-between w-full">
<div class="flex flex-row items-center justify-start gap-x-2.5">Dynamic condition</div>
<GeneralIcon
v-if="filter.dynamic || filter.fk_value_col_id"
icon="check"
class="w-4 h-4 text-primary"
/>
</div>
<div class="flex flex-row text-xs text-gray-400">Filter based on dynamic value</div>
</div>
</div>
</div>
</template>
</NcDropdown>
</template>
</div>
<NcButton
v-if="!filter.readOnly"
v-e="['c:filter:delete']"
v-e="['c:filter:delete', { link: !!link, webHook: !!webHook }]"
type="text"
size="small"
class="nc-filter-item-remove-btn self-center"
@ -720,7 +913,7 @@ watchEffect(() => {
</NcButton>
<NcButton
v-if="!webHook && nestedLevel < 5"
v-if="!link && !webHook && nestedLevel < 5"
class="nc-btn-focus"
type="text"
size="small"
@ -736,7 +929,7 @@ watchEffect(() => {
</template>
</template>
<div
v-if="!filters.length"
v-if="!visibleFilters || !visibleFilters.length"
class="flex flex-row text-gray-400 mt-2"
:class="{
'ml-1': nested,
@ -774,11 +967,13 @@ watchEffect(() => {
.nc-filter-wrapper {
@apply bg-white !rounded-lg border-1px border-[#E7E7E9];
& > * {
& > *,
.nc-filter-value-select {
@apply !border-none;
}
& > * > :deep(.ant-select-selector) {
& > div > :deep(.ant-select-selector),
:deep(.nc-filter-field-select) > div {
border: none !important;
box-shadow: none !important;
}
@ -789,6 +984,11 @@ watchEffect(() => {
border-top-right-radius: 0 !important;
}
.nc-settings-dropdown {
border-left: 1px solid #eee !important;
border-radius: 0 !important;
}
& > :not(:first-child) {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
@ -846,6 +1046,7 @@ watchEffect(() => {
.nc-filter-where-label {
@apply text-gray-400;
}
:deep(.ant-select-disabled.ant-select:not(.ant-select-customize-input) .ant-select-selector) {
@apply bg-transparent text-gray-400;
}
@ -869,6 +1070,7 @@ watchEffect(() => {
.nc-filter-input-wrapper :deep(input) {
@apply !px-2;
}
.nc-btn-focus:focus {
@apply !text-brand-500 !shadow-none;
}

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

@ -27,7 +27,11 @@ watch(
() => activeView?.value?.id,
async (viewId) => {
if (viewId) {
await loadFilters(undefined, false, true)
await loadFilters({
hookId: undefined,
isWebhook: false,
loadAllFilters: true,
})
filtersLength.value = nonDeletedFilters.value.length || 0
}
},

7
packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { SelectProps } from 'ant-design-vue'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isHiddenCol, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
const { modelValue, isSort, allowEmpty, ...restProps } = defineProps<{
@ -8,13 +8,14 @@ const { modelValue, isSort, allowEmpty, ...restProps } = defineProps<{
isSort?: boolean
columns?: ColumnType[]
allowEmpty?: boolean
meta: TableType
}>()
const emit = defineEmits(['update:modelValue'])
const customColumns = toRef(restProps, 'columns')
const meta = inject(MetaInj, ref())
const meta = toRef(restProps, 'meta')
const { metas } = useMetas()
@ -81,7 +82,7 @@ const filterOption = (input: string, option: any) => option.label.toLowerCase()?
// when a new filter is created, select a field by default
if (!localValue.value && allowEmpty !== true) {
localValue.value = (options.value?.[0].value as string) || ''
localValue.value = (options.value?.[0]?.value as string) || ''
}
const relationColor = {

1
packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue

@ -203,6 +203,7 @@ eventBus.on(async (event, column) => {
class="caption nc-sort-field-select w-44 flex flex-grow"
:columns="fieldsToGroupBy"
:allow-empty="true"
:meta="meta"
@change="saveGroupBy"
@click.stop
/>

1
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -144,6 +144,7 @@ onMounted(() => {
class="flex caption nc-sort-field-select w-44 flex-grow"
:columns="columns"
is-sort
:meta="meta"
@click.stop
@update:model-value="saveOrUpdate(sort, i)"
/>

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

@ -392,8 +392,7 @@ const onFilterChange = () => {
<p v-if="childrenExcludedListPagination.query">{{ $t('msg.noRecordsMatchYourSearchQuery') }}</p>
<p v-else>
{{ $t('msg.thereAreNoRecordsInTable') }}
{{ relatedTableMeta?.title }}
{{ $t('msg.noRecordsAvailForLinking') }}
</p>
</div>
</div>

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

@ -53,6 +53,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
isXcdbBaseFunc(meta.value?.source_id ? meta.value?.source_id : Object.keys(sqlUis.value)[0]),
)
let postSaveOrUpdateCbk:
| ((params: { update?: boolean; colId: string; column?: ColumnType | undefined }) => Promise<void>)
| null
const idType = null
const additionalValidations = ref<ValidationsObj>({})
@ -60,6 +64,9 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const setAdditionalValidations = (validations: ValidationsObj) => {
additionalValidations.value = { ...additionalValidations.value, ...validations }
}
const setPostSaveOrUpdateCbk = (cbk: typeof postSaveOrUpdateCbk) => {
postSaveOrUpdateCbk = cbk
}
const formState = ref<Record<string, any>>({
title: 'title',
@ -273,6 +280,8 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.validate = ''
}
await $api.dbTableColumn.update(column.value?.id as string, formState.value)
await postSaveOrUpdateCbk?.({ update: true, colId: column.value?.id })
// Column updated
// message.success(t('msg.success.columnUpdated'))
} else {
@ -285,12 +294,18 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
// };
// }
}
await $api.dbTableColumn.create(meta.value?.id as string, {
const tableMeta = await $api.dbTableColumn.create(meta.value?.id as string, {
...formState.value,
...columnPosition,
view_id: activeView.value!.id as string,
})
const savedColumn = tableMeta.columns?.find(
(c) => c.title === formState.value.title || c.column_name === formState.value.column_name,
)
await postSaveOrUpdateCbk?.({ update: false, colId: savedColumn?.id as string, column: savedColumn })
/** 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(() => {})
@ -333,6 +348,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
isMysql,
isXcdbBase,
disableSubmitBtn,
setPostSaveOrUpdateCbk,
}
},
)

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

@ -29,6 +29,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const { $api, $e } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref())
const isForm = inject(IsFormInj, ref(false))
const { addUndo, clone, defineViewScope } = useUndoRedo()
@ -182,6 +183,13 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const route = router.currentRoute
let row
// if shared form extract the current form state
if (isForm.value) {
const { formState } = useSharedFormStoreOrThrow()
row = formState?.value
}
childrenExcludedList.value = await $api.public.dataRelationList(
route.value.params.viewId as string,
column.value.id,
@ -197,6 +205,9 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value],
// todo: include only required fields
rowData: JSON.stringify(row),
} as RequestParams,
},
)
@ -214,9 +225,24 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
// fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value],
// todo: include only required fields
linkColumnId: column.value.fk_column_id || column.value.id,
linkRowData: JSON.stringify(row.value.row),
} as any,
)
} else {
// extract changed data and include with the api call if any
let changedRowData
try {
if (row.value?.row) {
changedRowData = Object.keys(row.value?.row).reduce((acc: Record<string, any>, key: string) => {
if (row.value.row[key] !== row.value.oldRow[key]) acc[key] = row.value.row[key]
return acc
}, {})
}
} catch {}
childrenExcludedList.value = await $api.dbTableRow.nestedChildrenExcludedList(
NOCO,
baseId,
@ -231,6 +257,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
where:
childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
linkRowData: changedRowData ? JSON.stringify(changedRowData) : undefined,
} as any,
)
}

1
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -66,7 +66,6 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const preFilledDefaultValueformState = ref<Record<string, any>>({})
useProvideSmartsheetLtarHelpers(meta)
const { state: additionalState } = useProvideSmartsheetRowStore(
ref({
row: formState,

107
packages/nc-gui/composables/useViewFilters.ts

@ -18,6 +18,8 @@ export function useViewFilters(
_currentFilters?: Filter[],
isNestedRoot?: boolean,
isWebhook?: boolean,
isLink?: boolean,
linkColId?: Ref<string>,
) {
const parentId = ref(_parentId)
@ -41,21 +43,23 @@ export function useViewFilters(
const { addUndo, clone, defineViewScope } = useUndoRedo()
const _filters = ref<Filter[]>([])
const _filters = ref<FilterType[]>([...(currentFilters.value || [])])
const nestedMode = computed(() => isPublic.value || !isUIAllowed('filterSync') || !isUIAllowed('filterChildrenRead'))
const filters = computed<Filter[]>({
const filters = computed<FilterType[]>({
get: () => {
return nestedMode.value ? currentFilters.value! : _filters.value
return nestedMode.value && !isLink && !isWebhook ? currentFilters.value! : _filters.value
},
set: (value: Filter[]) => {
if (nestedMode.value) {
currentFilters.value = value
if (isNestedRoot) {
nestedFilters.value = value
if (!isLink && !isWebhook) {
if (isNestedRoot) {
nestedFilters.value = value
}
nestedFilters.value = [...nestedFilters.value]
}
nestedFilters.value = [...nestedFilters.value]
reloadHook?.trigger()
return
}
@ -187,7 +191,7 @@ export function useViewFilters(
return {
comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) =>
isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp),
)?.[0].value as FilterType['comparison_op'],
)?.[0]?.value as FilterType['comparison_op'],
value: null,
status: 'create',
logical_op: logicalOps.size === 1 ? logicalOps.values().next().value : 'and',
@ -229,10 +233,20 @@ export function useViewFilters(
await Promise.all(promises)
// Push all child filters into the allFilters array
allFilters.value.push(...allChildFilters)
if (!isLink && !isWebhook) allFilters.value.push(...allChildFilters)
}
const loadFilters = async (hookId?: string, isWebhook = false, loadAllFilters = false) => {
const loadFilters = async ({
hookId,
isLink,
isWebhook,
loadAllFilters,
}: {
hookId?: string
isWebhook?: boolean
loadAllFilters?: boolean
isLink?: boolean
} = {}) => {
if (!view.value?.id) return
if (nestedMode.value) {
@ -244,17 +258,25 @@ export function useViewFilters(
if (isWebhook || hookId) {
if (parentId.value) {
filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[]
} else if (hookId) {
} else if (hookId && !isNestedRoot) {
filters.value = (await $api.dbTableWebhookFilter.read(hookId)).list as Filter[]
}
} else {
if (parentId.value) {
filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[]
if (isLink || linkColId?.value) {
if (parentId.value) {
filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[]
} else if (linkColId?.value && !isNestedRoot) {
filters.value = (await $api.dbTableLinkFilter.read(linkColId?.value)).list as Filter[]
}
} else {
filters.value = (await $api.dbTableFilter.read(view.value!.id!)).list as Filter[]
if (loadAllFilters) {
allFilters.value = [...filters.value]
await loadAllChildFilters(allFilters.value)
if (parentId.value) {
filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[]
} else {
filters.value = (await $api.dbTableFilter.read(view.value!.id!)).list as Filter[]
if (loadAllFilters) {
allFilters.value = [...filters.value]
await loadAllChildFilters(allFilters.value)
}
}
}
}
@ -264,7 +286,7 @@ export function useViewFilters(
}
}
const sync = async (hookId?: string, _nested = false) => {
const sync = async ({ hookId, linkId }: { hookId?: string; nested?: boolean; linkId?: string }) => {
try {
for (const [i, filter] of Object.entries(filters.value)) {
if (filter.status === 'delete') {
@ -272,7 +294,7 @@ export function useViewFilters(
if (filter.is_group) {
deleteFilterGroupFromAllFilters(filter)
} else {
allFilters.value = allFilters.value.filter((f) => f.id !== filter.id)
if (!isLink && !isWebhook) allFilters.value = allFilters.value.filter((f) => f.id !== filter.id)
}
} else if (filter.status === 'update') {
await $api.dbTableFilter.update(filter.id as string, {
@ -285,6 +307,13 @@ export function useViewFilters(
if (hookId) {
filters.value[+i] = (await $api.dbTableWebhookFilter.create(hookId, {
...filter,
children: undefined,
fk_parent_id: parentId.value,
})) as unknown as FilterType
} else if (linkId || linkColId?.value) {
filters.value[+i] = (await $api.dbTableLinkFilter.create(linkId || linkColId.value, {
...filter,
children: undefined,
fk_parent_id: parentId.value,
})) as unknown as FilterType
} else {
@ -296,11 +325,11 @@ export function useViewFilters(
if (children) filters.value[+i].children = children
allFilters.value.push(filters.value[+i])
if (!isLink && !isWebhook) allFilters.value.push(filters.value[+i])
}
}
if (!isWebhook) reloadData?.()
if (!isWebhook && !isLink) reloadData?.()
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
@ -308,7 +337,7 @@ export function useViewFilters(
}
const saveOrUpdate = async (filter: Filter, i: number, force = false, undo = false, skipDataReload = false) => {
if (!view.value) return
if (!view.value && !linkColId?.value) return
if (!undo) {
const lastFilter = lastFilters.value[i]
@ -356,14 +385,22 @@ export function useViewFilters(
$e('a:filter:update', {
logical: filter.logical_op,
comparison: filter.comparison_op,
link: !!isLink,
webHook: !!isWebhook,
})
} else {
filters.value[i] = await $api.dbTableFilter.create(view.value.id!, {
...filter,
fk_parent_id: parentId.value,
})
allFilters.value.push(filters.value[+i])
if (linkColId?.value) {
filters.value[i] = await $api.dbTableLinkFilter.create(linkColId.value, {
...filter,
fk_parent_id: parentId,
})
} else {
filters.value[i] = await $api.dbTableFilter.create(view.value.id!, {
...filter,
fk_parent_id: parentId.value,
})
}
if (!isLink && !isWebhook) allFilters.value.push(filters.value[+i])
}
} catch (e: any) {
console.log(e)
@ -372,10 +409,12 @@ export function useViewFilters(
lastFilters.value = clone(filters.value)
if (!isWebhook && !skipDataReload) reloadData?.()
if (!isWebhook && !skipDataReload && !isLink) reloadData?.()
}
function deleteFilterGroupFromAllFilters(filter: Filter) {
if (!isLink && !isWebhook) return
// Find all child filters of the specified parentId
const childFilters = allFilters.value.filter((f) => f.fk_parent_id === filter.id)
@ -414,7 +453,7 @@ export function useViewFilters(
if (nestedMode.value) {
filters.value.splice(i, 1)
filters.value = [...filters.value]
if (!isWebhook) reloadData?.()
if (!isWebhook && !isLink) reloadData?.()
} else {
if (filter.id) {
// if auto-apply disabled mark it as disabled
@ -426,7 +465,7 @@ export function useViewFilters(
try {
await $api.dbTableFilter.delete(filter.id)
if (!isWebhook) reloadData?.()
if (!isWebhook && !isLink) reloadData?.()
filters.value.splice(i, 1)
} catch (e: any) {
console.log(e)
@ -437,13 +476,13 @@ export function useViewFilters(
} else {
filters.value.splice(i, 1)
}
$e('a:filter:delete', { length: nonDeletedFilters.value.length })
$e('a:filter:delete', { length: nonDeletedFilters.value.length, link: !!isLink, webHook: !!isWebhook })
}
if (filter.is_group) {
deleteFilterGroupFromAllFilters(filter)
} else {
allFilters.value = allFilters.value.filter((f) => f.id !== filter.id)
if (!isLink && !isWebhook) allFilters.value = allFilters.value.filter((f) => f.id !== filter.id)
}
}
@ -474,7 +513,7 @@ export function useViewFilters(
lastFilters.value = clone(filters.value)
$e('a:filter:add', { length: filters.value.length })
$e('a:filter:add', { length: filters.value.length, link: !!isLink, webHook: !!isWebhook })
}
const addFilterGroup = async () => {
@ -492,7 +531,7 @@ export function useViewFilters(
lastFilters.value = clone(filters.value)
$e('a:filter:add', { length: filters.value.length, group: true })
$e('a:filter:add', { length: filters.value.length, group: true, link: !!isLink, webHook: !!isWebhook })
}
/** on column delete reload filters, identify by checking columns count */

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

@ -454,6 +454,7 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results"
},
"labels": {
"selectView": "Select a View",
"connectionDetails": "Connection Details",
"metaSync": "Meta Sync",
"mention": "Mention",
@ -492,6 +493,7 @@
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"childView": "Child View",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -1210,6 +1212,7 @@
"selectFieldToSort": "Select Field to Sort",
"selectFieldToGroup": "Select Field to Group",
"thereAreNoRecordsInTable": "There are no records in table",
"noRecordsAvailForLinking": "No records are currently available for linking",
"createWebhookMsg1": "Get started with web-hooks!",
"createWebhookMsg2": "Power your automations. Get notified as soon as there are changes in your data",
"areYouSureUWantTo": "Are you sure you want to delete the following",

67
packages/noco-docs/docs/070.fields/040.field-types/040.links-based/010.links.md

@ -19,19 +19,21 @@ NocoDB supports following types of relations:
- Links can be created only between tables in the same database.
- Self-referencing links are supported. (Link to the same table)
- For every `Has-Many` relation defined, NocoDB augments `Belongs-to` relationship field in the adjacent table automatically
:::
:::
## Create a link field
1. Click on `+` icon to the right of `Fields header`
2. On the dropdown modal, enter the field name (Optional).
3. Select the field type as `Links` from the dropdown.
4. Select the relation type : Either `Has-Many` or `Many-to-Many`.
4. Select the relation type : `One-to-one`, `Has-Many`, OR `Many-to-Many`.
5. Select the table to which the relation is to be established with.
6. Configure label to be used for display in the cell. Defaults to `Link`, `Links` (Optional).
6. Advance settings: Configure label to be used for display in the cell. Defaults to `Link`, `Links` (Optional).
7. Click on `Save Field` button.
![image](/img/v2/fields/types/links.png)
You can control record visibility in the `Link records` modal by limiting the records available for selection. [Read more](#limit-record-selection)
### Cell display
The cell will display number of links for a record to the related table.
@ -82,6 +84,63 @@ To unlink a record, open the `Linked Records` modal & click on the `-` icon on t
Multiple records can be unlinked at once.
:::
### Related fields
## Limit record selection
This feature enables users to restrict the records available for selection within the `Link records` modal. This can be achieved by two modes:
- Limit by view
- Limit by filter ☁
Note that,
- These options are not mutually exclusive & can be used together. When both are used, the records available for selection will be the **intersection of the records satisfying all conditions**.
- This feature only affects the records available for selection & **does not affect the existing links**.
- The limit is applied only on the **Link records** modal & does not restrict the link modification process via API, Copy-Paste or any other mechanisms.
- **Limit by filter** option is available only in cloud version.
### Limit by view
To limit the records available for selection based on a view, follow the steps below:
#### Steps
1. Enable `Limit record selection to view`
2. Select the view from the dropdown
3. Click on `Save Field` button
![image](/img/v2/fields/types/limit-by-view.png)
In the example image above, the records available for selection will be limited to the records available in `Trailers` view.
Note that,
- Only one view can be selected at a time.
- Deleting the view associated with links field will disable the limit by view option.
- Change in filters within the view will also reflect records available for selection during linking.
### Limit by filter ☁
To limit the records available for selection based on filters, follow the steps below:
#### Steps
1. Enable `Limit record selection by filter`
2. Configure filter
- Click on `Add filter` button.
- Select the field, operator
- Configure value
3. Click on `Save Field` button
![image](/img/v2/fields/types/limit-by-filter.png)
In the example image above, the records available for selection will be limited to the records available in `Trailers` view where `rating` is `PG`.
Note that,
- Multiple filters can be added.
- Deleting the field associated with links field will disable associated filters.
#### Filter Type
Conditions in the filter can be of two types: Static & Dynamic.
- **Static condition**: Filter value is a constant value.
- **Dynamic condition**: Filter value is a field value from the current record.
Use the ⚙ icon to the right of the filter to toggle between static & dynamic filter values.
![image](/img/v2/fields/types/link-filter-settings.png)
![image](/img/v2/fields/types/link-filter-settings-2.png)
Note that, it's allowed to have a mix of static & dynamic conditions in the filter.
## Related fields
- [Lookup](020.lookup.md)
- [Rollup](030.rollup.md)

BIN
packages/noco-docs/static/img/v2/fields/types/limit-by-filter.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
packages/noco-docs/static/img/v2/fields/types/limit-by-view.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
packages/noco-docs/static/img/v2/fields/types/link-filter-settings-2.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

BIN
packages/noco-docs/static/img/v2/fields/types/link-filter-settings.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
packages/noco-docs/static/img/v2/fields/types/links.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 179 KiB

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

@ -21,7 +21,7 @@ import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class FiltersController {
constructor(private readonly filtersService: FiltersService) {}
constructor(protected readonly filtersService: FiltersService) {}
@Get([
'/api/v1/db/meta/views/:viewId/filters',

11
packages/nocodb/src/controllers/public-datas.controller.ts

@ -97,11 +97,22 @@ export class PublicDatasController {
@Param('sharedViewUuid') sharedViewUuid: string,
@Param('columnId') columnId: string,
) {
let rowData: any;
if (req.query.rowData) {
try {
rowData = JSON.parse(req.query.rowData as string);
} catch {
rowData = {};
}
}
const pagedResponse = await this.publicDatasService.relDataList({
query: req.query,
password: req.headers?.['xc-password'] as string,
sharedViewUuid: sharedViewUuid,
columnId: columnId,
rowData,
});
return pagedResponse;

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

@ -151,6 +151,19 @@ export async function getColumnName(column: Column<any>, columns?: Column[]) {
}
}
export function replaceDynamicFieldWithValue(
_row: any,
_rowId,
_tableColumns: Column[],
_readByPk: typeof BaseModelSqlv2.prototype.readByPk,
_queryParams?: Record<string, string>,
) {
const replaceWithValue = async (conditions: Filter[]) => {
return conditions;
};
return replaceWithValue;
}
/**
* Base class for models
*
@ -322,6 +335,7 @@ class BaseModelSqlv2 {
fieldsSet?: Set<string>;
limitOverride?: number;
pks?: string;
customConditions?: Filter[];
} = {},
options: {
ignoreViewFilterAndSort?: boolean;
@ -372,6 +386,14 @@ class BaseModelSqlv2 {
await conditionV2(
this,
[
...(args.customConditions
? [
new Filter({
children: args.customConditions,
is_group: true,
}),
]
: []),
new Filter({
children:
(await Filter.rootFilterList({ viewId: this.viewId })) || [],
@ -403,6 +425,14 @@ class BaseModelSqlv2 {
await conditionV2(
this,
[
...(args.customConditions
? [
new Filter({
children: args.customConditions,
is_group: true,
}),
]
: []),
new Filter({
children: args.filterArr || [],
is_group: true,
@ -475,7 +505,12 @@ class BaseModelSqlv2 {
}
public async count(
args: { where?: string; limit?; filterArr?: Filter[] } = {},
args: {
where?: string;
limit?;
filterArr?: Filter[];
customConditions?: Filter[];
} = {},
ignoreViewFilterAndSort = false,
throwErrorIfInvalidParams = false,
): Promise<any> {
@ -496,6 +531,14 @@ class BaseModelSqlv2 {
await conditionV2(
this,
[
...(args.customConditions
? [
new Filter({
children: args.customConditions,
is_group: true,
}),
]
: []),
new Filter({
children:
(await Filter.rootFilterList({ viewId: this.viewId })) || [],
@ -521,6 +564,14 @@ class BaseModelSqlv2 {
await conditionV2(
this,
[
...(args.customConditions
? [
new Filter({
children: args.customConditions,
is_group: true,
}),
]
: []),
new Filter({
children: args.filterArr || [],
is_group: true,
@ -1009,8 +1060,6 @@ class BaseModelSqlv2 {
const { where, sort, ...rest } = this._getListArgs(args as any);
// todo: get only required fields
// const { cn } = this.hasManyRelations.find(({ tn }) => tn === child) || {};
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
@ -1065,8 +1114,6 @@ class BaseModelSqlv2 {
.as('list'),
);
// console.log(childQb.toQuery())
const children = await this.execAndParse(
childQb,
await childTable.getColumns(),
@ -1090,24 +1137,77 @@ class BaseModelSqlv2 {
}
}
protected async applySortAndFilter({
table,
where,
qb,
sort,
}: {
table: Model;
where: string;
qb;
sort: string;
}) {
const childAliasColMap = await table.getAliasColObjMap();
public async mmList(
{ colId, parentId },
args: { limit?; offset?; fieldsSet?: Set<string> } = {},
selectAllRecords = false,
) {
const { where, sort, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const filter = extractFilterFromXwhere(where, childAliasColMap);
await conditionV2(this, filter, qb);
if (!sort) return;
const sortObj = extractSortsObject(sort, childAliasColMap);
if (sortObj) await sortV2(this, sortObj, qb);
// const tn = this.model.tn;
// const cn = (await relColOptions.getChildColumn()).title;
const mmTable = await relColOptions.getMMModel();
const vtn = this.getTnPath(mmTable);
const vcn = (await relColOptions.getMMChildColumn()).column_name;
const vrcn = (await relColOptions.getMMParentColumn()).column_name;
const rcn = (await relColOptions.getParentColumn()).column_name;
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getParentColumn()).getModel();
const parentTable = await (await relColOptions.getChildColumn()).getModel();
await parentTable.getColumns();
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = childTn;
const rtnId = childTable.id;
const qb = this.dbDriver(rtn)
.join(vtn, `${vtn}.${vrcn}`, `${rtn}.${rcn}`)
.whereIn(
`${vtn}.${vcn}`,
this.dbDriver(parentTn)
.select(cn)
// .where(parentTable.primaryKey.cn, id)
.where(_wherePk(parentTable.primaryKeys, parentId)),
);
await childModel.selectObject({
qb,
fieldsSet: args.fieldsSet,
});
await this.applySortAndFilter({
table: childTable,
where,
qb,
sort,
});
// todo: sanitize
if (!selectAllRecords) {
qb.limit(+rest?.limit || 25);
}
qb.offset(selectAllRecords ? 0 : +rest?.offset || 0);
const children = await this.execAndParse(qb, await childTable.getColumns());
const proto = await (
await Model.getBaseModelSQL({ id: rtnId, dbDriver: this.dbDriver })
).getProto();
return children.map((c) => {
c.__proto__ = proto;
return c;
});
}
async multipleHmListCount({ colId, ids }) {
@ -1200,7 +1300,17 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
await childModel.selectObject({ qb, fieldsSet: args.fieldSet });
await childModel.selectObject({
qb,
fieldsSet: args.fieldSet,
});
await this.applySortAndFilter({
table: childTable,
where,
qb,
sort,
});
const children = await this.execAndParse(
qb,
@ -1254,7 +1364,17 @@ class BaseModelSqlv2 {
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, query);
await conditionV2(
this,
[
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
query,
);
return (await this.execAndParse(query, null, { raw: true, first: true }))
?.count;
@ -1314,7 +1434,12 @@ class BaseModelSqlv2 {
await childModel.selectObject({ qb, fieldsSet: args.fieldsSet });
await this.applySortAndFilter({ table: childTable, where, qb, sort });
await this.applySortAndFilter({
table: childTable,
where,
qb,
sort,
});
const finalQb = this.dbDriver.unionAll(
parentIds.map((id) => {
@ -1359,20 +1484,18 @@ class BaseModelSqlv2 {
return _parentIds.map((id) => gs[id] || []);
}
public async mmList(
{ colId, parentId },
args: { limit?; offset?; fieldsSet?: Set<string> } = {},
selectAllRecords = false,
) {
const { where, sort, ...rest } = this._getListArgs(args as any);
// todo: naming & optimizing
public async getMmChildrenExcludedListCount(
{ colId, pid = null },
args,
): Promise<any> {
const { where } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
// const tn = this.model.tn;
// const cn = (await relColOptions.getChildColumn()).title;
const mmTable = await relColOptions.getMMModel();
const vtn = this.getTnPath(mmTable);
const vcn = (await relColOptions.getMMChildColumn()).column_name;
@ -1380,48 +1503,69 @@ class BaseModelSqlv2 {
const rcn = (await relColOptions.getParentColumn()).column_name;
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getParentColumn()).getModel();
const childView = await relColOptions.getChildView();
let listArgs: any = {};
if (childView) {
const { dependencyFields } = await getAst({
model: childTable,
query: {},
view: childView,
throwErrorIfInvalidParams: false,
});
listArgs = dependencyFields;
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
try {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
}
const parentTable = await (await relColOptions.getChildColumn()).getModel();
await parentTable.getColumns();
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = childTn;
const rtnId = childTable.id;
const qb = this.dbDriver(rtn)
.join(vtn, `${vtn}.${vrcn}`, `${rtn}.${rcn}`)
.whereIn(
`${vtn}.${vcn}`,
this.dbDriver(parentTn)
.select(cn)
// .where(parentTable.primaryKey.cn, id)
.where(_wherePk(parentTable.primaryKeys, parentId)),
);
await childModel.selectObject({ qb, fieldsSet: args.fieldsSet });
await this.applySortAndFilter({ table: childTable, where, qb, sort });
// todo: sanitize
if (!selectAllRecords) {
qb.limit(+rest?.limit || 25);
}
qb.offset(selectAllRecords ? 0 : +rest?.offset || 0);
.count(`*`, { as: 'count' })
.where((qb) => {
qb.whereNotIn(
rcn,
this.dbDriver(rtn)
.select(`${rtn}.${rcn}`)
.join(vtn, `${rtn}.${rcn}`, `${vtn}.${vrcn}`)
.whereIn(
`${vtn}.${vcn}`,
this.dbDriver(parentTn)
.select(cn)
// .where(parentTable.primaryKey.cn, pid)
.where(_wherePk(parentTable.primaryKeys, pid)),
),
).orWhereNull(rcn);
});
const children = await this.execAndParse(qb, await childTable.getColumns());
const proto = await (
await Model.getBaseModelSQL({ id: rtnId, dbDriver: this.dbDriver })
).getProto();
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
return children.map((c) => {
c.__proto__ = proto;
return c;
await this.getCustomConditionsAndApply({
column: relColumn,
view: childView,
filters: filterObj,
args,
qb,
rowId: pid,
});
return (
await this.execAndParse(qb, await childTable.getColumns(), {
raw: true,
first: true,
})
)?.count;
}
public async multipleMmListCount({ colId, parentIds }) {
@ -1496,6 +1640,7 @@ class BaseModelSqlv2 {
const rcn = (await relColOptions.getParentColumn()).column_name;
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getParentColumn()).getModel();
const parentTable = await (await relColOptions.getChildColumn()).getModel();
await parentTable.getColumns();
@ -1520,17 +1665,27 @@ class BaseModelSqlv2 {
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
await conditionV2(
this,
[
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
qb,
);
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
}
// todo: naming & optimizing
public async getMmChildrenExcludedListCount(
public async getMmChildrenExcludedList(
{ colId, pid = null },
args,
): Promise<any> {
const { where } = this._getListArgs(args as any);
const { where, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
@ -1544,6 +1699,22 @@ class BaseModelSqlv2 {
const rcn = (await relColOptions.getParentColumn()).column_name;
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getParentColumn()).getModel();
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const childView = await relColOptions.getChildView();
let listArgs: any = {};
if (childView) {
const { dependencyFields } = await getAst({
model: childTable,
query: {},
view: childView,
throwErrorIfInvalidParams: false,
});
listArgs = dependencyFields;
}
const parentTable = await (await relColOptions.getChildColumn()).getModel();
await parentTable.getColumns();
@ -1551,10 +1722,10 @@ class BaseModelSqlv2 {
const parentTn = this.getTnPath(parentTable);
const rtn = childTn;
const qb = this.dbDriver(rtn)
.count(`*`, { as: 'count' })
.where((qb) => {
qb.whereNotIn(
const qb = this.dbDriver(rtn).where((qb) =>
qb
.whereNotIn(
rcn,
this.dbDriver(rtn)
.select(`${rtn}.${rcn}`)
@ -1566,19 +1737,52 @@ class BaseModelSqlv2 {
// .where(parentTable.primaryKey.cn, pid)
.where(_wherePk(parentTable.primaryKeys, pid)),
),
).orWhereNull(rcn);
});
)
.orWhereNull(rcn),
);
if (+rest?.shuffle) {
await this.shuffle({ qb });
}
await childModel.selectObject({
qb,
fieldsSet: listArgs?.fieldsSet,
viewId: childView?.id,
});
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
await this.getCustomConditionsAndApply({
column: relColumn,
view: childView,
filters: filterObj,
args,
qb,
rowId: pid,
});
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (childTable.primaryKey && childTable.primaryKey.ai) {
qb.orderBy(childTable.primaryKey.column_name);
} else if (childTable.columns.find((c) => c.column_name === 'created_at')) {
qb.orderBy('created_at');
}
applyPaginate(qb, rest);
const proto = await childModel.getProto();
const data = await this.execAndParse(qb, await childTable.getColumns());
return data.map((c) => {
c.__proto__ = proto;
return c;
});
}
// todo: naming & optimizing
public async getMmChildrenExcludedList(
public async getHmChildrenExcludedList(
{ colId, pid = null },
args,
): Promise<any> {
@ -1589,42 +1793,35 @@ class BaseModelSqlv2 {
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const mmTable = await relColOptions.getMMModel();
const vtn = this.getTnPath(mmTable);
const vcn = (await relColOptions.getMMChildColumn()).column_name;
const vrcn = (await relColOptions.getMMParentColumn()).column_name;
const rcn = (await relColOptions.getParentColumn()).column_name;
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getParentColumn()).getModel();
const rcn = (await relColOptions.getParentColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const parentTable = await (await relColOptions.getChildColumn()).getModel();
await parentTable.getColumns();
const childView = await relColOptions.getChildView();
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = childTn;
const tn = childTn;
const rtn = parentTn;
const qb = this.dbDriver(rtn).where((qb) =>
qb
.whereNotIn(
rcn,
this.dbDriver(rtn)
.select(`${rtn}.${rcn}`)
.join(vtn, `${rtn}.${rcn}`, `${vtn}.${vrcn}`)
.whereIn(
`${vtn}.${vcn}`,
this.dbDriver(parentTn)
.select(cn)
// .where(parentTable.primaryKey.cn, pid)
.where(_wherePk(parentTable.primaryKeys, pid)),
),
)
.orWhereNull(rcn),
);
const qb = this.dbDriver(tn).where((qb) => {
qb.whereNotIn(
cn,
this.dbDriver(rtn)
.select(rcn)
// .where(parentTable.primaryKey.cn, pid)
.where(_wherePk(parentTable.primaryKeys, pid)),
).orWhereNull(cn);
});
if (+rest?.shuffle) {
await this.shuffle({ qb });
@ -1634,8 +1831,14 @@ class BaseModelSqlv2 {
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
await this.getCustomConditionsAndApply({
column: relColumn,
view: childView,
filters: filterObj,
args,
qb,
rowId: pid,
});
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (childTable.primaryKey && childTable.primaryKey.ai) {
@ -1648,6 +1851,7 @@ class BaseModelSqlv2 {
const proto = await childModel.getProto();
const data = await this.execAndParse(qb, await childTable.getColumns());
return data.map((c) => {
c.__proto__ = proto;
return c;
@ -1663,6 +1867,7 @@ class BaseModelSqlv2 {
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
@ -1673,6 +1878,8 @@ class BaseModelSqlv2 {
await relColOptions.getParentColumn()
).getModel();
const childView = await relColOptions.getChildView();
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
@ -1695,15 +1902,22 @@ class BaseModelSqlv2 {
const aliasColObjMap = await childTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
await this.getCustomConditionsAndApply({
column: relColumn,
view: childView,
filters: filterObj,
args,
qb,
rowId: pid,
});
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
}
// todo: naming & optimizing
public async getHmChildrenExcludedList(
{ colId, pid = null },
public async getExcludedOneToOneChildrenList(
{ colId, cid = null },
args,
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
@ -1713,56 +1927,90 @@ class BaseModelSqlv2 {
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const cn = (await relColOptions.getChildColumn()).column_name;
const rcn = (await relColOptions.getParentColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const parentModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: parentTable,
});
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
await parentTable.getColumns();
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const childView = await relColOptions.getChildView();
let listArgs: any = {};
if (childView) {
const { dependencyFields } = await getAst({
model: childTable,
query: {},
view: childView,
throwErrorIfInvalidParams: false,
});
listArgs = dependencyFields;
}
const tn = childTn;
const rtn = parentTn;
const rtn = this.getTnPath(parentTable);
const tn = this.getTnPath(childTable);
await childTable.getColumns();
const qb = this.dbDriver(tn).where((qb) => {
// 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
const isBt = relColumn.meta?.bt;
const qb = this.dbDriver(isBt ? rtn : tn).where((qb) => {
qb.whereNotIn(
cn,
this.dbDriver(rtn)
.select(rcn)
// .where(parentTable.primaryKey.cn, pid)
.where(_wherePk(parentTable.primaryKeys, pid)),
).orWhereNull(cn);
isBt ? rcn : cn,
this.dbDriver(isBt ? tn : rtn)
.select(isBt ? cn : rcn)
.where(_wherePk((isBt ? childTable : parentTable).primaryKeys, cid))
.whereNotNull(isBt ? cn : rcn),
).orWhereNull(isBt ? rcn : cn);
});
if (+rest?.shuffle) {
await this.shuffle({ qb });
}
await childModel.selectObject({ qb });
await (isBt ? parentModel : childModel).selectObject({
qb,
fieldsSet: listArgs.fieldsSet,
viewId: childView?.id,
});
const aliasColObjMap = await childTable.getAliasColObjMap();
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
await this.getCustomConditionsAndApply({
column: relColumn,
view: childView,
filters: filterObj,
args,
qb,
rowId: cid,
});
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (childTable.primaryKey && childTable.primaryKey.ai) {
qb.orderBy(childTable.primaryKey.column_name);
} else if (childTable.columns.find((c) => c.column_name === 'created_at')) {
if (parentTable.primaryKey && parentTable.primaryKey.ai) {
qb.orderBy(parentTable.primaryKey.column_name);
} else if (
parentTable.columns.find((c) => c.column_name === 'created_at')
) {
qb.orderBy('created_at');
}
applyPaginate(qb, rest);
const proto = await childModel.getProto();
const data = await this.execAndParse(qb, await childTable.getColumns());
const proto = await (isBt ? parentModel : childModel).getProto();
const data = await this.execAndParse(
qb,
await (isBt ? parentTable : childTable).getColumns(),
);
return data.map((c) => {
c.__proto__ = proto;
@ -1812,7 +2060,17 @@ class BaseModelSqlv2 {
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
const targetView = await relColOptions.getChildView();
await this.getCustomConditionsAndApply({
column: relColumn,
view: targetView,
filters: filterObj,
args,
qb,
rowId: cid,
});
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
}
@ -1836,6 +2094,8 @@ class BaseModelSqlv2 {
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const childView = await relColOptions.getChildView();
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
@ -1862,7 +2122,15 @@ class BaseModelSqlv2 {
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
await this.getCustomConditionsAndApply({
column: relColumn,
view: childView,
filters: filterObj,
args,
qb,
rowId: cid,
});
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
}
@ -1916,7 +2184,16 @@ class BaseModelSqlv2 {
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
const targetView = await relColOptions.getChildView();
await this.getCustomConditionsAndApply({
column: relColumn,
view: targetView,
filters: filterObj,
args,
qb,
rowId: cid,
});
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
@ -1939,83 +2216,41 @@ class BaseModelSqlv2 {
});
}
// todo: naming & optimizing
public async getExcludedOneToOneChildrenList(
{ colId, cid = null },
args,
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const rcn = (await relColOptions.getParentColumn()).column_name;
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const parentModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: parentTable,
});
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const rtn = this.getTnPath(parentTable);
const tn = this.getTnPath(childTable);
await childTable.getColumns();
// 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
const isBt = relColumn.meta?.bt;
const qb = this.dbDriver(isBt ? rtn : tn).where((qb) => {
qb.whereNotIn(
isBt ? rcn : cn,
this.dbDriver(isBt ? tn : rtn)
.select(isBt ? cn : rcn)
.where(_wherePk((isBt ? childTable : parentTable).primaryKeys, cid))
.whereNotNull(isBt ? cn : rcn),
).orWhereNull(isBt ? rcn : cn);
});
if (+rest?.shuffle) {
await this.shuffle({ qb });
}
await (isBt ? parentModel : childModel).selectObject({ qb });
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (parentTable.primaryKey && parentTable.primaryKey.ai) {
qb.orderBy(parentTable.primaryKey.column_name);
} else if (
parentTable.columns.find((c) => c.column_name === 'created_at')
) {
qb.orderBy('created_at');
}
applyPaginate(qb, rest);
protected async applySortAndFilter({
table,
view,
where,
qb,
sort,
}: {
table: Model;
view?: View;
where: string;
qb;
sort: string;
}) {
const childAliasColMap = await table.getAliasColObjMap();
const proto = await (isBt ? parentModel : childModel).getProto();
const data = await this.execAndParse(
const filter = extractFilterFromXwhere(where, childAliasColMap);
await conditionV2(
this,
[
...(view
? [
new Filter({
children:
(await Filter.rootFilterList({ viewId: view.id })) || [],
is_group: true,
}),
]
: []),
...filter,
],
qb,
await (isBt ? parentTable : childTable).getColumns(),
);
return data.map((c) => {
c.__proto__ = proto;
return c;
});
if (!sort) return;
const sortObj = extractSortsObject(sort, childAliasColMap);
if (sortObj) await sortV2(this, sortObj, qb);
}
protected async getSelectQueryBuilderForFormula(
@ -6663,6 +6898,18 @@ class BaseModelSqlv2 {
.utc()
.format(this.isMySQL ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ');
}
async getCustomConditionsAndApply(_params: {
view?: View;
column: Column<any>;
qb?;
filters?;
args;
rowId;
columns?: Column[];
}): Promise<any> {
return;
}
}
export function extractSortsObject(

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

@ -1,9 +1,11 @@
import { NcDataErrorCodes, RelationTypes } from 'nocodb-sdk';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { LinksColumn } from '~/models';
import type { RollupColumn } from '~/models';
import type {
LinksColumn,
LinkToAnotherRecordColumn,
RollupColumn,
} from '~/models';
import type { XKnex } from '~/db/CustomKnex';
import type { LinkToAnotherRecordColumn } from '~/models';
import type { Knex } from 'knex';
export default async function ({
@ -30,48 +32,56 @@ export default async function ({
const refTableAlias = `__nc_rollup`;
switch (relationColumnOption.type) {
case RelationTypes.HAS_MANY:
return {
builder: knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias,
]),
case RelationTypes.HAS_MANY: {
const queryBuilder: any = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias,
]),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${childCol.column_name}`),
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
};
case RelationTypes.ONE_TO_ONE:
'=',
knex.ref(`${refTableAlias}.${childCol.column_name}`),
);
return {
builder: knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias,
]),
builder: queryBuilder,
};
}
case RelationTypes.ONE_TO_ONE: {
const qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias,
]),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${childCol.column_name}`),
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${childCol.column_name}`),
);
return {
builder: qb,
};
}
case RelationTypes.MANY_TO_MANY: {
const mmModel = await relationColumnOption.getMMModel();
const mmChildCol = await relationColumnOption.getMMChildColumn();
@ -83,39 +93,41 @@ export default async function ({
]);
}
return {
builder: knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel?.table_name),
refTableAlias,
]),
const qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel?.table_name),
refTableAlias,
]),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.innerJoin(
baseModelSqlv2.getTnPath(mmModel.table_name),
knex.ref(
`${baseModelSqlv2.getTnPath(mmModel.table_name)}.${
mmParentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${parentCol.column_name}`),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.innerJoin(
baseModelSqlv2.getTnPath(mmModel.table_name),
knex.ref(
`${baseModelSqlv2.getTnPath(mmModel.table_name)}.${
mmParentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${parentCol.column_name}`),
)
.where(
knex.ref(
`${baseModelSqlv2.getTnPath(mmModel.table_name)}.${
mmChildCol.column_name
}`,
),
'=',
knex.ref(
`${alias || baseModelSqlv2.getTnPath(childModel.table_name)}.${
childCol.column_name
}`,
),
.where(
knex.ref(
`${baseModelSqlv2.getTnPath(mmModel.table_name)}.${
mmChildCol.column_name
}`,
),
'=',
knex.ref(
`${alias || baseModelSqlv2.getTnPath(childModel.table_name)}.${
childCol.column_name
}`,
),
);
return {
builder: qb,
};
}

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

@ -13,10 +13,10 @@ import type {
RollupColumnReqType,
TableType,
} from 'nocodb-sdk';
import type { RollupColumn } from '~/models';
import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn';
import type LookupColumn from '~/models/LookupColumn';
import type Model from '~/models/Model';
import type { RollupColumn, View } from '~/models';
import { GridViewColumn } from '~/models';
import validateParams from '~/helpers/validateParams';
import { getUniqueColumnAliasName } from '~/helpers/getUniqueName';
@ -32,6 +32,7 @@ export async function createHmAndBtColumn(
child: Model,
parent: Model,
childColumn: Column,
childView?: View,
type?: RelationTypes,
alias?: string,
fkColName?: string,
@ -74,6 +75,7 @@ export async function createHmAndBtColumn(
(type === 'hm' && alias) || pluralize(child.title),
);
const meta = {
...(columnMeta || {}),
plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title),
};
@ -83,6 +85,7 @@ export async function createHmAndBtColumn(
fk_model_id: parent.id,
uidt: isLinks ? UITypes.Links : UITypes.LinkToAnotherRecord,
type: 'hm',
fk_target_view_id: childView?.id,
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: child.id,
@ -101,6 +104,7 @@ export async function createHmAndBtColumn(
* @param {Model} child - The child model.
* @param {Model} parent - The parent model.
* @param {Column} childColumn - The child column.
* @param {View} childView - The child column.
* @param {RelationTypes} [type] - The type of relationship.
* @param {string} [alias] - The alias for the column.
* @param {string} [fkColName] - The foreign key column name.
@ -113,6 +117,7 @@ export async function createOOColumn(
child: Model,
parent: Model,
childColumn: Column,
childView?: View,
type?: RelationTypes,
alias?: string,
fkColName?: string,
@ -133,7 +138,8 @@ export async function createOOColumn(
// ref_db_alias
uidt: UITypes.LinkToAnotherRecord,
type: RelationTypes.ONE_TO_ONE,
// 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_related_model_id: parent.id,
@ -158,6 +164,7 @@ export async function createOOColumn(
alias || child.title,
);
const meta = {
...(columnMeta || {}),
plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title),
};
@ -167,6 +174,7 @@ export async function createOOColumn(
fk_model_id: parent.id,
uidt: UITypes.LinkToAnotherRecord,
type: 'oo',
fk_target_view_id: childView?.id,
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: child.id,

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -34,6 +34,7 @@ import * as nc_044_view_column_index from '~/meta/migrations/v2/nc_044_view_colu
import * as nc_045_extensions from '~/meta/migrations/v2/nc_045_extensions';
import * as nc_046_comment_mentions from '~/meta/migrations/v2/nc_046_comment_mentions';
import * as nc_047_comment_migration from '~/meta/migrations/v2/nc_047_comment_migration';
import * as nc_048_view_links from '~/meta/migrations/v2/nc_048_view_links';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -79,6 +80,7 @@ export default class XcMigrationSourcev2 {
'nc_045_extensions',
'nc_046_comment_mentions',
'nc_047_comment_migration',
'nc_048_view_links',
]);
}
@ -160,6 +162,8 @@ export default class XcMigrationSourcev2 {
return nc_046_comment_mentions;
case 'nc_047_comment_migration':
return nc_047_comment_migration;
case 'nc_048_view_links':
return nc_048_view_links;
}
}
}

26
packages/nocodb/src/meta/migrations/v2/nc_048_view_links.ts

@ -0,0 +1,26 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_RELATIONS, (table) => {
table.string('fk_target_view_id', 20).index();
});
await knex.schema.alterTable(MetaTable.FILTER_EXP, (table) => {
table.string('fk_link_col_id', 20).index();
table.string('fk_value_col_id', 20).index();
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_RELATIONS, (table) => {
table.dropColumn('fk_target_view_id');
});
await knex.schema.alterTable(MetaTable.FILTER_EXP, (table) => {
table.dropColumn('fk_link_col_id');
table.dropColumn('fk_value_col_id');
});
};
export { up, down };

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

@ -265,6 +265,7 @@ export default class Column<T = any> implements ColumnType {
fk_child_column_id: column.fk_child_column_id,
fk_parent_column_id: column.fk_parent_column_id,
fk_target_view_id: column.fk_target_view_id,
fk_mm_model_id: column.fk_mm_model_id,
fk_mm_child_column_id: column.fk_mm_child_column_id,
fk_mm_parent_column_id: column.fk_mm_parent_column_id,
@ -583,7 +584,7 @@ export default class Column<T = any> implements ColumnType {
return columns.map(c => new Column(c));*/
}
public static async get(
public static async get<T = any>(
{
source_id,
db_alias,
@ -594,7 +595,7 @@ export default class Column<T = any> implements ColumnType {
colId: string;
},
ncMeta = Noco.ncMeta,
): Promise<Column> {
): Promise<Column<T>> {
let colData =
colId &&
(await NocoCache.get(
@ -1251,6 +1252,27 @@ export default class Column<T = any> implements ColumnType {
);
}
static async updateTargetView(
{ colId, fk_target_view_id }: { colId: string; fk_target_view_id: string },
ncMeta = Noco.ncMeta,
) {
await ncMeta.metaUpdate(
null,
null,
MetaTable.COL_RELATIONS,
{
fk_target_view_id,
},
{
fk_column_id: colId,
},
);
await NocoCache.update(`${CacheScope.COL_RELATION}:${colId}`, {
fk_target_view_id,
});
}
static async bulkInsert(
param: {
columns: Column[];

51
packages/nocodb/src/models/Filter.ts

@ -24,6 +24,8 @@ export default class Filter implements FilterType {
fk_hook_id?: string;
fk_column_id?: string;
fk_parent_id?: string;
fk_link_col_id?: string;
fk_value_col_id?: string;
comparison_op?: (typeof COMPARISON_OPS)[number];
comparison_sub_op?: (typeof COMPARISON_SUB_OPS)[number];
@ -68,6 +70,8 @@ export default class Filter implements FilterType {
'id',
'fk_view_id',
'fk_hook_id',
'fk_link_col_id',
'fk_value_col_id',
'fk_column_id',
'comparison_op',
'comparison_sub_op',
@ -80,9 +84,12 @@ export default class Filter implements FilterType {
'order',
]);
const referencedModelColName = filter.fk_hook_id
? 'fk_hook_id'
: 'fk_view_id';
const referencedModelColName = [
'fk_view_id',
'fk_hook_id',
'fk_link_col_id',
].find((k) => filter[k]);
insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.FILTER_EXP, {
[referencedModelColName]: filter[referencedModelColName],
});
@ -93,6 +100,8 @@ export default class Filter implements FilterType {
model = await View.get(filter.fk_view_id, ncMeta);
} else if (filter.fk_hook_id) {
model = await Hook.get(filter.fk_hook_id, ncMeta);
} else if (filter.fk_link_col_id) {
model = await Column.get({ colId: filter.fk_link_col_id }, ncMeta);
} else if (filter.fk_column_id) {
model = await Column.get({ colId: filter.fk_column_id }, ncMeta);
} else {
@ -118,8 +127,7 @@ export default class Filter implements FilterType {
{
...f,
fk_parent_id: row.id,
[filter.fk_hook_id ? 'fk_hook_id' : 'fk_view_id']:
filter.fk_hook_id ? filter.fk_hook_id : filter.fk_view_id,
[referencedModelColName]: filter[referencedModelColName],
},
ncMeta,
),
@ -229,6 +237,7 @@ export default class Filter implements FilterType {
'fk_parent_id',
'is_group',
'logical_op',
'fk_value_col_id',
]);
if (typeof updateObj.value === 'string')
@ -359,20 +368,32 @@ export default class Filter implements FilterType {
{
viewId,
hookId,
linkColId,
}: {
viewId?: string;
hookId?: string;
linkColId?: string;
},
ncMeta = Noco.ncMeta,
): Promise<FilterType> {
const cachedList = await NocoCache.getList(CacheScope.FILTER_EXP, [
viewId || hookId,
viewId || hookId || linkColId,
]);
let { list: filters } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !filters.length) {
const condition: Record<string, string> = {};
if (viewId) {
condition.fk_view_id = viewId;
} else if (hookId) {
condition.fk_hook_id = hookId;
} else if (linkColId) {
condition.fk_link_col_id = linkColId;
}
filters = await ncMeta.metaList2(null, null, MetaTable.FILTER_EXP, {
condition: viewId ? { fk_view_id: viewId } : { fk_hook_id: hookId },
condition,
orderBy: {
order: 'asc',
},
@ -380,7 +401,7 @@ export default class Filter implements FilterType {
await NocoCache.setList(
CacheScope.FILTER_EXP,
[viewId || hookId],
[viewId || hookId || linkColId],
filters,
);
}
@ -412,13 +433,6 @@ export default class Filter implements FilterType {
if (idFilterMapping?.[id]) idFilterMapping[id].children = children;
}
// if (!result) {
// return (await Filter.insert({
// fk_view_id: viewId,
// is_group: true,
// logical_op: 'AND'
// })) as any;
// }
return result;
}
@ -620,4 +634,11 @@ export default class Filter implements FilterType {
);
return emptyOrNullFilterObjs.length > 0;
}
static async rootFilterListByLink(
{ columnId: _columnId }: { columnId: any },
_ncMeta = Noco.ncMeta,
) {
return [];
}
}

19
packages/nocodb/src/models/LinkToAnotherRecordColumn.ts

@ -1,10 +1,12 @@
import type { BoolType } from 'nocodb-sdk';
import type Filter from '~/models/Filter';
import Model from '~/models/Model';
import Column from '~/models/Column';
import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { View } from '~/models/index';
export default class LinkToAnotherRecordColumn {
id: string;
@ -16,6 +18,8 @@ export default class LinkToAnotherRecordColumn {
fk_mm_parent_column_id?: string;
fk_related_model_id?: string;
fk_target_view_id?: string | null;
dr?: string;
ur?: string;
fk_index_name?: string;
@ -32,6 +36,8 @@ export default class LinkToAnotherRecordColumn {
childColumn?: Column;
parentColumn?: Column;
filter?: Filter;
constructor(data: Partial<LinkToAnotherRecordColumn>) {
Object.assign(this, data);
}
@ -99,6 +105,7 @@ export default class LinkToAnotherRecordColumn {
'fk_mm_model_id',
'fk_mm_child_column_id',
'fk_mm_parent_column_id',
'fk_target_view_id',
'ur',
'dr',
'fk_index_name',
@ -109,6 +116,11 @@ export default class LinkToAnotherRecordColumn {
return this.read(data.fk_column_id, ncMeta);
}
async getChildView(ncMeta = Noco.ncMeta) {
if (!this.fk_target_view_id) return;
return await View.get(this.fk_target_view_id, ncMeta);
}
public static async read(columnId: string, ncMeta = Noco.ncMeta) {
let colData =
columnId &&
@ -127,4 +139,11 @@ export default class LinkToAnotherRecordColumn {
}
return colData ? new LinkToAnotherRecordColumn(colData) : null;
}
static async update(
_fk_column_id: string,
_param: { fk_target_view_id: string | null },
) {
// placeholder method
}
}

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

@ -32,6 +32,7 @@ import {
prepareForResponse,
stringifyMetaProp,
} from '~/utils/modelUtils';
import { LinkToAnotherRecordColumn } from '~/models';
const { v4: uuidv4 } = require('uuid');
@ -1214,6 +1215,25 @@ export default class View implements ViewType {
`${CacheScope.VIEW_ALIAS}:${view.fk_model_id}:${view.id}`,
]);
if (view?.id) {
// get all Links associated with the view and remove the view from the link
const links = await ncMeta.metaList2(
null,
null,
MetaTable.COL_RELATIONS,
{
condition: {
fk_target_view_id: view.id,
},
},
);
for (const link of links) {
await LinkToAnotherRecordColumn.update(link.fk_column_id, {
fk_target_view_id: null,
});
}
}
// on update, delete any optimised single query cache
await View.clearSingleQueryCache(view.fk_model_id, [view], ncMeta);

2
packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts

@ -277,7 +277,7 @@ export class DuplicateProcessor {
const exportedModel = (
await this.exportService.serializeModels({
modelIds: [sourceModel.id],
modelIds: [sourceModel.id, ...relatedModelIds],
excludeData,
excludeHooks: true,
excludeViews: true,

59
packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts

@ -5,11 +5,16 @@ import debug from 'debug';
import { Injectable } from '@nestjs/common';
import { elapsedTime, initTime } from '../../helpers';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { View } from '~/models';
import { Base, Hook, Model, Source } from '~/models';
import type { LinkToAnotherRecordColumn } from '~/models';
import { View } from '~/models';
import { Base, Filter, Hook, Model, Source } from '~/models';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { getViewAndModelByAliasOrId } from '~/helpers/dataHelpers';
import { clearPrefix, generateBaseIdMap } from '~/helpers/exportImportHelpers';
import {
clearPrefix,
generateBaseIdMap,
getEntityIdentifier,
} from '~/helpers/exportImportHelpers';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { NcError } from '~/helpers/catchError';
import { DatasService } from '~/services/datas.service';
@ -122,6 +127,18 @@ export class ExportService {
case 'fk_barcode_value_column_id':
column.colOptions[k] = idMap.get(v as string);
break;
case 'fk_target_view_id':
if (v) {
const view = await View.get(v as string);
idMap.set(
view.id,
`${source.base_id}::${source.id}::${getEntityIdentifier(
view.fk_model_id,
)}::${view.id}`,
);
column.colOptions[k] = idMap.get(v as string);
}
break;
case 'options':
for (const o of column.colOptions['options']) {
delete o.id;
@ -168,6 +185,40 @@ export class ExportService {
}
}
}
// Link column filters
if (isLinksOrLTAR(column)) {
const colOptions = column.colOptions as LinkToAnotherRecordColumn;
colOptions.filter = (await Filter.getFilterObject({
linkColId: column.id,
})) as any;
if (colOptions.filter?.children?.length) {
const export_filters = [];
for (const fl of colOptions.filter.children) {
const tempFl = {
id: `${idMap.get(column.id)}::${fl.id}`,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: `${idMap.get(column.id)}::${fl.fk_parent_id}`,
fk_link_col_id: idMap.get(column.id),
fk_value_col_id: fl.fk_value_col_id
? idMap.get(fl.fk_value_col_id)
: null,
is_group: fl.is_group,
logical_op: fl.logical_op,
comparison_op: fl.comparison_op,
comparison_sub_op: fl.comparison_sub_op,
value: fl.value,
};
if (tempFl.is_group) {
delete tempFl.comparison_op;
delete tempFl.comparison_sub_op;
delete tempFl.value;
}
export_filters.push(tempFl);
}
colOptions.filter.children = export_filters;
}
}
}
for (const view of model.views) {
@ -181,7 +232,7 @@ export class ExportService {
const tempFl = {
id: `${idMap.get(view.id)}::${fl.id}`,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: fl.fk_parent_id,
fk_parent_id: `${idMap.get(view.id)}::${fl.fk_parent_id}`,
is_group: fl.is_group,
logical_op: fl.logical_op,
comparison_op: fl.comparison_op,

61
packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts

@ -13,6 +13,7 @@ import type { UserType, ViewCreateReqType } from 'nocodb-sdk';
import type { Readable } from 'stream';
import type {
CalendarView,
LinksColumn,
LinkToAnotherRecordColumn,
User,
View,
@ -119,6 +120,7 @@ export class ImportService {
);
await model.getColumns();
await model.getViews();
const primaryKey = model.primaryKey;
if (primaryKey) {
@ -134,6 +136,13 @@ export class ImportService {
col.id,
);
}
for (const view of model.views) {
externalIdMap.set(
`${model.base_id}::${model.source_id}::${model.id}::${view.id}`,
view.id,
);
}
}
}
@ -229,6 +238,8 @@ export class ImportService {
const referencedColumnSet = [];
const ltarFilterCreateCbks: (() => Promise<any>)[] = [];
// create LTAR columns
for (const data of param.data) {
const modelData = data.model;
@ -245,7 +256,7 @@ export class ImportService {
for (const col of linkedColumnSet) {
if (col.colOptions) {
const colOptions = col.colOptions;
const colOptions = col.colOptions as LinksColumn;
if (idMap.has(colOptions.fk_related_model_id)) {
if (colOptions.type === 'mm') {
if (!linkMap.has(colOptions.fk_mm_model_id)) {
@ -267,6 +278,9 @@ export class ImportService {
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
},
}),
req: param.req,
@ -367,6 +381,9 @@ export class ImportService {
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
},
}),
req: param.req,
@ -505,6 +522,9 @@ export class ImportService {
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
},
}) as any,
req: param.req,
@ -628,6 +648,9 @@ export class ImportService {
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
},
}) as any,
req: param.req,
@ -798,6 +821,9 @@ export class ImportService {
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
},
}) as any,
req: param.req,
@ -943,6 +969,32 @@ export class ImportService {
}
}
}
// filter creation for LTAR columns
if (colOptions.filter?.children?.length) {
ltarFilterCreateCbks.push(async () => {
// create filters
const filters = colOptions.filter?.children;
for (const fl of filters) {
const fg = await this.filtersService.linkFilterCreate({
columnId: getIdOrExternalId(col.id),
filter: withoutId({
...fl,
fk_value_col_id: getIdOrExternalId(fl.fk_value_col_id),
fk_link_col_id: getIdOrExternalId(fl.fk_link_col_id),
fk_column_id: getIdOrExternalId(fl.fk_column_id),
fk_parent_id: getIdOrExternalId(fl.fk_parent_id),
}),
user: param.user,
req: param.req,
});
if (fg) {
idMap.set(fl.id, fg.id);
}
}
});
}
}
}
@ -1354,6 +1406,13 @@ export class ImportService {
elapsedTime(hrTime, 'create hooks', 'importModels');
// create link filter, triggers at the end since it requires all columns to be created
for (const ltarFilterCreateCbk of ltarFilterCreateCbks) {
await ltarFilterCreateCbk();
}
elapsedTime(hrTime, 'create link filters', 'importModels');
return idMap;
}

3
packages/nocodb/src/schema/swagger-v2.json

@ -15419,6 +15419,9 @@
"fk_mm_model_id": {
"type": "string"
},
"fk_target_view_id": {
"type": "string"
},
"fk_mm_parent_column_id": {
"type": "string"
},

29
packages/nocodb/src/schema/swagger.json

@ -19103,6 +19103,14 @@
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to View"
},
"fk_value_col_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to dynamic value Column"
},
"fk_link_col_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Link Column"
},
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
@ -21619,6 +21627,10 @@
}
],
"properties": {
"childViewId": {
"$ref": "#/components/schemas/IdOrNull",
"description": "Foreign Key to child view"
},
"childId": {
"$ref": "#/components/schemas/Id",
"description": "Foreign Key to chhild column"
@ -21707,6 +21719,9 @@
"fk_index_name": {
"type": "string"
},
"fk_relation_view_id": {
"type": "string"
},
"fk_mm_child_column_id": {
"type": "string"
},
@ -23710,6 +23725,20 @@
],
"title": "StringOrNullOrBooleanOrNumber Model"
},
"IdOrNull": {
"description": "Model for IdOrNull",
"examples": [
"string"
],
"oneOf": [{
"$ref": "#/components/schemas/Id"
},
{
"type": "null"
}
],
"title": "IdOrNull Model"
},
"Table": {
"description": "Model for Table",
"examples": [

1
packages/nocodb/src/services/app-hooks/interfaces.ts

@ -126,6 +126,7 @@ export interface FilterEvent extends NcBaseEvent {
ip?: string;
hook?: HookType;
view?: ViewType;
column?: ColumnType;
}
export interface ColumnEvent extends NcBaseEvent {

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

@ -13,19 +13,28 @@ import {
} from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection';
import hash from 'object-hash';
import type SqlMgrv2 from '~/db/sql-mgr/v2/SqlMgrv2';
import type { Base, LinkToAnotherRecordColumn } from '~/models';
import type {
ColumnReqType,
LinkToAnotherColumnReqType,
LinkToAnotherRecordType,
UserType,
} from 'nocodb-sdk';
import type SqlMgrv2 from '~/db/sql-mgr/v2/SqlMgrv2';
import type { Base, LinkToAnotherRecordColumn } from '~/models';
import type CustomKnex from '~/db/CustomKnex';
import type SqlClient from '~/db/sql-client/lib/SqlClient';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { NcRequest } from '~/interface/config';
import { CalendarRange } from '~/models';
import {
BaseUser,
CalendarRange,
Column,
FormulaColumn,
KanbanView,
Model,
Source,
View,
} from '~/models';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import ProjectMgrv2 from '~/db/sql-mgr/v2/ProjectMgrv2';
@ -48,15 +57,6 @@ import {
} from '~/helpers/getUniqueName';
import mapDefaultDisplayValue from '~/helpers/mapDefaultDisplayValue';
import validateParams from '~/helpers/validateParams';
import {
BaseUser,
Column,
FormulaColumn,
KanbanView,
Model,
Source,
View,
} from '~/models';
import Noco from '~/Noco';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { MetaTable } from '~/utils/globals';
@ -234,6 +234,7 @@ export class ColumnsService {
formula?: string;
formula_raw?: string;
parsed_tree?: any;
colOptions?: any;
} & Partial<Pick<ColumnReqType, 'column_order'>>;
if (
@ -311,11 +312,9 @@ export class ColumnsService {
}
if (
'meta' in colBody &&
[
UITypes.Links,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
].includes(column.uidt)
[UITypes.CreatedTime, UITypes.LastModifiedTime].includes(
column.uidt,
)
) {
await Column.updateMeta({
colId: param.columnId,
@ -323,6 +322,42 @@ export class ColumnsService {
});
}
if (isLinksOrLTAR(column)) {
if ('meta' in colBody) {
await Column.updateMeta({
colId: param.columnId,
meta: {
...column.meta,
...colBody.meta,
},
});
}
// check alias value present in colBody
if (
(colBody as any).childViewId === null ||
(colBody as any).childViewId
) {
colBody.colOptions = colBody.colOptions || {};
(
colBody as Column<LinkToAnotherRecordColumn>
).colOptions.fk_target_view_id = (colBody as any).childViewId;
}
if (
(colBody as Column<LinkToAnotherRecordColumn>).colOptions
.fk_target_view_id ||
(colBody as Column<LinkToAnotherRecordColumn>).colOptions
.fk_target_view_id === null
) {
await Column.updateTargetView({
colId: param.columnId,
fk_target_view_id: (
colBody as Column<LinkToAnotherRecordColumn>
).colOptions.fk_target_view_id,
});
}
}
// handle reorder column for Links and LinkToAnotherRecord
if (
[UITypes.Links, UITypes.LinkToAnotherRecord].includes(
@ -2601,6 +2636,13 @@ export class ColumnsService {
id: (param.column as LinkToAnotherColumnReqType).childId,
});
let childColumn: Column;
const childView: View | null = (param.column as LinkToAnotherColumnReqType)
?.childViewId
? await View.getByTitleOrId({
fk_model_id: child.id,
titleOrId: (param.column as LinkToAnotherColumnReqType).childViewId,
})
: null;
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr({
@ -2702,10 +2744,12 @@ export class ColumnsService {
});
}
}
await createHmAndBtColumn(
child,
parent,
childColumn,
childView,
(param.column as LinkToAnotherColumnReqType).type as RelationTypes,
(param.column as LinkToAnotherColumnReqType).title,
foreignKeyName,
@ -2803,6 +2847,7 @@ export class ColumnsService {
child,
parent,
childColumn,
childView,
(param.column as LinkToAnotherColumnReqType).type as RelationTypes,
(param.column as LinkToAnotherColumnReqType).title,
foreignKeyName,
@ -2912,6 +2957,7 @@ export class ColumnsService {
childCol,
null,
null,
null,
foreignKeyName1,
(param.column as LinkToAnotherColumnReqType).virtual,
true,
@ -2925,6 +2971,7 @@ export class ColumnsService {
parentCol,
null,
null,
null,
foreignKeyName2,
(param.column as LinkToAnotherColumnReqType).virtual,
true,
@ -2947,7 +2994,8 @@ export class ColumnsService {
fk_child_column_id: childPK.id,
fk_parent_column_id: parentPK.id,
// Adding view ID here applies the view filter in reverse also
fk_target_view_id: null,
fk_mm_model_id: assocModel.id,
fk_mm_child_column_id: childCol.id,
fk_mm_parent_column_id: parentCol.id,
@ -2973,6 +3021,7 @@ export class ColumnsService {
fk_child_column_id: parentPK.id,
fk_parent_column_id: childPK.id,
fk_target_view_id: childView?.id,
fk_mm_model_id: assocModel.id,
fk_mm_child_column_id: parentCol.id,
@ -2980,6 +3029,7 @@ export class ColumnsService {
fk_related_model_id: child.id,
virtual: (param.column as LinkToAnotherColumnReqType).virtual,
meta: {
...(param.column['meta'] || {}),
plural: param.column['meta']?.plural || pluralize(child.title),
singular: param.column['meta']?.singular || singularize(child.title),
},
@ -3170,13 +3220,15 @@ export class ColumnsService {
if (op.op === 'add') {
try {
await this.columnAdd({
const tableMeta = await this.columnAdd({
tableId,
column: column as ColumnReqType,
req,
user: req.user,
reuse,
});
await this.postColumnAdd(column as ColumnReqType, tableMeta);
} catch (e) {
failedOps.push({
...op,
@ -3192,6 +3244,8 @@ export class ColumnsService {
user: req.user,
reuse,
});
await this.postColumnUpdate(column as ColumnReqType);
} catch (e) {
failedOps.push({
...op,
@ -3218,4 +3272,12 @@ export class ColumnsService {
failedOps,
};
}
protected async postColumnAdd(_columnBody: ColumnReqType, _tableMeta: Model) {
// placeholder for post column add hook
}
protected async postColumnUpdate(_columnBody: ColumnReqType) {
// placeholder for post column update hook
}
}

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

@ -5,7 +5,9 @@ import papaparse from 'papaparse';
import { nocoExecute } from 'nc-help';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { PathParams } from '~/helpers/dataHelpers';
import { getDbRows, getViewAndModelByAliasOrId } from '~/helpers/dataHelpers';
import type { Filter } from '~/models';
import { getDbRows } from '~/helpers/dataHelpers';
import { getViewAndModelByAliasOrId } from '~/helpers/dataHelpers';
import { Base, Column, Model, Source, View } from '~/models';
import { NcBaseError, NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
@ -154,8 +156,14 @@ export class DatasService {
ignoreViewFilterAndSort?: boolean;
ignorePagination?: boolean;
limitOverride?: number;
customConditions?: Filter[];
}) {
const { model, view, query = {}, ignoreViewFilterAndSort = false } = param;
const {
model,
view: view,
query = {},
ignoreViewFilterAndSort = false,
} = param;
const source = await Source.get(model.source_id);
@ -170,7 +178,7 @@ export class DatasService {
const { ast, dependencyFields } = await getAst({
model,
query,
view,
view: view,
throwErrorIfInvalidParams: param.throwErrorIfInvalidParams,
});
@ -182,6 +190,8 @@ export class DatasService {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
listArgs.customConditions = param.customConditions;
const [count, data] = await Promise.all([
baseModel.count(listArgs, false, param.throwErrorIfInvalidParams),
(async () => {

10
packages/nocodb/src/services/filters.service.ts

@ -122,4 +122,14 @@ export class FiltersService {
const filter = await Filter.rootFilterList({ viewId: param.viewId });
return filter;
}
async linkFilterCreate(_param: {
filter: any;
columnId: string;
user: UserType;
req: NcRequest;
}): Promise<any> {
// placeholder method
return null;
}
}

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

@ -15,6 +15,8 @@ import { getColumnByIdOrName } from '~/helpers/dataHelpers';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { mimeIcons } from '~/utils/mimeTypes';
import { utf8ify } from '~/helpers/stringHelpers';
import { replaceDynamicFieldWithValue } from '~/db/BaseModelSqlv2';
import { Filter } from '~/models';
// todo: move to utils
export function sanitizeUrlPath(paths) {
@ -427,6 +429,7 @@ export class PublicDatasService {
sharedViewUuid: string;
password?: string;
columnId: string;
rowData: Record<string, any>;
}) {
const view = await View.getByUUID(param.sharedViewUuid);
@ -441,6 +444,8 @@ export class PublicDatasService {
}
const column = await Column.get({ colId: param.columnId });
const currentModel = await view.getModel();
await currentModel.getColumns();
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
const model = await colOptions.getRelatedTable();
@ -449,7 +454,7 @@ export class PublicDatasService {
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
viewId: colOptions.fk_target_view_id,
dbDriver: await NcConnectionMgrv2.get(source),
});
@ -464,13 +469,30 @@ export class PublicDatasService {
let count = 0;
try {
const customConditions = await replaceDynamicFieldWithValue(
param.rowData || {},
null,
currentModel.columns,
baseModel.readByPk,
)(
(column.meta?.enableConditions
? await Filter.rootFilterListByLink({ columnId: param.columnId })
: []) || [],
);
data = data = await nocoExecute(
ast,
await baseModel.list(dependencyFields),
await baseModel.list({
...dependencyFields,
customConditions,
}),
{},
dependencyFields,
);
count = await baseModel.count(dependencyFields as any);
count = await baseModel.count({
...dependencyFields,
customConditions,
} as any);
} catch (e) {
console.log(e);
NcError.internalServerError('Please check server log for more details');

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

@ -209,7 +209,7 @@ export class FieldsPage extends BasePage {
case 'Links':
await this.addOrEditColumn.locator('.nc-ltar-relation-type').getByTestId(relationType).click();
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable);
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).first().fill(childTable);
await this.rootPage
.locator(`.nc-dropdown-ltar-child-table >> .ant-select-item`, {
hasText: childTable,

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

@ -67,7 +67,17 @@ export class ExpandedFormPage extends BasePage {
await this.dashboard.waitForLoaderToDisappear();
}
async fillField({ columnTitle, value, type = 'text' }: { columnTitle: string; value: string; type?: string }) {
async fillField({
columnTitle,
value,
type = 'text',
ltarCount,
}: {
columnTitle: string;
value: string;
type?: string;
ltarCount?: number;
}) {
const field = this.get().getByTestId(`nc-expand-col-${columnTitle}`);
switch (type) {
case 'text':
@ -84,12 +94,18 @@ export class ExpandedFormPage extends BasePage {
case 'belongsTo':
await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click();
if (ltarCount !== undefined && ltarCount !== null) {
await this.dashboard.linkRecord.verifyCount(ltarCount);
}
await this.dashboard.linkRecord.select(value, false);
break;
case 'hasMany':
case 'manyToMany':
await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click();
if (ltarCount !== undefined && ltarCount !== null) {
await this.dashboard.linkRecord.verifyCount(ltarCount);
}
await this.dashboard.linkRecord.select(value);
break;
case 'dateTime':

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

@ -46,6 +46,11 @@ export class LinkRecord extends BasePage {
}
}
async verifyCount(count: string) {
await this.rootPage.waitForTimeout(100);
await expect(this.get().locator('button.nc-list-item-link-unlink-btn')).toHaveCount(parseInt(count));
}
async close() {
await this.get().getByTestId('nc-link-count-info').click();
await this.rootPage.keyboard.press('Escape');

13
tests/playwright/pages/Dashboard/Grid/Column/LTARFilterOption.ts

@ -0,0 +1,13 @@
import { ToolbarPage } from './index';
import { ToolbarFilterPage } from '../../common/Toolbar/Filter';
import BasePage from '../../../Base';
export class LTARFilterPage extends ToolbarFilterPage {
constructor(rootPage) {
super({ rootPage });
}
get() {
return this.rootPage.getByTestId(`nc-filter`);
}
}

61
tests/playwright/pages/Dashboard/Grid/Column/LTAROptionColumn.ts

@ -0,0 +1,61 @@
import { ColumnPageObject } from '.';
import BasePage from '../../../Base';
import { expect } from '@playwright/test';
import { LTARFilterPage } from './LTARFilterOption';
export class LTAROptionColumnPageObject extends BasePage {
readonly column: ColumnPageObject;
readonly filter: LTARFilterPage;
constructor(column: ColumnPageObject) {
super(column.rootPage);
this.column = column;
this.filter = new LTARFilterPage(this.rootPage);
}
get() {
return this.column.get();
}
// add multiple options at once after column creation is completed
//
async addFilters(filters: Parameters<typeof LTARFilterPage.prototype.add>[0][]) {
await this.get().getByTestId('nc-limit-record-filters').click();
for (let i = 0; i < filters.length; i++) {
await this.filter.add(filters[i]);
}
}
async editFilter({
columnTitle,
...filterUpdateParams
}: { columnTitle: string } & Parameters<typeof LTARFilterPage.prototype.edit>[0]) {
await this.column.openEdit({ title: columnTitle });
await this.filter.edit(filterUpdateParams);
await this.column.save({ isUpdated: true });
}
async deleteFilter({
columnTitle,
...filterDeleteParams
}: { index: number } & Parameters<typeof LTARFilterPage.prototype.delete>[0]) {
await this.column.openEdit({ title: columnTitle });
await this.filter.delete(filterDeleteParams);
await this.column.save({ isUpdated: true });
}
async selectView({ ltarView }: { ltarView: string }) {
await this.get().getByTestId('nc-limit-record-view').click();
await this.rootPage.locator(`.nc-ltar-child-view >> input[type="search"]`).fill(ltarView);
await this.rootPage
.locator(`.nc-dropdown-ltar-child-view >> .ant-select-item`, {
hasText: ltarView,
})
.nth(0)
.click();
}
}

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

@ -5,12 +5,14 @@ import { SelectOptionColumnPageObject } from './SelectOptionColumn';
import { AttachmentColumnPageObject } from './Attachment';
import { getTextExcludeIconText } from '../../../../tests/utils/general';
import { UserOptionColumnPageObject } from './UserOptionColumn';
import { LTAROptionColumnPageObject } from './LTAROptionColumn';
export class ColumnPageObject extends BasePage {
readonly grid: GridPage;
readonly selectOption: SelectOptionColumnPageObject;
readonly attachmentColumnPageObject: AttachmentColumnPageObject;
readonly userOption: UserOptionColumnPageObject;
readonly ltarOption: LTAROptionColumnPageObject;
constructor(grid: GridPage) {
super(grid.rootPage);
@ -18,6 +20,7 @@ export class ColumnPageObject extends BasePage {
this.selectOption = new SelectOptionColumnPageObject(this);
this.attachmentColumnPageObject = new AttachmentColumnPageObject(this);
this.userOption = new UserOptionColumnPageObject(this);
this.ltarOption = new LTAROptionColumnPageObject(this);
}
get() {
@ -69,6 +72,8 @@ export class ColumnPageObject extends BasePage {
insertAfterColumnTitle,
insertBeforeColumnTitle,
isDisplayValue = false,
ltarFilters,
ltarView,
}: {
title: string;
type?: string;
@ -86,6 +91,8 @@ export class ColumnPageObject extends BasePage {
insertBeforeColumnTitle?: string;
insertAfterColumnTitle?: string;
isDisplayValue?: boolean;
ltarFilters?: any[];
ltarView?: string;
}) {
if (insertBeforeColumnTitle) {
await this.grid.get().locator(`th[data-title="${insertBeforeColumnTitle}"] .nc-ui-dt-dropdown`).click();
@ -187,6 +194,9 @@ export class ColumnPageObject extends BasePage {
.click();
break;
case 'Links':
// kludge, fix me
await this.rootPage.waitForTimeout(2000);
await this.get().locator('.nc-ltar-relation-type').getByTestId(relationType).click();
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable);
@ -196,6 +206,15 @@ export class ColumnPageObject extends BasePage {
})
.nth(0)
.click();
if (ltarView) {
await this.ltarOption.selectView({ ltarView: ltarView });
}
if (ltarFilters) {
await this.ltarOption.addFilters(ltarFilters);
}
break;
case 'User':
break;

4
tests/playwright/pages/Dashboard/Grid/Group.ts

@ -28,11 +28,15 @@ export class GroupPageObject extends BasePage {
}
async openGroup({ indexMap }: { indexMap: number[] }) {
await this.rootPage.waitForTimeout(500);
let root = this.rootPage.locator('.nc-group');
for (const n of indexMap) {
await root.nth(n).click();
root = root.nth(n).locator('.nc-group');
}
await this.rootPage.waitForTimeout(500);
}
async verifyGroupHeader({ indexMap, count, title }: { indexMap: number[]; count: number; title: string }) {

22
tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts

@ -362,16 +362,20 @@ export class ToolbarFilterPage extends BasePage {
default:
fillFilter = async () => {
await this.rootPage.locator('.nc-filter-value-select > input').last().clear({ force: true });
return this.rootPage.locator('.nc-filter-value-select > input').last().fill(value);
await this.get().locator('.nc-filter-value-select > input').last().clear({ force: true });
return this.get().locator('.nc-filter-value-select > input').last().fill(value);
};
await this.waitForResponse({
uiAction: fillFilter,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading();
if (!skipWaitingResponse) {
await this.waitForResponse({
uiAction: fillFilter,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading();
} else {
await fillFilter();
}
break;
}
}

1
tests/playwright/tests/db/general/toolbarOperations.spec.ts

@ -162,6 +162,7 @@ test.describe('Toolbar operations (GRID)', () => {
// GroupBy Category Descending Order
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.rootPage.waitForTimeout(500);
await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false });
// Hide Field and Verify

9
tests/playwright/tests/db/views/viewGridShare.spec.ts

@ -58,6 +58,9 @@ test.describe('Shared view', () => {
});
await dashboard.grid.toolbar.clickFilter();
// kludge: wait for 2 seconds to avoid flaky test
await page.waitForTimeout(2000);
await page.goto(sharedLink);
await page.reload();
@ -84,6 +87,9 @@ test.describe('Shared view', () => {
await dashboard.grid.toolbar.filter.reset();
// kludge: wait for 2 seconds to avoid flaky test
await page.waitForTimeout(2000);
await page.goto(sharedLink);
await page.reload();
@ -104,6 +110,9 @@ test.describe('Shared view', () => {
await dashboard.grid.toolbar.groupBy.remove({ index: 0 });
// kludge: wait for 2 seconds to avoid flaky test
await page.waitForTimeout(2000);
await page.goto(sharedLink);
await page.reload();
// kludge: wait for 3 seconds to avoid flaky test

Loading…
Cancel
Save