Browse Source

Merge branch 'develop' into fix/minor-bug-fixes

pull/7270/head
Ramesh Mane 9 months ago committed by GitHub
parent
commit
365499c11c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 26
      packages/nc-gui/assets/style.scss
  2. 4
      packages/nc-gui/components/account/UsersModal.vue
  3. 4
      packages/nc-gui/components/cell/Checkbox.vue
  4. 2
      packages/nc-gui/components/cell/Currency.vue
  5. 2
      packages/nc-gui/components/cell/DatePicker.vue
  6. 1
      packages/nc-gui/components/cell/DateTimePicker.vue
  7. 51
      packages/nc-gui/components/cell/MultiSelect.vue
  8. 48
      packages/nc-gui/components/cell/Percent.vue
  9. 23
      packages/nc-gui/components/cell/Rating.vue
  10. 5
      packages/nc-gui/components/cell/RichText.vue
  11. 63
      packages/nc-gui/components/cell/SingleSelect.vue
  12. 19
      packages/nc-gui/components/cell/TextArea.vue
  13. 435
      packages/nc-gui/components/cell/User.vue
  14. 2
      packages/nc-gui/components/cell/YearPicker.vue
  15. 3
      packages/nc-gui/components/cell/attachment/index.vue
  16. 1
      packages/nc-gui/components/nc/Button.vue
  17. 94
      packages/nc-gui/components/project/AccessSettings.vue
  18. 35
      packages/nc-gui/components/project/View.vue
  19. 3
      packages/nc-gui/components/smartsheet/Cell.vue
  20. 11
      packages/nc-gui/components/smartsheet/DivDataCell.vue
  21. 8
      packages/nc-gui/components/smartsheet/column/DecimalOptions.vue
  22. 7
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  23. 20
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  24. 819
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  25. 40
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  26. 66
      packages/nc-gui/components/smartsheet/column/UserOptions.vue
  27. 32
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  28. 12
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  29. 19
      packages/nc-gui/components/smartsheet/grid/Table.vue
  30. 6
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  31. 7
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  32. 5
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  33. 23
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  34. 28
      packages/nc-gui/components/virtual-cell/Links.vue
  35. 14
      packages/nc-gui/components/virtual-cell/Lookup.vue
  36. 2
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  37. 2
      packages/nc-gui/components/webhook/Editor.vue
  38. 4
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  39. 3
      packages/nc-gui/composables/useData.ts
  40. 18
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  41. 12
      packages/nc-gui/composables/useMultiSelect/index.ts
  42. 8
      packages/nc-gui/composables/useSharedView.ts
  43. 11
      packages/nc-gui/composables/useViewGroupBy.ts
  44. 1
      packages/nc-gui/lib/types.ts
  45. 7
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  46. 7
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  47. 4
      packages/nc-gui/store/base.ts
  48. 35
      packages/nc-gui/store/bases.ts
  49. 3
      packages/nc-gui/store/users.ts
  50. 1
      packages/nc-gui/utils/cell.ts
  51. 4
      packages/nc-gui/utils/columnUtils.ts
  52. 398
      packages/nc-gui/utils/filterUtils.ts
  53. 623
      packages/nc-gui/utils/formulaUtils.ts
  54. 5
      packages/nc-gui/utils/iconUtils.ts
  55. 5
      packages/nocodb-sdk/jest.config.js
  56. 7
      packages/nocodb-sdk/package.json
  57. 19
      packages/nocodb-sdk/src/lib/Api.ts
  58. 1
      packages/nocodb-sdk/src/lib/UITypes.ts
  59. 77
      packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts
  60. 1791
      packages/nocodb-sdk/src/lib/formulaHelpers.ts
  61. 42
      packages/nocodb-sdk/src/lib/helperFunctions.ts
  62. 1
      packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts
  63. 2
      packages/nocodb-sdk/src/lib/sqlUi/MysqlUi.ts
  64. 1
      packages/nocodb-sdk/src/lib/sqlUi/OracleUi.ts
  65. 1
      packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts
  66. 5
      packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts
  67. 2
      packages/nocodb-sdk/tsconfig.json
  68. 1
      packages/nocodb/src/cache/CacheMgr.ts
  69. 3
      packages/nocodb/src/cache/NocoCache.ts
  70. 16
      packages/nocodb/src/cache/RedisCacheMgr.ts
  71. 16
      packages/nocodb/src/cache/RedisMockCacheMgr.ts
  72. 13
      packages/nocodb/src/controllers/base-users.controller.ts
  73. 289
      packages/nocodb/src/db/BaseModelSqlv2.ts
  74. 82
      packages/nocodb/src/db/conditionV2.ts
  75. 151
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  76. 160
      packages/nocodb/src/db/functionMappings/commonFns.ts
  77. 15
      packages/nocodb/src/db/functionMappings/mysql.ts
  78. 65
      packages/nocodb/src/db/functionMappings/pg.ts
  79. 25
      packages/nocodb/src/db/sortV2.ts
  80. 14
      packages/nocodb/src/helpers/columnHelpers.ts
  81. 10
      packages/nocodb/src/helpers/initAdminFromEnv.ts
  82. 2
      packages/nocodb/src/helpers/syncMigration.ts
  83. 2
      packages/nocodb/src/meta/meta.service.ts
  84. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  85. 16
      packages/nocodb/src/meta/migrations/v2/nc_038_formula_parsed_tree_column.ts
  86. 27
      packages/nocodb/src/models/Base.ts
  87. 252
      packages/nocodb/src/models/BaseUser.ts
  88. 7
      packages/nocodb/src/models/Column.ts
  89. 30
      packages/nocodb/src/models/FormulaColumn.ts
  90. 66
      packages/nocodb/src/models/User.ts
  91. 28
      packages/nocodb/src/modules/datas/helpers.ts
  92. 4
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts
  93. 2
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts
  94. 11
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  95. 6
      packages/nocodb/src/schema/swagger-v2.json
  96. 27
      packages/nocodb/src/schema/swagger.json
  97. 2
      packages/nocodb/src/services/api-docs/swagger/getSwaggerColumnMetas.ts
  98. 2
      packages/nocodb/src/services/api-docs/swaggerV2/getSwaggerColumnMetas.ts
  99. 42
      packages/nocodb/src/services/base-users/base-users.service.ts
  100. 2
      packages/nocodb/src/services/bases.service.ts
  101. Some files were not shown because too many files have changed in this diff Show More

26
packages/nc-gui/assets/style.scss

