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. 46
      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. 286
      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. 85
      packages/nc-gui/composables/useViewFilters.ts
  21. 3
      packages/nc-gui/lang/en.json
  22. 65
      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. 637
      packages/nocodb/src/db/BaseModelSqlv2.ts
  31. 40
      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. 8
      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, ...formState.value,
...others, ...others,
} }
if (colOptions) { if (colOptions) {
const meta = formState.value.meta || {}
onUidtOrIdTypeChange() onUidtOrIdTypeChange()
formState.value = { formState.value = {
...formState.value, ...formState.value,
colOptions: { colOptions: {
...colOptions, ...colOptions,
}, },
meta,
} }
} }
} else {
formState.value.filters = undefined
} }
// for cases like formula // for cases like formula
@ -269,21 +274,16 @@ const submitBtnLabel = computed(() => {
loadingLabel: `${isEdit.value && !props.columnLabel ? t('general.updating') : t('general.saving')} ${columnLabel.value}`, 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> </script>
<template> <template>
<div <div
class="overflow-auto" class="overflow-auto max-h-[max(80vh,500px)]"
:class="{ :class="{
'bg-white': !props.fromTableExplorer, 'bg-white': !props.fromTableExplorer,
'w-[384px]': !props.embedMode, '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-116 overflow-visible': formState.uidt === UITypes.Formula && !props.embedMode,
'!w-[500px]': formState.uidt === UITypes.Attachment && !props.embedMode && !appInfo.ee, '!w-[500px]': formState.uidt === UITypes.Attachment && !props.embedMode && !appInfo.ee,
'!w-[600px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links, '!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" class="nc-column-type-input !rounded-lg"
:disabled="isKanban || readOnly || (isEdit && !!onlyNameUpdateOnEditColumns.find((col) => col === formState.uidt))" :disabled="isKanban || readOnly || (isEdit && !!onlyNameUpdateOnEditColumns.find((col) => col === formState.uidt))"
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200" dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200"
:filter-option="filterOption"
@dropdown-visible-change="onDropdownChange" @dropdown-visible-change="onDropdownChange"
@change="onUidtOrIdTypeChange" @change="onUidtOrIdTypeChange"
@dblclick="showDeprecated = !showDeprecated" @dblclick="showDeprecated = !showDeprecated"
@ -539,6 +538,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
&:not(:hover):not(:focus) { &:not(:hover):not(:focus) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08); box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
} }
&:hover:not(:focus) { &:hover:not(:focus) {
@apply border-gray-300; @apply border-gray-300;
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24); 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) { &:not(:hover):not(:focus-within):not(.shadow-selected) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08); box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
} }
&:hover:not(:focus-within):not(.shadow-selected) { &:hover:not(:focus-within):not(.shadow-selected) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24); 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; box-shadow: none;
} }
} }
&:not(.ant-radio-wrapper-disabled):not(:hover):not(:focus-within):not(.shadow-selected) { &: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); box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
} }
&:hover:not(:focus-within):not(.ant-radio-wrapper-disabled) { &:hover:not(:focus-within):not(.ant-radio-wrapper-disabled) {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24); 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 { &:not(.ant-select-disabled):hover.ant-select-disabled .ant-select-selector {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08); box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
} }
&:hover:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector { &:hover:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector {
@apply border-gray-300; @apply border-gray-300;
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.24); 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 { .ant-alert-message {
@apply text-sm text-gray-800 font-weight-600; @apply text-sm text-gray-800 font-weight-600;
} }
.ant-alert-description { .ant-alert-description {
@apply text-small text-gray-500 font-weight-500; @apply text-small text-gray-500 font-weight-500;
} }
@ -651,6 +656,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
:deep(textarea::placeholder) { :deep(textarea::placeholder) {
@apply text-gray-500; @apply text-gray-500;
} }
.nc-column-options-wrapper { .nc-column-options-wrapper {
&:empty { &:empty {
@apply hidden; @apply hidden;

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

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

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <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<{ const props = defineProps<{
value: any value: any
@ -14,11 +14,17 @@ const isEdit = toRef(props, 'isEdit')
const meta = inject(MetaInj, ref()) 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 baseStore = useBase()
const { tables } = storeToRefs(baseStore) const { tables } = storeToRefs(baseStore)
const viewsStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewsStore)
const { t } = useI18n() const { t } = useI18n()
if (!isEdit.value) { if (!isEdit.value) {
@ -31,7 +37,6 @@ const onUpdateDeleteOptions = sqlUi === MssqlUi ? ['NO ACTION'] : ['NO ACTION',
if (!isEdit.value) { if (!isEdit.value) {
if (!vModel.value.parentId) vModel.value.parentId = meta.value?.id 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.childColumn) vModel.value.childColumn = `${meta.value?.table_name}_id`
if (!vModel.value.childTable) vModel.value.childTable = meta.value?.table_name if (!vModel.value.childTable) vModel.value.childTable = meta.value?.table_name
if (!vModel.value.parentTable) vModel.value.parentTable = vModel.value.rtn || '' 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.virtual) vModel.value.virtual = sqlUi === SqliteUi // appInfo.isCloud || sqlUi === SqliteUi
if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name 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 advancedOptions = ref(false)
const refTables = computed(() => { 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) 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 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 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({ const referenceTableChildId = computed({
get: () => (isEdit.value ? vModel.value?.colOptions?.fk_related_model_id : vModel.value?.childId) ?? null, get: () => (isEdit.value ? vModel.value?.colOptions?.fk_related_model_id : vModel.value?.childId) ?? null,
set: (value) => { set: (value) => {
@ -128,6 +200,74 @@ const linkType = computed({
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </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> </div>
<template v-if="(!isXcdbBase && !isEdit) || isLinks"> <template v-if="(!isXcdbBase && !isEdit) || isLinks">
<div> <div>
@ -218,19 +358,24 @@ const linkType = computed({
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.nc-filter-grid) {
@apply !pr-0;
}
:deep(.nc-ltar-relation-type .ant-radio-group) { :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); @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 { .ant-radio-wrapper {
@apply transition-all flex-row-reverse justify-between items-center py-1 pl-1 pr-3; @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; @apply border-brand-500;
} }
span:not(.ant-radio):not(.nc-ltar-icon) { span:not(.ant-radio):not(.nc-ltar-icon) {
@apply flex-1 pl-0 flex items-center gap-2; @apply flex-1 pl-0 flex items-center gap-2;
} }
.ant-radio { .ant-radio {
@apply top-0; @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 vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, isEdit } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
// const { base } = storeToRefs(useBase()) // const { base } = storeToRefs(useBase())
@ -186,7 +186,7 @@ const syncOptions = () => {
return renderA - renderB return renderA - renderB
}) })
.map((op) => { .map((op) => {
const { status: _s, ...rest } = op const { index: _i, status: _s, ...rest } = op
return rest return rest
}) })
} }
@ -221,13 +221,7 @@ const removeRenderedOption = (index: number) => {
} }
const optionChanged = (changedElement: Option) => { const optionChanged = (changedElement: Option) => {
const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => { const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => o.id === changedElement.id)
if (o.id !== undefined && changedElement.id !== undefined) {
return o.id === changedElement.id
} else {
return o.index === changedElement.index
}
})
if (changedDefaultOptionIndex !== -1) { if (changedDefaultOptionIndex !== -1) {
if (vModel.value.uidt === UITypes.SingleSelect) { 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 searchQuery = ref('')
const filteredOptions = computed( const filteredOptions = computed(
() => () => options.value?.filter((c) => c.name.toLowerCase().includes(searchQuery.value.toLowerCase())) ?? [],
options.value?.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(UITypesName[c.name] && UITypesName[c.name].toLowerCase().includes(searchQuery.value.toLowerCase())),
) ?? [],
) )
const inputRef = ref() const inputRef = ref()

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

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

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

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { diff } from 'deep-object-diff' import { diff } from 'deep-object-diff'
import { message } from 'ant-design-vue' 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 Draggable from 'vuedraggable'
import { onKeyDown, useMagicKeys } from '@vueuse/core' import { onKeyDown, useMagicKeys } from '@vueuse/core'
import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
interface TableExplorerColumn extends ColumnType { interface TableExplorerColumn extends ColumnType {
id?: string id?: string
@ -271,8 +271,21 @@ const duplicateField = async (field: TableExplorerColumn) => {
duplicateFieldHook.value = fieldPayload as 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 // 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)) const col = fields.value.find((col) => compareCols(col, state))
if (!col) return if (!col) return
@ -317,6 +330,17 @@ const onFieldUpdate = (state: TableExplorerColumn) => {
if (field || (field && moveField)) { if (field || (field && moveField)) {
field.column = state 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 { } else {
ops.value.push({ ops.value.push({
op: 'update', op: 'update',
@ -415,21 +439,27 @@ const onMove = (_event: { moved: { newIndex: number; oldIndex: number } }) => {
} }
if (op) { if (op) {
onFieldUpdate({ onFieldUpdate(
{
...op.column, ...op.column,
column_order: { column_order: {
order, order,
view_id: view.value?.id as string, view_id: view.value?.id as string,
}, },
}) },
true,
)
} else { } else {
onFieldUpdate({ onFieldUpdate(
{
...field, ...field,
column_order: { column_order: {
order, order,
view_id: view.value?.id as string, view_id: view.value?.id as string,
}, },
}) },
true,
)
} }
} }
@ -961,7 +991,7 @@ watch(
{{ $t('labels.multiField.deletedField') }} {{ $t('labels.multiField.deletedField') }}
</NcBadge> </NcBadge>
<NcBadge <NcBadge
v-else-if="fieldStatus(field) === 'add'" v-else-if="isColumnValid(field) && fieldStatus(field) === 'add'"
color="orange" color="orange"
:border="false" :border="false"
class="bg-green-50 text-green-700" class="bg-green-50 text-green-700"

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

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

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

@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Editor } from '@tiptap/vue-3' import type { Editor } from '@tiptap/vue-3'
import MdiFormatStrikeThrough from '~icons/mdi/format-strikethrough'
interface Props { interface Props {
editor: Editor | undefined 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"> <NcMenuItem v-if="!column?.pv" @click="hideOrShowField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item"> <div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
<GeneralLoader v-if="isLoading === 'hideOrShow'" size="regular" /> <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 --> <!-- Hide Field -->
{{ isHiddenCol ? $t('general.showField') : $t('general.hideField') }} {{ isHiddenCol ? $t('general.showField') : $t('general.hideField') }}
</div> </div>

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <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' import { PlanLimitTypes, UITypes } from 'nocodb-sdk'
interface Props { interface Props {
@ -8,10 +8,13 @@ interface Props {
autoSave: boolean autoSave: boolean
hookId?: string hookId?: string
showLoading?: boolean showLoading?: boolean
modelValue?: undefined | Filter[] modelValue?: FilterType[] | null
webHook?: boolean webHook?: boolean
link?: boolean
draftFilter?: Partial<FilterType> draftFilter?: Partial<FilterType>
isOpen?: boolean isOpen?: boolean
rootMeta?: any
linkColId?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -21,17 +24,21 @@ const props = withDefaults(defineProps<Props>(), {
parentId: undefined, parentId: undefined,
hookId: undefined, hookId: undefined,
webHook: false, webHook: false,
link: false,
linkColId: undefined,
}) })
const emit = defineEmits(['update:filtersLength', 'update:draftFilter', 'update:modelValue']) const emit = defineEmits(['update:filtersLength', 'update:draftFilter', 'update:modelValue'])
const initialModelValue = props.modelValue
const excludedFilterColUidt = [UITypes.QrCode, UITypes.Barcode] const excludedFilterColUidt = [UITypes.QrCode, UITypes.Barcode]
const draftFilter = useVModel(props, 'draftFilter', emit) const draftFilter = useVModel(props, 'draftFilter', emit)
const modelValue = useVModel(props, 'modelValue', 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) const nested = computed(() => nestedLevel.value > 0)
@ -53,6 +60,9 @@ const isPublic = inject(IsPublicInj, ref(false))
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { nestedFilters } = useSmartsheetStoreOrThrow() const { nestedFilters } = useSmartsheetStoreOrThrow()
const currentFilters = modelValue.value || (!link.value && !webHook.value && nestedFilters.value) || []
const { const {
filters, filters,
nonDeletedFilters, nonDeletedFilters,
@ -73,9 +83,11 @@ const {
parentId, parentId,
computed(() => autoSave.value), computed(() => autoSave.value),
() => reloadDataHook.trigger({ shouldShowLoading: showLoading.value, offset: 0 }), () => reloadDataHook.trigger({ shouldShowLoading: showLoading.value, offset: 0 }),
modelValue.value || nestedFilters.value, currentFilters,
!modelValue.value, props.nestedLevel > 0,
webHook.value, webHook.value,
link.value,
linkColId,
) )
const { getPlanLimit } = useWorkspace() const { getPlanLimit } = useWorkspace()
@ -89,7 +101,12 @@ const isMounted = ref(false)
const columns = computed(() => meta.value?.columns) 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) => { const getColumn = (filter: Filter) => {
// extract looked up column if available // extract looked up column if available
@ -167,6 +184,8 @@ const filterUpdateCondition = (filter: FilterType, i: number) => {
logical: filter.logical_op, logical: filter.logical_op,
comparison: filter.comparison_op, comparison: filter.comparison_op,
comparison_sub_op: filter.comparison_sub_op, comparison_sub_op: filter.comparison_sub_op,
link: !!link.value,
webHook: !!webHook.value,
}) })
} }
@ -174,7 +193,13 @@ watch(
() => activeView.value?.id, () => activeView.value?.id,
(n, o) => { (n, o) => {
// if nested no need to reload since it will get reloaded from parent // 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) }, 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 // 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 // it's used for bulk webhooks with filters since bulk webhooks don't support conditions at the moment
if (!isConditionSupported) { if (!isConditionSupported) {
@ -203,21 +228,36 @@ const applyChanges = async (hookId?: string, nested = false, isConditionSupporte
await deleteFilter(filters.value[i], i) await deleteFilter(filters.value[i], i)
} }
} }
if (link.value) {
await sync(hookId, nested) if (!hookOrColId && !props.nestedLevel) return
await sync({ linkId: hookOrColId, nested })
} else {
await sync({ hookId: hookOrColId, nested })
}
if (!localNestedFilters.value?.length) return if (!localNestedFilters.value?.length) return
for (const nestedFilter of localNestedFilters.value) { for (const nestedFilter of localNestedFilters.value) {
if (nestedFilter.parentId) { if (nestedFilter.parentId) {
await nestedFilter.applyChanges(hookId, true) await nestedFilter.applyChanges(hookOrColId, true)
} }
} }
} }
const selectFilterField = (filter: Filter, index: number) => { const selectFilterField = (filter: Filter, index: number) => {
const col = getColumn(filter) const col = getColumn(filter)
if (!col) return 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, // when we change the field,
// the corresponding default filter operator needs to be changed as well // the corresponding default filter operator needs to be changed as well
// since the existing one may not be supported for the new field // since the existing one may not be supported for the new field
@ -316,7 +356,18 @@ const showFilterInput = (filter: Filter) => {
} }
onMounted(async () => { 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 isMounted.value = true
}) })
@ -381,34 +432,99 @@ const onLogicalOpUpdate = async (filter: Filter, index: number) => {
// watch for changes in filters and update the modelValue // watch for changes in filters and update the modelValue
watch( watch(
filters, filters,
() => { (value) => {
if (modelValue.value !== filters.value) modelValue.value = filters.value if (value && value !== modelValue.value) {
modelValue.value = value
}
}, },
{ {
immediate: true, immediate: true,
}, },
) )
const addFilterBtnRef = ref() async function resetDynamicField(filter: any, i) {
watchEffect(() => { filter.dynamic = false
if (props.isOpen && !nested.value && addFilterBtnRef.value) { filter.fk_value_col_id = null
setTimeout(() => { await saveOrUpdate(filter, i)
addFilterBtnRef.value?.$el?.focus()
}, 10)
} }
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> </script>
<template> <template>
<div <div
class="menu-filter-dropdown" data-testid="nc-filter"
class="menu-filter-dropdown w-min"
:class="{ :class="{
'max-h-[max(80vh,500px)] min-w-112 py-2 pl-4': !nested, '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 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="[`nc-filter-logical-op-level-${nestedLevel}`]">
<slot name="start"></slot>
</div>
<div class="flex-grow"></div> <div class="flex-grow"></div>
<NcDropdown :trigger="['hover']" overlay-class-name="nc-dropdown-filter-group-sub-menu"> <NcDropdown :trigger="['hover']" overlay-class-name="nc-dropdown-filter-group-sub-menu">
<GeneralIcon icon="plus" class="cursor-pointer" /> <GeneralIcon icon="plus" class="cursor-pointer" />
@ -443,13 +559,13 @@ watchEffect(() => {
</div> </div>
</NcMenuItem> </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"> <div class="flex items-center gap-1">
<!-- Add Filter Group --> <!-- Add Filter Group -->
<component :is="iconMap.plusSquare" /> <component :is="iconMap.plusSquare" />
{{ $t('activity.addFilterGroup') }} {{ $t('activity.addFilterGroup') }}
</div> </div>
</NcMenuItem> </NcButton>
</template> </template>
</NcMenu> </NcMenu>
</template> </template>
@ -459,29 +575,35 @@ watchEffect(() => {
</div> </div>
</div> </div>
<div <div
v-if="filters && filters.length" v-if="visibleFilters && visibleFilters.length"
ref="wrapperDomRef" 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 }" :class="{ 'max-h-420px nc-scrollbar-thin nc-filter-top-wrapper pr-4 my-2 py-1': !nested }"
@click.stop @click.stop
> >
<template v-for="(filter, i) in filters" :key="i"> <template v-for="(filter, i) in filters" :key="i">
<template v-if="filter.status !== 'delete'"> <template v-if="filter.status !== 'delete'">
<template v-if="filter.is_group"> <template v-if="filter.is_group">
<div class="flex flex-col w-full gap-y-2"> <div class="flex flex-col min-w-full w-min gap-y-2">
<div class="flex rounded-lg p-2 w-full border-1" :class="[`nc-filter-nested-level-${nestedLevel}`]"> <div class="flex rounded-lg p-2 min-w-full w-min border-1" :class="[`nc-filter-nested-level-${nestedLevel}`]">
<LazySmartsheetToolbarColumnFilter <LazySmartsheetToolbarColumnFilter
v-if="filter.id || filter.children || !autoSave" v-if="filter.id || filter.children || !autoSave"
:key="filter.id ?? i" :key="i"
ref="localNestedFilters" ref="localNestedFilters"
v-model="filter.children" v-model="filter.children"
:nested-level="nestedLevel + 1" :nested-level="nestedLevel + 1"
:parent-id="filter.id" :parent-id="filter.id"
:auto-save="autoSave" :auto-save="autoSave"
:web-hook="webHook" :web-hook="webHook"
:link="link"
:show-loading="false"
:root-meta="rootMeta"
:link-col-id="linkColId"
> >
<template #start> <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"> <div v-else :key="`${i}nested`" class="flex nc-filter-logical-op">
<NcSelect <NcSelect
v-model:value="filter.logical_op" v-model:value="filter.logical_op"
@ -513,7 +635,7 @@ watchEffect(() => {
<NcButton <NcButton
v-if="!filter.readOnly" v-if="!filter.readOnly"
:key="i" :key="i"
v-e="['c:filter:delete']" v-e="['c:filter:delete', { link: !!link, webHook: !!webHook }]"
type="text" type="text"
size="small" size="small"
class="nc-filter-item-remove-btn cursor-pointer" class="nc-filter-item-remove-btn cursor-pointer"
@ -528,14 +650,14 @@ watchEffect(() => {
</template> </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-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') }} {{ $t('labels.where') }}
</div> </div>
<NcSelect <NcSelect
v-else v-else
v-model:value="filter.logical_op" 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" :dropdown-match-select-width="false"
class="h-full !min-w-18 !max-w-18 capitalize" class="h-full !min-w-18 !max-w-18 capitalize"
hide-details hide-details
@ -566,13 +688,14 @@ watchEffect(() => {
class="nc-filter-field-select min-w-32 max-w-32 max-h-8" class="nc-filter-field-select min-w-32 max-w-32 max-h-8"
:columns="fieldsToFilter" :columns="fieldsToFilter"
:disabled="filter.readOnly" :disabled="filter.readOnly"
:meta="meta"
@click.stop @click.stop
@change="selectFilterField(filter, i)" @change="selectFilterField(filter, i)"
/> />
<NcSelect <NcSelect
v-model:value="filter.comparison_op" 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" :dropdown-match-select-width="false"
class="caption nc-filter-operation-select !min-w-26.75 !max-w-26.75 max-h-8" class="caption nc-filter-operation-select !min-w-26.75 !max-w-26.75 max-h-8"
:placeholder="$t('labels.operation')" :placeholder="$t('labels.operation')"
@ -606,7 +729,7 @@ watchEffect(() => {
<NcSelect <NcSelect
v-else-if="isDateType(types[filter.fk_column_id])" v-else-if="isDateType(types[filter.fk_column_id])"
v-model:value="filter.comparison_sub_op" 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" :dropdown-match-select-width="false"
class="caption nc-filter-sub_operation-select min-w-28" class="caption nc-filter-sub_operation-select min-w-28"
:class="{ :class="{
@ -641,7 +764,19 @@ watchEffect(() => {
</a-select-option> </a-select-option>
</template> </template>
</NcSelect> </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>
<template v-else>
<a-checkbox <a-checkbox
v-if="filter.field && types[filter.field] === 'boolean'" v-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value" v-model:checked="filter.value"
@ -660,10 +795,68 @@ watchEffect(() => {
/> />
<div v-else-if="!isDateType(types[filter.fk_column_id])" class="flex-grow"></div> <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 <NcButton
v-if="!filter.readOnly" v-if="!filter.readOnly"
v-e="['c:filter:delete']" v-e="['c:filter:delete', { link: !!link, webHook: !!webHook }]"
type="text" type="text"
size="small" size="small"
class="nc-filter-item-remove-btn self-center" class="nc-filter-item-remove-btn self-center"
@ -720,7 +913,7 @@ watchEffect(() => {
</NcButton> </NcButton>
<NcButton <NcButton
v-if="!webHook && nestedLevel < 5" v-if="!link && !webHook && nestedLevel < 5"
class="nc-btn-focus" class="nc-btn-focus"
type="text" type="text"
size="small" size="small"
@ -736,7 +929,7 @@ watchEffect(() => {
</template> </template>
</template> </template>
<div <div
v-if="!filters.length" v-if="!visibleFilters || !visibleFilters.length"
class="flex flex-row text-gray-400 mt-2" class="flex flex-row text-gray-400 mt-2"
:class="{ :class="{
'ml-1': nested, 'ml-1': nested,
@ -774,11 +967,13 @@ watchEffect(() => {
.nc-filter-wrapper { .nc-filter-wrapper {
@apply bg-white !rounded-lg border-1px border-[#E7E7E9]; @apply bg-white !rounded-lg border-1px border-[#E7E7E9];
& > * { & > *,
.nc-filter-value-select {
@apply !border-none; @apply !border-none;
} }
& > * > :deep(.ant-select-selector) { & > div > :deep(.ant-select-selector),
:deep(.nc-filter-field-select) > div {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
@ -789,6 +984,11 @@ watchEffect(() => {
border-top-right-radius: 0 !important; border-top-right-radius: 0 !important;
} }
.nc-settings-dropdown {
border-left: 1px solid #eee !important;
border-radius: 0 !important;
}
& > :not(:first-child) { & > :not(:first-child) {
border-bottom-left-radius: 0 !important; border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important; border-top-left-radius: 0 !important;
@ -846,6 +1046,7 @@ watchEffect(() => {
.nc-filter-where-label { .nc-filter-where-label {
@apply text-gray-400; @apply text-gray-400;
} }
:deep(.ant-select-disabled.ant-select:not(.ant-select-customize-input) .ant-select-selector) { :deep(.ant-select-disabled.ant-select:not(.ant-select-customize-input) .ant-select-selector) {
@apply bg-transparent text-gray-400; @apply bg-transparent text-gray-400;
} }
@ -869,6 +1070,7 @@ watchEffect(() => {
.nc-filter-input-wrapper :deep(input) { .nc-filter-input-wrapper :deep(input) {
@apply !px-2; @apply !px-2;
} }
.nc-btn-focus:focus { .nc-btn-focus:focus {
@apply !text-brand-500 !shadow-none; @apply !text-brand-500 !shadow-none;
} }

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

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

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectProps } from 'ant-design-vue' 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' import { RelationTypes, UITypes, isHiddenCol, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
const { modelValue, isSort, allowEmpty, ...restProps } = defineProps<{ const { modelValue, isSort, allowEmpty, ...restProps } = defineProps<{
@ -8,13 +8,14 @@ const { modelValue, isSort, allowEmpty, ...restProps } = defineProps<{
isSort?: boolean isSort?: boolean
columns?: ColumnType[] columns?: ColumnType[]
allowEmpty?: boolean allowEmpty?: boolean
meta: TableType
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const customColumns = toRef(restProps, 'columns') const customColumns = toRef(restProps, 'columns')
const meta = inject(MetaInj, ref()) const meta = toRef(restProps, 'meta')
const { metas } = useMetas() 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 // when a new filter is created, select a field by default
if (!localValue.value && allowEmpty !== true) { if (!localValue.value && allowEmpty !== true) {
localValue.value = (options.value?.[0].value as string) || '' localValue.value = (options.value?.[0]?.value as string) || ''
} }
const relationColor = { 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" class="caption nc-sort-field-select w-44 flex flex-grow"
:columns="fieldsToGroupBy" :columns="fieldsToGroupBy"
:allow-empty="true" :allow-empty="true"
:meta="meta"
@change="saveGroupBy" @change="saveGroupBy"
@click.stop @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" class="flex caption nc-sort-field-select w-44 flex-grow"
:columns="columns" :columns="columns"
is-sort is-sort
:meta="meta"
@click.stop @click.stop
@update:model-value="saveOrUpdate(sort, i)" @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-if="childrenExcludedListPagination.query">{{ $t('msg.noRecordsMatchYourSearchQuery') }}</p>
<p v-else> <p v-else>
{{ $t('msg.thereAreNoRecordsInTable') }} {{ $t('msg.noRecordsAvailForLinking') }}
{{ relatedTableMeta?.title }}
</p> </p>
</div> </div>
</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]), 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 idType = null
const additionalValidations = ref<ValidationsObj>({}) const additionalValidations = ref<ValidationsObj>({})
@ -60,6 +64,9 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const setAdditionalValidations = (validations: ValidationsObj) => { const setAdditionalValidations = (validations: ValidationsObj) => {
additionalValidations.value = { ...additionalValidations.value, ...validations } additionalValidations.value = { ...additionalValidations.value, ...validations }
} }
const setPostSaveOrUpdateCbk = (cbk: typeof postSaveOrUpdateCbk) => {
postSaveOrUpdateCbk = cbk
}
const formState = ref<Record<string, any>>({ const formState = ref<Record<string, any>>({
title: 'title', title: 'title',
@ -273,6 +280,8 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.validate = '' formState.value.validate = ''
} }
await $api.dbTableColumn.update(column.value?.id as string, formState.value) await $api.dbTableColumn.update(column.value?.id as string, formState.value)
await postSaveOrUpdateCbk?.({ update: true, colId: column.value?.id })
// Column updated // Column updated
// message.success(t('msg.success.columnUpdated')) // message.success(t('msg.success.columnUpdated'))
} else { } 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, ...formState.value,
...columnPosition, ...columnPosition,
view_id: activeView.value!.id as string, 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 LTAR column then force reload related table meta */
if (isLinksOrLTAR(formState.value) && meta.value?.id !== formState.value.childId) { if (isLinksOrLTAR(formState.value) && meta.value?.id !== formState.value.childId) {
getMeta(formState.value.childId, true).then(() => {}) getMeta(formState.value.childId, true).then(() => {})
@ -333,6 +348,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
isMysql, isMysql,
isXcdbBase, isXcdbBase,
disableSubmitBtn, disableSubmitBtn,
setPostSaveOrUpdateCbk,
} }
}, },
) )

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

@ -29,6 +29,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref()) const activeView = inject(ActiveViewInj, ref())
const isForm = inject(IsFormInj, ref(false))
const { addUndo, clone, defineViewScope } = useUndoRedo() const { addUndo, clone, defineViewScope } = useUndoRedo()
@ -182,6 +183,13 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const route = router.currentRoute 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( childrenExcludedList.value = await $api.public.dataRelationList(
route.value.params.viewId as string, route.value.params.viewId as string,
column.value.id, column.value.id,
@ -197,6 +205,9 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
childrenExcludedListPagination.query && childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`, `(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value], fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value],
// todo: include only required fields
rowData: JSON.stringify(row),
} as RequestParams, } as RequestParams,
}, },
) )
@ -214,9 +225,24 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
childrenExcludedListPagination.query && childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`, `(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
// fields: [relatedTableDisplayValueProp.value, ...relatedTablePrimaryKeyProps.value], // 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, } as any,
) )
} else { } 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( childrenExcludedList.value = await $api.dbTableRow.nestedChildrenExcludedList(
NOCO, NOCO,
baseId, baseId,
@ -231,6 +257,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
where: where:
childrenExcludedListPagination.query && childrenExcludedListPagination.query &&
`(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`, `(${relatedTableDisplayValueProp.value},like,${childrenExcludedListPagination.query})`,
linkRowData: changedRowData ? JSON.stringify(changedRowData) : undefined,
} as any, } as any,
) )
} }

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

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

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

@ -18,6 +18,8 @@ export function useViewFilters(
_currentFilters?: Filter[], _currentFilters?: Filter[],
isNestedRoot?: boolean, isNestedRoot?: boolean,
isWebhook?: boolean, isWebhook?: boolean,
isLink?: boolean,
linkColId?: Ref<string>,
) { ) {
const parentId = ref(_parentId) const parentId = ref(_parentId)
@ -41,21 +43,23 @@ export function useViewFilters(
const { addUndo, clone, defineViewScope } = useUndoRedo() 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 nestedMode = computed(() => isPublic.value || !isUIAllowed('filterSync') || !isUIAllowed('filterChildrenRead'))
const filters = computed<Filter[]>({ const filters = computed<FilterType[]>({
get: () => { get: () => {
return nestedMode.value ? currentFilters.value! : _filters.value return nestedMode.value && !isLink && !isWebhook ? currentFilters.value! : _filters.value
}, },
set: (value: Filter[]) => { set: (value: Filter[]) => {
if (nestedMode.value) { if (nestedMode.value) {
currentFilters.value = value currentFilters.value = value
if (!isLink && !isWebhook) {
if (isNestedRoot) { if (isNestedRoot) {
nestedFilters.value = value nestedFilters.value = value
} }
nestedFilters.value = [...nestedFilters.value] nestedFilters.value = [...nestedFilters.value]
}
reloadHook?.trigger() reloadHook?.trigger()
return return
} }
@ -187,7 +191,7 @@ export function useViewFilters(
return { return {
comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) => comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) =>
isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp), isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp),
)?.[0].value as FilterType['comparison_op'], )?.[0]?.value as FilterType['comparison_op'],
value: null, value: null,
status: 'create', status: 'create',
logical_op: logicalOps.size === 1 ? logicalOps.values().next().value : 'and', logical_op: logicalOps.size === 1 ? logicalOps.values().next().value : 'and',
@ -229,10 +233,20 @@ export function useViewFilters(
await Promise.all(promises) await Promise.all(promises)
// Push all child filters into the allFilters array // 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 (!view.value?.id) return
if (nestedMode.value) { if (nestedMode.value) {
@ -244,9 +258,16 @@ export function useViewFilters(
if (isWebhook || hookId) { if (isWebhook || hookId) {
if (parentId.value) { if (parentId.value) {
filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[] 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[] filters.value = (await $api.dbTableWebhookFilter.read(hookId)).list as Filter[]
} }
} else {
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 { } else {
if (parentId.value) { if (parentId.value) {
filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[] filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[]
@ -258,13 +279,14 @@ export function useViewFilters(
} }
} }
} }
}
} catch (e: any) { } catch (e: any) {
console.log(e) console.log(e)
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
const sync = async (hookId?: string, _nested = false) => { const sync = async ({ hookId, linkId }: { hookId?: string; nested?: boolean; linkId?: string }) => {
try { try {
for (const [i, filter] of Object.entries(filters.value)) { for (const [i, filter] of Object.entries(filters.value)) {
if (filter.status === 'delete') { if (filter.status === 'delete') {
@ -272,7 +294,7 @@ export function useViewFilters(
if (filter.is_group) { if (filter.is_group) {
deleteFilterGroupFromAllFilters(filter) deleteFilterGroupFromAllFilters(filter)
} else { } 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') { } else if (filter.status === 'update') {
await $api.dbTableFilter.update(filter.id as string, { await $api.dbTableFilter.update(filter.id as string, {
@ -285,6 +307,13 @@ export function useViewFilters(
if (hookId) { if (hookId) {
filters.value[+i] = (await $api.dbTableWebhookFilter.create(hookId, { filters.value[+i] = (await $api.dbTableWebhookFilter.create(hookId, {
...filter, ...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, fk_parent_id: parentId.value,
})) as unknown as FilterType })) as unknown as FilterType
} else { } else {
@ -296,11 +325,11 @@ export function useViewFilters(
if (children) filters.value[+i].children = children 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) { } catch (e: any) {
console.log(e) console.log(e)
message.error(await extractSdkResponseErrorMsg(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) => { 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) { if (!undo) {
const lastFilter = lastFilters.value[i] const lastFilter = lastFilters.value[i]
@ -356,14 +385,22 @@ export function useViewFilters(
$e('a:filter:update', { $e('a:filter:update', {
logical: filter.logical_op, logical: filter.logical_op,
comparison: filter.comparison_op, comparison: filter.comparison_op,
link: !!isLink,
webHook: !!isWebhook,
})
} else {
if (linkColId?.value) {
filters.value[i] = await $api.dbTableLinkFilter.create(linkColId.value, {
...filter,
fk_parent_id: parentId,
}) })
} else { } else {
filters.value[i] = await $api.dbTableFilter.create(view.value.id!, { filters.value[i] = await $api.dbTableFilter.create(view.value.id!, {
...filter, ...filter,
fk_parent_id: parentId.value, fk_parent_id: parentId.value,
}) })
}
allFilters.value.push(filters.value[+i]) if (!isLink && !isWebhook) allFilters.value.push(filters.value[+i])
} }
} catch (e: any) { } catch (e: any) {
console.log(e) console.log(e)
@ -372,10 +409,12 @@ export function useViewFilters(
lastFilters.value = clone(filters.value) lastFilters.value = clone(filters.value)
if (!isWebhook && !skipDataReload) reloadData?.() if (!isWebhook && !skipDataReload && !isLink) reloadData?.()
} }
function deleteFilterGroupFromAllFilters(filter: Filter) { function deleteFilterGroupFromAllFilters(filter: Filter) {
if (!isLink && !isWebhook) return
// Find all child filters of the specified parentId // Find all child filters of the specified parentId
const childFilters = allFilters.value.filter((f) => f.fk_parent_id === filter.id) const childFilters = allFilters.value.filter((f) => f.fk_parent_id === filter.id)
@ -414,7 +453,7 @@ export function useViewFilters(
if (nestedMode.value) { if (nestedMode.value) {
filters.value.splice(i, 1) filters.value.splice(i, 1)
filters.value = [...filters.value] filters.value = [...filters.value]
if (!isWebhook) reloadData?.() if (!isWebhook && !isLink) reloadData?.()
} else { } else {
if (filter.id) { if (filter.id) {
// if auto-apply disabled mark it as disabled // if auto-apply disabled mark it as disabled
@ -426,7 +465,7 @@ export function useViewFilters(
try { try {
await $api.dbTableFilter.delete(filter.id) await $api.dbTableFilter.delete(filter.id)
if (!isWebhook) reloadData?.() if (!isWebhook && !isLink) reloadData?.()
filters.value.splice(i, 1) filters.value.splice(i, 1)
} catch (e: any) { } catch (e: any) {
console.log(e) console.log(e)
@ -437,13 +476,13 @@ export function useViewFilters(
} else { } else {
filters.value.splice(i, 1) 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) { if (filter.is_group) {
deleteFilterGroupFromAllFilters(filter) deleteFilterGroupFromAllFilters(filter)
} else { } 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) 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 () => { const addFilterGroup = async () => {
@ -492,7 +531,7 @@ export function useViewFilters(
lastFilters.value = clone(filters.value) 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 */ /** 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" "noResultsMatchedYourSearch": "Your search did not yield any matching results"
}, },
"labels": { "labels": {
"selectView": "Select a View",
"connectionDetails": "Connection Details", "connectionDetails": "Connection Details",
"metaSync": "Meta Sync", "metaSync": "Meta Sync",
"mention": "Mention", "mention": "Mention",
@ -492,6 +493,7 @@
"userOptions": "User Options", "userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation", "deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone", "dangerZone": "Dangerzone",
"childView": "Child View",
"selectYear": "Select Year", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -1210,6 +1212,7 @@
"selectFieldToSort": "Select Field to Sort", "selectFieldToSort": "Select Field to Sort",
"selectFieldToGroup": "Select Field to Group", "selectFieldToGroup": "Select Field to Group",
"thereAreNoRecordsInTable": "There are no records in table", "thereAreNoRecordsInTable": "There are no records in table",
"noRecordsAvailForLinking": "No records are currently available for linking",
"createWebhookMsg1": "Get started with web-hooks!", "createWebhookMsg1": "Get started with web-hooks!",
"createWebhookMsg2": "Power your automations. Get notified as soon as there are changes in your data", "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", "areYouSureUWantTo": "Are you sure you want to delete the following",

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

@ -25,13 +25,15 @@ NocoDB supports following types of relations:
1. Click on `+` icon to the right of `Fields header` 1. Click on `+` icon to the right of `Fields header`
2. On the dropdown modal, enter the field name (Optional). 2. On the dropdown modal, enter the field name (Optional).
3. Select the field type as `Links` from the dropdown. 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. 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. 7. Click on `Save Field` button.
![image](/img/v2/fields/types/links.png) ![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 ### Cell display
The cell will display number of links for a record to the related table. 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. 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) - [Lookup](020.lookup.md)
- [Rollup](030.rollup.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() @Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard) @UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class FiltersController { export class FiltersController {
constructor(private readonly filtersService: FiltersService) {} constructor(protected readonly filtersService: FiltersService) {}
@Get([ @Get([
'/api/v1/db/meta/views/:viewId/filters', '/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('sharedViewUuid') sharedViewUuid: string,
@Param('columnId') columnId: 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({ const pagedResponse = await this.publicDatasService.relDataList({
query: req.query, query: req.query,
password: req.headers?.['xc-password'] as string, password: req.headers?.['xc-password'] as string,
sharedViewUuid: sharedViewUuid, sharedViewUuid: sharedViewUuid,
columnId: columnId, columnId: columnId,
rowData,
}); });
return pagedResponse; return pagedResponse;

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

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

@ -1,9 +1,11 @@
import { NcDataErrorCodes, RelationTypes } from 'nocodb-sdk'; import { NcDataErrorCodes, RelationTypes } from 'nocodb-sdk';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { LinksColumn } from '~/models'; import type {
import type { RollupColumn } from '~/models'; LinksColumn,
LinkToAnotherRecordColumn,
RollupColumn,
} from '~/models';
import type { XKnex } from '~/db/CustomKnex'; import type { XKnex } from '~/db/CustomKnex';
import type { LinkToAnotherRecordColumn } from '~/models';
import type { Knex } from 'knex'; import type { Knex } from 'knex';
export default async function ({ export default async function ({
@ -30,9 +32,8 @@ export default async function ({
const refTableAlias = `__nc_rollup`; const refTableAlias = `__nc_rollup`;
switch (relationColumnOption.type) { switch (relationColumnOption.type) {
case RelationTypes.HAS_MANY: case RelationTypes.HAS_MANY: {
return { const queryBuilder: any = knex(
builder: knex(
knex.raw(`?? as ??`, [ knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name), baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias, refTableAlias,
@ -49,11 +50,15 @@ export default async function ({
), ),
'=', '=',
knex.ref(`${refTableAlias}.${childCol.column_name}`), knex.ref(`${refTableAlias}.${childCol.column_name}`),
), );
};
case RelationTypes.ONE_TO_ONE:
return { return {
builder: knex( builder: queryBuilder,
};
}
case RelationTypes.ONE_TO_ONE: {
const qb = knex(
knex.raw(`?? as ??`, [ knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name), baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias, refTableAlias,
@ -70,8 +75,13 @@ export default async function ({
), ),
'=', '=',
knex.ref(`${refTableAlias}.${childCol.column_name}`), knex.ref(`${refTableAlias}.${childCol.column_name}`),
), );
return {
builder: qb,
}; };
}
case RelationTypes.MANY_TO_MANY: { case RelationTypes.MANY_TO_MANY: {
const mmModel = await relationColumnOption.getMMModel(); const mmModel = await relationColumnOption.getMMModel();
const mmChildCol = await relationColumnOption.getMMChildColumn(); const mmChildCol = await relationColumnOption.getMMChildColumn();
@ -83,8 +93,7 @@ export default async function ({
]); ]);
} }
return { const qb = knex(
builder: knex(
knex.raw(`?? as ??`, [ knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel?.table_name), baseModelSqlv2.getTnPath(parentModel?.table_name),
refTableAlias, refTableAlias,
@ -115,7 +124,10 @@ export default async function ({
childCol.column_name childCol.column_name
}`, }`,
), ),
), );
return {
builder: qb,
}; };
} }

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

@ -13,10 +13,10 @@ import type {
RollupColumnReqType, RollupColumnReqType,
TableType, TableType,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import type { RollupColumn } from '~/models';
import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn'; import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn';
import type LookupColumn from '~/models/LookupColumn'; import type LookupColumn from '~/models/LookupColumn';
import type Model from '~/models/Model'; import type Model from '~/models/Model';
import type { RollupColumn, View } from '~/models';
import { GridViewColumn } from '~/models'; import { GridViewColumn } from '~/models';
import validateParams from '~/helpers/validateParams'; import validateParams from '~/helpers/validateParams';
import { getUniqueColumnAliasName } from '~/helpers/getUniqueName'; import { getUniqueColumnAliasName } from '~/helpers/getUniqueName';
@ -32,6 +32,7 @@ export async function createHmAndBtColumn(
child: Model, child: Model,
parent: Model, parent: Model,
childColumn: Column, childColumn: Column,
childView?: View,
type?: RelationTypes, type?: RelationTypes,
alias?: string, alias?: string,
fkColName?: string, fkColName?: string,
@ -74,6 +75,7 @@ export async function createHmAndBtColumn(
(type === 'hm' && alias) || pluralize(child.title), (type === 'hm' && alias) || pluralize(child.title),
); );
const meta = { const meta = {
...(columnMeta || {}),
plural: columnMeta?.plural || pluralize(child.title), plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title), singular: columnMeta?.singular || singularize(child.title),
}; };
@ -83,6 +85,7 @@ export async function createHmAndBtColumn(
fk_model_id: parent.id, fk_model_id: parent.id,
uidt: isLinks ? UITypes.Links : UITypes.LinkToAnotherRecord, uidt: isLinks ? UITypes.Links : UITypes.LinkToAnotherRecord,
type: 'hm', type: 'hm',
fk_target_view_id: childView?.id,
fk_child_column_id: childColumn.id, fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id, fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: child.id, fk_related_model_id: child.id,
@ -101,6 +104,7 @@ export async function createHmAndBtColumn(
* @param {Model} child - The child model. * @param {Model} child - The child model.
* @param {Model} parent - The parent model. * @param {Model} parent - The parent model.
* @param {Column} childColumn - The child column. * @param {Column} childColumn - The child column.
* @param {View} childView - The child column.
* @param {RelationTypes} [type] - The type of relationship. * @param {RelationTypes} [type] - The type of relationship.
* @param {string} [alias] - The alias for the column. * @param {string} [alias] - The alias for the column.
* @param {string} [fkColName] - The foreign key column name. * @param {string} [fkColName] - The foreign key column name.
@ -113,6 +117,7 @@ export async function createOOColumn(
child: Model, child: Model,
parent: Model, parent: Model,
childColumn: Column, childColumn: Column,
childView?: View,
type?: RelationTypes, type?: RelationTypes,
alias?: string, alias?: string,
fkColName?: string, fkColName?: string,
@ -133,7 +138,8 @@ export async function createOOColumn(
// ref_db_alias // ref_db_alias
uidt: UITypes.LinkToAnotherRecord, uidt: UITypes.LinkToAnotherRecord,
type: RelationTypes.ONE_TO_ONE, 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_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id, fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: parent.id, fk_related_model_id: parent.id,
@ -158,6 +164,7 @@ export async function createOOColumn(
alias || child.title, alias || child.title,
); );
const meta = { const meta = {
...(columnMeta || {}),
plural: columnMeta?.plural || pluralize(child.title), plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title), singular: columnMeta?.singular || singularize(child.title),
}; };
@ -167,6 +174,7 @@ export async function createOOColumn(
fk_model_id: parent.id, fk_model_id: parent.id,
uidt: UITypes.LinkToAnotherRecord, uidt: UITypes.LinkToAnotherRecord,
type: 'oo', type: 'oo',
fk_target_view_id: childView?.id,
fk_child_column_id: childColumn.id, fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id, fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: child.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_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_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_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 // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -79,6 +80,7 @@ export default class XcMigrationSourcev2 {
'nc_045_extensions', 'nc_045_extensions',
'nc_046_comment_mentions', 'nc_046_comment_mentions',
'nc_047_comment_migration', 'nc_047_comment_migration',
'nc_048_view_links',
]); ]);
} }
@ -160,6 +162,8 @@ export default class XcMigrationSourcev2 {
return nc_046_comment_mentions; return nc_046_comment_mentions;
case 'nc_047_comment_migration': case 'nc_047_comment_migration':
return 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_child_column_id: column.fk_child_column_id,
fk_parent_column_id: column.fk_parent_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_model_id: column.fk_mm_model_id,
fk_mm_child_column_id: column.fk_mm_child_column_id, fk_mm_child_column_id: column.fk_mm_child_column_id,
fk_mm_parent_column_id: column.fk_mm_parent_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));*/ return columns.map(c => new Column(c));*/
} }
public static async get( public static async get<T = any>(
{ {
source_id, source_id,
db_alias, db_alias,
@ -594,7 +595,7 @@ export default class Column<T = any> implements ColumnType {
colId: string; colId: string;
}, },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
): Promise<Column> { ): Promise<Column<T>> {
let colData = let colData =
colId && colId &&
(await NocoCache.get( (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( static async bulkInsert(
param: { param: {
columns: Column[]; columns: Column[];

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

@ -24,6 +24,8 @@ export default class Filter implements FilterType {
fk_hook_id?: string; fk_hook_id?: string;
fk_column_id?: string; fk_column_id?: string;
fk_parent_id?: string; fk_parent_id?: string;
fk_link_col_id?: string;
fk_value_col_id?: string;
comparison_op?: (typeof COMPARISON_OPS)[number]; comparison_op?: (typeof COMPARISON_OPS)[number];
comparison_sub_op?: (typeof COMPARISON_SUB_OPS)[number]; comparison_sub_op?: (typeof COMPARISON_SUB_OPS)[number];
@ -68,6 +70,8 @@ export default class Filter implements FilterType {
'id', 'id',
'fk_view_id', 'fk_view_id',
'fk_hook_id', 'fk_hook_id',
'fk_link_col_id',
'fk_value_col_id',
'fk_column_id', 'fk_column_id',
'comparison_op', 'comparison_op',
'comparison_sub_op', 'comparison_sub_op',
@ -80,9 +84,12 @@ export default class Filter implements FilterType {
'order', 'order',
]); ]);
const referencedModelColName = filter.fk_hook_id const referencedModelColName = [
? 'fk_hook_id' 'fk_view_id',
: 'fk_view_id'; 'fk_hook_id',
'fk_link_col_id',
].find((k) => filter[k]);
insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.FILTER_EXP, { insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.FILTER_EXP, {
[referencedModelColName]: filter[referencedModelColName], [referencedModelColName]: filter[referencedModelColName],
}); });
@ -93,6 +100,8 @@ export default class Filter implements FilterType {
model = await View.get(filter.fk_view_id, ncMeta); model = await View.get(filter.fk_view_id, ncMeta);
} else if (filter.fk_hook_id) { } else if (filter.fk_hook_id) {
model = await Hook.get(filter.fk_hook_id, ncMeta); 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) { } else if (filter.fk_column_id) {
model = await Column.get({ colId: filter.fk_column_id }, ncMeta); model = await Column.get({ colId: filter.fk_column_id }, ncMeta);
} else { } else {
@ -118,8 +127,7 @@ export default class Filter implements FilterType {
{ {
...f, ...f,
fk_parent_id: row.id, fk_parent_id: row.id,
[filter.fk_hook_id ? 'fk_hook_id' : 'fk_view_id']: [referencedModelColName]: filter[referencedModelColName],
filter.fk_hook_id ? filter.fk_hook_id : filter.fk_view_id,
}, },
ncMeta, ncMeta,
), ),
@ -229,6 +237,7 @@ export default class Filter implements FilterType {
'fk_parent_id', 'fk_parent_id',
'is_group', 'is_group',
'logical_op', 'logical_op',
'fk_value_col_id',
]); ]);
if (typeof updateObj.value === 'string') if (typeof updateObj.value === 'string')
@ -359,20 +368,32 @@ export default class Filter implements FilterType {
{ {
viewId, viewId,
hookId, hookId,
linkColId,
}: { }: {
viewId?: string; viewId?: string;
hookId?: string; hookId?: string;
linkColId?: string;
}, },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
): Promise<FilterType> { ): Promise<FilterType> {
const cachedList = await NocoCache.getList(CacheScope.FILTER_EXP, [ const cachedList = await NocoCache.getList(CacheScope.FILTER_EXP, [
viewId || hookId, viewId || hookId || linkColId,
]); ]);
let { list: filters } = cachedList; let { list: filters } = cachedList;
const { isNoneList } = cachedList; const { isNoneList } = cachedList;
if (!isNoneList && !filters.length) { 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, { filters = await ncMeta.metaList2(null, null, MetaTable.FILTER_EXP, {
condition: viewId ? { fk_view_id: viewId } : { fk_hook_id: hookId }, condition,
orderBy: { orderBy: {
order: 'asc', order: 'asc',
}, },
@ -380,7 +401,7 @@ export default class Filter implements FilterType {
await NocoCache.setList( await NocoCache.setList(
CacheScope.FILTER_EXP, CacheScope.FILTER_EXP,
[viewId || hookId], [viewId || hookId || linkColId],
filters, filters,
); );
} }
@ -412,13 +433,6 @@ export default class Filter implements FilterType {
if (idFilterMapping?.[id]) idFilterMapping[id].children = children; 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; return result;
} }
@ -620,4 +634,11 @@ export default class Filter implements FilterType {
); );
return emptyOrNullFilterObjs.length > 0; 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 { BoolType } from 'nocodb-sdk';
import type Filter from '~/models/Filter';
import Model from '~/models/Model'; import Model from '~/models/Model';
import Column from '~/models/Column'; import Column from '~/models/Column';
import Noco from '~/Noco'; import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps'; import { extractProps } from '~/helpers/extractProps';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { View } from '~/models/index';
export default class LinkToAnotherRecordColumn { export default class LinkToAnotherRecordColumn {
id: string; id: string;
@ -16,6 +18,8 @@ export default class LinkToAnotherRecordColumn {
fk_mm_parent_column_id?: string; fk_mm_parent_column_id?: string;
fk_related_model_id?: string; fk_related_model_id?: string;
fk_target_view_id?: string | null;
dr?: string; dr?: string;
ur?: string; ur?: string;
fk_index_name?: string; fk_index_name?: string;
@ -32,6 +36,8 @@ export default class LinkToAnotherRecordColumn {
childColumn?: Column; childColumn?: Column;
parentColumn?: Column; parentColumn?: Column;
filter?: Filter;
constructor(data: Partial<LinkToAnotherRecordColumn>) { constructor(data: Partial<LinkToAnotherRecordColumn>) {
Object.assign(this, data); Object.assign(this, data);
} }
@ -99,6 +105,7 @@ export default class LinkToAnotherRecordColumn {
'fk_mm_model_id', 'fk_mm_model_id',
'fk_mm_child_column_id', 'fk_mm_child_column_id',
'fk_mm_parent_column_id', 'fk_mm_parent_column_id',
'fk_target_view_id',
'ur', 'ur',
'dr', 'dr',
'fk_index_name', 'fk_index_name',
@ -109,6 +116,11 @@ export default class LinkToAnotherRecordColumn {
return this.read(data.fk_column_id, ncMeta); 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) { public static async read(columnId: string, ncMeta = Noco.ncMeta) {
let colData = let colData =
columnId && columnId &&
@ -127,4 +139,11 @@ export default class LinkToAnotherRecordColumn {
} }
return colData ? new LinkToAnotherRecordColumn(colData) : null; 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, prepareForResponse,
stringifyMetaProp, stringifyMetaProp,
} from '~/utils/modelUtils'; } from '~/utils/modelUtils';
import { LinkToAnotherRecordColumn } from '~/models';
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
@ -1214,6 +1215,25 @@ export default class View implements ViewType {
`${CacheScope.VIEW_ALIAS}:${view.fk_model_id}:${view.id}`, `${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 // on update, delete any optimised single query cache
await View.clearSingleQueryCache(view.fk_model_id, [view], ncMeta); 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 = ( const exportedModel = (
await this.exportService.serializeModels({ await this.exportService.serializeModels({
modelIds: [sourceModel.id], modelIds: [sourceModel.id, ...relatedModelIds],
excludeData, excludeData,
excludeHooks: true, excludeHooks: true,
excludeViews: 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 { Injectable } from '@nestjs/common';
import { elapsedTime, initTime } from '../../helpers'; import { elapsedTime, initTime } from '../../helpers';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { View } from '~/models'; import type { LinkToAnotherRecordColumn } from '~/models';
import { Base, Hook, Model, Source } from '~/models'; import { View } from '~/models';
import { Base, Filter, Hook, Model, Source } from '~/models';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { getViewAndModelByAliasOrId } from '~/helpers/dataHelpers'; import { getViewAndModelByAliasOrId } from '~/helpers/dataHelpers';
import { clearPrefix, generateBaseIdMap } from '~/helpers/exportImportHelpers'; import {
clearPrefix,
generateBaseIdMap,
getEntityIdentifier,
} from '~/helpers/exportImportHelpers';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2'; import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { NcError } from '~/helpers/catchError'; import { NcError } from '~/helpers/catchError';
import { DatasService } from '~/services/datas.service'; import { DatasService } from '~/services/datas.service';
@ -122,6 +127,18 @@ export class ExportService {
case 'fk_barcode_value_column_id': case 'fk_barcode_value_column_id':
column.colOptions[k] = idMap.get(v as string); column.colOptions[k] = idMap.get(v as string);
break; 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': case 'options':
for (const o of column.colOptions['options']) { for (const o of column.colOptions['options']) {
delete o.id; 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) { for (const view of model.views) {
@ -181,7 +232,7 @@ export class ExportService {
const tempFl = { const tempFl = {
id: `${idMap.get(view.id)}::${fl.id}`, id: `${idMap.get(view.id)}::${fl.id}`,
fk_column_id: idMap.get(fl.fk_column_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, is_group: fl.is_group,
logical_op: fl.logical_op, logical_op: fl.logical_op,
comparison_op: fl.comparison_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 { Readable } from 'stream';
import type { import type {
CalendarView, CalendarView,
LinksColumn,
LinkToAnotherRecordColumn, LinkToAnotherRecordColumn,
User, User,
View, View,
@ -119,6 +120,7 @@ export class ImportService {
); );
await model.getColumns(); await model.getColumns();
await model.getViews();
const primaryKey = model.primaryKey; const primaryKey = model.primaryKey;
if (primaryKey) { if (primaryKey) {
@ -134,6 +136,13 @@ export class ImportService {
col.id, 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 referencedColumnSet = [];
const ltarFilterCreateCbks: (() => Promise<any>)[] = [];
// create LTAR columns // create LTAR columns
for (const data of param.data) { for (const data of param.data) {
const modelData = data.model; const modelData = data.model;
@ -245,7 +256,7 @@ export class ImportService {
for (const col of linkedColumnSet) { for (const col of linkedColumnSet) {
if (col.colOptions) { if (col.colOptions) {
const colOptions = col.colOptions; const colOptions = col.colOptions as LinksColumn;
if (idMap.has(colOptions.fk_related_model_id)) { if (idMap.has(colOptions.fk_related_model_id)) {
if (colOptions.type === 'mm') { if (colOptions.type === 'mm') {
if (!linkMap.has(colOptions.fk_mm_model_id)) { if (!linkMap.has(colOptions.fk_mm_model_id)) {
@ -267,6 +278,9 @@ export class ImportService {
virtual: colOptions.virtual, virtual: colOptions.virtual,
ur: colOptions.ur, ur: colOptions.ur,
dr: colOptions.dr, dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
}, },
}), }),
req: param.req, req: param.req,
@ -367,6 +381,9 @@ export class ImportService {
virtual: colOptions.virtual, virtual: colOptions.virtual,
ur: colOptions.ur, ur: colOptions.ur,
dr: colOptions.dr, dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
}, },
}), }),
req: param.req, req: param.req,
@ -505,6 +522,9 @@ export class ImportService {
virtual: colOptions.virtual, virtual: colOptions.virtual,
ur: colOptions.ur, ur: colOptions.ur,
dr: colOptions.dr, dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
}, },
}) as any, }) as any,
req: param.req, req: param.req,
@ -628,6 +648,9 @@ export class ImportService {
virtual: colOptions.virtual, virtual: colOptions.virtual,
ur: colOptions.ur, ur: colOptions.ur,
dr: colOptions.dr, dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
}, },
}) as any, }) as any,
req: param.req, req: param.req,
@ -798,6 +821,9 @@ export class ImportService {
virtual: colOptions.virtual, virtual: colOptions.virtual,
ur: colOptions.ur, ur: colOptions.ur,
dr: colOptions.dr, dr: colOptions.dr,
childViewId:
colOptions.fk_target_view_id &&
getIdOrExternalId(colOptions.fk_target_view_id),
}, },
}) as any, }) as any,
req: param.req, 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'); 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; return idMap;
} }

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

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

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

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

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

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

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

@ -13,19 +13,28 @@ import {
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection'; import { pluralize, singularize } from 'inflection';
import hash from 'object-hash'; import hash from 'object-hash';
import type SqlMgrv2 from '~/db/sql-mgr/v2/SqlMgrv2';
import type { Base, LinkToAnotherRecordColumn } from '~/models';
import type { import type {
ColumnReqType, ColumnReqType,
LinkToAnotherColumnReqType, LinkToAnotherColumnReqType,
LinkToAnotherRecordType, LinkToAnotherRecordType,
UserType, UserType,
} from 'nocodb-sdk'; } 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 CustomKnex from '~/db/CustomKnex';
import type SqlClient from '~/db/sql-client/lib/SqlClient'; import type SqlClient from '~/db/sql-client/lib/SqlClient';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { NcRequest } from '~/interface/config'; 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 { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2'; import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import ProjectMgrv2 from '~/db/sql-mgr/v2/ProjectMgrv2'; import ProjectMgrv2 from '~/db/sql-mgr/v2/ProjectMgrv2';
@ -48,15 +57,6 @@ import {
} from '~/helpers/getUniqueName'; } from '~/helpers/getUniqueName';
import mapDefaultDisplayValue from '~/helpers/mapDefaultDisplayValue'; import mapDefaultDisplayValue from '~/helpers/mapDefaultDisplayValue';
import validateParams from '~/helpers/validateParams'; import validateParams from '~/helpers/validateParams';
import {
BaseUser,
Column,
FormulaColumn,
KanbanView,
Model,
Source,
View,
} from '~/models';
import Noco from '~/Noco'; import Noco from '~/Noco';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { MetaTable } from '~/utils/globals'; import { MetaTable } from '~/utils/globals';
@ -234,6 +234,7 @@ export class ColumnsService {
formula?: string; formula?: string;
formula_raw?: string; formula_raw?: string;
parsed_tree?: any; parsed_tree?: any;
colOptions?: any;
} & Partial<Pick<ColumnReqType, 'column_order'>>; } & Partial<Pick<ColumnReqType, 'column_order'>>;
if ( if (
@ -311,11 +312,9 @@ export class ColumnsService {
} }
if ( if (
'meta' in colBody && 'meta' in colBody &&
[ [UITypes.CreatedTime, UITypes.LastModifiedTime].includes(
UITypes.Links, column.uidt,
UITypes.CreatedTime, )
UITypes.LastModifiedTime,
].includes(column.uidt)
) { ) {
await Column.updateMeta({ await Column.updateMeta({
colId: param.columnId, 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 // handle reorder column for Links and LinkToAnotherRecord
if ( if (
[UITypes.Links, UITypes.LinkToAnotherRecord].includes( [UITypes.Links, UITypes.LinkToAnotherRecord].includes(
@ -2601,6 +2636,13 @@ export class ColumnsService {
id: (param.column as LinkToAnotherColumnReqType).childId, id: (param.column as LinkToAnotherColumnReqType).childId,
}); });
let childColumn: Column; 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 () => const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr({ ProjectMgrv2.getSqlMgr({
@ -2702,10 +2744,12 @@ export class ColumnsService {
}); });
} }
} }
await createHmAndBtColumn( await createHmAndBtColumn(
child, child,
parent, parent,
childColumn, childColumn,
childView,
(param.column as LinkToAnotherColumnReqType).type as RelationTypes, (param.column as LinkToAnotherColumnReqType).type as RelationTypes,
(param.column as LinkToAnotherColumnReqType).title, (param.column as LinkToAnotherColumnReqType).title,
foreignKeyName, foreignKeyName,
@ -2803,6 +2847,7 @@ export class ColumnsService {
child, child,
parent, parent,
childColumn, childColumn,
childView,
(param.column as LinkToAnotherColumnReqType).type as RelationTypes, (param.column as LinkToAnotherColumnReqType).type as RelationTypes,
(param.column as LinkToAnotherColumnReqType).title, (param.column as LinkToAnotherColumnReqType).title,
foreignKeyName, foreignKeyName,
@ -2912,6 +2957,7 @@ export class ColumnsService {
childCol, childCol,
null, null,
null, null,
null,
foreignKeyName1, foreignKeyName1,
(param.column as LinkToAnotherColumnReqType).virtual, (param.column as LinkToAnotherColumnReqType).virtual,
true, true,
@ -2925,6 +2971,7 @@ export class ColumnsService {
parentCol, parentCol,
null, null,
null, null,
null,
foreignKeyName2, foreignKeyName2,
(param.column as LinkToAnotherColumnReqType).virtual, (param.column as LinkToAnotherColumnReqType).virtual,
true, true,
@ -2947,7 +2994,8 @@ export class ColumnsService {
fk_child_column_id: childPK.id, fk_child_column_id: childPK.id,
fk_parent_column_id: parentPK.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_model_id: assocModel.id,
fk_mm_child_column_id: childCol.id, fk_mm_child_column_id: childCol.id,
fk_mm_parent_column_id: parentCol.id, fk_mm_parent_column_id: parentCol.id,
@ -2973,6 +3021,7 @@ export class ColumnsService {
fk_child_column_id: parentPK.id, fk_child_column_id: parentPK.id,
fk_parent_column_id: childPK.id, fk_parent_column_id: childPK.id,
fk_target_view_id: childView?.id,
fk_mm_model_id: assocModel.id, fk_mm_model_id: assocModel.id,
fk_mm_child_column_id: parentCol.id, fk_mm_child_column_id: parentCol.id,
@ -2980,6 +3029,7 @@ export class ColumnsService {
fk_related_model_id: child.id, fk_related_model_id: child.id,
virtual: (param.column as LinkToAnotherColumnReqType).virtual, virtual: (param.column as LinkToAnotherColumnReqType).virtual,
meta: { meta: {
...(param.column['meta'] || {}),
plural: param.column['meta']?.plural || pluralize(child.title), plural: param.column['meta']?.plural || pluralize(child.title),
singular: param.column['meta']?.singular || singularize(child.title), singular: param.column['meta']?.singular || singularize(child.title),
}, },
@ -3170,13 +3220,15 @@ export class ColumnsService {
if (op.op === 'add') { if (op.op === 'add') {
try { try {
await this.columnAdd({ const tableMeta = await this.columnAdd({
tableId, tableId,
column: column as ColumnReqType, column: column as ColumnReqType,
req, req,
user: req.user, user: req.user,
reuse, reuse,
}); });
await this.postColumnAdd(column as ColumnReqType, tableMeta);
} catch (e) { } catch (e) {
failedOps.push({ failedOps.push({
...op, ...op,
@ -3192,6 +3244,8 @@ export class ColumnsService {
user: req.user, user: req.user,
reuse, reuse,
}); });
await this.postColumnUpdate(column as ColumnReqType);
} catch (e) { } catch (e) {
failedOps.push({ failedOps.push({
...op, ...op,
@ -3218,4 +3272,12 @@ export class ColumnsService {
failedOps, 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 { nocoExecute } from 'nc-help';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { PathParams } from '~/helpers/dataHelpers'; 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 { Base, Column, Model, Source, View } from '~/models';
import { NcBaseError, NcError } from '~/helpers/catchError'; import { NcBaseError, NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst'; import getAst from '~/helpers/getAst';
@ -154,8 +156,14 @@ export class DatasService {
ignoreViewFilterAndSort?: boolean; ignoreViewFilterAndSort?: boolean;
ignorePagination?: boolean; ignorePagination?: boolean;
limitOverride?: number; 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); const source = await Source.get(model.source_id);
@ -170,7 +178,7 @@ export class DatasService {
const { ast, dependencyFields } = await getAst({ const { ast, dependencyFields } = await getAst({
model, model,
query, query,
view, view: view,
throwErrorIfInvalidParams: param.throwErrorIfInvalidParams, throwErrorIfInvalidParams: param.throwErrorIfInvalidParams,
}); });
@ -182,6 +190,8 @@ export class DatasService {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson); listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {} } catch (e) {}
listArgs.customConditions = param.customConditions;
const [count, data] = await Promise.all([ const [count, data] = await Promise.all([
baseModel.count(listArgs, false, param.throwErrorIfInvalidParams), baseModel.count(listArgs, false, param.throwErrorIfInvalidParams),
(async () => { (async () => {

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

@ -122,4 +122,14 @@ export class FiltersService {
const filter = await Filter.rootFilterList({ viewId: param.viewId }); const filter = await Filter.rootFilterList({ viewId: param.viewId });
return filter; 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 NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { mimeIcons } from '~/utils/mimeTypes'; import { mimeIcons } from '~/utils/mimeTypes';
import { utf8ify } from '~/helpers/stringHelpers'; import { utf8ify } from '~/helpers/stringHelpers';
import { replaceDynamicFieldWithValue } from '~/db/BaseModelSqlv2';
import { Filter } from '~/models';
// todo: move to utils // todo: move to utils
export function sanitizeUrlPath(paths) { export function sanitizeUrlPath(paths) {
@ -427,6 +429,7 @@ export class PublicDatasService {
sharedViewUuid: string; sharedViewUuid: string;
password?: string; password?: string;
columnId: string; columnId: string;
rowData: Record<string, any>;
}) { }) {
const view = await View.getByUUID(param.sharedViewUuid); const view = await View.getByUUID(param.sharedViewUuid);
@ -441,6 +444,8 @@ export class PublicDatasService {
} }
const column = await Column.get({ colId: param.columnId }); const column = await Column.get({ colId: param.columnId });
const currentModel = await view.getModel();
await currentModel.getColumns();
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>(); const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
const model = await colOptions.getRelatedTable(); const model = await colOptions.getRelatedTable();
@ -449,7 +454,7 @@ export class PublicDatasService {
const baseModel = await Model.getBaseModelSQL({ const baseModel = await Model.getBaseModelSQL({
id: model.id, id: model.id,
viewId: view?.id, viewId: colOptions.fk_target_view_id,
dbDriver: await NcConnectionMgrv2.get(source), dbDriver: await NcConnectionMgrv2.get(source),
}); });
@ -464,13 +469,30 @@ export class PublicDatasService {
let count = 0; let count = 0;
try { 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( data = data = await nocoExecute(
ast, ast,
await baseModel.list(dependencyFields), await baseModel.list({
...dependencyFields,
customConditions,
}),
{}, {},
dependencyFields, dependencyFields,
); );
count = await baseModel.count(dependencyFields as any); count = await baseModel.count({
...dependencyFields,
customConditions,
} as any);
} catch (e) { } catch (e) {
console.log(e); console.log(e);
NcError.internalServerError('Please check server log for more details'); 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': case 'Links':
await this.addOrEditColumn.locator('.nc-ltar-relation-type').getByTestId(relationType).click(); await this.addOrEditColumn.locator('.nc-ltar-relation-type').getByTestId(relationType).click();
await this.addOrEditColumn.locator('.ant-select-single').nth(1).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 await this.rootPage
.locator(`.nc-dropdown-ltar-child-table >> .ant-select-item`, { .locator(`.nc-dropdown-ltar-child-table >> .ant-select-item`, {
hasText: childTable, hasText: childTable,

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

@ -67,7 +67,17 @@ export class ExpandedFormPage extends BasePage {
await this.dashboard.waitForLoaderToDisappear(); 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}`); const field = this.get().getByTestId(`nc-expand-col-${columnTitle}`);
switch (type) { switch (type) {
case 'text': case 'text':
@ -84,12 +94,18 @@ export class ExpandedFormPage extends BasePage {
case 'belongsTo': case 'belongsTo':
await field.locator('.nc-virtual-cell').hover(); await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click(); await field.locator('.nc-action-icon').click();
if (ltarCount !== undefined && ltarCount !== null) {
await this.dashboard.linkRecord.verifyCount(ltarCount);
}
await this.dashboard.linkRecord.select(value, false); await this.dashboard.linkRecord.select(value, false);
break; break;
case 'hasMany': case 'hasMany':
case 'manyToMany': case 'manyToMany':
await field.locator('.nc-virtual-cell').hover(); await field.locator('.nc-virtual-cell').hover();
await field.locator('.nc-action-icon').click(); await field.locator('.nc-action-icon').click();
if (ltarCount !== undefined && ltarCount !== null) {
await this.dashboard.linkRecord.verifyCount(ltarCount);
}
await this.dashboard.linkRecord.select(value); await this.dashboard.linkRecord.select(value);
break; break;
case 'dateTime': 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() { async close() {
await this.get().getByTestId('nc-link-count-info').click(); await this.get().getByTestId('nc-link-count-info').click();
await this.rootPage.keyboard.press('Escape'); 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 { AttachmentColumnPageObject } from './Attachment';
import { getTextExcludeIconText } from '../../../../tests/utils/general'; import { getTextExcludeIconText } from '../../../../tests/utils/general';
import { UserOptionColumnPageObject } from './UserOptionColumn'; import { UserOptionColumnPageObject } from './UserOptionColumn';
import { LTAROptionColumnPageObject } from './LTAROptionColumn';
export class ColumnPageObject extends BasePage { export class ColumnPageObject extends BasePage {
readonly grid: GridPage; readonly grid: GridPage;
readonly selectOption: SelectOptionColumnPageObject; readonly selectOption: SelectOptionColumnPageObject;
readonly attachmentColumnPageObject: AttachmentColumnPageObject; readonly attachmentColumnPageObject: AttachmentColumnPageObject;
readonly userOption: UserOptionColumnPageObject; readonly userOption: UserOptionColumnPageObject;
readonly ltarOption: LTAROptionColumnPageObject;
constructor(grid: GridPage) { constructor(grid: GridPage) {
super(grid.rootPage); super(grid.rootPage);
@ -18,6 +20,7 @@ export class ColumnPageObject extends BasePage {
this.selectOption = new SelectOptionColumnPageObject(this); this.selectOption = new SelectOptionColumnPageObject(this);
this.attachmentColumnPageObject = new AttachmentColumnPageObject(this); this.attachmentColumnPageObject = new AttachmentColumnPageObject(this);
this.userOption = new UserOptionColumnPageObject(this); this.userOption = new UserOptionColumnPageObject(this);
this.ltarOption = new LTAROptionColumnPageObject(this);
} }
get() { get() {
@ -69,6 +72,8 @@ export class ColumnPageObject extends BasePage {
insertAfterColumnTitle, insertAfterColumnTitle,
insertBeforeColumnTitle, insertBeforeColumnTitle,
isDisplayValue = false, isDisplayValue = false,
ltarFilters,
ltarView,
}: { }: {
title: string; title: string;
type?: string; type?: string;
@ -86,6 +91,8 @@ export class ColumnPageObject extends BasePage {
insertBeforeColumnTitle?: string; insertBeforeColumnTitle?: string;
insertAfterColumnTitle?: string; insertAfterColumnTitle?: string;
isDisplayValue?: boolean; isDisplayValue?: boolean;
ltarFilters?: any[];
ltarView?: string;
}) { }) {
if (insertBeforeColumnTitle) { if (insertBeforeColumnTitle) {
await this.grid.get().locator(`th[data-title="${insertBeforeColumnTitle}"] .nc-ui-dt-dropdown`).click(); await this.grid.get().locator(`th[data-title="${insertBeforeColumnTitle}"] .nc-ui-dt-dropdown`).click();
@ -187,6 +194,9 @@ export class ColumnPageObject extends BasePage {
.click(); .click();
break; break;
case 'Links': 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('.nc-ltar-relation-type').getByTestId(relationType).click();
await this.get().locator('.ant-select-single').nth(1).click(); await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable); await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable);
@ -196,6 +206,15 @@ export class ColumnPageObject extends BasePage {
}) })
.nth(0) .nth(0)
.click(); .click();
if (ltarView) {
await this.ltarOption.selectView({ ltarView: ltarView });
}
if (ltarFilters) {
await this.ltarOption.addFilters(ltarFilters);
}
break; break;
case 'User': case 'User':
break; break;

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

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

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

@ -362,9 +362,10 @@ export class ToolbarFilterPage extends BasePage {
default: default:
fillFilter = async () => { fillFilter = async () => {
await this.rootPage.locator('.nc-filter-value-select > input').last().clear({ force: true }); await this.get().locator('.nc-filter-value-select > input').last().clear({ force: true });
return this.rootPage.locator('.nc-filter-value-select > input').last().fill(value); return this.get().locator('.nc-filter-value-select > input').last().fill(value);
}; };
if (!skipWaitingResponse) {
await this.waitForResponse({ await this.waitForResponse({
uiAction: fillFilter, uiAction: fillFilter,
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
@ -372,6 +373,9 @@ export class ToolbarFilterPage extends BasePage {
}); });
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading(); await this.toolbar.parent.waitLoading();
} else {
await fillFilter();
}
break; break;
} }
} }

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

@ -162,6 +162,7 @@ test.describe('Toolbar operations (GRID)', () => {
// GroupBy Category Descending Order // GroupBy Category Descending Order
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); 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 }); await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false });
// Hide Field and Verify // 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(); await dashboard.grid.toolbar.clickFilter();
// kludge: wait for 2 seconds to avoid flaky test
await page.waitForTimeout(2000);
await page.goto(sharedLink); await page.goto(sharedLink);
await page.reload(); await page.reload();
@ -84,6 +87,9 @@ test.describe('Shared view', () => {
await dashboard.grid.toolbar.filter.reset(); 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.goto(sharedLink);
await page.reload(); await page.reload();
@ -104,6 +110,9 @@ test.describe('Shared view', () => {
await dashboard.grid.toolbar.groupBy.remove({ index: 0 }); 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.goto(sharedLink);
await page.reload(); await page.reload();
// kludge: wait for 3 seconds to avoid flaky test // kludge: wait for 3 seconds to avoid flaky test

Loading…
Cancel
Save