@ -57,7 +57,7 @@ main {
}
.mobile {
.nc-scrollbar-md, .nc-scrollbar-x-md, .nc-scrollbar-dark-md, .nc-scrollbar-x-md-dark, .nc-scrollbar-x-lg {
.nc-scrollbar-md, .nc-scrollbar-lg, .nc-scrollbar-x-md, .nc-scrollbar-dark-md, .nc-scrollbar-x-md-dark, .nc-scrollbar-x-lg {
&::-webkit-scrollbar {
width: 0px;
}
@ -88,6 +88,30 @@ main {
}
}
.nc-scrollbar-lg {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 10px;
height: 10px;
}
&::-webkit-scrollbar-track-piece {
width: 0px;
}
&::-webkit-scrollbar {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
width: 4px;
@apply bg-gray-200;
}
&::-webkit-scrollbar-thumb:hover {
@apply bg-gray-300;
}
}
.nc-scrollbar-x-md {
overflow-x: scroll;
scrollbar-width: thin !important;

4
packages/nc-gui/components/account/UsersModal.vue

@ -34,6 +34,8 @@ const { copy } = useCopy()
const { dashboardUrl } = useDashboard()
const { clearBasesUser } = useBases()
const usersData = ref<Users>({ emails: '', role: OrgUserRoles.VIEWER, invitationToken: undefined })
const formRef = ref()
@ -64,6 +66,8 @@ const saveUser = async () => {
// Successfully updated the user details
message.success(t('msg.success.userAdded'))
clearBasesUser()
} catch (e: any) {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))

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

@ -84,7 +84,7 @@ useSelectedCellKeyupListener(active, (e) => {
<template>
<div
class="flex cursor-pointer w-full h-full items-center"
class="flex cursor-pointer w-full h-full items-center focus:outline-transparent"
:class="{
'w-full flex-start pl-2': isForm || isGallery || isExpandedFormOpen,
'w-full justify-center': !isForm && !isGallery && !isExpandedFormOpen,
@ -94,7 +94,9 @@ useSelectedCellKeyupListener(active, (e) => {
:style="{
height: isForm || isExpandedFormOpen || isGallery ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
tabindex="0"
@click="onClick(false, $event)"
@keydown.enter.stop="onClick(false, $event)"
>
<div
class="flex items-center"

2
packages/nc-gui/components/cell/Currency.vue

@ -78,7 +78,7 @@ onMounted(() => {
:ref="focus"
v-model="vModel"
type="number"
class="w-full h-full text-sm border-none rounded-md outline-none"
class="w-full h-full text-sm border-none rounded-md outline-none focus:outline-transparent focus:ring-0"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="submitCurrency"
@keydown.down.stop

2
packages/nc-gui/components/cell/DatePicker.vue

@ -238,6 +238,7 @@ const clickHandler = () => {
<a-date-picker
v-model:value="localState"
:picker="picker"
tabindex="0"
:bordered="false"
class="!w-full !px-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull }"
@ -249,6 +250,7 @@ const clickHandler = () => {
:open="isOpen"
@click="clickHandler"
@update:open="updateOpen"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

1
packages/nc-gui/components/cell/DateTimePicker.vue

@ -303,6 +303,7 @@ const isColDisabled = computed(() => {
:open="isOpen"
@click="clickHandler"
@ok="okHandler"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

51
packages/nc-gui/components/cell/MultiSelect.vue

@ -1,12 +1,10 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
@ -53,14 +51,14 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value)
const active = computed(() => activeCell.value || isEditable.value || isForm.value)
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
@ -71,6 +69,8 @@ const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isFocusing = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const searchVal = ref<string | null>()
@ -180,9 +180,7 @@ watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
if (n) {
aselect.value?.$el?.querySelector('input')?.focus()
}
}
@ -299,22 +297,11 @@ const onTagClick = (e: Event, onClose: Function) => {
}
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
if (isFocusing.value) return
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
// close dropdown if clicked outside of dropdown
@ -341,6 +328,26 @@ const selectedOpts = computed(() => {
return selectedOptions
}, [])
})
const onKeyDown = (e: KeyboardEvent) => {
// Tab
if (e.key === 'Tab') {
isOpen.value = false
return
}
e.stopPropagation()
}
const onFocus = () => {
isFocusing.value = true
setTimeout(() => {
isFocusing.value = false
}, 250)
isOpen.value = true
}
</script>
<template>
@ -403,7 +410,9 @@ const selectedOpts = computed(() => {
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
@search="search"
@keydown.stop
@keydown="onKeyDown"
@focus="onFocus"
@blur="isOpen = false"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />

48
packages/nc-gui/components/cell/Percent.vue

@ -20,6 +20,8 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const _vModel = useVModel(props, 'modelValue', emits)
const wrapperRef = ref<HTMLElement>()
const vModel = computed({
get: () => _vModel.value,
set: (value) => {
@ -56,6 +58,18 @@ const onBlur = () => {
const onFocus = () => {
cellFocused.value = true
editEnabled.value = true
expandedEditEnabled.value = true
}
const onWrapperFocus = () => {
cellFocused.value = true
editEnabled.value = true
expandedEditEnabled.value = true
nextTick(() => {
wrapperRef.value?.querySelector('input')?.focus()
})
}
const onMouseover = () => {
@ -67,10 +81,41 @@ const onMouseleave = () => {
expandedEditEnabled.value = false
}
}
const onTabPress = (e: KeyboardEvent) => {
if (e.shiftKey) {
e.preventDefault()
// Shift + Tab does not work for percent cell
// so we manually focus on the last form item
const focusesNcCellIndex = Array.from(document.querySelectorAll('.nc-expanded-form-row .nc-data-cell')).findIndex((el) => {
return el.querySelector('.nc-filter-value-select') === wrapperRef.value
})
if (focusesNcCellIndex >= 0) {
const nodes = document.querySelectorAll('.nc-expanded-form-row .nc-data-cell')
for (let i = focusesNcCellIndex - 1; i >= 0; i--) {
const lastFormItem = nodes[i].querySelector('[tabindex="0"]') as HTMLElement
if (lastFormItem) {
lastFormItem.focus()
break
}
}
}
}
}
</script>
<template>
<div class="nc-filter-value-select w-full" @mouseover="onMouseover" @mouseleave="onMouseleave">
<div
ref="wrapperRef"
tabindex="0"
class="nc-filter-value-select w-full focus:outline-transparent"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
@focus="onWrapperFocus"
>
<input
v-if="(!isExpandedFormOpen && editEnabled) || (isExpandedFormOpen && expandedEditEnabled)"
:ref="focus"
@ -86,6 +131,7 @@ const onMouseleave = () => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.tab="onTabPress"
@selectstart.capture.stop
@mousedown.stop
/>

23
packages/nc-gui/components/cell/Rating.vue

@ -36,14 +36,37 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
vModel.value = +e.key === +vModel.value ? 0 : +e.key
}
})
const onKeyPress = (e: KeyboardEvent) => {
if (/^\d$/.test(e.key)) {
e.stopPropagation()
vModel.value = +e.key === +vModel.value ? 0 : +e.key
}
}
const rateDomRef = ref()
// Remove tabindex from rate inputs set by antd
watch(rateDomRef, () => {
if (!rateDomRef.value) return
const rateInputs = rateDomRef.value.$el.querySelectorAll('div[role="radio"]')
if (!rateInputs) return
for (let i = 0; i < rateInputs.length; i++) {
rateInputs[i].setAttribute('tabindex', '-1')
}
})
</script>
<template>
<a-rate
ref="rateDomRef"
v-model:value="vModel"
:disabled="readonly"
:count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`"
@keydown="onKeyPress"
>
<template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />

5
packages/nc-gui/components/cell/RichText.vue

@ -19,6 +19,8 @@ const props = defineProps<{
const emits = defineEmits(['update:value'])
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', {
@ -171,7 +173,8 @@ watch(editorDom, () => {
class="flex flex-col nc-textarea-rich-editor w-full"
:class="{
'ml-1 mt-2.5 flex-grow': props.fullMode,
'nc-scrollbar-md': !props.fullMode && !props.readonly,
'nc-scrollbar-md': (!props.fullMode && !props.readonly) || isExpandedFormOpen,
'flex-grow': isExpandedFormOpen,
}"
/>
</div>

63
packages/nc-gui/components/cell/SingleSelect.vue

@ -1,12 +1,10 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
@ -47,9 +45,11 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value)
const active = computed(() => activeCell.value || isEditable.value || isForm.value)
const aselect = ref<typeof AntSelect>()
@ -61,8 +61,6 @@ const isPublic = inject(IsPublicInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { $api } = useNuxtApp()
const searchVal = ref()
@ -77,6 +75,8 @@ const { isPg, isMysql } = useBase()
// temporary until it's add the option to column meta
const tempSelectedOptState = ref<string>()
const isFocusing = ref(false)
const isNewOptionCreateEnabled = computed(() => !isPublic.value && !disableOptionCreation && isUIAllowed('fieldEdit'))
const options = computed<(SelectOptionType & { value: string })[]>(() => {
@ -97,7 +97,7 @@ const isOptionMissing = computed(() => {
return (options.value ?? []).every((op) => op.title !== searchVal.value)
})
const hasEditRoles = computed(() => isUIAllowed('dataEdit'))
const hasEditRoles = computed(() => isUIAllowed('dataEdit') || isForm.value)
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
@ -215,6 +215,14 @@ const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.stopPropagation()
}
if (e.key === 'Escape') {
isOpen.value = false
setTimeout(() => {
aselect.value?.$el.querySelector('.ant-select-selection-search > input').focus()
}, 100)
}
}
const onSelect = () => {
@ -222,8 +230,6 @@ const onSelect = () => {
isEditable.value = false
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = (e: Event) => {
// todo: refactor
// check clicked element is clear icon
@ -234,19 +240,11 @@ const toggleMenu = (e: Event) => {
vModel.value = ''
return e.stopPropagation()
}
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
if (isFocusing.value) return
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
@ -259,12 +257,27 @@ useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => {
return options.value.find((o) => o.value === vModel.value || o.value === vModel.value?.trim())
})
const onFocus = () => {
isFocusing.value = true
setTimeout(() => {
isFocusing.value = false
}, 250)
isOpen.value = true
}
</script>
<template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
class="h-full w-full flex items-center nc-single-select focus:outline-transparent"
:class="{ 'read-only': readOnly }"
@click="toggleMenu"
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!(active || isEditable)" class="w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -293,7 +306,7 @@ const selectedOpt = computed(() => {
</a-tag>
</div>
<a-select
<NcSelect
v-else
ref="aselect"
v-model:value="vModel"
@ -304,12 +317,14 @@ const selectedOpt = computed(() => {
:bordered="false"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:show-arrow="hasEditRoles && !readOnly && active && vModel === null"
:dropdown-class-name="`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
:show-search="!isMobileMode && isOpen && active"
:show-arrow="hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && active ? 'active' : ''}`"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"
@blur="isOpen = false"
@focus="onFocus"
>
<a-select-option
v-for="op of options"
@ -355,7 +370,7 @@ const selectedOpt = computed(() => {
</div>
</div>
</a-select-option>
</a-select>
</NcSelect>
</div>
</template>

19
packages/nc-gui/components/cell/TextArea.vue

@ -58,6 +58,8 @@ const isDragging = ref(false)
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLTextAreaElement)?.focus()
const height = computed(() => {
if (isExpandedFormOpen.value) return 36 * 4
if (!rowHeight.value || rowHeight.value === 1) return 36
return rowHeight.value * 36
@ -169,16 +171,16 @@ watch(editEnabled, () => {
<template>
<NcDropdown
v-model:visible="isVisible"
class="overflow-visible"
class="overflow-hidden group"
:trigger="[]"
placement="bottomLeft"
:overlay-class-name="isVisible ? 'nc-textarea-dropdown-active' : undefined"
>
<div
class="flex flex-row pt-0.5 w-full rich-wrapper"
class="flex flex-row pt-0.5 w-full rich-wrapper long-text-wrapper"
:class="{
'min-h-10': rowHeight !== 1,
'min-h-6.5': rowHeight === 1,
'min-h-10': rowHeight !== 1 || isExpandedFormOpen,
'min-h-9': rowHeight === 1 && !isExpandedFormOpen,
'h-full': isForm,
}"
>
@ -198,7 +200,7 @@ watch(editEnabled, () => {
:ref="focus"
v-model="vModel"
rows="4"
class="h-full w-full outline-none border-none"
class="h-full w-full outline-none border-none nc-scrollbar-lg"
:class="{
'p-2': editEnabled,
'py-1 h-full': isForm,
@ -240,7 +242,12 @@ watch(editEnabled, () => {
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 bottom-1 hidden nc-text-area-expand-btn"
:class="{ 'right-0 bottom-1': editEnabled, '!bottom-0': !isRichMode }"
:class="{
'right-0 bottom-1': editEnabled,
'!bottom-0': !isRichMode,
'top-1 hidden !group-hover:block': isExpandedFormOpen,
'bottom-1': !isExpandedFormOpen,
}"
>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">

435
packages/nc-gui/components/cell/User.vue

@ -0,0 +1,435 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { UserFieldRecordType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
IsKanbanInj,
ReadonlyInj,
RowHeightInj,
computed,
h,
inject,
isDrawerOrModalExist,
onMounted,
ref,
useEventListener,
useRoles,
useSelectedCellKeyupListener,
watch,
} from '#imports'
import MdiCloseCircle from '~icons/mdi/close-circle'
interface Props {
modelValue?: UserFieldRecordType[] | string | null
rowIndex?: number
location?: 'cell' | 'filter'
forceMulti?: boolean
}
const { modelValue, forceMulti } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMobileMode } = useGlobal()
const meta = inject(MetaInj)!
const column = inject(ColumnInj)!
const readOnly = inject(ReadonlyInj)!
const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : []))
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value)
const isForm = inject(IsFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isMultiple = computed(() => forceMulti || (column.value.meta as { is_multi: boolean; notify: boolean })?.is_multi)
const rowHeight = inject(RowHeightInj, ref(undefined))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const searchVal = ref<string | null>()
const { isUIAllowed } = useRoles()
const options = computed<UserFieldRecordType[]>(() => {
const collaborators: UserFieldRecordType[] = []
collaborators.push(
...(baseUsers.value?.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
deleted: user.deleted,
})) || []),
)
return collaborators
})
const hasEditRoles = computed(() => isUIAllowed('dataEdit'))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
const vModel = computed({
get: () => {
let selected: { label: string; value: string }[] = []
if (typeof modelValue === 'string') {
const idsOrMails = modelValue.split(',').map((idOrMail) => idOrMail.trim())
selected = idsOrMails.reduce((acc, idOrMail) => {
const user = options.value.find((u) => u.id === idOrMail || u.email === idOrMail)
if (user) {
acc.push({
label: user?.display_name || user?.email,
value: user.id,
})
}
return acc
}, [] as { label: string; value: string }[])
} else {
selected =
modelValue?.reduce((acc, item) => {
const label = item?.display_name || item?.email
if (label) {
acc.push({
label,
value: item.id,
})
}
return acc
}, [] as { label: string; value: string }[]) || []
}
return selected
},
set: (val) => {
const value: string[] = []
if (val && val.length) {
val.forEach((item) => {
// @ts-expect-error antd select returns string[] instead of { label: string, value: string }[]
const user = options.value.find((u) => u.id === item)
if (user) {
value.push(user.id)
}
})
}
if (isMultiple.value) {
emit('update:modelValue', val?.length ? value.join(',') : null)
} else {
emit('update:modelValue', val?.length ? value[value.length - 1] : null)
isOpen.value = false
}
},
})
watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
}
})
// set isOpen to false when active cell is changed
watch(active, (n, _o) => {
if (!n) isOpen.value = false
})
useSelectedCellKeyupListener(activeCell, (e) => {
switch (e.key) {
case 'Escape':
isOpen.value = false
break
case 'Enter':
if (editAllowed.value && active.value && !isOpen.value) {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
break
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft':
case 'Delete':
// skip
break
default:
if (!editAllowed.value) {
e.preventDefault()
break
}
// toggle only if char key pressed
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation()
isOpen.value = true
}
break
}
})
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
}
const onTagClick = (e: Event, onClose: Function) => {
// check clicked element is remove icon
if (
(e.target as HTMLElement)?.classList.contains('ant-tag-close-icon') ||
(e.target as HTMLElement)?.closest('.ant-tag-close-icon')
) {
e.stopPropagation()
onClose()
}
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
// close dropdown if clicked outside of dropdown
if (
isOpen.value &&
aselect.value &&
!aselect.value.$el.contains(e.target) &&
!document.querySelector('.nc-dropdown-user-select-cell.active')?.contains(e.target as Node)
) {
// loose focus when clicked outside
isEditable.value = false
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
// search with email
const filterOption = (input: string, option: any) => {
const opt = options.value.find((o) => o.id === option.value)
const searchVal = opt?.display_name || opt?.email
if (searchVal) {
return searchVal.toLowerCase().includes(input.toLowerCase())
}
}
</script>
<template>
<div class="nc-user-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.label }}
</span>
</a-tag>
</template>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-user-select-cell ${isOpen ? 'active' : ''}`"
:filter-option="filterOption"
@search="search"
@keydown.stop
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
</template>
<template v-for="op of options" :key="op.id || op.email">
<a-select-option
v-if="!op.deleted"
:value="op.id"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.email}`"
@click.stop
>
<a-tag class="rounded-tag" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.display_name?.length ? op.display_name : op.email }}
</span>
</a-tag>
</a-select-option>
</template>
<template #tagRender="{ label, value: val, onClose }">
<a-tag
v-if="options.find((el) => el.id === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
color="'#ccc'"
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
>
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ label }}
</span>
</a-tag>
</template>
</a-select>
</div>
</template>
<style scoped lang="scss">
.ms-close-icon {
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
font-size: 12px;
font-style: normal;
height: 12px;
line-height: 1;
text-align: center;
text-transform: none;
transition: color 0.3s ease, opacity 0.15s ease;
width: 12px;
z-index: 1;
margin-right: -6px;
margin-left: 3px;
}
.ms-close-icon:before {
display: block;
}
.ms-close-icon:hover {
color: rgba(0, 0, 0, 0.45);
}
.read-only {
.ms-close-icon {
display: none;
}
}
.rounded-tag {
@apply bg-gray-200 py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
}
:deep(.ant-tag-close-icon) {
@apply "text-slate-500";
}
:deep(.ant-select-selection-overflow-item) {
@apply "flex overflow-hidden";
}
:deep(.ant-select-selection-overflow) {
@apply flex-nowrap overflow-hidden;
}
.nc-user-select:not(.read-only) {
:deep(.ant-select-selector),
:deep(.ant-select-selector input) {
@apply "!cursor-pointer";
}
}
:deep(.ant-select-selector) {
@apply !px-0;
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}
</style>

2
packages/nc-gui/components/cell/YearPicker.vue

@ -113,6 +113,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
<template>
<a-date-picker
v-model:value="localState"
tabindex="0"
picker="year"
:bordered="false"
class="!w-full !px-1 !border-none"
@ -125,6 +126,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
@click="open = (active || editable) && !open"
@change="open = (active || editable) && !open"
@ok="open = !open"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

3
packages/nc-gui/components/cell/attachment/index.vue

@ -180,7 +180,6 @@ const onImageClick = (item: any) => {
<template>
<div
ref="attachmentCellRef"
tabindex="0"
:style="{
height: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
@ -207,7 +206,9 @@ const onImageClick = (item: any) => {
:class="{ 'sm:(mx-auto px-4) xs:(w-full min-w-8)': !visibleItems.length }"
class="group cursor-pointer py-1 flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
data-testid="attachment-cell-file-picker-button"
tabindex="0"
@click="open"
@keydown.enter="open"
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

1
packages/nc-gui/components/nc/Button.vue

@ -84,6 +84,7 @@ useEventListener(NcButton, 'mousedown', () => {
xxsmall: size === 'xxsmall',
focused: isFocused,
}"
:tabindex="props.disabled ? -1 : 0"
@focus="onFocus"
@blur="onBlur"
>

94
packages/nc-gui/components/project/AccessSettings.vue

@ -8,11 +8,10 @@ import {
timeAgo,
} from 'nocodb-sdk'
import type { WorkspaceUserRoles } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
import { isEeUI, storeToRefs } from '#imports'
const basesStore = useBases()
const { getProjectUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { activeProjectId } = storeToRefs(basesStore)
const { orgRoles, baseRoles } = useRoles()
@ -30,7 +29,6 @@ interface Collaborators {
const collaborators = ref<Collaborators[]>([])
const totalCollaborators = ref(0)
const userSearchText = ref('')
const currentPage = ref(0)
const isLoading = ref(false)
const isSearching = ref(false)
@ -38,52 +36,32 @@ const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>(
const loadCollaborators = async () => {
try {
currentPage.value += 1
const { users, totalRows } = await getProjectUsers({
const { users, totalRows } = await getBaseUsers({
baseId: activeProjectId.value!,
page: currentPage.value,
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)),
limit: 20,
force: true,
})
totalCollaborators.value = totalRows
collaborators.value = [
...collaborators.value,
...users.map((user: any) => ({
...user,
base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
? OrgUserRoles.SUPER_ADMIN
: user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
})),
...users
.filter((u: any) => !u?.deleted)
.map((user: any) => ({
...user,
base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
? OrgUserRoles.SUPER_ADMIN
: user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
})),
]
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadListData = async ($state: any) => {
const prevUsersCount = collaborators.value?.length || 0
if (collaborators.value?.length === totalCollaborators.value) {
$state.complete()
return
}
$state.loading()
// const oldPagesCount = currentPage.value || 0
await loadCollaborators()
if (prevUsersCount === collaborators.value?.length) {
$state.complete()
return
}
$state.loaded()
}
const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
try {
if (
@ -115,29 +93,6 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
}
}
watchDebounced(
userSearchText,
async () => {
isSearching.value = true
currentPage.value = 0
totalCollaborators.value = 0
collaborators.value = []
try {
await loadCollaborators()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isSearching.value = false
}
},
{
debounce: 300,
maxWait: 600,
},
)
onMounted(async () => {
isLoading.value = true
try {
@ -156,6 +111,10 @@ onMounted(async () => {
isLoading.value = false
}
})
const filteredCollaborators = computed(() =>
collaborators.value.filter((collab) => collab.email.toLowerCase().includes(userSearchText.value.toLowerCase())),
)
</script>
<template>
@ -177,7 +136,7 @@ onMounted(async () => {
</div>
<div
v-else-if="!collaborators?.length"
v-else-if="!filteredCollaborators?.length"
class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36"
>
<Empty description="$t('title.noMembersFound')" />
@ -192,7 +151,7 @@ onMounted(async () => {
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of collaborators"
v-for="(collab, i) of filteredCollaborators"
:key="i"
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center"
>
@ -224,17 +183,6 @@ onMounted(async () => {
</div>
</div>
</div>
<InfiniteLoading v-bind="$attrs" @infinite="loadListData">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
</div>
</template>
</div>

35
packages/nc-gui/components/project/View.vue

@ -5,9 +5,7 @@ import { isEeUI } from '#imports'
const basesStore = useBases()
const { getProjectUsers } = basesStore
const { openedProject, activeProjectId, baseUserCount } = storeToRefs(basesStore)
const { openedProject, activeProjectId, basesUser } = storeToRefs(basesStore)
const { activeTables } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
@ -32,24 +30,9 @@ const { isMobileMode } = useGlobal()
const baseSettingsState = ref('')
const userCount = isEeUI ? workspaceUserCount : baseUserCount
const updateBaseUserCount = async () => {
if (!baseUserCount || !isUIAllowed('newUser')) return
try {
const { totalRows } = await getProjectUsers({
baseId: activeProjectId.value!,
page: 1,
searchText: undefined,
limit: 20,
})
baseUserCount.value = totalRows
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const userCount = computed(() =>
isEeUI ? workspaceUserCount : activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0,
)
watch(
() => route.value.query?.page,
@ -82,16 +65,6 @@ watch(projectPageTab, () => {
}
})
watch(
() => route.value.params.baseId,
(newVal, oldVal) => {
if (newVal && oldVal === undefined) {
updateBaseUserCount()
}
},
{ immediate: true },
)
watch(
() => openedProject.value?.title,
() => {

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

@ -40,6 +40,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
provide,
ref,
@ -204,7 +205,6 @@ onUnmounted(() => {
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30 resize-y': isTextArea(column) && (isForm || isSurveyForm),
'!border-2 !border-brand-500': props.editEnabled && (isSurveyForm || isForm) && !isDrawerExist(),
},
]"
@keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"
@ -245,6 +245,7 @@ onUnmounted(() => {
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />

11
packages/nc-gui/components/smartsheet/DivDataCell.vue

@ -7,7 +7,16 @@ provide(CurrentCellInj, el)
</script>
<template>
<div ref="el" class="select-none">
<div ref="el" class="select-none nc-data-cell">
<slot />
</div>
</template>
<style lang="scss" scoped>
.nc-data-cell:focus-within {
@apply !border-1 !border-brand-500 !rounded-lg !shadow-none !ring-0;
}
.nc-data-cell {
@apply border-1 border-gray-200 overflow-hidden rounded-lg;
}
</style>

8
packages/nc-gui/components/smartsheet/column/DecimalOptions.vue

@ -30,6 +30,13 @@ onMounted(() => {
vModel.value.meta.precision = precisionFormats[0]
}
})
// update datatype precision when precision is less than the new value
// avoid downgrading precision if the new value is less than the current precision
// to avoid fractional part data loss(eg. 1.2345 -> 1.23)
const onPrecisionChange = (value: number) => {
vModel.value.dtxs = Math.max(value, vModel.value.dtxs)
}
</script>
<template>
@ -38,6 +45,7 @@ onMounted(() => {
v-if="vModel.meta?.precision"
v-model:value="vModel.meta.precision"
dropdown-class-name="nc-dropdown-decimal-format"
@change="onPrecisionChange"
>
<a-select-option v-for="(format, i) of precisionFormats" :key="i" :value="format">
<div class="flex gap-2 w-full justify-between items-center">

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

@ -34,6 +34,13 @@ const updateCdfValue = (cdf: string | null) => {
onMounted(() => {
updateCdfValue(vModel.value?.cdf ? vModel.value.cdf : null)
})
watch(
() => vModel.value.cdf,
(newValue) => {
cdfValue.value = newValue
},
)
</script>
<template>

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

@ -81,6 +81,10 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup, UITypes.Links]
// To close column type dropdown on escape and
// close modal only when the type popup is close
const isColumnTypeOpen = ref(false)
const geoDataToggleCondition = (t: { name: UITypes }) => {
if (!appInfo.value.ee) return true
@ -199,6 +203,8 @@ onMounted(() => {
})
const handleEscape = (event: KeyboardEvent): void => {
if (isColumnTypeOpen.value) return
if (event.key === 'Escape') emit('cancel')
}
@ -206,6 +212,16 @@ const isFieldsTab = computed(() => {
return openedViewsTab.value === 'field'
})
const onDropdownChange = (value: boolean) => {
if (value) {
isColumnTypeOpen.value = value
} else {
setTimeout(() => {
isColumnTypeOpen.value = value
}, 300)
}
}
if (props.fromTableExplorer) {
watch(
formState,
@ -224,7 +240,7 @@ if (props.fromTableExplorer) {
'bg-white': !props.fromTableExplorer,
'w-[400px]': !props.embedMode,
'!w-146': isTextArea(formState) && formState.meta?.richMode,
'!w-[600px]': 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,
'shadow-lg border-1 border-gray-200 shadow-gray-300 rounded-xl p-6': !embedMode,
}"
@ -275,6 +291,7 @@ if (props.fromTableExplorer) {
class="nc-column-type-input !rounded"
:disabled="isKanban || readOnly"
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-md border-gray-200"
@dropdown-visible-change="onDropdownChange"
@change="onUidtOrIdTypeChange"
@dblclick="showDeprecated = !showDeprecated"
>
@ -323,6 +340,7 @@ if (props.fromTableExplorer) {
<LazySmartsheetColumnLinkOptions v-if="isEdit && formState.uidt === UITypes.Links" v-model:value="formState" />
<LazySmartsheetColumnPercentOptions v-if="formState.uidt === UITypes.Percent" v-model:value="formState" />
<LazySmartsheetColumnSpecificDBTypeOptions v-if="formState.uidt === UITypes.SpecificDBType" />
<LazySmartsheetColumnUserOptions v-if="formState.uidt === UITypes.User" v-model:value="formState" :is-edit="isEdit" />
<SmartsheetColumnSelectOptions
v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect"
v-model:value="formState"

819
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -2,34 +2,28 @@
import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep'
import type { ColumnType, FormulaType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import {
FormulaError,
UITypes,
isLinksOrLTAR,
isNumericCol,
isSystemColumn,
jsepCurlyHook,
substituteColumnIdWithAliasInFormula,
validateDateWithUnknownFormat,
validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk'
import type { ColumnType, FormulaType } from 'nocodb-sdk'
import {
MetaInj,
NcAutocompleteTree,
computed,
formulaList,
formulaTypes,
formulas,
getUIDTIcon,
getWordUntilCaret,
iconMap,
inject,
insertAtCursor,
isDate,
nextTick,
onMounted,
ref,
storeToRefs,
useBase,
useColumnCreateStoreOrThrow,
useDebounceFn,
useI18n,
@ -52,59 +46,40 @@ const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCrea
const { t } = useI18n()
const baseStore = useBase()
const { tables } = storeToRefs(baseStore)
const { predictFunction: _predictFunction } = useNocoEe()
enum JSEPNode {
COMPOUND = 'Compound',
IDENTIFIER = 'Identifier',
MEMBER_EXP = 'MemberExpression',
LITERAL = 'Literal',
THIS_EXP = 'ThisExpression',
CALL_EXP = 'CallExpression',
UNARY_EXP = 'UnaryExpression',
BINARY_EXP = 'BinaryExpression',
ARRAY_EXP = 'ArrayExpression',
}
const meta = inject(MetaInj, ref())
const supportedColumns = computed(
() => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [],
)
const { metas } = useMetas()
const { getMeta } = useMetas()
const refTables = computed(() => {
if (!tables.value || !tables.value.length || !meta.value || !meta.value.columns) {
return []
}
const _refTables = meta.value.columns
.filter((column) => isLinksOrLTAR(column) && !column.system && column.source_id === meta.value?.source_id)
.map((column) => ({
col: column.colOptions,
column,
...tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id),
}))
.filter((table) => (table.col as LinkToAnotherRecordType)?.fk_related_model_id === table.id && !table.mm)
return _refTables as Required<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]
})
const suggestionPreviewed = ref<Record<any, string> | undefined>()
const validators = {
formula_raw: [
{
validator: (_: any, formula: any) => {
return new Promise<void>((resolve, reject) => {
if (!formula?.trim()) return reject(new Error('Required'))
const res = parseAndValidateFormula(formula)
if (res !== true) {
return reject(new Error(res))
return (async () => {
if (!formula?.trim()) throw new Error('Required')
try {
await validateFormulaAndExtractTreeWithType({
column: column.value,
formula,
columns: supportedColumns.value,
clientOrSqlUi: sqlUi.value,
getMeta,
})
} catch (e: any) {
if (e instanceof FormulaError && e.extra?.key) {
throw new Error(t(e.extra.key, e.extra))
}
throw new Error(e.message)
}
resolve()
})
})()
},
},
],
@ -120,6 +95,8 @@ const formulaRef = ref()
const sugListRef = ref()
const variableListRef = ref<(typeof AntListItem)[]>([])
const sugOptionsRef = ref<(typeof AntListItem)[]>([])
const wordToComplete = ref<string | undefined>('')
@ -143,6 +120,7 @@ const suggestionsList = computed(() => {
description: formulas[fn].description,
syntax: formulas[fn].syntax,
examples: formulas[fn].examples,
docsUrl: formulas[fn].docsUrl,
})),
...supportedColumns.value
.filter((c) => {
@ -176,521 +154,13 @@ const acTree = computed(() => {
return ref
})
function parseAndValidateFormula(formula: string) {
try {
const parsedTree = jsep(formula)
const metaErrors = validateAgainstMeta(parsedTree)
if (metaErrors.size) {
return [...metaErrors].join(', ')
}
return true
} catch (e: any) {
return e.message
}
}
function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) {
if (parsedTree.type === JSEPNode.CALL_EXP) {
const calleeName = parsedTree.callee.name.toUpperCase()
// validate function name
if (!availableFunctions.includes(calleeName)) {
errors.add(t('msg.formula.functionNotAvailable', { function: calleeName }))
}
// validate arguments
const validation = formulas[calleeName] && formulas[calleeName].validation
if (validation && validation.args) {
if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) {
errors.add(t('msg.formula.requiredArgumentsFormula', { requiredArguments: validation.args.rqd, calleeName }))
} else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) {
errors.add(t('msg.formula.minRequiredArgumentsFormula', { minRequiredArguments: validation.args.min, calleeName }))
} else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) {
errors.add(t('msg.formula.maxRequiredArgumentsFormula', { maxRequiredArguments: validation.args.max, calleeName }))
}
}
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstMeta(arg, errors))
// validate data type
if (parsedTree.callee.type === JSEPNode.IDENTIFIER) {
const expectedType = formulas[calleeName.toUpperCase()].type
if (expectedType === formulaTypes.NUMERIC) {
if (calleeName === 'WEEKDAY') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.firstParamWeekDayHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[1] = startDayOfWeek (optional)
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.STRING,
(v: any) => {
if (
typeof v !== 'string' ||
!['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes(v.toLowerCase())
) {
typeErrors.add(t('msg.formula.secondParamWeekDayHaveDate'))
}
},
typeErrors,
)
} else {
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstType(arg, expectedType, null, typeErrors))
}
} else if (expectedType === formulaTypes.DATE) {
if (calleeName === 'DATEADD') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.firstParamDateAddHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[1] = numeric
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.NUMERIC,
(v: any) => {
if (typeof v !== 'number') {
typeErrors.add(t('msg.formula.secondParamDateAddHaveNumber'))
}
},
typeErrors,
)
// parsedTree.arguments[2] = ["day" | "week" | "month" | "year"]
validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
(v: any) => {
if (!['day', 'week', 'month', 'year'].includes(v)) {
typeErrors.add(typeErrors.add(t('msg.formula.thirdParamDateAddHaveDate')))
}
},
typeErrors,
)
} else if (calleeName === 'DATETIME_DIFF') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.firstParamDateDiffHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[1] = date
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.secondParamDateDiffHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[2] = ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"]
validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
(v: any) => {
if (
![
'milliseconds',
'ms',
'seconds',
's',
'minutes',
'm',
'hours',
'h',
'days',
'd',
'weeks',
'w',
'months',
'M',
'quarters',
'Q',
'years',
'y',
].includes(v)
) {
typeErrors.add(t('msg.formula.thirdParamDateDiffHaveDate'))
}
},
typeErrors,
)
}
}
}
errors = new Set([...errors, ...typeErrors])
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
if (supportedColumns.value.filter((c) => !column || column.value?.id !== c.id).every((c) => c.title !== parsedTree.name)) {
errors.add(
t('msg.formula.columnNotAvailable', {
columnName: parsedTree.name,
}),
)
}
// check circular reference
// e.g. formula1 -> formula2 -> formula1 should return circular reference error
// get all formula columns excluding itself
const formulaPaths = supportedColumns.value
.filter((c) => c.id !== column.value?.id && c.uidt === UITypes.Formula)
.reduce((res: Record<string, any>[], c: Record<string, any>) => {
// in `formula`, get all the (unique) target neighbours
// i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type
const neighbours = [
...new Set(
(c.colOptions.formula.match(/cl_\w{14}/g) || []).filter(
(colId: string) =>
supportedColumns.value.filter((col: ColumnType) => col.id === colId && col.uidt === UITypes.Formula).length,
),
),
]
if (neighbours.length > 0) {
// e.g. formula column 1 -> [formula column 2, formula column3]
res.push({ [c.id]: neighbours })
}
return res
}, [])
// include target formula column (i.e. the one to be saved if applicable)
const targetFormulaCol = supportedColumns.value.find(
(c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula,
)
if (targetFormulaCol && column.value?.id) {
formulaPaths.push({
[column.value?.id as string]: [targetFormulaCol.id],
})
}
const vertices = formulaPaths.length
if (vertices > 0) {
// perform kahn's algo for cycle detection
const adj = new Map()
const inDegrees = new Map()
// init adjacency list & indegree
for (const [_, v] of Object.entries(formulaPaths)) {
const src = Object.keys(v)[0]
const neighbours = v[src]
inDegrees.set(src, inDegrees.get(src) || 0)
for (const neighbour of neighbours) {
adj.set(src, (adj.get(src) || new Set()).add(neighbour))
inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1)
}
}
const queue: string[] = []
// put all vertices with in-degree = 0 (i.e. no incoming edges) to queue
inDegrees.forEach((inDegree, col) => {
if (inDegree === 0) {
// in-degree = 0 means we start traversing from this node
queue.push(col)
}
})
// init count of visited vertices
let visited = 0
// BFS
while (queue.length !== 0) {
// remove a vertex from the queue
const src = queue.shift()
// if this node has neighbours, increase visited by 1
const neighbours = adj.get(src) || new Set()
if (neighbours.size > 0) {
visited += 1
}
// iterate each neighbouring nodes
neighbours.forEach((neighbour: string) => {
// decrease in-degree of its neighbours by 1
inDegrees.set(neighbour, inDegrees.get(neighbour) - 1)
// if in-degree becomes 0
if (inDegrees.get(neighbour) === 0) {
// then put the neighboring node to the queue
queue.push(neighbour)
}
})
}
// vertices not same as visited = cycle found
if (vertices !== visited) {
errors.add(t('msg.formula.cantSaveCircularReference'))
}
}
} else if (parsedTree.type === JSEPNode.BINARY_EXP) {
if (!availableBinOps.includes(parsedTree.operator)) {
errors.add(t('msg.formula.operationNotAvailable', { operation: parsedTree.operator }))
}
validateAgainstMeta(parsedTree.left, errors)
validateAgainstMeta(parsedTree.right, errors)
} else if (parsedTree.type === JSEPNode.LITERAL || parsedTree.type === JSEPNode.UNARY_EXP) {
// do nothing
} else if (parsedTree.type === JSEPNode.COMPOUND) {
if (parsedTree.body.length) {
errors.add(t('msg.formula.cantSaveFieldFormulaInvalid'))
}
} else {
errors.add(t('msg.formula.cantSaveFieldFormulaInvalid'))
}
return errors
}
function validateAgainstType(parsedTree: any, expectedType: string, func: any, typeErrors = new Set()) {
if (parsedTree === false || typeof parsedTree === 'undefined') {
return typeErrors
}
if (parsedTree.type === JSEPNode.LITERAL) {
if (typeof func === 'function') {
func(parsedTree.value)
} else if (expectedType === formulaTypes.NUMERIC) {
if (typeof parsedTree.value !== 'number') {
typeErrors.add(t('msg.formula.numericTypeIsExpected'))
}
} else if (expectedType === formulaTypes.STRING) {
if (typeof parsedTree.value !== 'string') {
typeErrors.add(t('msg.formula.stringTypeIsExpected'))
}
}
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = supportedColumns.value.find((c) => c.title === parsedTree.name)
if (col === undefined) {
return
}
if (col.uidt === UITypes.Formula) {
const foundType = getRootDataType(jsep(col.colOptions?.formula_raw))
if (foundType === 'N/A') {
typeErrors.add(t('msg.formula.notSupportedToReferenceColumn', { columnName: col.title }))
} else if (expectedType !== foundType) {
typeErrors.add(
t('msg.formula.typeIsExpectedButFound', {
type: expectedType,
found: foundType,
}),
)
}
} else {
switch (col.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
if (expectedType !== formulaTypes.STRING) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.STRING,
expectedType,
}),
)
}
break
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
case UITypes.Currency:
if (expectedType !== formulaTypes.NUMERIC) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.NUMERIC,
expectedType,
}),
)
}
break
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
if (expectedType !== formulaTypes.DATE) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.DATE,
expectedType,
}),
)
}
break
case UITypes.Rollup: {
const rollupFunction = col.colOptions.rollup_function
if (['count', 'avg', 'sum', 'countDistinct', 'sumDistinct', 'avgDistinct'].includes(rollupFunction)) {
// these functions produce a numeric value, which can be used in numeric functions
if (expectedType !== formulaTypes.NUMERIC) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.NUMERIC,
expectedType,
}),
)
}
} else {
// the value is based on the foreign rollup column type
const selectedTable = refTables.value.find((t) => t.column.id === col.colOptions.fk_relation_column_id)
const refTableColumns = metas.value[selectedTable.id].columns.filter(
(c: ColumnType) =>
vModel.value.fk_lookup_column_id === c.id ||
(!isSystemColumn(c) && c.id !== vModel.value.id && c.uidt !== UITypes.Links),
)
const childFieldColumn = refTableColumns.find(
(column: ColumnType) => column.id === col.colOptions.fk_rollup_column_id,
)
const abstractType = sqlUi.value.getAbstractType(childFieldColumn)
if (expectedType === formulaTypes.DATE && !isDate(childFieldColumn, sqlUi.value.getAbstractType(childFieldColumn))) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: abstractType,
expectedType,
}),
)
} else if (expectedType === formulaTypes.NUMERIC && !isNumericCol(childFieldColumn)) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: abstractType,
expectedType,
}),
)
}
}
break
}
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
case UITypes.QrCode:
default:
typeErrors.add(t('msg.formula.notSupportedToReferenceColumn', { columnName: parsedTree.name }))
break
}
}
} else if (parsedTree.type === JSEPNode.UNARY_EXP || parsedTree.type === JSEPNode.BINARY_EXP) {
if (expectedType !== formulaTypes.NUMERIC) {
// parsedTree.name won't be available here
typeErrors.add(
t('msg.formula.typeIsExpectedButFound', {
type: formulaTypes.NUMERIC,
found: expectedType,
}),
)
}
} else if (parsedTree.type === JSEPNode.CALL_EXP) {
const calleeName = parsedTree.callee.name.toUpperCase()
if (formulas[calleeName]?.type && expectedType !== formulas[calleeName].type) {
typeErrors.add(
t('msg.formula.typeIsExpectedButFound', {
type: expectedType,
found: formulas[calleeName].type,
}),
)
}
}
return typeErrors
}
const suggestedFormulas = computed(() => {
return suggestion.value.filter((s) => s && s.type !== 'column')
})
function getRootDataType(parsedTree: any): any {
// given a parse tree, return the data type of it
if (parsedTree.type === JSEPNode.CALL_EXP) {
return formulas[parsedTree.callee.name.toUpperCase()].type
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = supportedColumns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
if (col?.uidt === UITypes.Formula) {
return getRootDataType(jsep(col?.formula_raw))
} else {
switch (col?.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
return formulaTypes.STRING
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
return formulaTypes.NUMERIC
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
return formulaTypes.DATE
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rollup:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
case UITypes.QrCode:
default:
return 'N/A'
}
}
} else if (parsedTree.type === JSEPNode.BINARY_EXP || parsedTree.type === JSEPNode.UNARY_EXP) {
return formulaTypes.NUMERIC
} else if (parsedTree.type === JSEPNode.LITERAL) {
return typeof parsedTree.value
} else {
return 'N/A'
}
}
const variableList = computed(() => {
return suggestion.value.filter((s) => s && s.type === 'column')
})
function isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets
@ -739,6 +209,11 @@ function handleInput() {
suggestion.value = acTree.value
.complete(wordToComplete.value)
?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type])
if (suggestion.value.length > 0 && suggestion.value[0].type !== 'column') {
suggestionPreviewed.value = suggestion.value[0]
}
if (!isCurlyBracketBalanced()) {
suggestion.value = suggestion.value.filter((v) => v.type === 'column')
}
@ -746,14 +221,21 @@ function handleInput() {
}
function selectText() {
if (suggestion.value && selected.value > -1 && selected.value < suggestion.value.length) {
appendText(suggestion.value[selected.value])
if (suggestion.value && selected.value > -1 && selected.value < suggestionsList.value.length) {
if (selected.value < suggestedFormulas.value.length) {
appendText(suggestedFormulas.value[selected.value])
} else {
appendText(variableList.value[selected.value + suggestedFormulas.value.length])
}
}
selected.value = 0
}
function suggestionListUp() {
if (suggestion.value) {
selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
scrollToSelectedOption()
}
}
@ -761,6 +243,8 @@ function suggestionListUp() {
function suggestionListDown() {
if (suggestion.value) {
selected.value = ++selected.value % suggestion.value.length
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
scrollToSelectedOption()
}
}
@ -769,9 +253,9 @@ function scrollToSelectedOption() {
nextTick(() => {
if (sugOptionsRef.value[selected.value]) {
try {
sugListRef.value.$el.scrollTo({
top: sugOptionsRef.value[selected.value].$el.offsetTop,
behavior: 'smooth',
sugOptionsRef.value[selected.value].$el.scrollIntoView({
block: 'nearest',
inline: 'start',
})
} catch (e) {}
}
@ -796,15 +280,55 @@ setAdditionalValidations({
onMounted(() => {
jsep.plugins.register(jsepCurlyHook)
})
// const predictFunction = async () => {
// await _predictFunction(formState, meta, supportedColumns, suggestionsList, vModel)
// }
</script>
<template>
<div class="formula-wrapper">
<a-form-item v-bind="validateInfos.formula_raw" :label="$t('datatype.Formula')">
<div class="formula-wrapper relative">
<div
v-if="suggestionPreviewed && suggestionPreviewed.type === 'function'"
class="absolute -left-91 w-84 top-0 bg-white z-10 pl-3 pt-3 border-1 shadow-md rounded-xl"
>
<div class="pr-3">
<div class="flex flex-row w-full justify-between pb-1 border-b-1">
<div class="flex items-center gap-x-1 font-semibold text-base">
<component :is="iconMap.function" class="text-lg" />
{{ suggestionPreviewed.text }}
</div>
<NcButton type="text" size="small" @click="suggestionPreviewed = undefined">
<GeneralIcon icon="close" />
</NcButton>
</div>
</div>
<div class="flex flex-col max-h-120 nc-scrollbar-md pr-2">
<div class="flex mt-3">{{ suggestionPreviewed.description }}</div>
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Syntax</div>
<div class="bg-white rounded-md py-1 px-2 border-1">{{ suggestionPreviewed.syntax }}</div>
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Examples</div>
<div
v-for="(example, index) of suggestionPreviewed.examples"
:key="example"
class="bg-gray-100 py-1 px-2"
:class="{
'border-t-1 border-gray-200': index !== 0,
'rounded-b-md': index === suggestionPreviewed.examples.length - 1 && suggestionPreviewed.examples.length !== 1,
'rounded-t-md': index === 0 && suggestionPreviewed.examples.length !== 1,
'rounded-md': suggestionPreviewed.examples.length === 1,
}"
>
{{ example }}
</div>
</div>
<div class="flex flex-row mt-1 mb-3 justify-end pr-3">
<a target="_blank" rel="noopener noreferrer" :href="suggestionPreviewed.docsUrl">
<NcButton type="text" class="!text-gray-400 !hover:text-gray-800 !text-xs"
>View in Docs
<GeneralIcon icon="openInNew" class="ml-1" />
</NcButton>
</a>
</div>
</div>
<a-form-item v-bind="validateInfos.formula_raw" class="!pb-1" :label="$t('datatype.Formula')">
<!-- <GeneralIcon
v-if="isEeUI"
icon="magic"
@ -815,7 +339,7 @@ onMounted(() => {
<a-textarea
ref="formulaRef"
v-model:value="vModel.formula_raw"
class="mb-2 nc-formula-input"
class="nc-formula-input !rounded-md !my-1"
@keydown.down.prevent="suggestionListDown"
@keydown.up.prevent="suggestionListUp"
@keydown.enter.prevent="selectText"
@ -823,73 +347,90 @@ onMounted(() => {
/>
</a-form-item>
<div class="text-gray-600 mt-2 mb-4 prose-sm">
{{
// As using {} in translation will be treated as placeholder, and this translation contain {} as part of th text
$t('msg.formula.hintStart', {
placeholder1: '{}',
placeholder2: '{column_name}',
})
}}
<a
class="prose-sm"
href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features"
target="_blank"
rel="noopener"
>
{{ $t('msg.formula.hintEnd') }}
</a>
</div>
<div ref="sugListRef" class="h-[250px] overflow-auto nc-scrollbar-md">
<template v-if="suggestedFormulas.length > 0">
<div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Formulas</div>
<a-list
:data-source="suggestedFormulas"
:locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
class="border-1 border-t-0 rounded-b-lg !mb-4"
>
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
class="cursor-pointer !overflow-hidden hover:bg-gray-50"
:class="{
'!bg-gray-100': selected === index,
}"
@click.prevent.stop="appendText(item)"
@mouseenter="suggestionPreviewed = item"
>
<a-list-item-meta>
<template #title>
<div class="flex items-center gap-x-1">
<component :is="iconMap.function" v-if="item.type === 'function'" class="text-lg" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
<span class="prose-sm text-gray-600">{{ item.text }}</span>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
<template v-if="variableList.length > 0">
<div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Fields</div>
<a-list
ref="variableListRef"
:data-source="variableList"
:locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
class="border-1 border-t-0 rounded-b-lg !overflow-hidden"
>
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index + suggestedFormulas.length] = el
}
"
:class="{
'!bg-gray-100': selected === index + suggestedFormulas.length,
}"
class="cursor-pointer hover:bg-gray-50"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta>
<template #title>
<div class="flex items-center gap-x-1">
<component :is="item.icon" class="text-lg" />
<div class="h-[250px] overflow-auto scrollbar-thin-primary">
<a-list ref="sugListRef" :data-source="suggestion" :locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }">
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
class="cursor-pointer"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta>
<template #title>
<div class="flex">
<a-col :span="6">
<span class="prose-sm text-gray-600">{{ item.text }}</span>
</a-col>
<a-col :span="18">
<div v-if="item.type === 'function'" class="text-xs text-gray-500">
{{ item.description }} <br /><br />
{{ $t('labels.syntax') }}: <br />
{{ item.syntax }} <br /><br />
{{ $t('labels.examples') }}: <br />
<div v-for="(example, idx) of item.examples" :key="idx">
<div>({{ idx + 1 }}): {{ example }}</div>
</div>
</div>
<div v-if="item.type === 'column'" class="float-right mr-5 -mt-2">
<a-badge-ribbon :text="item.uidt" color="gray" />
</div>
</a-col>
</div>
</template>
<template #avatar>
<component :is="iconMap.function" v-if="item.type === 'function'" class="text-lg" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
<div v-if="suggestion.length === 0">
<span class="text-gray-500">Empty</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-list-item) {
@apply !pt-1.75 pb-0.75 !px-2;
}
</style>

40
packages/nc-gui/components/smartsheet/column/RollupOptions.vue

@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType, UITypes } from 'nocodb-sdk'
import { isLinksOrLTAR, isNumericCol, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { getAvailableRollupForUiType, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from '#imports'
import {
MetaInj,
@ -102,31 +102,27 @@ const cellIcon = (column: ColumnType) =>
const aggFunctionsList: Ref<Record<string, string>[]> = ref([])
const allFunctions = [
{ text: t('datatype.Count'), value: 'count' },
{ text: t('general.min'), value: 'min' },
{ text: t('general.max'), value: 'max' },
{ text: t('general.avg'), value: 'avg' },
{ text: t('general.sum'), value: 'sum' },
{ text: t('general.countDistinct'), value: 'countDistinct' },
{ text: t('general.sumDistinct'), value: 'sumDistinct' },
{ text: t('general.avgDistinct'), value: 'avgDistinct' },
]
watch(
() => vModel.value.fk_rollup_column_id,
() => {
const childFieldColumn = columns.value?.find((column: ColumnType) => column.id === vModel.value.fk_rollup_column_id)
const showNumericFunctions = isNumericCol(childFieldColumn)
const nonNumericFunctions = [
// functions for non-numeric types,
// e.g. count / min / max / countDistinct date field
{ text: t('datatype.Count'), value: 'count' },
{ text: t('general.min'), value: 'min' },
{ text: t('general.max'), value: 'max' },
{ text: t('general.countDistinct'), value: 'countDistinct' },
]
const numericFunctions = showNumericFunctions
? [
{ text: t('general.avg'), value: 'avg' },
{ text: t('general.sum'), value: 'sum' },
{ text: t('general.sumDistinct'), value: 'sumDistinct' },
{ text: t('general.avgDistinct'), value: 'avgDistinct' },
]
: []
aggFunctionsList.value = [...nonNumericFunctions, ...numericFunctions]
if (!showNumericFunctions && ['avg', 'sum', 'sumDistinct', 'avgDistinct'].includes(vModel.value.rollup_function)) {
aggFunctionsList.value = allFunctions.filter((func) =>
getAvailableRollupForUiType(childFieldColumn?.uidt as UITypes).includes(func.value),
)
if (!aggFunctionsList.value.includes(vModel.value.rollup_function)) {
// when the previous roll up function was numeric type and the current child field is non-numeric
// reset rollup function with a non-numeric type
vModel.value.rollup_function = aggFunctionsList.value[0].value

66
packages/nc-gui/components/smartsheet/column/UserOptions.vue

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useVModel } from '#imports'
const props = defineProps<{
value: any
isEdit: boolean
}>()
const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const future = ref(false)
const initialIsMulti = ref()
const validators = {}
const { setAdditionalValidations } = useColumnCreateStoreOrThrow()
setAdditionalValidations({
...validators,
})
// set default value
vModel.value.meta = {
is_multi: false,
notify: false,
...vModel.value.meta,
}
onMounted(() => {
initialIsMulti.value = vModel.value.meta.is_multi
})
const updateIsMulti = (e) => {
vModel.value.meta.is_multi = e.target.checked
if (!vModel.value.meta.is_multi) {
vModel.value.cdf = vModel.value.cdf?.split(',')[0] || null
}
}
</script>
<template>
<div class="flex flex-col">
<div>
<a-checkbox
v-if="vModel.meta"
:checked="vModel.meta.is_multi"
class="ml-1 mb-1"
data-testid="user-column-allow-multiple"
@change="updateIsMulti"
>
<span class="text-[10px] text-gray-600">Allow adding multiple users</span>
</a-checkbox>
</div>
<div v-if="future">
<a-checkbox v-if="vModel.meta" v-model:checked="vModel.meta.notify" class="ml-1 mb-1">
<span class="text-[10px] text-gray-600">Notify users with base access when they're added</span>
</a-checkbox>
</div>
<div v-if="initialIsMulti && isEdit && !vModel.meta.is_multi" class="text-error text-[10px] mb-1 mt-2">
<span>Changing from multiple mode to single will retain only first user in each cell!!!</span>
</div>
</div>
</template>

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

@ -90,6 +90,8 @@ const { isUIAllowed } = useRoles()
const readOnly = computed(() => !isUIAllowed('dataEdit') || isPublic.value)
const expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
@ -439,6 +441,13 @@ const preventModalStatus = computed({
})
const onIsExpandedUpdate = (v: boolean) => {
let isDropdownOpen = false
document.querySelectorAll('.ant-select-dropdown').forEach((el) => {
isDropdownOpen = isDropdownOpen || el.checkVisibility()
})
if (isDropdownOpen) return
if (changedColumns.value.size === 0 && !isUnsavedFormExist.value) {
isExpanded.value = v
} else if (!v) {
@ -451,6 +460,22 @@ const onIsExpandedUpdate = (v: boolean) => {
const isReadOnlyVirtualCell = (column: ColumnType) => {
return isRollup(column) || isFormula(column) || isBarcode(column) || isLookup(column) || isQrCode(column)
}
// Small hack. We need to scroll to the bottom of the form after its mounted and back to top.
// So that tab to next row works properly, as otherwise browser will focus to save button
// when we reach to the bottom of the visual scrollable area, not the actual bottom of the form
watch([expandedFormScrollWrapper, isLoading], () => {
if (isMobileMode.value) return
if (expandedFormScrollWrapper.value && !isLoading.value) {
const height = expandedFormScrollWrapper.value.scrollHeight
expandedFormScrollWrapper.value.scrollTop = height
setTimeout(() => {
expandedFormScrollWrapper.value.scrollTop = 0
}, 125)
}
})
</script>
<script lang="ts">
@ -620,6 +645,7 @@ export default {
}"
>
<div
ref="expandedFormScrollWrapper"
class="flex flex-col flex-grow mt-2 h-full max-h-full nc-scrollbar-md pb-6 items-center w-full bg-white p-4 xs:p-0"
>
<div
@ -658,7 +684,7 @@ export default {
<SmartsheetDivDataCell
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="bg-white rounded-lg w-80 xs:w-full border-1 border-gray-200 overflow-hidden px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
class="bg-white w-80 xs:w-full px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
:class="{
'!bg-gray-50 !px-0 !select-text': isReadOnlyVirtualCell(col),
}"
@ -894,4 +920,8 @@ export default {
:deep(.ant-select-selection-item) {
@apply !xs:(mt-1.75 ml-1);
}
.nc-data-cell:focus-within {
@apply !border-1 !border-brand-500 !rounded-lg !shadow-none !ring-0;
}
</style>

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

@ -167,11 +167,21 @@ const parseKey = (group: Group) => {
if (key && group.column?.uidt === UITypes.Time && dayjs(key).isValid()) {
return [dayjs(key).format(timeFormats[0])]
}
if (key && group.column?.uidt === UITypes.User) {
try {
const parsedKey = JSON.parse(key)
return [parsedKey]
} catch {
return null
}
}
return [key]
}
const shouldRenderCell = (column) =>
[UITypes.Lookup, UITypes.Attachment, UITypes.Barcode, UITypes.QrCode, UITypes.Links].includes(column?.uidt)
[UITypes.Lookup, UITypes.Attachment, UITypes.Barcode, UITypes.QrCode, UITypes.Links, UITypes.User].includes(column?.uidt)
</script>
<template>

19
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -791,7 +791,7 @@ onClickOutside(tableBodyEl, (e) => {
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options
const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-dropdown-user-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
)
if (
e.target &&
@ -1005,7 +1005,13 @@ const showFillHandle = computed(
!readOnly.value &&
!editEnabled.value &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) &&
!dataRef.value[(isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) ?? -1]?.rowMeta?.new,
!dataRef.value[(isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) ?? -1]?.rowMeta?.new &&
activeCell.col !== null &&
!(
isLookup(fields.value[activeCell.col]) ||
isRollup(fields.value[activeCell.col]) ||
isFormula(fields.value[activeCell.col])
),
)
watch(
@ -1266,7 +1272,10 @@ onKeyStroke('ArrowDown', onDown)
></div>
</div>
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 relative" :class="gridWrapperClass">
<div v-show="isPaginationLoading" class="flex items-center justify-center absolute l-0 t-0 w-full h-full z-10 pb-10">
<div
v-show="isPaginationLoading"
class="flex items-center justify-center absolute l-0 t-0 w-full h-full z-10 pb-10 pointer-events-none"
>
<div class="flex flex-col justify-center gap-2">
<GeneralLoader size="xlarge" />
<span class="text-center" v-html="loaderText"></span>
@ -1583,7 +1592,7 @@ onKeyStroke('ArrowDown', onDown)
@dblclick="makeEditable(row, columnObj)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
<div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj) && columnObj.title"
v-model="row.row[columnObj.title]"
@ -1728,6 +1737,7 @@ onKeyStroke('ArrowDown', onDown)
"
class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
data-testid="context-menu-item-clear"
@click="clearCell(contextMenuTarget)"
>
<div v-e="['a:row:clear']" class="flex gap-2 items-center">
@ -1741,6 +1751,7 @@ onKeyStroke('ArrowDown', onDown)
v-else-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
data-testid="context-menu-item-clear"
@click="clearSelectedRangeOfCells()"
>
<div v-e="['a:row:clear-range']" class="flex gap-2 items-center">

6
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -31,6 +31,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
storeToRefs,
toRef,
@ -82,6 +83,11 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.percent
} else if (isGeometry(column)) {
return iconMap.calculator
} else if (isUser(column)) {
if ((column.meta as { is_multi: boolean; notify: boolean }).is_multi) {
return iconMap.phUsers
}
return iconMap.phUser
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) {
return iconMap.number
} else if (isString(column, abstractType)) {

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

@ -21,6 +21,7 @@ import {
isSingleSelect,
isTextArea,
isTime,
isUser,
isYear,
provide,
ref,
@ -42,6 +43,7 @@ import Decimal from '~/components/cell/Decimal.vue'
import Integer from '~/components/cell/Integer.vue'
import Float from '~/components/cell/Float.vue'
import Text from '~/components/cell/Text.vue'
import User from '~/components/cell/User.vue'
interface Props {
column: ColumnType
@ -82,6 +84,7 @@ const checkTypeFunctions = {
isFloat,
isTextArea,
isLinks: (col: ColumnType) => col.uidt === UITypes.Links,
isUser,
}
type FilterType = keyof typeof checkTypeFunctions
@ -148,6 +151,7 @@ const componentMap: Partial<Record<FilterType, any>> = computed(() => {
isInt: Integer,
isFloat: Float,
isLinks: Integer,
isUser: User,
}
})
@ -171,6 +175,9 @@ const componentProps = computed(() => {
case 'isDuration': {
return { showValidationError: false }
}
case 'isUser': {
return { forceMulti: true }
}
default: {
return {}
}

5
packages/nc-gui/components/tabs/auth/UserManagement.vue

@ -33,6 +33,8 @@ const { isUIAllowed } = useRoles()
const { dashboardUrl } = useDashboard()
const { clearBasesUser } = useBases()
const users = ref<null | User[]>(null)
const selectedUser = ref<null | User>(null)
@ -84,6 +86,9 @@ const inviteUser = async (user: User) => {
await api.auth.baseUserAdd(base.value.id, user as ProjectUserReqType)
// clear bases user state
clearBasesUser()
// Successfully added user to base
message.success(t('msg.success.userAddedToProject'))
await loadUsers()

23
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -83,6 +83,14 @@ const belongsToColumn = computed(
() =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script>
<template>
@ -101,11 +109,14 @@ const belongsToColumn = computed(
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
class="flex justify-end gap-1 min-h-[30px] items-center"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus"
class="text-sm nc-action-icon group-focus:visible invisible text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus"
@click.stop="listItemsDlg = true"
/>
</div>
@ -121,7 +132,7 @@ const belongsToColumn = computed(
<style scoped lang="scss">
.nc-action-icon {
@apply hidden cursor-pointer;
@apply cursor-pointer;
}
.chips-wrapper:hover,
@ -130,4 +141,10 @@ const belongsToColumn = computed(
@apply inline-block;
}
}
.chips-wrapper:hover {
.nc-action-icon {
@apply visible;
}
}
</style>

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

@ -102,6 +102,21 @@ const openListDlg = () => {
listItemsDlg.value = true
}
const plusBtnRef = ref<HTMLElement | null>(null)
const childListDlgRef = ref<HTMLElement | null>(null)
watch([childListDlg], () => {
if (!childListDlg.value) {
childListDlgRef.value?.focus()
}
})
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script>
<template>
@ -109,21 +124,30 @@ const openListDlg = () => {
<div class="block flex-shrink truncate">
<component
:is="isUnderLookup ? 'span' : 'a'"
ref="childListDlgRef"
v-e="['c:cell:links:modal:open']"
:title="textVal"
class="text-center nc-datatype-link underline-transparent"
:class="{ '!text-gray-300': !textVal }"
tabindex="0"
@click.stop.prevent="openChildList"
@keydown.enter.stop.prevent="openChildList"
>
{{ textVal }}
</component>
</div>
<div class="flex-grow" />
<div v-if="!isUnderLookup" class="!xs:hidden flex justify-end hidden group-hover:flex items-center">
<div
v-if="!isUnderLookup"
ref="plusBtnRef"
tabindex="0"
class="!xs:hidden flex group justify-end group-hover:flex items-center"
@keydown.enter.stop="openListDlg"
>
<MdiPlus
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="openListDlg"
/>
</div>

14
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -99,18 +99,19 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<template>
<div
class="h-full w-full nc-lookup-cell"
tabindex="-1"
:style="{ height: rowHeight && rowHeight !== 1 ? `${rowHeight * 2}rem` : `2.85rem` }"
@dblclick="activateShowEditNonEditableFieldWarning"
>
<div
class="h-full w-full flex gap-1 p-1"
class="h-full w-full flex gap-1"
:class="{
'!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden': rowHeight === 1,
}"
>
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)" class="flex">
<div v-if="isVirtualCol(lookupColumn)" class="flex h-full">
<!-- If non-belongs-to LTAR column then pass the array value, else iterate and render -->
<template
v-if="
@ -151,7 +152,7 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
}"
>
<div
class="flex gap-1.5 w-full"
class="flex gap-1.5 w-full h-full py-[3px]"
:class="{
'flex-wrap': rowHeight !== 1 && !isAttachment(lookupColumn),
'!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden':
@ -162,11 +163,12 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
v-for="(v, i) of arrValue"
:key="i"
:class="{
'bg-gray-100 px-1 rounded-full min-h-7.5': !isAttachment(lookupColumn),
'border-gray-200 rounded border-1 pt-0.75': ![
'bg-gray-100 rounded-full': !isAttachment(lookupColumn),
'border-gray-200 rounded border-1': ![
UITypes.Attachment,
UITypes.MultiSelect,
UITypes.SingleSelect,
UITypes.User,
].includes(lookupColumn.uidt),
'min-h-0 min-w-0': isAttachment(lookupColumn),
}"
@ -180,7 +182,7 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
class=""
:class="{
'min-h-0 min-w-0': isAttachment(lookupColumn),
'!max-w-40 !min-w-20 !w-auto px-2': !isAttachment(lookupColumn),
'!min-w-20 !w-auto pl-2': !isAttachment(lookupColumn),
}"
/>
</div>

2
packages/nc-gui/components/virtual-cell/barcode/Barcode.vue

@ -64,6 +64,7 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
<JsBarcodeWrapper
v-if="showBarcode && rowHeight"
:barcode-value="barcodeValue"
tabindex="-1"
:barcode-format="barcodeMeta.barcodeFormat"
:custom-style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
@on-click-barcode="showBarcodeModal"
@ -76,6 +77,7 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
</JsBarcodeWrapper>
<JsBarcodeWrapper
v-else-if="showBarcode"
tabindex="-1"
:barcode-value="barcodeValue"
:barcode-format="barcodeMeta.barcodeFormat"
@on-click-barcode="showBarcodeModal"

2
packages/nc-gui/components/webhook/Editor.vue

@ -795,7 +795,7 @@ onMounted(async () => {
:show-loading="false"
:hook-id="hookRef.id"
:web-hook="true"
@update:filtersLength="hookRef.condition = $event > 0"
@update:filters-length="hookRef.condition = $event > 0"
/>
</div>
</a-col>

4
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -85,13 +85,13 @@ onMounted(async () => {
:role="collab.roles"
:roles="accessibleRoles"
:description="false"
class="bg-[red]"
class="cursor-pointer"
:on-role-change="(role: WorkspaceUserRoles) => updateCollaborator(collab, role)"
/>
</div>
</template>
<template v-else>
<RolesBadge :role="collab.roles" />
<RolesBadge :role="collab.roles" class="cursor-default" />
</template>
</div>
<div class="w-1/8 pl-6">

3
packages/nc-gui/composables/useData.ts

@ -241,6 +241,7 @@ export function useData(args: {
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.uidt === UITypes.Checkbox ||
col.uidt === UITypes.User ||
col.au ||
col.cdf?.includes(' on update ')
)
@ -387,6 +388,8 @@ export function useData(args: {
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.uidt === UITypes.Checkbox ||
col.uidt === UITypes.User ||
col.au ||
col.cdf?.includes(' on update ')
)

18
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -195,6 +195,24 @@ export default function convertCellData(
return validVals.join(',')
}
case UITypes.User: {
let parsedVal
try {
try {
parsedVal = typeof value === 'string' ? JSON.parse(value) : value
} catch {
parsedVal = value
}
} catch (e) {
if (isMultiple) {
return null
} else {
throw new Error('Invalid user data')
}
}
return parsedVal || value
}
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
case UITypes.Rollup:

12
packages/nc-gui/composables/useMultiSelect/index.ts

@ -2,7 +2,7 @@ import { computed } from 'vue'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
import type { MaybeRef } from '@vueuse/core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType, UserFieldRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, dateFormats, isDateMonthFormat, isSystemColumn, isVirtualCol, timeFormats } from 'nocodb-sdk'
import { parse } from 'papaparse'
import type { Cell } from './cellRange'
@ -112,6 +112,16 @@ export function useMultiSelect(
textToCopy = !!textToCopy
}
if (columnObj.uidt === UITypes.User) {
if (textToCopy && Array.isArray(textToCopy)) {
textToCopy = textToCopy
.map((user: UserFieldRecordType) => {
return user.email
})
.join(', ')
}
}
if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy)
} else {

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

@ -19,6 +19,10 @@ export function useSharedView() {
const baseStore = useBase()
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const { base } = storeToRefs(baseStore)
const appInfoDefaultLimit = appInfo.value.defaultLimit || 25
@ -99,6 +103,10 @@ export function useSharedView() {
const relatedMetas = { ...viewMeta.relatedMetas }
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
if (viewMeta.users) {
basesUser.value.set(viewMeta.base_id, viewMeta.users)
}
}
const fetchSharedViewData = async (param: {

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

@ -90,6 +90,12 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
return value ? GROUP_BY_VARS.TRUE : GROUP_BY_VARS.FALSE
}
if (col.uidt === UITypes.User) {
if (!value) {
return GROUP_BY_VARS.NULL
}
}
// convert to JSON string if non-string value
if (value && typeof value === 'object') {
value = JSON.stringify(value)
@ -155,6 +161,11 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
acc += `${acc.length ? '~and' : ''}(${curr.title},${curr.key === GROUP_BY_VARS.TRUE ? 'checked' : 'notchecked'})`
} else if ([UITypes.Date, UITypes.DateTime].includes(curr.column_uidt as UITypes)) {
acc += `${acc.length ? '~and' : ''}(${curr.title},eq,exactDate,${curr.key})`
} else if (curr.column_uidt === UITypes.User) {
try {
const value = JSON.parse(curr.key)
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${value.map((v: any) => v.id).join(',')})`
} catch (e) {}
} else {
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${curr.key})`
}

1
packages/nc-gui/lib/types.ts

@ -126,6 +126,7 @@ type NcProject = BaseType & {
edit?: boolean
starred?: boolean
uuid?: string
users?: User[]
}
interface UndoRedoAction {

7
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue

@ -27,8 +27,6 @@ const scannerIsReady = ref(false)
const showCodeScannerOverlay = ref(false)
const editEnabled = ref<boolean[]>([])
const onLoaded = async () => {
scannerIsReady.value = true
}
@ -168,10 +166,7 @@ const onDecode = async (scannedCodeValue: string) => {
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="editEnabled[index]"
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
edit-enabled
/>
<a-button
v-if="field.enable_scanner"

7
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue

@ -47,8 +47,6 @@ const animationTarget = ref<AnimationTarget>(AnimationTarget.ArrowRight)
const isAnimating = ref(false)
const editEnabled = ref<boolean[]>([])
const el = ref<HTMLDivElement>()
provide(DropZoneRef, el)
@ -299,10 +297,7 @@ onMounted(() => {
class="nc-input h-auto"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="editEnabled[index]"
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
edit-enabled
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">

4
packages/nc-gui/store/base.ts

@ -172,6 +172,10 @@ export const useBase = defineStore('baseStore', () => {
await loadTables()
await basesStore.getBaseUsers({
baseId: base.value.id || baseId.value,
})
// if (withTheme) setTheme(baseMeta.value?.theme)
return baseLoadedHook.trigger(base.value)

35
packages/nc-gui/store/bases.ts

@ -12,7 +12,7 @@ export const useBases = defineStore('basesStore', () => {
const bases = ref<Map<string, NcProject>>(new Map())
const basesList = computed<NcProject[]>(() => Array.from(bases.value.values()).sort((a, b) => a.updated_at - b.updated_at))
const baseUserCount = ref<number | undefined>(undefined)
const basesUser = ref<Map<string, User[]>>(new Map())
const router = useRouter()
const route = router.currentRoute
@ -48,33 +48,35 @@ export const useBases = defineStore('basesStore', () => {
const isProjectsLoading = ref(false)
async function getProjectUsers({
baseId,
limit,
page,
searchText,
}: {
baseId: string
limit: number
page: number
searchText: string | undefined
}) {
async function getBaseUsers({ baseId, searchText, force = false }: { baseId: string; searchText?: string; force?: boolean }) {
if (!force && basesUser.value.has(baseId)) {
const users = basesUser.value.get(baseId)
return {
users,
totalRows: users?.length ?? 0,
}
}
const response: any = await api.auth.baseUserList(baseId, {
query: {
limit,
offset: (page - 1) * limit,
query: searchText,
},
} as RequestParams)
const totalRows = response.users.pageInfo.totalRows ?? 0
basesUser.value.set(baseId, response.users.list)
return {
users: response.users.list,
totalRows,
}
}
const clearBasesUser = () => {
basesUser.value.clear()
}
const createProjectUser = async (baseId: string, user: User) => {
await api.auth.baseUserAdd(baseId, user as ProjectUserReqType)
}
@ -295,7 +297,6 @@ export const useBases = defineStore('basesStore', () => {
return {
bases,
basesList,
baseUserCount,
loadProjects,
loadProject,
getSqlUi,
@ -312,13 +313,15 @@ export const useBases = defineStore('basesStore', () => {
activeProjectId,
openedProject,
openedProjectBasesMap,
getProjectUsers,
getBaseUsers,
createProjectUser,
updateProjectUser,
navigateToProject,
removeProjectUser,
navigateToFirstProjectOrHome,
toggleStarred,
basesUser,
clearBasesUser,
}
})

3
packages/nc-gui/store/users.ts

@ -4,6 +4,7 @@ export const useUsers = defineStore('userStore', () => {
const { api } = useApi()
const { user } = useGlobal()
const { loadRoles } = useRoles()
const basesStore = useBases()
const updateUserProfile = async ({
attrs,
@ -20,6 +21,8 @@ export const useUsers = defineStore('userStore', () => {
...user.value,
...attrs,
}
basesStore.clearBasesUser()
}
const loadCurrentUser = loadRoles

1
packages/nc-gui/utils/cell.ts

@ -32,6 +32,7 @@ export const isGeoData = (column: ColumnType) => column.uidt === UITypes.GeoData
export const isPercent = (column: ColumnType) => column.uidt === UITypes.Percent
export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType
export const isGeometry = (column: ColumnType) => column.uidt === UITypes.Geometry
export const isUser = (column: ColumnType) => column.uidt === UITypes.User
export const isAutoSaved = (column: ColumnType) =>
[
UITypes.SingleLineText,

4
packages/nc-gui/utils/columnUtils.ts

@ -134,6 +134,10 @@ const uiTypes = [
name: UITypes.SpecificDBType,
icon: iconMap.specificDbType,
},
{
name: UITypes.User,
icon: iconMap.account,
},
]
const getUIDTIcon = (uidt: UITypes | string) => {

398
packages/nc-gui/utils/filterUtils.ts

@ -70,6 +70,7 @@ const getLteText = (fieldUiType: UITypes) => {
export const comparisonOpList = (
fieldUiType: UITypes,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
dateFormat?: string,
): {
text: string
@ -77,203 +78,206 @@ export const comparisonOpList = (
ignoreVal: boolean
includedTypes?: UITypes[]
excludedTypes?: UITypes[]
}[] => {
const isDateMonth = dateFormat && isDateMonthFormat(dateFormat)
return [
{
text: 'is checked',
value: 'checked',
ignoreVal: true,
includedTypes: [UITypes.Checkbox],
},
{
text: 'is not checked',
value: 'notchecked',
ignoreVal: true,
includedTypes: [UITypes.Checkbox],
},
{
text: getEqText(fieldUiType),
value: 'eq',
ignoreVal: false,
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment],
},
{
text: getNeqText(fieldUiType),
value: 'neq',
ignoreVal: false,
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment],
},
{
text: getLikeText(fieldUiType),
value: 'like',
ignoreVal: false,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: getNotLikeText(fieldUiType),
value: 'nlike',
ignoreVal: false,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: 'is empty',
value: 'empty',
ignoreVal: true,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: 'is not empty',
value: 'notempty',
ignoreVal: true,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: 'is null',
value: 'null',
ignoreVal: true,
excludedTypes: [
...numericUITypes,
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
],
},
{
text: 'is not null',
value: 'notnull',
ignoreVal: true,
excludedTypes: [
...numericUITypes,
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
],
},
{
text: 'contains all of',
value: 'allof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect],
},
{
text: 'contains any of',
value: 'anyof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect],
},
{
text: 'does not contain all of',
value: 'nallof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect],
},
{
text: 'does not contain any of',
value: 'nanyof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect],
},
{
text: getGtText(fieldUiType),
value: 'gt',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: getLtText(fieldUiType),
value: 'lt',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: getGteText(fieldUiType),
value: 'gte',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: getLteText(fieldUiType),
value: 'lte',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: 'is within',
value: 'isWithin',
ignoreVal: true,
includedTypes: [...(isDateMonth ? [] : [UITypes.Date, UITypes.DateTime])],
},
{
text: 'is blank',
value: 'blank',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup],
},
{
text: 'is not blank',
value: 'notblank',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup],
},
]
}
}[] => [
{
text: 'is checked',
value: 'checked',
ignoreVal: true,
includedTypes: [UITypes.Checkbox],
},
{
text: 'is not checked',
value: 'notchecked',
ignoreVal: true,
includedTypes: [UITypes.Checkbox],
},
{
text: getEqText(fieldUiType),
value: 'eq',
ignoreVal: false,
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment, UITypes.User],
},
{
text: getNeqText(fieldUiType),
value: 'neq',
ignoreVal: false,
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment, UITypes.User],
},
{
text: getLikeText(fieldUiType),
value: 'like',
ignoreVal: false,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.User,
UITypes.Collaborator,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: getNotLikeText(fieldUiType),
value: 'nlike',
ignoreVal: false,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.User,
UITypes.Collaborator,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: 'is empty',
value: 'empty',
ignoreVal: true,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.User,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: 'is not empty',
value: 'notempty',
ignoreVal: true,
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.User,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
...numericUITypes,
],
},
{
text: 'is null',
value: 'null',
ignoreVal: true,
excludedTypes: [
...numericUITypes,
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.User,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
],
},
{
text: 'is not null',
value: 'notnull',
ignoreVal: true,
excludedTypes: [
...numericUITypes,
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.User,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
],
},
{
text: 'contains all of',
value: 'allof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect, UITypes.User],
},
{
text: 'contains any of',
value: 'anyof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect, UITypes.User],
},
{
text: 'does not contain all of',
value: 'nallof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect, UITypes.User],
},
{
text: 'does not contain any of',
value: 'nanyof',
ignoreVal: false,
includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect, UITypes.User],
},
{
text: getGtText(fieldUiType),
value: 'gt',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: getLtText(fieldUiType),
value: 'lt',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: getGteText(fieldUiType),
value: 'gte',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: getLteText(fieldUiType),
value: 'lte',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
},
{
text: 'is within',
value: 'isWithin',
ignoreVal: true,
includedTypes: [UITypes.Date, UITypes.DateTime],
},
{
text: 'is blank',
value: 'blank',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup],
},
{
text: 'is not blank',
value: 'notblank',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup],
},
]
export const comparisonSubOpList = (
// TODO: type

623
packages/nc-gui/utils/formulaUtils.ts

@ -1,624 +1,5 @@
import type { Input as AntInput } from 'ant-design-vue'
const formulaTypes = {
NUMERIC: 'numeric',
STRING: 'string',
DATE: 'date',
LOGICAL: 'logical',
COND_EXP: 'conditional_expression',
}
const formulas: Record<string, any> = {
AVG: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Average of input parameters',
syntax: 'AVG(value1, [value2, ...])',
examples: ['AVG(10, 5) => 7.5', 'AVG({column1}, {column2})', 'AVG({column1}, {column2}, {column3})'],
},
ADD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Sum of input parameters',
syntax: 'ADD(value1, [value2, ...])',
examples: ['ADD(5, 5) => 10', 'ADD({column1}, {column2})', 'ADD({column1}, {column2}, {column3})'],
},
DATEADD: {
type: formulaTypes.DATE,
validation: {
args: {
rqd: 3,
},
},
description: 'Adds a "count" units to Datetime.',
syntax: 'DATEADD(date | datetime, value, ["day" | "week" | "month" | "year"])',
examples: [
'DATEADD({column1}, 2, "day")',
'DATEADD({column1}, -2, "day")',
'DATEADD({column1}, 2, "week")',
'DATEADD({column1}, -2, "week")',
'DATEADD({column1}, 2, "month")',
'DATEADD({column1}, -2, "month")',
'DATEADD({column1}, 2, "year")',
'DATEADD({column1}, -2, "year")',
],
},
DATETIME_DIFF: {
type: formulaTypes.DATE,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'Calculate the difference of two given date / datetime in specified units.',
syntax:
'DATETIME_DIFF(date | datetime, date | datetime, ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"])',
examples: [
'DATEDIFF({column1}, {column2})',
'DATEDIFF({column1}, {column2}, "seconds")',
'DATEDIFF({column1}, {column2}, "s")',
'DATEDIFF({column1}, {column2}, "years")',
'DATEDIFF({column1}, {column2}, "y")',
'DATEDIFF({column1}, {column2}, "minutes")',
'DATEDIFF({column1}, {column2}, "m")',
'DATEDIFF({column1}, {column2}, "days")',
'DATEDIFF({column1}, {column2}, "d")',
],
},
AND: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 1,
},
},
description: 'TRUE if all expr evaluate to TRUE',
syntax: 'AND(expr1, [expr2, ...])',
examples: ['AND(5 > 2, 5 < 10) => 1', 'AND({column1} > 2, {column2} < 10)'],
},
OR: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 1,
},
},
description: 'TRUE if at least one expr evaluates to TRUE',
syntax: 'OR(expr1, [expr2, ...])',
examples: ['OR(5 > 2, 5 < 10) => 1', 'OR({column1} > 2, {column2} < 10)'],
},
CONCAT: {
type: formulaTypes.STRING,
validation: {
args: {
min: 1,
},
},
description: 'Concatenated string of input parameters',
syntax: 'CONCAT(str1, [str2, ...])',
examples: ['CONCAT("AA", "BB", "CC") => "AABBCC"', 'CONCAT({column1}, {column2}, {column3})'],
},
TRIM: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Remove trailing and leading whitespaces from input parameter',
syntax: 'TRIM(str)',
examples: ['TRIM(" HELLO WORLD ") => "HELLO WORLD"', 'TRIM({column1})'],
},
UPPER: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Upper case converted string of input parameter',
syntax: 'UPPER(str)',
examples: ['UPPER("nocodb") => "NOCODB"', 'UPPER({column1})'],
},
LOWER: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Lower case converted string of input parameter',
syntax: 'LOWER(str)',
examples: ['LOWER("NOCODB") => "nocodb"', 'LOWER({column1})'],
},
LEN: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Input parameter character length',
syntax: 'LEN(value)',
examples: ['LEN("NocoDB") => 6', 'LEN({column1})'],
},
MIN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Minimum value amongst input parameters',
syntax: 'MIN(value1, [value2, ...])',
examples: ['MIN(1000, 2000) => 1000', 'MIN({column1}, {column2})'],
},
MAX: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Maximum value amongst input parameters',
syntax: 'MAX(value1, [value2, ...])',
examples: ['MAX(1000, 2000) => 2000', 'MAX({column1}, {column2})'],
},
CEILING: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Rounded next largest integer value of input parameter',
syntax: 'CEILING(value)',
examples: ['CEILING(1.01) => 2', 'CEILING({column1})'],
},
FLOOR: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Rounded largest integer less than or equal to input parameter',
syntax: 'FLOOR(value)',
examples: ['FLOOR(3.1415) => 3', 'FLOOR({column1})'],
},
ROUND: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Rounded number to a specified number of decimal places or the nearest integer if not specified',
syntax: 'ROUND(value, precision), ROUND(value)',
examples: ['ROUND(3.1415) => 3', 'ROUND(3.1415, 2) => 3.14', 'ROUND({column1}, 3)'],
},
MOD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 2,
},
},
description: 'Remainder after integer division of input parameters',
syntax: 'MOD(value1, value2)',
examples: ['MOD(1024, 1000) => 24', 'MOD({column}, 2)'],
},
REPEAT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Specified copies of the input parameter string concatenated together',
syntax: 'REPEAT(str, count)',
examples: ['REPEAT("A", 5) => "AAAAA"', 'REPEAT({column}, 5)'],
},
LOG: {
type: formulaTypes.NUMERIC,
validation: {},
description: 'Logarithm of input parameter to the base (default = e) specified',
syntax: 'LOG([base], value)',
examples: ['LOG(2, 1024) => 10', 'LOG(2, {column1})'],
},
EXP: {
type: formulaTypes.NUMERIC,
validation: {},
description: 'Exponential value of input parameter (e ^ power)',
syntax: 'EXP(power)',
examples: ['EXP(1) => 2.718281828459045', 'EXP({column1})'],
},
POWER: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 2,
},
},
description: 'base to the exponent power, as in base ^ exponent',
syntax: 'POWER(base, exponent)',
examples: ['POWER(2, 10) => 1024', 'POWER({column1}, 10)'],
},
SQRT: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Square root of the input parameter',
syntax: 'SQRT(value)',
examples: ['SQRT(100) => 10', 'SQRT({column1})'],
},
ABS: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Absolute value of the input parameter',
syntax: 'ABS(value)',
examples: ['ABS({column1})'],
},
NOW: {
type: formulaTypes.DATE,
validation: {
args: {
rqd: 0,
},
},
description: 'Returns the current time and day',
syntax: 'NOW()',
examples: ['NOW() => 2022-05-19 17:20:43'],
},
REPLACE: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'String, after replacing all occurrences of srchStr with rplcStr',
syntax: 'REPLACE(str, srchStr, rplcStr)',
examples: ['REPLACE("AABBCC", "AA", "BB") => "BBBBCC"', 'REPLACE({column1}, {column2}, {column3})'],
},
SEARCH: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Index of srchStr specified if found, 0 otherwise',
syntax: 'SEARCH(str, srchStr)',
examples: ['SEARCH("HELLO WORLD", "WORLD") => 7', 'SEARCH({column1}, "abc")'],
},
INT: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Integer value of input parameter',
syntax: 'INT(value)',
examples: ['INT(3.1415) => 3', 'INT({column1})'],
},
RIGHT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'n characters from the end of input parameter',
syntax: 'RIGHT(str, n)',
examples: ['RIGHT("HELLO WORLD", 5) => WORLD', 'RIGHT({column1}, 3)'],
},
LEFT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'n characters from the beginning of input parameter',
syntax: 'LEFT(str, n)',
examples: ['LEFT({column1}, 2)', 'LEFT("ABCD", 2) => "AB"'],
},
SUBSTR: {
type: formulaTypes.STRING,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'Substring of length n of input string from the postition specified',
syntax: ' SUBTR(str, position, [n])',
examples: ['SUBSTR("HELLO WORLD", 7) => WORLD', 'SUBSTR("HELLO WORLD", 7, 3) => WOR', 'SUBSTR({column1}, 7, 5)'],
},
MID: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'Alias for SUBSTR',
syntax: 'MID(str, position, [count])',
examples: ['MID("NocoDB", 3, 2) => "co"', 'MID({column1}, 3, 2)'],
},
IF: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'SuccessCase if expr evaluates to TRUE, elseCase otherwise',
syntax: 'IF(expr, successCase, elseCase)',
examples: ['IF(5 > 1, "YES", "NO") => "YES"', 'IF({column} > 1, "YES", "NO")'],
},
SWITCH: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 3,
},
},
description: 'Switch case value based on expr output',
syntax: 'SWITCH(expr, [pattern, value, ..., default])',
examples: [
'SWITCH(1, 1, "One", 2, "Two", "N/A") => "One""',
'SWITCH(2, 1, "One", 2, "Two", "N/A") => "Two"',
'SWITCH(3, 1, "One", 2, "Two", "N/A") => "N/A"',
'SWITCH({column1}, 1, "One", 2, "Two", "N/A")',
],
},
URL: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Convert to a hyperlink if it is a valid URL',
syntax: 'URL(str)',
examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'],
},
WEEKDAY: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default',
syntax: 'WEEKDAY(date, [startDayOfWeek])',
examples: ['WEEKDAY("2021-06-09")', 'WEEKDAY(NOW(), "sunday")'],
},
TRUE: {
type: formulaTypes.NUMERIC,
validation: {
args: {
max: 0,
},
},
description: 'Returns 1',
syntax: 'TRUE()',
examples: ['TRUE()'],
},
FALSE: {
type: formulaTypes.NUMERIC,
validation: {
args: {
max: 0,
},
},
description: 'Returns 0',
syntax: 'FALSE()',
examples: ['FALSE()'],
},
REGEX_MATCH: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Returns 1 if the input text matches a regular expression or 0 if it does not.',
syntax: 'REGEX_MATCH(string, regex)',
examples: ['REGEX_MATCH({title}, "abc.*")'],
},
REGEX_EXTRACT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Returns the first match of a regular expression in a string.',
syntax: 'REGEX_EXTRACT(string, regex)',
examples: ['REGEX_EXTRACT({title}, "abc.*")'],
},
REGEX_REPLACE: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'Replaces all matches of a regular expression in a string with a replacement string',
syntax: 'REGEX_MATCH(string, regex, replacement)',
examples: ['REGEX_EXTRACT({title}, "abc.*", "abcd")'],
},
BLANK: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 0,
},
},
description: 'Returns a blank value(null)',
syntax: 'BLANK()',
examples: ['BLANK()'],
},
XOR: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Returns true if an odd number of arguments are true, and false otherwise.',
syntax: 'XOR(expression, [exp2, ...])',
examples: ['XOR(TRUE(), FALSE(), TRUE())'],
},
EVEN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Returns the nearest even integer that is greater than or equal to the specified value',
syntax: 'EVEN(value)',
examples: ['EVEN({column})'],
},
ODD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Returns the nearest odd integer that is greater than or equal to the specified value',
syntax: 'ODD(value)',
examples: ['ODD({column})'],
},
RECORD_ID: {
validation: {
args: {
rqd: 0,
},
},
description: 'Returns the record id of the current record',
syntax: 'RECORD_ID()',
examples: ['RECORD_ID()'],
},
COUNTA: {
validation: {
args: {
min: 1,
},
},
description: 'Counts the number of non-empty arguments',
syntax: 'COUNTA(value1, [value2, ...])',
examples: ['COUNTA({field1}, {field2})'],
},
COUNT: {
validation: {
args: {
min: 1,
},
},
description: 'Count the number of arguments that are numbers',
syntax: 'COUNT(value1, [value2, ...])',
examples: ['COUNT({field1}, {field2})'],
},
COUNTALL: {
validation: {
args: {
min: 1,
},
},
description: 'Counts the number of arguments',
syntax: 'COUNTALL(value1, [value2, ...])',
examples: ['COUNTALL({field1}, {field2})'],
},
ROUNDDOWN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description:
'Round down the value after the decimal point to the number of decimal places given by "precision"(default is 0)',
syntax: 'ROUNDDOWN(value, [precision])',
examples: ['ROUNDDOWN({field1})', 'ROUNDDOWN({field1}, 2)'],
},
ROUNDUP: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Round up the value after the decimal point to the number of decimal places given by "precision"(default is 0)',
syntax: 'ROUNDUP(value, [precision])',
examples: ['ROUNDUP({field1})', 'ROUNDUP({field1}, 2)'],
},
VALUE: {
validation: {
args: {
rqd: 1,
},
},
description:
'Extract the numeric value from a string, if `%` or `-` is present, it will handle it accordingly and return the numeric value',
syntax: 'VALUE(value)',
examples: ['VALUE({field})', 'VALUE("abc10000%")', 'VALUE("$10000")'],
},
// Disabling these functions for now; these act as alias for CreatedAt & UpdatedAt fields;
// Issue: Error noticed if CreatedAt & UpdatedAt fields are removed from the table after creating these formulas
//
// CREATED_TIME: {
// validation: {
// args: {
// rqd: 0,
// },
// },
// description: 'Returns the created time of the current record if it exists',
// syntax: 'CREATED_TIME()',
// examples: ['CREATED_TIME()'],
// },
// LAST_MODIFIED_TIME: {
// validation: {
// args: {
// rqd: 0,
// },
// },
// description: 'Returns the last modified time of the current record if it exists',
// syntax: ' LAST_MODIFIED_TIME()',
// examples: [' LAST_MODIFIED_TIME()'],
// },
}
import { formulas } from 'nocodb-sdk'
const formulaList = Object.keys(formulas)
@ -671,4 +52,4 @@ function GetCaretPosition(ctrl: typeof AntInput) {
return CaretPos
}
export { formulaList, formulas, formulaTypes, getWordUntilCaret, insertAtCursor }
export { formulaList, formulas, getWordUntilCaret, insertAtCursor }

5
packages/nc-gui/utils/iconUtils.ts

@ -91,6 +91,9 @@ import Project from '~icons/nc-icons/project'
import LookupIcon from '~icons/nc-icons/lookup'
import FileImageIcon from '~icons/nc-icons/file-image'
import PhUsers from '~icons/ph/users'
import PhUser from '~icons/ph/user'
// Roles
import SuperAdmin from '~icons/nc-icons/super-admin'
import Owner from '~icons/nc-icons/owner'
@ -320,6 +323,8 @@ export const iconMap = {
lock: h('span', { class: 'material-symbols' }, 'lock'),
account: h('span', { class: 'material-symbols' }, 'person'),
accountCircle: h('span', { class: 'material-symbols' }, 'account_circle'),
phUser: PhUser,
phUsers: PhUsers,
users: NcUsers,
cloudDownload: h('span', { class: 'material-symbols' }, 'cloud_download'),
download: MsDownloadRounded,

5
packages/nocodb-sdk/jest.config.js

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

7
packages/nocodb-sdk/package.json

@ -30,6 +30,7 @@
"fix:prettier": "prettier \"src/**/*.ts\" --write",
"fix:lint": "eslint src --ext .ts --fix",
"test": "run-s build test:*",
"test:unit": "ENV_FILE=./config/.env.test jest",
"test:lint": "eslint src --ext .ts",
"test:prettier": "prettier \"src/**/*.ts\" --list-different",
"test:spelling": "cspell \"{README.md,.github/*.md,src/**/*.ts}\"",
@ -45,6 +46,7 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@types/jest": "^29.5.2",
"cspell": "^4.2.8",
"eslint": "^8.54.0",
"eslint-config-prettier": "^8.10.0",
@ -56,7 +58,8 @@
"prettier": "^2.8.8",
"rimraf": "^5.0.5",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"ts-jest": "^29.1.1"
},
"files": [
"build/main",
@ -70,4 +73,4 @@
"prettier": {
"singleQuote": true
}
}
}

19
packages/nocodb-sdk/src/lib/Api.ts

@ -463,7 +463,8 @@ export interface ColumnType {
| 'URL'
| 'Year'
| 'QrCode'
| 'Links';
| 'Links'
| 'User';
/** Is Unsigned? */
un?: BoolType;
/** Is unique? */
@ -1780,7 +1781,8 @@ export interface NormalColumnRequestType {
| 'URL'
| 'Year'
| 'QrCode'
| 'Links';
| 'Links'
| 'User';
/** Is this column unique? */
un?: BoolType;
/** Is this column unique? */
@ -2761,6 +2763,13 @@ export interface NotificationUpdateType {
is_read?: boolean;
}
export interface UserFieldRecordType {
id: string;
display_name?: string;
email: string;
deleted?: boolean;
}
import type {
AxiosInstance,
AxiosRequestConfig,
@ -3341,7 +3350,7 @@ export class Api<
}),
/**
* @description Regenerate user refresh token
* @description Creates a new refresh token and JWT auth token for the user. The refresh token is sent as a cookie, while the JWT auth token is included in the response body.
*
* @tags Auth
* @name TokenRefresh
@ -3349,7 +3358,7 @@ export class Api<
* @request POST:/api/v1/auth/token/refresh
* @response `200` `{
\**
* New access token for user
* New JWT auth token for user
* @example 96751db2d53fb834382b682268874a2ea9ee610e4d904e688d1513f11d3c30d62d36d9e05dec0d63
*\
token?: string,
@ -3365,7 +3374,7 @@ export class Api<
this.request<
{
/**
* New access token for user
* New JWT auth token for user
* @example 96751db2d53fb834382b682268874a2ea9ee610e4d904e688d1513f11d3c30d62d36d9e05dec0d63
*/
token?: string;

1
packages/nocodb-sdk/src/lib/UITypes.ts

@ -39,6 +39,7 @@ enum UITypes {
QrCode = 'QrCode',
Button = 'Button',
Links = 'Links',
User = 'User',
}
export const numericUITypes = [

77
packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts

@ -0,0 +1,77 @@
import {
FormulaDataTypes,
validateFormulaAndExtractTreeWithType,
} from './formulaHelpers';
import UITypes from './UITypes';
describe('Formula parsing and type validation', () => {
it('Simple formula', async () => {
const result = await validateFormulaAndExtractTreeWithType({
formula: '1 + 2',
columns: [],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result.dataType).toEqual(FormulaDataTypes.NUMERIC);
});
it('Formula with IF condition', async () => {
const result = await validateFormulaAndExtractTreeWithType({
formula: 'IF({column}, "Found", BLANK())',
columns: [
{
id: 'cid',
title: 'column',
uidt: UITypes.Number,
},
],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result.dataType).toEqual(FormulaDataTypes.STRING);
});
it('Complex formula', async () => {
const result = await validateFormulaAndExtractTreeWithType({
formula:
'SWITCH({column2},"value1",IF({column1}, "Found", BLANK()),"value2", 2)',
columns: [
{
id: 'id1',
title: 'column1',
uidt: UITypes.Number,
},
{
id: 'id2',
title: 'column2',
uidt: UITypes.SingleLineText,
},
],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result.dataType).toEqual(FormulaDataTypes.STRING);
const result1 = await validateFormulaAndExtractTreeWithType({
formula: 'SWITCH({column2},"value1",IF({column1}, 1, 2),"value2", 2)',
columns: [
{
id: 'id1',
title: 'column1',
uidt: UITypes.Number,
},
{
id: 'id2',
title: 'column2',
uidt: UITypes.SingleLineText,
},
],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result1.dataType).toEqual(FormulaDataTypes.NUMERIC);
});
});

1791
packages/nocodb-sdk/src/lib/formulaHelpers.ts

File diff suppressed because it is too large Load Diff

42
packages/nocodb-sdk/src/lib/helperFunctions.ts

@ -1,4 +1,4 @@
import UITypes from './UITypes';
import UITypes, { isNumericCol } from './UITypes';
import { RolesObj, RolesType } from './globals';
// import {RelationTypes} from "./globals";
@ -45,6 +45,45 @@ const stringifyRolesObj = (roles?: RolesObj | null): string => {
return rolesArr.join(',');
};
const getAvailableRollupForUiType = (type: string) => {
if (isNumericCol(type as UITypes)) {
return [
'sum',
'count',
'min',
'max',
'avg',
'countDistinct',
'sumDistinct',
'avgDistinct',
];
} else if ([UITypes.Date, UITypes.DateTime].includes(type as UITypes)) {
return ['count', 'min', 'max', 'countDistinct'];
} else if (
[
UITypes.SingleLineText,
UITypes.LongText,
UITypes.User,
UITypes.Email,
UITypes.PhoneNumber,
UITypes.URL,
].includes(type as UITypes)
) {
return ['count'];
} else {
return [
'sum',
'count',
'min',
'max',
'avg',
'countDistinct',
'sumDistinct',
'avgDistinct',
];
}
};
export {
filterOutSystemColumns,
getSystemColumnsIds,
@ -52,4 +91,5 @@ export {
isSystemColumn,
extractRolesObj,
stringifyRolesObj,
getAvailableRollupForUiType,
};

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

@ -919,6 +919,7 @@ export class MssqlUi {
'COUNT',
'ROUNDDOWN',
'ROUNDUP',
'DATESTR',
];
}
}

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

@ -1289,6 +1289,6 @@ export class MysqlUi {
}
static getUnsupportedFnList() {
return ['COUNTA', 'COUNT'];
return ['COUNTA', 'COUNT', 'DATESTR'];
}
}

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

@ -948,6 +948,7 @@ export class OracleUi {
'COUNT',
'ROUNDDOWN',
'ROUNDUP',
'DATESTR',
];
}
}

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

@ -980,6 +980,7 @@ export class SnowflakeUi {
'VALUE',
'COUNTA',
'COUNT',
'DATESTR',
];
}
}

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

@ -882,7 +882,10 @@ export class SqliteUi {
'COUNT',
'ROUNDDOWN',
'ROUNDUP',
'DATESTR',
'DAY',
'MONTH',
'HOUR',
];
}
}

2
packages/nocodb-sdk/tsconfig.json

@ -38,7 +38,7 @@
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"lib": ["es2017","dom"],
"types": [],
"types": ["jest"],
"typeRoots": ["node_modules/@types", "src/types"],
"baseUrl": "./src",
"paths": {

1
packages/nocodb/src/cache/CacheMgr.ts vendored

@ -21,6 +21,7 @@ export default abstract class CacheMgr {
scope: string,
subListKeys: string[],
list: any[],
props?: string[],
): Promise<boolean>;
public abstract deepDel(
scope: string,

3
packages/nocodb/src/cache/NocoCache.ts vendored

@ -86,9 +86,10 @@ export default class NocoCache {
scope: string,
subListKeys: string[],
list: any[],
props: string[] = [],
): Promise<boolean> {
if (this.cacheDisabled) return Promise.resolve(true);
return this.client.setList(scope, subListKeys, list);
return this.client.setList(scope, subListKeys, list, props);
}
public static async deepDel(

16
packages/nocodb/src/cache/RedisCacheMgr.ts vendored

@ -185,6 +185,7 @@ export default class RedisCacheMgr extends CacheMgr {
scope: string,
subListKeys: string[],
list: any[],
props: string[] = [],
): Promise<boolean> {
// remove null from arrays
subListKeys = subListKeys.filter((k) => k);
@ -203,11 +204,18 @@ export default class RedisCacheMgr extends CacheMgr {
(await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
for (const o of list) {
// construct key for Get
// e.g. nc:<orgs>:<scope>:<model_id_1>
let getKey = `${this.prefix}:${scope}:${o.id}`;
// special case - MODEL_ROLE_VISIBILITY
if (scope === CacheScope.MODEL_ROLE_VISIBILITY) {
getKey = `${this.prefix}:${scope}:${o.fk_view_id}:${o.role}`;
if (props.length) {
const propValues = props.map((p) => o[p]);
// e.g. nc:<orgs>:<scope>:<prop_value_1>:<prop_value_2>
getKey = `${this.prefix}:${scope}:${propValues.join(':')}`;
} else {
// e.g. nc:<orgs>:<scope>:<model_id_1>
getKey = `${this.prefix}:${scope}:${o.id}`;
// special case - MODEL_ROLE_VISIBILITY
if (scope === CacheScope.MODEL_ROLE_VISIBILITY) {
getKey = `${this.prefix}:${scope}:${o.fk_view_id}:${o.role}`;
}
}
// set Get Key
log(`RedisCacheMgr::setList: setting key ${getKey}`);

16
packages/nocodb/src/cache/RedisMockCacheMgr.ts vendored

@ -185,6 +185,7 @@ export default class RedisMockCacheMgr extends CacheMgr {
scope: string,
subListKeys: string[],
list: any[],
props: string[] = [],
): Promise<boolean> {
// remove null from arrays
subListKeys = subListKeys.filter((k) => k);
@ -203,11 +204,18 @@ export default class RedisMockCacheMgr extends CacheMgr {
(await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
for (const o of list) {
// construct key for Get
// e.g. nc:<orgs>:<scope>:<model_id_1>
let getKey = `${this.prefix}:${scope}:${o.id}`;
// special case - MODEL_ROLE_VISIBILITY
if (scope === CacheScope.MODEL_ROLE_VISIBILITY) {
getKey = `${this.prefix}:${scope}:${o.fk_view_id}:${o.role}`;
if (props.length) {
const propValues = props.map((p) => o[p]);
// e.g. nc:<orgs>:<scope>:<prop_value_1>:<prop_value_2>
getKey = `${this.prefix}:${scope}:${propValues.join(':')}`;
} else {
// e.g. nc:<orgs>:<scope>:<model_id_1>
getKey = `${this.prefix}:${scope}:${o.id}`;
// special case - MODEL_ROLE_VISIBILITY
if (scope === CacheScope.MODEL_ROLE_VISIBILITY) {
getKey = `${this.prefix}:${scope}:${o.fk_view_id}:${o.role}`;
}
}
// set Get Key
log(`RedisMockCacheMgr::setList: setting key ${getKey}`);

13
packages/nocodb/src/controllers/base-users.controller.ts

@ -11,7 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { ProjectUserReqType } from 'nocodb-sdk';
import { ProjectRoles, ProjectUserReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { BaseUsersService } from '~/services/base-users/base-users.service';
import { NcError } from '~/helpers/catchError';
@ -27,12 +27,19 @@ export class BaseUsersController {
'/api/v1/db/meta/projects/:baseId/users',
'/api/v2/meta/bases/:baseId/users',
])
@Acl('userList')
@Acl('baseUserList')
async userList(@Param('baseId') baseId: string, @Req() req: Request) {
const baseRoles = Object.keys(req.user?.base_roles ?? {});
const mode =
baseRoles.includes(ProjectRoles.OWNER) ||
baseRoles.includes(ProjectRoles.CREATOR)
? 'full'
: 'viewer';
return {
users: await this.baseUsersService.userList({
baseId,
query: req.query,
mode,
}),
};
}

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

@ -33,6 +33,7 @@ import type {
QrCodeColumn,
RollupColumn,
SelectOption,
User,
} from '~/models';
import type { SortType } from 'nocodb-sdk';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
@ -45,6 +46,7 @@ import { NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
import {
Audit,
BaseUser,
Column,
Filter,
Model,
@ -706,11 +708,34 @@ class BaseModelSqlv2 {
continue;
}
qb.orderBy(
groupByColumns[sort.fk_column_id].id,
sort.direction,
sort.direction === 'desc' ? 'LAST' : 'FIRST',
);
const column = groupByColumns[sort.fk_column_id];
if (column.uidt === UITypes.User) {
const baseUsers = await BaseUser.getUsersList({
base_id: column.base_id,
});
// create nested replace statement for each user
const finalStatement = baseUsers.reduce((acc, user) => {
const qb = this.dbDriver.raw(`REPLACE(${acc}, ?, ?)`, [
user.id,
user.display_name || user.email,
]);
return qb.toQuery();
}, this.dbDriver.raw(`??`, [column.column_name]).toQuery());
qb.orderBy(
sanitize(this.dbDriver.raw(finalStatement)),
sort.direction,
sort.direction === 'desc' ? 'LAST' : 'FIRST',
);
} else {
qb.orderBy(
column.id,
sort.direction,
sort.direction === 'desc' ? 'LAST' : 'FIRST',
);
}
}
// group by using the column aliases
@ -2304,7 +2329,7 @@ class BaseModelSqlv2 {
await this.beforeInsert(insertObj, trx, cookie);
}
await this.prepareAttachmentData(insertObj);
await this.prepareNocoData(insertObj);
let response;
// const driver = trx ? trx : this.dbDriver;
@ -2547,7 +2572,7 @@ class BaseModelSqlv2 {
await this.beforeUpdate(data, trx, cookie);
await this.prepareAttachmentData(updateObj);
await this.prepareNocoData(updateObj);
const prevData = await this.readByPk(
id,
@ -3015,7 +3040,7 @@ class BaseModelSqlv2 {
}
}
await this.prepareAttachmentData(insertObj);
await this.prepareNocoData(insertObj);
// prepare nested link data for insert only if it is single record insertion
if (isSingleRecordInsertion) {
@ -3153,7 +3178,7 @@ class BaseModelSqlv2 {
continue;
}
if (!raw) {
await this.prepareAttachmentData(d);
await this.prepareNocoData(d);
const oldRecord = await this.readByPk(pkValues);
if (!oldRecord) {
@ -4376,12 +4401,14 @@ class BaseModelSqlv2 {
skipDateConversion?: boolean;
skipAttachmentConversion?: boolean;
skipSubstitutingColumnIds?: boolean;
skipUserConversion?: boolean;
raw?: boolean; // alias for skipDateConversion and skipAttachmentConversion
first?: boolean;
} = {
skipDateConversion: false,
skipAttachmentConversion: false,
skipSubstitutingColumnIds: false,
skipUserConversion: false,
raw: false,
first: false,
},
@ -4390,6 +4417,7 @@ class BaseModelSqlv2 {
options.skipDateConversion = true;
options.skipAttachmentConversion = true;
options.skipSubstitutingColumnIds = true;
options.skipUserConversion = true;
}
if (options.first && typeof qb !== 'string') {
@ -4422,6 +4450,11 @@ class BaseModelSqlv2 {
data = this.convertDateFormat(data, childTable);
}
// update user fields
if (!options.skipUserConversion) {
data = await this.convertUserFormat(data, childTable);
}
if (!options.skipSubstitutingColumnIds) {
data = await this.substituteColumnIdsWithColumnTitles(data, childTable);
}
@ -4515,6 +4548,84 @@ class BaseModelSqlv2 {
return data;
}
protected async convertUserFormat(
data: Record<string, any>,
childTable?: Model,
) {
// user is stored as id within the database
// convertUserFormat is used to convert the response in id to user object in API response
if (data) {
if (childTable && !childTable?.columns) {
await childTable.getColumns();
} else if (!this.model?.columns) {
await this.model.getColumns();
}
const userColumns = [];
const columns = childTable ? childTable.columns : this.model.columns;
for (const col of columns) {
if (col.uidt === UITypes.Lookup) {
if ((await this.getNestedUidt(col)) === UITypes.User) {
userColumns.push(col);
}
} else {
if (col.uidt === UITypes.User) {
userColumns.push(col);
}
}
}
if (userColumns.length) {
const baseUsers = await BaseUser.getUsersList({
base_id: childTable ? childTable.base_id : this.model.base_id,
});
if (Array.isArray(data)) {
data = await Promise.all(
data.map((d) => this._convertUserFormat(userColumns, baseUsers, d)),
);
} else {
data = await this._convertUserFormat(userColumns, baseUsers, data);
}
}
}
return data;
}
protected _convertUserFormat(
userColumns: Record<string, any>[],
baseUsers: Partial<User>[],
d: Record<string, any>,
) {
try {
if (d) {
for (const col of userColumns) {
if (d[col.id] && d[col.id].length) {
d[col.id] = d[col.id].split(',');
} else {
d[col.id] = null;
}
if (d[col.id]?.length) {
d[col.id] = d[col.id].map((fid) => {
const { id, email, display_name } = baseUsers.find(
(u) => u.id === fid,
);
return {
id,
email,
display_name: display_name?.length ? display_name : null,
};
});
}
}
}
} catch {}
return d;
}
protected async _convertAttachmentType(
attachmentColumns: Record<string, any>[],
d: Record<string, any>,
@ -5136,7 +5247,14 @@ class BaseModelSqlv2 {
if (childRows.length !== childIds.length) {
const missingIds = childIds.filter(
(id) =>
!childRows.find((r) => r[parentColumn.column_name] === id),
!childRows.find(
(r) =>
r[parentColumn.column_name] ===
(typeof id === 'object'
? id[parentTable.primaryKey.title] ||
id[parentTable.primaryKey.column_name]
: id),
),
);
NcError.unprocessableEntity(
@ -5175,9 +5293,28 @@ class BaseModelSqlv2 {
{
// validate Ids
{
const childRowsQb = this.dbDriver(childTn)
.select(childTable.primaryKey.column_name)
.whereIn(childTable.primaryKey.column_name, childIds);
const childRowsQb = this.dbDriver(childTn).select(
childTable.primaryKey.column_name,
);
if (parentTable.primaryKeys.length > 1) {
childRowsQb.where((qb) => {
for (const childId of childIds) {
qb.orWhere(_wherePk(parentTable.primaryKeys, childId));
}
});
} else if (typeof childIds[0] === 'object') {
childRowsQb.whereIn(
parentTable.primaryKey.column_name,
childIds.map(
(c) =>
c[parentTable.primaryKey.title] ||
c[parentTable.primaryKey.column_name],
),
);
} else {
childRowsQb.whereIn(parentTable.primaryKey.column_name, childIds);
}
const childRows = await this.execAndParse(childRowsQb, null, {
raw: true,
@ -5186,7 +5323,14 @@ class BaseModelSqlv2 {
if (childRows.length !== childIds.length) {
const missingIds = childIds.filter(
(id) =>
!childRows.find((r) => r[parentColumn.column_name] === id),
!childRows.find(
(r) =>
r[parentColumn.column_name] ===
(typeof id === 'object'
? id[parentTable.primaryKey.title] ||
id[parentTable.primaryKey.column_name]
: id),
),
);
NcError.unprocessableEntity(
@ -5353,8 +5497,12 @@ class BaseModelSqlv2 {
}
}
prepareAttachmentData(data) {
if (this.model.columns.some((c) => c.uidt === UITypes.Attachment)) {
async prepareNocoData(data) {
if (
this.model.columns.some((c) =>
[UITypes.Attachment, UITypes.User].includes(c.uidt),
)
) {
for (const column of this.model.columns) {
if (column.uidt === UITypes.Attachment) {
if (data[column.column_name]) {
@ -5371,6 +5519,113 @@ class BaseModelSqlv2 {
}
}
}
} else if (column.uidt === UITypes.User) {
if (data[column.column_name]) {
const userIds = [];
if (typeof data[column.column_name] === 'string') {
try {
data[column.column_name] = JSON.parse(data[column.column_name]);
} catch (e) {}
}
const baseUsers = await BaseUser.getUsersList({
base_id: this.model.base_id,
include_ws_deleted: false,
});
if (typeof data[column.column_name] === 'string') {
const users = data[column.column_name]
.split(',')
.map((u) => u.trim());
for (const user of users) {
try {
if (user.length === 0) continue;
if (user.includes('@')) {
const u = baseUsers.find((u) => u.email === user);
if (!u) {
NcError.unprocessableEntity(
`User with email '${user}' is not part of this workspace`,
);
}
userIds.push(u.id);
} else {
const u = baseUsers.find((u) => u.id === user);
if (!u) {
NcError.unprocessableEntity(
`User with id '${user}' is not part of this workspace`,
);
}
userIds.push(u.id);
}
} catch (e) {
NcError.unprocessableEntity(e.message);
}
}
} else {
const users: { id?: string; email?: string }[] = Array.isArray(
data[column.column_name],
)
? data[column.column_name]
: [data[column.column_name]];
for (const userObj of users) {
const user = extractProps(userObj, ['id', 'email']);
try {
if ('id' in user) {
const u = baseUsers.find((u) => u.id === user.id);
if (!u) {
NcError.unprocessableEntity(
`User with id '${user.id}' is not part of this workspace`,
);
}
userIds.push(u.id);
} else if ('email' in user) {
// skip null input
if (!user.email) continue;
// trim extra spaces
user.email = user.email.trim();
// skip empty input
if (user.email.length === 0) continue;
const u = baseUsers.find((u) => u.email === user.email);
if (!u) {
NcError.unprocessableEntity(
`User with email '${user.email}' is not part of this workspace`,
);
}
userIds.push(u.id);
} else {
NcError.unprocessableEntity('Invalid user object');
}
} catch (e) {
NcError.unprocessableEntity(e.message);
}
}
}
if (userIds.length === 0) {
data[column.column_name] = null;
} else {
const userSet = new Set(userIds);
if (userSet.size !== userIds.length) {
NcError.unprocessableEntity(
'Duplicate users not allowed for user field',
);
}
if (column.meta?.is_multi) {
data[column.column_name] = userIds.join(',');
} else {
if (userIds.length > 1) {
NcError.unprocessableEntity(
`Multiple users not allowed for '${column.title}'`,
);
} else {
data[column.column_name] = userIds[0];
}
}
}
}
}
}
}
@ -5654,7 +5909,7 @@ function getCompositePk(primaryKeys: Column[], row) {
return primaryKeys.map((c) => row[c.title]).join('___');
}
function haveFormulaColumn(columns: Column[]) {
export function haveFormulaColumn(columns: Column[]) {
return columns.some((c) => c.uidt === UITypes.Formula);
}

82
packages/nocodb/src/db/conditionV2.ts

@ -13,7 +13,7 @@ import type Column from '~/models/Column';
import type LookupColumn from '~/models/LookupColumn';
import type RollupColumn from '~/models/RollupColumn';
import type FormulaColumn from '~/models/FormulaColumn';
import type { BarcodeColumn, QrCodeColumn } from '~/models';
import { type BarcodeColumn, BaseUser, type QrCodeColumn } from '~/models';
import { NcError } from '~/helpers/catchError';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
@ -447,6 +447,86 @@ const parseConditionV2 = async (
alias,
builder,
);
} else if (
column.uidt === UITypes.User &&
['like', 'nlike'].includes(filter.comparison_op)
) {
const baseUsers = await BaseUser.getUsersList({
base_id: column.base_id,
});
return (qb: Knex.QueryBuilder) => {
const users = baseUsers.filter((user) => {
const filterVal = filter.value.toLowerCase();
if (filterVal.startsWith('%') && filterVal.endsWith('%')) {
return (user.display_name || user.email)
.toLowerCase()
.includes(filterVal.substring(1, filterVal.length - 1));
} else if (filterVal.startsWith('%')) {
return (user.display_name || user.email)
.toLowerCase()
.endsWith(filterVal.substring(1));
} else if (filterVal.endsWith('%')) {
return (user.display_name || user.email)
.toLowerCase()
.startsWith(filterVal.substring(0, filterVal.length - 1));
}
return (user.display_name || user.email)
.toLowerCase()
.includes(filterVal.toLowerCase());
});
// create nested replace statement for each user
const finalStatement = users.reduce((acc, user) => {
const qb = knex.raw(`REPLACE(${acc}, ?, ?)`, [
user.id,
user.display_name || user.email,
]);
return qb.toQuery();
}, knex.raw(`??`, [column.column_name]).toQuery());
let val = filter.value;
if (filter.comparison_op === 'like') {
val =
(val + '').startsWith('%') || (val + '').endsWith('%')
? val
: `%${val}%`;
if (qb?.client?.config?.client === 'pg') {
qb = qb.where(knex.raw(`(${finalStatement}) ilike ?`, [val]));
} else {
qb = qb.where(knex.raw(`(${finalStatement}) like ?`, [val]));
}
} else {
if (!val) {
// val is empty -> all values including NULL but empty strings
qb.whereNot(column.column_name, '');
qb.orWhereNull(column.column_name);
} else {
val = val.startsWith('%') || val.endsWith('%') ? val : `%${val}%`;
qb.where((nestedQb) => {
if (qb?.client?.config?.client === 'pg') {
nestedQb.whereNot(
knex.raw(`(${finalStatement}) ilike ?`, [val]),
);
} else {
nestedQb.whereNot(
knex.raw(`(${finalStatement}) like ?`, [val]),
);
}
if (val !== '%%') {
// if value is not empty, empty or null should be included
nestedQb.orWhere(column.column_name, '');
nestedQb.orWhereNull(column.column_name);
} else {
// if value is empty, then only null is included
nestedQb.orWhereNull(column.column_name);
}
});
}
}
};
} else {
if (
filter.comparison_op === 'empty' ||

151
packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts

@ -1,23 +1,27 @@
import jsep from 'jsep';
import {
FormulaDataTypes,
jsepCurlyHook,
UITypes,
validateDateWithUnknownFormat,
validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk';
import { Logger } from '@nestjs/common';
import mapFunctionName from '../mapFunctionName';
import genRollupSelectv2 from '../genRollupSelectv2';
import type Column from '~/models/Column';
import type Model from '~/models/Model';
import type RollupColumn from '~/models/RollupColumn';
import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn';
import type LookupColumn from '~/models/LookupColumn';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type Column from '~/models/Column';
import Model from '~/models/Model';
import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope } from '~/utils/globals';
import { convertDateFormatForConcat } from '~/helpers/formulaFnHelper';
import FormulaColumn from '~/models/FormulaColumn';
import { Base, BaseUser } from '~/models';
// todo: switch function based on database
const logger = new Logger('FormulaQueryBuilderv2');
// @ts-ignore
const getAggregateFn: (fnName: string) => (args: { qb; knex?; cn }) => any = (
@ -59,17 +63,56 @@ async function _formulaQueryBuilder(
model: Model,
aliasToColumn: Record<string, () => Promise<{ builder: any }>> = {},
tableAlias?: string,
parsedTree?: any,
column: Column = null,
) {
const knex = baseModelSqlv2.dbDriver;
// formula may include double curly brackets in previous version
// convert to single curly bracket here for compatibility
const tree = jsep(_tree.replaceAll('{{', '{').replaceAll('}}', '}'));
const columns = await model.getColumns();
let tree = parsedTree;
if (!tree) {
// formula may include double curly brackets in previous version
// convert to single curly bracket here for compatibility
// const _tree1 = jsep(_tree.replaceAll('{{', '{').replaceAll('}}', '}'));
tree = await validateFormulaAndExtractTreeWithType({
formula: _tree.replaceAll('{{', '{').replaceAll('}}', '}'),
columns,
column,
clientOrSqlUi: baseModelSqlv2.clientType as
| 'mysql'
| 'pg'
| 'sqlite3'
| 'mssql'
| 'mysql2'
| 'oracledb'
| 'mariadb'
| 'sqlite'
| 'snowflake',
getMeta: async (modelId) => {
const model = await Model.get(modelId);
await model.getColumns();
return model;
},
});
// populate and save parsedTree to column if not exist
if (column) {
FormulaColumn.update(column.id, { parsed_tree: tree }).then(
() => {
// ignore
},
(err) => {
logger.error(err);
},
);
}
}
const columnIdToUidt = {};
// todo: improve - implement a common solution for filter, sort, formula, etc
for (const col of await model.getColumns()) {
for (const col of columns) {
columnIdToUidt[col.id] = col.uidt;
if (col.id in aliasToColumn) continue;
switch (col.uidt) {
@ -84,6 +127,7 @@ async function _formulaQueryBuilder(
model,
{ ...aliasToColumn, [col.id]: null },
tableAlias,
formulOption.getParsedTree(),
);
builder.sql = '(' + builder.sql + ')';
return {
@ -404,6 +448,7 @@ async function _formulaQueryBuilder(
'',
lookupModel,
aliasToColumn,
formulaOption.getParsedTree(),
);
if (isMany) {
const qb = selectQb;
@ -637,6 +682,26 @@ async function _formulaQueryBuilder(
Promise.resolve({ builder: col.column_name });
}
break;
case UITypes.User:
{
const base = await Base.get(model.base_id);
const baseUsers = await BaseUser.getUsersList({
base_id: base.id,
});
// create nested replace statement for each user
const finalStatement = baseUsers.reduce((acc, user) => {
const qb = knex.raw(`REPLACE(${acc}, ?, ?)`, [user.id, user.email]);
return qb.toQuery();
}, knex.raw(`??`, [col.column_name]).toQuery());
aliasToColumn[col.id] = async (): Promise<any> => {
return {
builder: knex.raw(finalStatement).wrap('(', ')'),
};
};
}
break;
default:
aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });
@ -792,8 +857,48 @@ async function _formulaQueryBuilder(
);
}
// if operator is + and expected return type is string, convert to concat
if (pt.operator === '+' && pt.dataType === FormulaDataTypes.STRING) {
return fn(
{
type: 'CallExpression',
arguments: [pt.left, pt.right],
callee: {
type: 'Identifier',
name: 'CONCAT',
},
},
alias,
prevBinaryOp,
);
}
if (pt.operator === '==') {
pt.operator = '=';
// if left/right is of different type, convert to string and compare
if (
pt.left.dataType !== pt.right.dataType &&
[pt.left.dataType, pt.right.dataType].every(
(type) => type !== FormulaDataTypes.NULL,
)
) {
pt.left = {
type: 'CallExpression',
arguments: [pt.left],
callee: {
type: 'Identifier',
name: 'STRING',
},
};
pt.right = {
type: 'CallExpression',
arguments: [pt.right],
callee: {
type: 'Identifier',
name: 'STRING',
},
};
}
}
if (pt.operator === '/') {
@ -947,23 +1052,29 @@ export default async function formulaQueryBuilderv2(
aliasToColumn = {},
tableAlias?: string,
validateFormula = false,
parsedTree?: any,
) {
const knex = baseModelSqlv2.dbDriver;
// register jsep curly hook once only
jsep.plugins.register(jsepCurlyHook);
// generate qb
const qb = await _formulaQueryBuilder(
baseModelSqlv2,
_tree,
alias,
model,
aliasToColumn,
tableAlias,
);
if (!validateFormula) return qb;
let qb;
try {
// generate qb
qb = await _formulaQueryBuilder(
baseModelSqlv2,
_tree,
alias,
model,
aliasToColumn,
tableAlias,
parsedTree ??
(await column
?.getColOptions<FormulaColumn>()
.then((formula) => formula?.getParsedTree())),
);
if (!validateFormula) return qb;
// dry run qb.builder to see if it will break the grid view or not
// if so, set formula error and show empty selectQb instead
await baseModelSqlv2.execAndParse(
@ -985,6 +1096,8 @@ export default async function formulaQueryBuilderv2(
}
}
} catch (e) {
if (!validateFormula) throw e;
console.error(e);
if (column) {
const formula = await column.getColOptions<FormulaColumn>();

160
packages/nocodb/src/db/functionMappings/commonFns.ts

@ -1,33 +1,107 @@
import { FormulaDataTypes } from 'nocodb-sdk';
import type { MapFnArgs } from '../mapFunctionName';
import { NcError } from '~/helpers/catchError';
async function treatArgAsConditionalExp(
args: MapFnArgs,
argument = args.pt?.arguments?.[0],
) {
const condArg = (await args.fn(argument)).builder.toQuery();
let cond = condArg;
// based on the data type of the argument, we need to handle the condition
// if string - value is not null and not empty then true
// if number - value is not null and not 0 then true
// if boolean - value is not null and not false then true
// if date - value is not null then true
switch (argument.dataType as FormulaDataTypes) {
case FormulaDataTypes.NUMERIC:
cond = `(${condArg}) IS NOT NULL AND (${condArg}) != 0`;
break;
case FormulaDataTypes.STRING:
cond = `(${condArg}) IS NOT NULL AND (${condArg}) != ''`;
break;
case FormulaDataTypes.BOOLEAN:
cond = `(${condArg}) IS NOT NULL AND (${condArg}) != false`;
break;
case FormulaDataTypes.DATE:
cond = `(${condArg}) IS NOT NULL`;
break;
}
return { builder: args.knex.raw(cond) };
}
export default {
// todo: handle default case
SWITCH: async (args: MapFnArgs) => {
const count = Math.floor((args.pt.arguments.length - 1) / 2);
let query = '';
const returnArgsType = new Set(
args.pt.arguments
.filter(
(type, i) => i > 1 && i % 2 === 0 && type !== FormulaDataTypes.NULL,
)
.map((type) => type.dataType),
);
// if else case present then push that to types
if (args.pt.arguments.length % 2 === 0) {
returnArgsType.add(
args.pt.arguments[args.pt.arguments.length - 1].dataType,
);
}
const switchVal = (await args.fn(args.pt.arguments[0])).builder.toQuery();
for (let i = 0; i < count; i++) {
let val;
// cast to string if the return value types are different
if (returnArgsType.size > 1) {
val = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[i * 2 + 2]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder.toQuery();
} else {
val = (await args.fn(args.pt.arguments[i * 2 + 2])).builder.toQuery();
}
query += args.knex
.raw(
`\n\tWHEN ${(
await args.fn(args.pt.arguments[i * 2 + 1])
).builder.toQuery()} THEN ${(
await args.fn(args.pt.arguments[i * 2 + 2])
).builder.toQuery()}`,
).builder.toQuery()} THEN ${val}`,
)
.toQuery();
}
if (args.pt.arguments.length % 2 === 0) {
query += args.knex
.raw(
`\n\tELSE ${(
await args.fn(args.pt.arguments[args.pt.arguments.length - 1])
).builder.toQuery()}`,
)
.toQuery();
let val;
// cast to string if the return value types are different
if (returnArgsType.size > 1) {
val = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[args.pt.arguments.length - 1]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder.toQuery();
} else {
val = (
await args.fn(args.pt.arguments[args.pt.arguments.length - 1])
).builder.toQuery();
}
query += `\n\tELSE ${val}`;
}
return {
builder: args.knex.raw(
@ -36,24 +110,52 @@ export default {
};
},
IF: async (args: MapFnArgs) => {
let query = args.knex
.raw(
`\n\tWHEN ${(
await args.fn(args.pt.arguments[0])
).builder.toQuery()} THEN ${(
await args.fn(args.pt.arguments[1])
).builder.toQuery()}`,
)
.toQuery();
const cond = (await treatArgAsConditionalExp(args)).builder;
let thenArg;
let elseArg;
const returnArgsType = new Set(
[args.pt.arguments[1].dataType, args.pt.arguments[2].dataType].filter(
(type) => type !== FormulaDataTypes.NULL,
),
);
// cast to string if the return value types are different
if (returnArgsType.size > 1) {
thenArg = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[1]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder;
elseArg = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[2]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder;
} else {
thenArg = (await args.fn(args.pt.arguments[1])).builder.toQuery();
elseArg = (await args.fn(args.pt.arguments[2])).builder.toQuery();
}
let query = args.knex.raw(`\n\tWHEN ${cond} THEN ${thenArg}`).toQuery();
if (args.pt.arguments[2]) {
query += args.knex
.raw(
`\n\tELSE ${(await args.fn(args.pt.arguments[2])).builder.toQuery()}`,
)
.toQuery();
query += args.knex.raw(`\n\tELSE ${elseArg}`).toQuery();
}
return { builder: args.knex.raw(`CASE ${query}\n END${args.colAlias}`) };
},
// used only for casting to string internally, this one is dummy function
// and will work as fallback for dbs which don't support/implemented CAST
STRING(args: MapFnArgs) {
return args.fn(args.pt?.arguments?.[0]);
},
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
@ -62,7 +164,7 @@ export default {
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery(),
(await treatArgAsConditionalExp(args, ar)).builder.toQuery(),
),
)
).join(' AND ')}`,
@ -80,7 +182,7 @@ export default {
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery(),
(await treatArgAsConditionalExp(args, ar)).builder.toQuery(),
),
)
).join(' OR ')}`,
@ -124,7 +226,7 @@ export default {
const query = (await args.fn(args.pt.arguments[0])).builder;
return {
builder: args.knex.raw(
`CASE WHEN ${query} % 2 = 0 THEN CEIL(${query})\nELSE CEIL(${query} / 2.0) * 2\n END${args.colAlias}`,
`CASE WHEN ${query} >= 0 THEN CEIL((${query}) / 2.0) * 2 \n ELSE FLOOR((${query} + 2) / 2.0) * 2 - 2\n END${args.colAlias}`,
),
};
},
@ -201,7 +303,7 @@ export default {
return {
builder: knex.raw(
`ROUND(FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`,
`(FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`,
),
};
},
@ -215,7 +317,7 @@ export default {
return {
builder: knex.raw(
`ROUND(CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`,
`(CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`,
),
};
},

15
packages/nocodb/src/db/functionMappings/mysql.ts

@ -125,7 +125,9 @@ const mysql2 = {
const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder;
return {
builder: knex.raw(`REGEXP_SUBSTR(${source}, ${pattern}) ${colAlias}`),
builder: knex.raw(
`REGEXP_SUBSTR(${source}, ${pattern}, 1, 1, 'c') ${colAlias}`,
),
};
},
REGEX_REPLACE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
@ -134,7 +136,7 @@ const mysql2 = {
const replacement = (await fn(pt.arguments[2])).builder;
return {
builder: knex.raw(
`REGEXP_REPLACE(${source}, ${pattern}, ${replacement}) ${colAlias}`,
`REGEXP_REPLACE(${source}, ${pattern}, ${replacement}, 1, 0, 'c') ${colAlias}`,
),
};
},
@ -160,6 +162,15 @@ END) ${colAlias}`,
),
};
},
STRING: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} AS CHAR) ${
args.colAlias
}`,
),
};
},
};
export default mysql2;

65
packages/nocodb/src/db/functionMappings/pg.ts

@ -1,4 +1,5 @@
import dayjs from 'dayjs';
import { FormulaDataTypes } from 'nocodb-sdk';
import commonFns from './commonFns';
import type { MapFnArgs } from '../mapFunctionName';
import { convertUnits } from '~/helpers/convertUnits';
@ -57,9 +58,9 @@ const pg = {
DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`${(await fn(pt.arguments[0])).builder} + (${
(await fn(pt.arguments[1])).builder
} ||
`(${(await fn(pt.arguments[0])).builder})${
pt.arguments[0].dataType !== FormulaDataTypes.DATE ? '::DATE' : ''
} + (${(await fn(pt.arguments[1])).builder} ||
'${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
'',
@ -134,6 +135,42 @@ const pg = {
),
};
},
DATESTR: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`TO_CHAR((${
(await fn(pt?.arguments[0])).builder
}), 'YYYY-MM-DD')::text ${colAlias}`,
),
};
},
DAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`EXTRACT('Day' FROM ((${
(await fn(pt?.arguments[0])).builder
})::TIMESTAMP)) ${colAlias}`,
),
};
},
MONTH: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`EXTRACT('Month' FROM ((${
(await fn(pt?.arguments[0])).builder
})::TIMESTAMP)) ${colAlias}`,
),
};
},
HOUR: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`EXTRACT('Hour' FROM ((${
(await fn(pt?.arguments[0])).builder
})::TIMESTAMP)) ${colAlias}`,
),
};
},
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
@ -191,15 +228,17 @@ const pg = {
},
REGEX_MATCH: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder;
return {
builder: knex.raw(
`CASE WHEN (${source}::TEXT ~ ${pattern}::TEXT) THEN 1 ELSE 0 END ${colAlias}`,
`CASE WHEN REGEXP_MATCH(${source}::TEXT, ${pattern}::TEXT) IS NULL THEN 0 ELSE 1 END ${colAlias}`,
),
};
},
REGEX_EXTRACT: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder;
return {
builder: knex.raw(
@ -212,6 +251,7 @@ const pg = {
REGEX_REPLACE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder;
const replacement = (await fn(pt.arguments[2])).builder;
return {
builder: knex.raw(
@ -263,11 +303,11 @@ const pg = {
return {
builder: knex.raw(
`ROUND(CASE
`CASE
WHEN ${value} IS NULL OR REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g') IN ('.', '') OR LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^.]+', '', 'g')) > 1 THEN NULL
WHEN LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^%]', '','g')) > 0 THEN POW(-1, LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^-]','', 'g'))) * (REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g'))::NUMERIC / 100
ELSE POW(-1, LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^-]', '', 'g'))) * (REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g'))::NUMERIC
END) ${colAlias}`,
END ${colAlias}`,
),
};
},
@ -281,7 +321,7 @@ END) ${colAlias}`,
return {
builder: knex.raw(
`ROUND((FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder})::numeric(30,${precisionBuilder})))${colAlias}`,
`(FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder})::numeric(30,${precisionBuilder}))${colAlias}`,
),
};
},
@ -295,7 +335,16 @@ END) ${colAlias}`,
return {
builder: knex.raw(
`ROUND((CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))::numeric(30,${precisionBuilder}))${colAlias}`,
`(CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder})::numeric(30,${precisionBuilder}))${colAlias}`,
),
};
},
STRING: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`(${(await args.fn(args.pt.arguments[0])).builder})::text ${
args.colAlias
}`,
),
};
},

25
packages/nocodb/src/db/sortV2.ts

@ -6,7 +6,7 @@ import { NcError } from '~/helpers/catchError';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
import { sanitize } from '~/helpers/sqlSanitize';
import { Sort } from '~/models';
import { Base, BaseUser, Sort } from '~/models';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
export default async function sortV2(
@ -138,6 +138,29 @@ export default async function sortV2(
}
break;
}
case UITypes.User: {
const base = await Base.get(model.base_id);
const baseUsers = await BaseUser.getUsersList({
base_id: base.id,
});
// create nested replace statement for each user
const finalStatement = baseUsers.reduce((acc, user) => {
const qb = knex.raw(`REPLACE(${acc}, ?, ?)`, [
user.id,
user.display_name || user.email,
]);
return qb.toQuery();
}, knex.raw(`??`, [column.column_name]).toQuery());
qb.orderBy(
sanitize(knex.raw(finalStatement)),
sort.direction || 'asc',
nulls,
);
break;
}
default:
qb.orderBy(
sanitize(column.column_name),

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

@ -1,5 +1,5 @@
import { customAlphabet } from 'nanoid';
import { UITypes } from 'nocodb-sdk';
import { getAvailableRollupForUiType, UITypes } from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection';
import type { RollupColumn } from '~/models';
import type {
@ -135,6 +135,18 @@ export async function validateRollupPayload(payload: ColumnReqType | Column) {
)
)
throw new Error('Rollup column not found in related table');
if (
!getAvailableRollupForUiType(relatedColumn.uidt).includes(
(payload as RollupColumnReqType).rollup_function,
)
) {
throw new Error(
`Rollup function (${
(payload as RollupColumnReqType).rollup_function
}) not available for type (${relatedColumn.uidt})`,
);
}
}
export async function validateLookupPayload(

10
packages/nocodb/src/helpers/initAdminFromEnv.ts

@ -169,11 +169,6 @@ export default async function initAdminFromEnv(_ncMeta = Noco.ncMeta) {
existingUserWithNewEmail.id,
);
// clear cache
await NocoCache.delAll(
CacheScope.USER,
`${existingUserWithNewEmail.email}___*`,
);
await NocoCache.del(
`${CacheScope.USER}:${existingUserWithNewEmail.id}`,
);
@ -237,11 +232,6 @@ export default async function initAdminFromEnv(_ncMeta = Noco.ncMeta) {
// check user account already present with the new admin email
const existingUserWithNewEmail = await User.getByEmail(email, ncMeta);
if (existingUserWithNewEmail?.id) {
// clear cache
await NocoCache.delAll(
CacheScope.USER,
`${existingUserWithNewEmail.email}___*`,
);
await NocoCache.del(
`${CacheScope.USER}:${existingUserWithNewEmail.id}`,
);

2
packages/nocodb/src/helpers/syncMigration.ts

@ -2,7 +2,7 @@ import type { Base, Source } from '~/models';
import KnexMigratorv2 from '~/db/sql-migrator/lib/KnexMigratorv2';
export default async function syncMigration(base: Base): Promise<void> {
for (const source of await base.getBases()) {
for (const source of await base.getSources()) {
try {
/* create sql-migrator */
const migrator = new KnexMigratorv2(base);

2
packages/nocodb/src/meta/meta.service.ts

@ -912,7 +912,7 @@ export class MetaService {
);
}
private now(): any {
public now(): any {
return dayjs()
.utc()
.format(this.isMySQL() ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ');

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

@ -24,6 +24,7 @@ import * as nc_034_erd_filter_and_notification from '~/meta/migrations/v2/nc_034
import * as nc_035_add_username_to_users from '~/meta/migrations/v2/nc_035_add_username_to_users';
import * as nc_036_base_deleted from '~/meta/migrations/v2/nc_036_base_deleted';
import * as nc_037_rename_project_and_base from '~/meta/migrations/v2/nc_037_rename_project_and_base';
import * as nc_038_formula_parsed_tree_column from '~/meta/migrations/v2/nc_038_formula_parsed_tree_column';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -59,6 +60,7 @@ export default class XcMigrationSourcev2 {
'nc_035_add_username_to_users',
'nc_036_base_deleted',
'nc_037_rename_project_and_base',
'nc_038_formula_parsed_tree_column',
]);
}
@ -120,6 +122,8 @@ export default class XcMigrationSourcev2 {
return nc_036_base_deleted;
case 'nc_037_rename_project_and_base':
return nc_037_rename_project_and_base;
case 'nc_038_formula_parsed_tree_column':
return nc_038_formula_parsed_tree_column;
}
}
}

16
packages/nocodb/src/meta/migrations/v2/nc_038_formula_parsed_tree_column.ts

@ -0,0 +1,16 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_FORMULA, (table) => {
table.text('parsed_tree');
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_FORMULA, (table) => {
table.dropColumn('parsed_tree');
});
};
export { up, down };

27
packages/nocodb/src/models/Base.ts

@ -117,7 +117,7 @@ export default class Base implements BaseType {
);
const castedProjectList = baseList.map((m) => this.castType(m));
await Promise.all(castedProjectList.map((base) => base.getBases(ncMeta)));
await Promise.all(castedProjectList.map((base) => base.getSources(ncMeta)));
return castedProjectList;
}
@ -147,7 +147,7 @@ export default class Base implements BaseType {
return this.castType(baseData);
}
async getBases(ncMeta = Noco.ncMeta): Promise<Source[]> {
async getSources(ncMeta = Noco.ncMeta): Promise<Source[]> {
return (this.sources = await Source.list({ baseId: this.id }, ncMeta));
}
@ -182,10 +182,11 @@ export default class Base implements BaseType {
}
}
if (baseData) {
const base = new Base(baseData);
await base.getBases(ncMeta);
const base = this.castType(baseData);
return this.castType(base);
await base.getSources(ncMeta);
return base;
}
return null;
}
@ -209,8 +210,6 @@ export default class Base implements BaseType {
await NocoCache.del(`${CacheScope.PROJECT}:ref:${o.id}`);
}
await NocoCache.delAll(CacheScope.USER_PROJECT, '*');
await NocoCache.del(CacheScope.INSTANCE_META);
// remove item in cache list
@ -296,8 +295,6 @@ export default class Base implements BaseType {
let base = await this.get(baseId);
const users = await BaseUser.getUsersList({
base_id: baseId,
offset: 0,
limit: 1000,
});
for (const user of users) {
@ -320,8 +317,6 @@ export default class Base implements BaseType {
await NocoCache.del(`${CacheScope.PROJECT}:ref:${base.id}`);
}
await NocoCache.delAll(CacheScope.USER_PROJECT, '*');
await NocoCache.deepDel(
CacheScope.PROJECT,
`${CacheScope.PROJECT}:${baseId}`,
@ -359,7 +354,9 @@ export default class Base implements BaseType {
static async getWithInfoByTitle(title: string, ncMeta = Noco.ncMeta) {
const base = await this.getByTitle(title, ncMeta);
if (base) await base.getBases(ncMeta);
if (base) {
await base.getSources(ncMeta);
}
return base;
}
@ -441,7 +438,9 @@ export default class Base implements BaseType {
// parse meta
base.meta = parseMetaProp(base);
if (base) await base.getBases(ncMeta);
if (base) {
await base.getSources(ncMeta);
}
return base;
}
@ -449,7 +448,7 @@ export default class Base implements BaseType {
static async clearConnectionPool(baseId: string, ncMeta = Noco.ncMeta) {
const base = await this.get(baseId, ncMeta);
if (base) {
const sources = await base.getBases(ncMeta);
const sources = await base.getSources(ncMeta);
for (const source of sources) {
await NcConnectionMgrv2.deleteAwait(source);
}

252
packages/nocodb/src/models/BaseUser.ts

@ -1,6 +1,6 @@
import { ProjectRoles } from 'nocodb-sdk';
import type { BaseType } from 'nocodb-sdk';
import User from '~/models/User';
import type User from '~/models/User';
import Base from '~/models/Base';
import {
// CacheDelDirection,
@ -44,10 +44,15 @@ export default class BaseUser {
true,
);
// reset all user bases cache
await NocoCache.delAll(CacheScope.USER_PROJECT, `${baseUser.fk_user_id}:*`);
const res = await this.get(base_id, fk_user_id, ncMeta);
return this.get(base_id, fk_user_id, ncMeta);
await NocoCache.appendToList(
CacheScope.BASE_USER,
[base_id],
`${CacheScope.BASE_USER}:${base_id}:${fk_user_id}`,
);
return res;
}
// public static async update(id, user: Partial<BaseUser>, ncMeta = Noco.ncMeta) {
@ -58,18 +63,45 @@ export default class BaseUser {
baseId &&
userId &&
(await NocoCache.get(
`${CacheScope.PROJECT_USER}:${baseId}:${userId}`,
`${CacheScope.BASE_USER}:${baseId}:${userId}`,
CacheGetType.TYPE_OBJECT,
));
if (!baseUser) {
baseUser = await ncMeta.metaGet2(null, null, MetaTable.PROJECT_USERS, {
fk_user_id: userId,
base_id: baseId,
if (!baseUser || !baseUser.roles) {
const queryBuilder = ncMeta
.knex(MetaTable.USERS)
.select(
`${MetaTable.USERS}.id`,
`${MetaTable.USERS}.email`,
`${MetaTable.USERS}.display_name`,
`${MetaTable.USERS}.invite_token`,
`${MetaTable.USERS}.roles as main_roles`,
`${MetaTable.USERS}.created_at as created_at`,
`${MetaTable.PROJECT_USERS}.base_id`,
`${MetaTable.PROJECT_USERS}.roles as roles`,
);
queryBuilder.leftJoin(MetaTable.PROJECT_USERS, function () {
this.on(
`${MetaTable.PROJECT_USERS}.fk_user_id`,
'=',
`${MetaTable.USERS}.id`,
).andOn(
`${MetaTable.PROJECT_USERS}.base_id`,
'=',
ncMeta.knex.raw('?', [baseId]),
);
});
await NocoCache.set(
`${CacheScope.PROJECT_USER}:${baseId}:${userId}`,
baseUser,
);
queryBuilder.where(`${MetaTable.USERS}.id`, userId);
baseUser = await queryBuilder.first();
if (baseUser) {
await NocoCache.set(
`${CacheScope.BASE_USER}:${baseId}:${userId}`,
baseUser,
);
}
}
return this.castType(baseUser);
}
@ -77,48 +109,61 @@ export default class BaseUser {
public static async getUsersList(
{
base_id,
limit = 25,
offset = 0,
query,
mode = 'full',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
include_ws_deleted = true,
}: {
base_id: string;
limit: number;
offset: number;
query?: string;
mode?: 'full' | 'viewer';
include_ws_deleted?: boolean;
},
ncMeta = Noco.ncMeta,
): Promise<(Partial<User> & BaseUser)[]> {
const queryBuilder = ncMeta
.knex(MetaTable.USERS)
.select(
`${MetaTable.USERS}.id`,
`${MetaTable.USERS}.email`,
`${MetaTable.USERS}.invite_token`,
`${MetaTable.USERS}.roles as main_roles`,
`${MetaTable.USERS}.created_at as created_at`,
`${MetaTable.PROJECT_USERS}.base_id`,
`${MetaTable.PROJECT_USERS}.roles as roles`,
)
.offset(offset)
.limit(limit);
const cachedList = await NocoCache.getList(CacheScope.BASE_USER, [base_id]);
let { list: baseUsers } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !baseUsers.length) {
const queryBuilder = ncMeta
.knex(MetaTable.USERS)
.select(
`${MetaTable.USERS}.id`,
`${MetaTable.USERS}.email`,
`${MetaTable.USERS}.display_name`,
...(mode === 'full'
? [
`${MetaTable.USERS}.invite_token`,
`${MetaTable.USERS}.roles as main_roles`,
`${MetaTable.USERS}.created_at as created_at`,
`${MetaTable.PROJECT_USERS}.base_id`,
`${MetaTable.PROJECT_USERS}.roles as roles`,
]
: []),
);
if (query) {
queryBuilder.where('email', 'like', `%${query.toLowerCase?.()}%`);
}
queryBuilder.leftJoin(MetaTable.PROJECT_USERS, function () {
this.on(
`${MetaTable.PROJECT_USERS}.fk_user_id`,
'=',
`${MetaTable.USERS}.id`,
).andOn(
`${MetaTable.PROJECT_USERS}.base_id`,
'=',
ncMeta.knex.raw('?', [base_id]),
);
});
queryBuilder.leftJoin(MetaTable.PROJECT_USERS, function () {
this.on(
`${MetaTable.PROJECT_USERS}.fk_user_id`,
'=',
`${MetaTable.USERS}.id`,
).andOn(
`${MetaTable.PROJECT_USERS}.base_id`,
'=',
ncMeta.knex.raw('?', [base_id]),
);
});
baseUsers = await queryBuilder;
baseUsers = baseUsers.map((baseUser) => {
baseUser.base_id = base_id;
return this.castType(baseUser);
});
const baseUsers = await queryBuilder;
await NocoCache.setList(CacheScope.BASE_USER, [base_id], baseUsers, [
'base_id',
'id',
]);
}
return baseUsers;
}
@ -161,26 +206,14 @@ export default class BaseUser {
ncMeta = Noco.ncMeta,
) {
// get existing cache
const key = `${CacheScope.PROJECT_USER}:${baseId}:${userId}`;
const key = `${CacheScope.BASE_USER}:${baseId}:${userId}`;
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
o.roles = roles;
// set cache
await NocoCache.set(key, o);
}
// update user cache
const user = await User.get(userId);
if (user) {
const email = user.email;
for (const key of [`${CacheScope.USER}:${email}___${baseId}`]) {
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
o.roles = roles;
// set cache
await NocoCache.set(key, o);
}
}
}
// set meta
return await ncMeta.metaUpdate(
null,
@ -204,76 +237,38 @@ export default class BaseUser {
) {
const updateObj = extractProps(baseUser, ['starred', 'hidden', 'order']);
// get existing cache
const key = `${CacheScope.PROJECT_USER}:${baseId}:${userId}`;
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
Object.assign(o, updateObj);
// set cache
await NocoCache.set(key, o);
}
// update user cache
const user = await User.get(userId);
if (user) {
const email = user.email;
for (const key of [
`${CacheScope.USER}:${email}`,
`${CacheScope.USER}:${email}___${baseId}`,
]) {
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
Object.assign(o, updateObj);
// set cache
await NocoCache.set(key, o);
}
}
}
const key = `${CacheScope.BASE_USER}:${baseId}:${userId}`;
// set meta
return await ncMeta.metaUpdate(
await ncMeta.metaUpdate(null, null, MetaTable.PROJECT_USERS, updateObj, {
fk_user_id: userId,
base_id: baseId,
});
// delete cache
await NocoCache.del(key);
// cache and return
return await this.get(baseId, userId, ncMeta);
}
static async delete(baseId: string, userId: string, ncMeta = Noco.ncMeta) {
// delete meta
const response = await ncMeta.metaDelete(
null,
null,
MetaTable.PROJECT_USERS,
updateObj,
{
fk_user_id: userId,
base_id: baseId,
},
);
}
static async delete(baseId: string, userId: string, ncMeta = Noco.ncMeta) {
const { email } = await ncMeta.metaGet2(null, null, MetaTable.USERS, {
id: userId,
});
if (email) {
await NocoCache.delAll(CacheScope.USER, `${email}*`);
}
// delete list cache to refresh list
await NocoCache.del(`${CacheScope.BASE_USER}:${baseId}:${userId}`);
await NocoCache.del(`${CacheScope.BASE_USER}:${baseId}:list`);
// remove base from user base list cache
const cachedList = await NocoCache.getList(CacheScope.USER_PROJECT, [
userId,
]);
let { list: cachedProjectList } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && cachedProjectList?.length) {
cachedProjectList = cachedProjectList.filter((p) => p.id !== baseId);
// delete the whole list first so that the old one won't be included
await NocoCache.del(`${CacheScope.USER_PROJECT}:${userId}:list`);
if (cachedProjectList.length > 0) {
// set the updated list (i.e. excluding the to-be-deleted base id)
await NocoCache.setList(
CacheScope.USER_PROJECT,
[userId],
cachedProjectList,
);
}
}
await NocoCache.del(`${CacheScope.PROJECT_USER}:${baseId}:${userId}`);
return await ncMeta.metaDelete(null, null, MetaTable.PROJECT_USERS, {
fk_user_id: userId,
base_id: baseId,
});
return response;
}
static async getProjectsIdList(
@ -290,18 +285,7 @@ export default class BaseUser {
params: any,
ncMeta = Noco.ncMeta,
): Promise<BaseType[]> {
// let baseList: BaseType[];
// todo: pagination
// todo: caching based on filter type
// = await NocoCache.getList(CacheScope.USER_PROJECT, [
// userId,
// ]);
// if (baseList.length) {
// return baseList;
// }
// TODO implement CacheScope.USER_BASE
const qb = ncMeta
.knex(MetaTable.PROJECT)
.select(`${MetaTable.PROJECT}.id`)
@ -373,15 +357,13 @@ export default class BaseUser {
for (const base of baseList) {
base.meta = parseMetaProp(base);
}
await NocoCache.setList(CacheScope.USER_PROJECT, [userId], baseList);
}
const castedProjectList = baseList
.filter((p) => !params?.type || p.type === params.type)
.map((m) => Base.castType(m));
await Promise.all(castedProjectList.map((base) => base.getBases(ncMeta)));
await Promise.all(castedProjectList.map((base) => base.getSources(ncMeta)));
return castedProjectList;
}

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

@ -308,6 +308,7 @@ export default class Column<T = any> implements ColumnType {
fk_column_id: colId,
formula: column.formula,
formula_raw: column.formula_raw,
parsed_tree: column.parsed_tree,
},
ncMeta,
);
@ -713,7 +714,11 @@ export default class Column<T = any> implements ColumnType {
title: col?.title,
})
)
await FormulaColumn.update(formulaCol.id, formula, ncMeta);
await FormulaColumn.update(
formulaCol.id,
formula as FormulaColumn & { parsed_tree?: any },
ncMeta,
);
}
}

30
packages/nocodb/src/models/FormulaColumn.ts

@ -2,19 +2,23 @@ import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { parseMetaProp, stringifyMetaProp } from '~/utils/modelUtils';
export default class FormulaColumn {
formula: string;
formula_raw: string;
fk_column_id: string;
error: string;
private parsed_tree?: any;
constructor(data: Partial<FormulaColumn>) {
Object.assign(this, data);
constructor(data: Partial<FormulaColumn> & { parsed_tree?: any }) {
const { parsed_tree, ...rest } = data;
this.parsed_tree = parsed_tree;
Object.assign(this, rest);
}
public static async insert(
formulaColumn: Partial<FormulaColumn>,
formulaColumn: Partial<FormulaColumn> & { parsed_tree?: any },
ncMeta = Noco.ncMeta,
) {
const insertObj = extractProps(formulaColumn, [
@ -22,11 +26,16 @@ export default class FormulaColumn {
'formula_raw',
'formula',
'error',
'parsed_tree',
]);
insertObj.parsed_tree = stringifyMetaProp(insertObj, 'parsed_tree');
await ncMeta.metaInsert2(null, null, MetaTable.COL_FORMULA, insertObj);
return this.read(formulaColumn.fk_column_id, ncMeta);
}
public static async read(columnId: string, ncMeta = Noco.ncMeta) {
let column =
columnId &&
@ -41,7 +50,10 @@ export default class FormulaColumn {
MetaTable.COL_FORMULA,
{ fk_column_id: columnId },
);
await NocoCache.set(`${CacheScope.COL_FORMULA}:${columnId}`, column);
if (column) {
column.parsed_tree = parseMetaProp(column, 'parsed_tree');
await NocoCache.set(`${CacheScope.COL_FORMULA}:${columnId}`, column);
}
}
return column ? new FormulaColumn(column) : null;
@ -51,7 +63,7 @@ export default class FormulaColumn {
static async update(
id: string,
formula: Partial<FormulaColumn>,
formula: Partial<FormulaColumn> & { parsed_tree?: any },
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(formula, [
@ -59,7 +71,11 @@ export default class FormulaColumn {
'formula_raw',
'fk_column_id',
'error',
'parsed_tree',
]);
updateObj.parsed_tree = stringifyMetaProp(updateObj, 'parsed_tree');
// get existing cache
const key = `${CacheScope.COL_FORMULA}:${id}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -71,4 +87,8 @@ export default class FormulaColumn {
// set meta
await ncMeta.metaUpdate(null, null, MetaTable.COL_FORMULA, updateObj, id);
}
public getParsedTree() {
return this.parsed_tree;
}
}

66
packages/nocodb/src/models/User.ts

@ -3,8 +3,13 @@ import { NcError } from '~/helpers/catchError';
import Noco from '~/Noco';
import { extractProps } from '~/helpers/extractProps';
import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { BaseUser } from '~/models';
import {
CacheDelDirection,
CacheGetType,
CacheScope,
MetaTable,
} from '~/utils/globals';
import { Base, BaseUser } from '~/models';
import { sanitiseUserObj } from '~/utils';
export default class User implements UserType {
@ -66,6 +71,16 @@ export default class User implements UserType {
await NocoCache.del(CacheScope.INSTANCE_META);
// clear all base user related cache for instance
const bases = await Base.list({}, ncMeta);
for (const base of bases) {
await NocoCache.deepDel(
CacheScope.BASE_USER,
`${CacheScope.BASE_USER}:${base.id}:list`,
CacheDelDirection.PARENT_TO_CHILD,
);
}
return this.get(id, ncMeta);
}
@ -106,25 +121,12 @@ export default class User implements UserType {
// delete the email-based cache to avoid unexpected behaviour since we can update email as well
await NocoCache.del(`${CacheScope.USER}:${existingUser.email}`);
// as <baseId> is unknown, delete user:<email>___<baseId> in cache
await NocoCache.delAll(CacheScope.USER, `${existingUser.email}___*`);
// get existing cache
const keys = [
// update user:<id>
`${CacheScope.USER}:${id}`,
];
for (const key of keys) {
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
o = { ...o, ...updateObj };
// set cache
await NocoCache.set(key, o);
}
}
await ncMeta.metaUpdate(null, null, MetaTable.USERS, updateObj, id);
// set meta
return await ncMeta.metaUpdate(null, null, MetaTable.USERS, updateObj, id);
// clear all user related cache
await this.clearCache(id, ncMeta);
return this.get(id, ncMeta);
}
public static async getByEmail(_email: string, ncMeta = Noco.ncMeta) {
@ -240,10 +242,7 @@ export default class User implements UserType {
if (!user) NcError.badRequest('User not found');
// clear all user related cache
await NocoCache.delAll(CacheScope.USER, `${userId}___*`);
await NocoCache.delAll(CacheScope.USER, `${user.email}___*`);
await NocoCache.del(`${CacheScope.USER}:${userId}`);
await NocoCache.del(`${CacheScope.USER}:${user.email}`);
await this.clearCache(userId, ncMeta);
return await ncMeta.metaDelete(null, null, MetaTable.USERS, userId);
}
@ -283,4 +282,23 @@ export default class User implements UserType {
base_roles: baseRoles ? baseRoles : null,
} as any;
}
protected static async clearCache(userId: string, ncMeta = Noco.ncMeta) {
const user = await this.get(userId, ncMeta);
if (!user) NcError.badRequest('User not found');
const bases = await BaseUser.getProjectsList(userId, {}, ncMeta);
for (const base of bases) {
await NocoCache.deepDel(
CacheScope.BASE_USER,
`${CacheScope.BASE_USER}:${base.id}:list`,
CacheDelDirection.PARENT_TO_CHILD,
);
}
// clear all user related cache
await NocoCache.del(`${CacheScope.USER}:${userId}`);
await NocoCache.del(`${CacheScope.USER}:${user.email}`);
}
}

28
packages/nocodb/src/modules/datas/helpers.ts

@ -160,14 +160,26 @@ export async function serializeCellValue({
}
} catch {}
return (data || []).map(
(attachment) =>
`${encodeURI(attachment.title)}(${encodeURI(
attachment.signedPath
? `${siteUrl}/${attachment.signedPath}`
: attachment.signedUrl,
)})`,
);
return (data || [])
.map(
(attachment) =>
`${encodeURI(attachment.title)}(${encodeURI(
attachment.signedPath
? `${siteUrl}/${attachment.signedPath}`
: attachment.signedUrl,
)})`,
)
.join(', ');
}
case UITypes.User: {
let data = value;
try {
if (typeof value === 'string') {
data = JSON.parse(value);
}
} catch {}
return (data || []).map((user) => `${user.email}`).join(', ');
}
case UITypes.Lookup:
{

4
packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts

@ -53,7 +53,7 @@ export class DuplicateController {
throw new Error(`Base not found for id '${sharedBaseId}'`);
}
const source = (await base.getBases())[0];
const source = (await base.getSources())[0];
if (!source) {
throw new Error(`Source not found!`);
@ -124,7 +124,7 @@ export class DuplicateController {
const source = sourceId
? await Source.get(sourceId)
: (await base.getBases())[0];
: (await base.getSources())[0];
if (!source) {
throw new Error(`Source not found!`);

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

@ -74,7 +74,7 @@ export class DuplicateProcessor {
throw new Error(`Export failed for source '${source.id}'`);
}
await dupProject.getBases();
await dupProject.getSources();
const dupBase = dupProject.sources[0];

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

@ -453,6 +453,17 @@ export class ExportService {
row[colId] = v;
}
break;
case UITypes.User:
if (v) {
const userIds = [];
for (const user of v as { id: string }[]) {
userIds.push(user.id);
}
row[colId] = userIds.join(',');
} else {
row[colId] = v;
}
break;
case UITypes.Formula:
case UITypes.Lookup:
case UITypes.Rollup:

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

@ -12126,7 +12126,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string"
},
@ -15343,7 +15344,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string",
"description": "UI Data Type"

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

@ -17343,7 +17343,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string"
},
@ -20563,7 +20564,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string",
"description": "UI Data Type"
@ -23862,6 +23864,27 @@
"type": "boolean"
}
}
},
"UserFieldRecord": {
"type": "object",
"properties": {
"id": {
"type": "string",
"required": true
},
"display_name": {
"type": "string",
"required": false
},
"email": {
"type": "string",
"required": true
},
"deleted": {
"type": "boolean",
"required": false
}
}
}
},
"responses": {

2
packages/nocodb/src/services/api-docs/swagger/getSwaggerColumnMetas.ts

@ -8,7 +8,7 @@ export default async (
base: Base,
ncMeta = Noco.ncMeta,
): Promise<SwaggerColumn[]> => {
const dbType = await base.getBases().then((b) => b?.[0]?.type);
const dbType = await base.getSources().then((b) => b?.[0]?.type);
return Promise.all(
columns.map(async (c) => {
const field: SwaggerColumn = {

2
packages/nocodb/src/services/api-docs/swaggerV2/getSwaggerColumnMetas.ts

@ -8,7 +8,7 @@ export default async (
base: Base,
ncMeta = Noco.ncMeta,
): Promise<SwaggerColumn[]> => {
const dbType = await base.getBases().then((b) => b?.[0]?.type);
const dbType = await base.getSources().then((b) => b?.[0]?.type);
return Promise.all(
columns.map(async (c) => {
const field: SwaggerColumn = {

42
packages/nocodb/src/services/base-users/base-users.service.ts

@ -11,7 +11,6 @@ import * as ejs from 'ejs';
import validator from 'validator';
import type { ProjectUserReqType, UserType } from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import NocoCache from '~/cache/NocoCache';
import { validatePayload } from '~/helpers';
import Noco from '~/Noco';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
@ -20,8 +19,7 @@ import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { randomTokenString } from '~/helpers/stringHelpers';
import { Base, BaseUser, User } from '~/models';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { MetaTable } from '~/utils/globals';
import { extractProps } from '~/helpers/extractProps';
import { getProjectRolePower } from '~/utils/roleHelper';
@ -29,20 +27,15 @@ import { getProjectRolePower } from '~/utils/roleHelper';
export class BaseUsersService {
constructor(protected appHooksService: AppHooksService) {}
async userList(param: { baseId: string; query: any }) {
return new PagedResponseImpl(
await BaseUser.getUsersList({
...param.query,
base_id: param.baseId,
}),
{
...param.query,
count: await BaseUser.getUsersCount({
base_id: param.baseId,
...param.query,
}),
},
);
async userList(param: { baseId: string; mode?: 'full' | 'viewer' }) {
const baseUsers = await BaseUser.getUsersList({
base_id: param.baseId,
mode: param.mode,
});
return new PagedResponseImpl(baseUsers, {
count: baseUsers.length,
});
}
async userInvite(param: {
@ -112,7 +105,7 @@ export class BaseUsersService {
return NcError.badRequest('Invalid base id');
}
if (baseUser) {
if (baseUser && baseUser.roles) {
NcError.badRequest(
`${user.email} with role ${baseUser.roles} already exists in this base`,
);
@ -131,19 +124,6 @@ export class BaseUsersService {
ip: param.req.clientIp,
req: param.req,
});
const cachedUser = await NocoCache.get(
`${CacheScope.USER}:${email}___${param.baseId}`,
CacheGetType.TYPE_OBJECT,
);
if (cachedUser) {
cachedUser.roles = param.baseUser.roles || 'editor';
await NocoCache.set(
`${CacheScope.USER}:${email}___${param.baseId}`,
cachedUser,
);
}
} else {
try {
// create new user with invite token

2
packages/nocodb/src/services/bases.service.ts

@ -203,7 +203,7 @@ export class BasesService {
await syncMigration(base);
// populate metadata if existing table
for (const source of await base.getBases()) {
for (const source of await base.getSources()) {
if (process.env.NC_CLOUD !== 'true' && !base.is_meta) {
const info = await populateMeta(source, base);

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save