Browse Source

Merge pull request #7202 from nocodb/nc-feat/user-field

feat: user field
pull/7273/head
Raju Udava 11 months ago committed by GitHub
parent
commit
75515c1393
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui/components/account/UsersModal.vue
  2. 435
      packages/nc-gui/components/cell/User.vue
  3. 94
      packages/nc-gui/components/project/AccessSettings.vue
  4. 35
      packages/nc-gui/components/project/View.vue
  5. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  6. 7
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  7. 1
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  8. 40
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  9. 66
      packages/nc-gui/components/smartsheet/column/UserOptions.vue
  10. 12
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  11. 19
      packages/nc-gui/components/smartsheet/grid/Table.vue
  12. 6
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  13. 7
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  14. 5
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  15. 19
      packages/nc-gui/components/virtual-cell/Lookup.vue
  16. 4
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  17. 3
      packages/nc-gui/composables/useData.ts
  18. 18
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  19. 12
      packages/nc-gui/composables/useMultiSelect/index.ts
  20. 8
      packages/nc-gui/composables/useSharedView.ts
  21. 11
      packages/nc-gui/composables/useViewGroupBy.ts
  22. 1
      packages/nc-gui/lib/types.ts
  23. 4
      packages/nc-gui/store/base.ts
  24. 35
      packages/nc-gui/store/bases.ts
  25. 3
      packages/nc-gui/store/users.ts
  26. 1
      packages/nc-gui/utils/cell.ts
  27. 4
      packages/nc-gui/utils/columnUtils.ts
  28. 398
      packages/nc-gui/utils/filterUtils.ts
  29. 5
      packages/nc-gui/utils/iconUtils.ts
  30. 13
      packages/nocodb-sdk/src/lib/Api.ts
  31. 1
      packages/nocodb-sdk/src/lib/UITypes.ts
  32. 42
      packages/nocodb-sdk/src/lib/helperFunctions.ts
  33. 1
      packages/nocodb/src/cache/CacheMgr.ts
  34. 3
      packages/nocodb/src/cache/NocoCache.ts
  35. 16
      packages/nocodb/src/cache/RedisCacheMgr.ts
  36. 16
      packages/nocodb/src/cache/RedisMockCacheMgr.ts
  37. 13
      packages/nocodb/src/controllers/base-users.controller.ts
  38. 244
      packages/nocodb/src/db/BaseModelSqlv2.ts
  39. 82
      packages/nocodb/src/db/conditionV2.ts
  40. 21
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  41. 25
      packages/nocodb/src/db/sortV2.ts
  42. 14
      packages/nocodb/src/helpers/columnHelpers.ts
  43. 10
      packages/nocodb/src/helpers/initAdminFromEnv.ts
  44. 2
      packages/nocodb/src/helpers/syncMigration.ts
  45. 2
      packages/nocodb/src/meta/meta.service.ts
  46. 27
      packages/nocodb/src/models/Base.ts
  47. 252
      packages/nocodb/src/models/BaseUser.ts
  48. 66
      packages/nocodb/src/models/User.ts
  49. 28
      packages/nocodb/src/modules/datas/helpers.ts
  50. 4
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts
  51. 2
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts
  52. 11
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  53. 6
      packages/nocodb/src/schema/swagger-v2.json
  54. 27
      packages/nocodb/src/schema/swagger.json
  55. 2
      packages/nocodb/src/services/api-docs/swagger/getSwaggerColumnMetas.ts
  56. 2
      packages/nocodb/src/services/api-docs/swaggerV2/getSwaggerColumnMetas.ts
  57. 42
      packages/nocodb/src/services/base-users/base-users.service.ts
  58. 2
      packages/nocodb/src/services/bases.service.ts
  59. 430
      packages/nocodb/src/services/columns.service.ts
  60. 15
      packages/nocodb/src/services/public-metas.service.ts
  61. 2
      packages/nocodb/src/services/utils.service.ts
  62. 2
      packages/nocodb/src/utils/acl.ts
  63. 4
      packages/nocodb/src/utils/globals.ts
  64. 23
      packages/nocodb/tests/unit/factory/column.ts
  65. 2
      packages/nocodb/tests/unit/factory/row.ts
  66. 4
      packages/nocodb/tests/unit/factory/table.ts
  67. 2
      packages/nocodb/tests/unit/init/cleanupMeta.ts
  68. 4
      packages/nocodb/tests/unit/init/index.ts
  69. 248
      packages/nocodb/tests/unit/rest/tests/newDataApis.test.ts
  70. 117
      tests/playwright/pages/Dashboard/Grid/Column/UserOptionColumn.ts
  71. 5
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  72. 5
      tests/playwright/pages/Dashboard/Grid/index.ts
  73. 154
      tests/playwright/pages/Dashboard/common/Cell/UserOptionCell.ts
  74. 3
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  75. 22
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  76. 14
      tests/playwright/pages/Dashboard/common/Toolbar/Groupby.ts
  77. 2
      tests/playwright/pages/WorkspacePage/CollaborationPage.ts
  78. 747
      tests/playwright/tests/db/columns/columnUserSelect.spec.ts

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))

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>

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,
() => {

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

@ -40,6 +40,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
provide,
ref,
@ -245,6 +246,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" />

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>

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

@ -323,6 +323,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"

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>

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()

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

@ -97,20 +97,16 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
</script>
<template>
<div
class="h-full w-full nc-lookup-cell"
:style="{ height: rowHeight && rowHeight !== 1 ? `${rowHeight * 2}rem` : `2.85rem` }"
@dblclick="activateShowEditNonEditableFieldWarning"
>
<div class="h-full w-full nc-lookup-cell" @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 +147,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 +158,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 +177,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>

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 {

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

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,

13
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,

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 = [

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/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,
}),
};
}

244
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>,
@ -5386,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]) {
@ -5404,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];
}
}
}
}
}
}
}

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' ||

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

@ -19,6 +19,7 @@ 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';
const logger = new Logger('FormulaQueryBuilderv2');
@ -681,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 });

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');

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;
}

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);

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

@ -44,7 +44,14 @@ import {
} from '~/helpers/getUniqueName';
import mapDefaultDisplayValue from '~/helpers/mapDefaultDisplayValue';
import validateParams from '~/helpers/validateParams';
import { Column, FormulaColumn, KanbanView, Model, Source } from '~/models';
import {
BaseUser,
Column,
FormulaColumn,
KanbanView,
Model,
Source,
} from '~/models';
import Noco from '~/Noco';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { MetaTable } from '~/utils/globals';
@ -977,6 +984,393 @@ export class ColumnsService {
await Column.update(param.columnId, {
...colBody,
});
} else if (colBody.uidt === UITypes.User) {
// handle default value for user column
if (colBody.cdf) {
const baseUsers = await BaseUser.getUsersList({
base_id: source.base_id,
include_ws_deleted: false,
});
const emailOrIds = colBody.cdf.split(',');
const emailsNotPresent = emailOrIds.filter((el) => {
return !baseUsers.find((user) => user.id === el || user.email === el);
});
if (emailsNotPresent.length) {
NcError.badRequest(
`The following default users are not part of workspace: ${emailsNotPresent.join(
', ',
)}`,
);
}
const ids = emailOrIds.map((el) => {
const user = baseUsers.find(
(user) => user.id === el || user.email === el,
);
return user.id;
});
colBody.cdf = ids.join(',');
}
if (column.uidt === UITypes.User) {
// multi user to single user
if (
colBody.meta?.is_multi === false &&
column.meta?.is_multi === true
) {
const baseModel = await reuseOrSave('baseModel', reuse, async () =>
Model.getBaseModelSQL({
id: table.id,
dbDriver: await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
),
}),
);
const dbDriver = await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
);
const driverType = dbDriver.clientType();
// MultiSelect to SingleSelect
if (driverType === 'mysql' || driverType === 'mysql2') {
await sqlClient.raw(
`UPDATE ?? SET ?? = SUBSTRING_INDEX(??, ',', 1) WHERE ?? LIKE '%,%';`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (driverType === 'pg') {
await sqlClient.raw(`UPDATE ?? SET ?? = split_part(??, ',', 1);`, [
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
]);
} else if (driverType === 'mssql') {
await sqlClient.raw(
`UPDATE ?? SET ?? = LEFT(cast(?? as varchar(max)), CHARINDEX(',', ??) - 1) WHERE CHARINDEX(',', ??) > 0;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (driverType === 'sqlite3') {
await sqlClient.raw(
`UPDATE ?? SET ?? = substr(??, 1, instr(??, ',') - 1) WHERE ?? LIKE '%,%';`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
}
}
colBody = await getColumnPropsFromUIDT(colBody, source);
const tableUpdateBody = {
...table,
tn: table.table_name,
originalColumns: table.columns.map((c) => ({
...c,
cn: c.column_name,
cno: c.column_name,
})),
columns: await Promise.all(
table.columns.map(async (c) => {
if (c.id === param.columnId) {
const res = {
...c,
...colBody,
cn: colBody.column_name,
cno: c.column_name,
altered: Altered.UPDATE_COLUMN,
};
// update formula with new column name
if (c.column_name != colBody.column_name) {
const formulas = await Noco.ncMeta
.knex(MetaTable.COL_FORMULA)
.where('formula', 'like', `%${c.id}%`);
if (formulas) {
const new_column = c;
new_column.column_name = colBody.column_name;
new_column.title = colBody.title;
for (const f of formulas) {
// the formula with column IDs only
const formula = f.formula;
// replace column IDs with alias to get the new formula_raw
const new_formula_raw =
substituteColumnIdWithAliasInFormula(formula, [
new_column,
]);
await FormulaColumn.update(c.id, {
formula_raw: new_formula_raw,
});
}
}
}
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
}),
),
};
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr({ id: source.base_id }),
);
await sqlMgr.sqlOpPlus(source, 'tableUpdate', tableUpdateBody);
await Column.update(param.columnId, {
...colBody,
});
} else if (
[UITypes.SingleLineText, UITypes.Email].includes(column.uidt)
) {
// email/text to user
const baseModel = await reuseOrSave('baseModel', reuse, async () =>
Model.getBaseModelSQL({
id: table.id,
dbDriver: await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
),
}),
);
const baseUsers = await BaseUser.getUsersList({
base_id: column.base_id,
});
try {
const data = await baseModel.execAndParse(
sqlClient.knex
.raw('SELECT DISTINCT ?? FROM ??', [
column.column_name,
baseModel.getTnPath(table.table_name),
])
.toQuery(),
);
let isMultiple = false;
const rows = data.map((el) => el[column.column_name]);
const emails = rows
.map((el) => {
const res = el.split(',').map((e) => e.trim());
if (res.length > 1) {
isMultiple = true;
}
return res;
})
.flat();
// check if emails are present baseUsers
const emailsNotPresent = emails.filter((el) => {
return !baseUsers.find((user) => user.email === el);
});
if (emailsNotPresent.length) {
NcError.badRequest(
`Some of the emails are not present in the database.`,
);
}
if (isMultiple) {
colBody.meta = {
is_multi: true,
};
}
} catch (e) {
NcError.badRequest('Some of the emails are present in the database.');
}
// create nested replace statement for each user
const setStatement = baseUsers.reduce((acc, user) => {
const qb = sqlClient.knex.raw(`REPLACE(${acc}, ?, ?)`, [
user.email,
user.id,
]);
return qb.toQuery();
}, sqlClient.knex.raw(`??`, [column.column_name]).toQuery());
await sqlClient.raw(`UPDATE ?? SET ?? = ${setStatement};`, [
baseModel.getTnPath(table.table_name),
column.column_name,
]);
colBody = await getColumnPropsFromUIDT(colBody, source);
const tableUpdateBody = {
...table,
tn: table.table_name,
originalColumns: table.columns.map((c) => ({
...c,
cn: c.column_name,
cno: c.column_name,
})),
columns: await Promise.all(
table.columns.map(async (c) => {
if (c.id === param.columnId) {
const res = {
...c,
...colBody,
cn: colBody.column_name,
cno: c.column_name,
altered: Altered.UPDATE_COLUMN,
};
// update formula with new column name
if (c.column_name != colBody.column_name) {
const formulas = await Noco.ncMeta
.knex(MetaTable.COL_FORMULA)
.where('formula', 'like', `%${c.id}%`);
if (formulas) {
const new_column = c;
new_column.column_name = colBody.column_name;
new_column.title = colBody.title;
for (const f of formulas) {
// the formula with column IDs only
const formula = f.formula;
// replace column IDs with alias to get the new formula_raw
const new_formula_raw =
substituteColumnIdWithAliasInFormula(formula, [
new_column,
]);
await FormulaColumn.update(c.id, {
formula_raw: new_formula_raw,
});
}
}
}
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
}),
),
};
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr({ id: source.base_id }),
);
await sqlMgr.sqlOpPlus(source, 'tableUpdate', tableUpdateBody);
await Column.update(param.columnId, {
...colBody,
});
} else {
NcError.notImplemented(
`Updating ${column.uidt} => ${colBody.uidt} is not supported at the moment`,
);
}
} else if (column.uidt === UITypes.User) {
if ([UITypes.SingleLineText, UITypes.Email].includes(colBody.uidt)) {
// user to email/text
const baseModel = await reuseOrSave('baseModel', reuse, async () =>
Model.getBaseModelSQL({
id: table.id,
dbDriver: await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
),
}),
);
const baseUsers = await BaseUser.getUsersList({
base_id: column.base_id,
});
// create nested replace statement for each user
const setStatement = baseUsers.reduce((acc, user) => {
const qb = sqlClient.knex.raw(`REPLACE(${acc}, ?, ?)`, [
user.id,
user.email,
]);
return qb.toQuery();
}, sqlClient.knex.raw(`??`, [column.column_name]).toQuery());
await sqlClient.raw(`UPDATE ?? SET ?? = ${setStatement};`, [
baseModel.getTnPath(table.table_name),
column.column_name,
]);
colBody = await getColumnPropsFromUIDT(colBody, source);
const tableUpdateBody = {
...table,
tn: table.table_name,
originalColumns: table.columns.map((c) => ({
...c,
cn: c.column_name,
cno: c.column_name,
})),
columns: await Promise.all(
table.columns.map(async (c) => {
if (c.id === param.columnId) {
const res = {
...c,
...colBody,
cn: colBody.column_name,
cno: c.column_name,
altered: Altered.UPDATE_COLUMN,
};
// update formula with new column name
if (c.column_name != colBody.column_name) {
const formulas = await Noco.ncMeta
.knex(MetaTable.COL_FORMULA)
.where('formula', 'like', `%${c.id}%`);
if (formulas) {
const new_column = c;
new_column.column_name = colBody.column_name;
new_column.title = colBody.title;
for (const f of formulas) {
// the formula with column IDs only
const formula = f.formula;
// replace column IDs with alias to get the new formula_raw
const new_formula_raw =
substituteColumnIdWithAliasInFormula(formula, [
new_column,
]);
await FormulaColumn.update(c.id, {
formula_raw: new_formula_raw,
});
}
}
}
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
}),
),
};
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr({ id: source.base_id }),
);
await sqlMgr.sqlOpPlus(source, 'tableUpdate', tableUpdateBody);
await Column.update(param.columnId, {
...colBody,
});
} else {
NcError.notImplemented(
`Updating ${column.uidt} => ${colBody.uidt} is not supported at the moment`,
);
}
} else {
colBody = await getColumnPropsFromUIDT(colBody, source);
const tableUpdateBody = {
@ -1420,6 +1814,40 @@ export class ColumnsService {
}
}
if (colBody.uidt === UITypes.User) {
// handle default value for user column
if (colBody.cdf) {
const baseUsers = await BaseUser.getUsersList({
base_id: base.id,
include_ws_deleted: false,
});
const emailOrIds = colBody.cdf.split(',');
const emailsNotPresent = emailOrIds.filter((el) => {
return !baseUsers.find(
(user) => user.id === el || user.email === el,
);
});
if (emailsNotPresent.length) {
NcError.badRequest(
`The following default users are not part of workspace: ${emailsNotPresent.join(
', ',
)}`,
);
}
const ids = emailOrIds.map((el) => {
const user = baseUsers.find(
(user) => user.id === el || user.email === el,
);
return user.id;
});
colBody.cdf = ids.join(',');
}
}
const tableUpdateBody = {
...table,
tn: table.table_name,

15
packages/nocodb/src/services/public-metas.service.ts

@ -3,13 +3,14 @@ import { ErrorMessages, RelationTypes, UITypes } from 'nocodb-sdk';
import { isLinksOrLTAR } from 'nocodb-sdk';
import type { LinkToAnotherRecordColumn, LookupColumn } from '~/models';
import { NcError } from '~/helpers/catchError';
import { Base, Column, Model, Source, View } from '~/models';
import { Base, BaseUser, Column, Model, Source, View } from '~/models';
@Injectable()
export class PublicMetasService {
async viewMetaGet(param: { sharedViewUuid: string; password: string }) {
const view: View & {
relatedMetas?: { [ket: string]: Model };
users?: { id: string; display_name: string; email: string }[];
client?: string;
} = await View.getByUUID(param.sharedViewUuid);
@ -68,6 +69,18 @@ export class PublicMetasService {
view.relatedMetas = relatedMetas;
if (view.model.columns.some((c) => c.uidt === UITypes.User)) {
const baseUsers = await BaseUser.getUsersList({
base_id: view.model.base_id,
});
view.users = baseUsers.map((u) => ({
id: u.id,
display_name: u.display_name,
email: u.email,
}));
}
return view;
}

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

@ -310,7 +310,7 @@ export class UtilsService {
// sorts count
Noco.ncMeta.metaCount(base.id, null, MetaTable.SORT),
// row count per base
base.getBases().then(async (sources) => {
base.getSources().then(async (sources) => {
return this.extractResultOrNull(
await Promise.allSettled(
sources.map(async (source) =>

2
packages/nocodb/src/utils/acl.ts

@ -120,6 +120,7 @@ const permissionScopes = {
'nestedDataList',
'nestedDataLink',
'nestedDataUnlink',
'baseUserList',
// Base API Tokens
'baseApiTokenList',
@ -184,6 +185,7 @@ const rolePermissions:
swaggerJson: true,
nestedDataList: true,
baseUserList: true,
},
},
[ProjectRoles.COMMENTER]: {

4
packages/nocodb/src/utils/globals.ts

@ -149,11 +149,11 @@ export enum CacheScope {
AUDIT = 'audit',
HOOK = 'hook',
PLUGIN = 'plugin',
PROJECT_USER = 'baseUser',
BASE_USER = 'baseUser',
MODEL_ROLE_VISIBILITY = 'modelRoleVisibility',
API_TOKEN = 'apiToken',
INSTANCE_META = 'instanceMeta',
USER_PROJECT = 'userProject',
USER_BASE = 'userBase',
DASHBOARD_PROJECT_DB_PROJECT_LINKING = 'dashboardProjectDBProjectLinking',
SINGLE_QUERY = 'singleQuery',
JOBS = 'nc_jobs',

23
packages/nocodb/tests/unit/factory/column.ts

@ -157,6 +157,25 @@ const customColumns = function (type: string, options: any = {}) {
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
];
case 'userBased':
return [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'userFieldSingle',
title: 'userFieldSingle',
uidt: UITypes.User,
},
{
column_name: 'userFieldMulti',
title: 'userFieldMulti',
uidt: UITypes.User,
meta: { is_multi: true },
},
];
case 'custom':
return [{ title: 'Id', column_name: 'Id', uidt: UITypes.ID }, ...options];
}
@ -194,7 +213,7 @@ const createRollupColumn = async (
relatedTableColumnTitle: string;
},
) => {
const childBases = await base.getBases();
const childBases = await base.getSources();
const childTable = await Model.getByIdOrName({
base_id: base.id,
source_id: childBases[0].id!,
@ -243,7 +262,7 @@ const createLookupColumn = async (
relationColumnId?: string;
},
) => {
const childBases = await base.getBases();
const childBases = await base.getSources();
const childTable = await Model.getByIdOrName({
base_id: base.id,
source_id: childBases[0].id!,

2
packages/nocodb/tests/unit/factory/row.ts

@ -224,7 +224,7 @@ const listRow = async ({
sortArr?: Sort[];
};
}) => {
const sources = await base.getBases();
const sources = await base.getSources();
const baseModel = await Model.getBaseModelSQL({
id: table.id,
dbDriver: await NcConnectionMgrv2.get(sources[0]!),

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

@ -27,7 +27,7 @@ const getTable = async ({
base: Base;
name: string;
}) => {
const sources = await base.getBases();
const sources = await base.getSources();
return await Model.getByIdOrName({
base_id: base.id,
source_id: sources[0].id!,
@ -36,7 +36,7 @@ const getTable = async ({
};
const getAllTables = async ({ base }: { base: Base }) => {
const sources = await base.getBases();
const sources = await base.getSources();
const tables = await Model.list({
base_id: base.id,
source_id: sources[0].id!,

2
packages/nocodb/tests/unit/init/cleanupMeta.ts

@ -11,7 +11,7 @@ const dropTablesAllNonExternalProjects = async () => {
bases
.filter((base) => base.is_meta)
.map(async (base) => {
await base.getBases();
await base.getSources();
const source = base.sources && base.sources[0];
if (!source) return;

4
packages/nocodb/tests/unit/init/index.ts

@ -26,7 +26,7 @@ const serverInit = async () => {
const isFirstTimeRun = () => !server;
export default async function (forceReset = false) {
export default async function (forceReset = false, roles = 'editor') {
const { default: TestDbMngr } = await import('../TestDbMngr');
if (isFirstTimeRun()) {
@ -39,7 +39,7 @@ export default async function (forceReset = false) {
// }
await cleanupMeta();
const { token } = await createUser({ app: server }, { roles: 'editor' });
const { token } = await createUser({ app: server }, { roles });
const extra: any = {};

248
packages/nocodb/tests/unit/rest/tests/newDataApis.test.ts

@ -82,7 +82,7 @@
*/
import 'mocha';
import { UITypes, ViewTypes } from 'nocodb-sdk';
import {UITypes, ViewTypes, WorkspaceUserRoles} from 'nocodb-sdk';
import { expect } from 'chai';
import request from 'supertest';
import init from '../../init';
@ -98,6 +98,7 @@ import {
import { createView, updateView } from '../../factory/view';
import { isPg } from '../../init/db';
import { defaultUserArgs } from '../../factory/user';
import type { ColumnType } from 'nocodb-sdk';
import type Base from '~/models/Base';
import type Model from '../../../../src/models/Model';
@ -2731,6 +2732,248 @@ function linkBased() {
});
}
function userFieldBased() {
// prepare data for test cases
beforeEach(async function () {
context = await init(false, 'creator');
base = await createProject(context);
table = await createTable(context, base, {
table_name: 'userBased',
title: 'userBased',
columns: customColumns('userBased'),
});
// retrieve column meta
columns = await table.getColumns();
// add users to workspace
const users = [
'a@nocodb.com',
'b@nocodb.com',
'c@nocodb.com',
'd@nocodb.com',
'e@nocodb.com',
];
for (const email of users) {
await addUsers(email);
}
const userList = await getUsers();
userList[userList.length] = { email: null };
userList[userList.length] = { email: '' };
// build records
const rowAttributes = [];
for (let i = 0; i < 400; i++) {
const row = {
userFieldSingle: [{ email: userList[i % userList.length].email }],
userFieldMulti: [
{ email: userList[i % userList.length].email },
{ email: userList[(i + 1) % userList.length].email },
],
};
rowAttributes.push(row);
}
// insert records
await createBulkRows(context, {
base,
table,
values: rowAttributes,
});
// retrieve inserted records
insertedRecords = await listRow({ base, table });
// verify length of unfiltered records to be 400
expect(insertedRecords.length).to.equal(400);
});
async function addUsers(email) {
const response = await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email, password: defaultUserArgs.password })
.expect(200);
const token = response.body.token;
expect(token).to.be.a('string');
// invite users to workspace
if (process.env.EE === 'true') {
let rsp = await request(context.app)
.post(`/api/v1/workspaces/${context.fk_workspace_id}/invitations`)
.set('xc-auth', context.token)
.send({ email, roles: WorkspaceUserRoles.VIEWER });
console.log(rsp);
}
}
async function getUsers() {
const response = await request(context.app)
.get(`/api/v2/meta/bases/${base.id}/users`)
.set('xc-auth', context.token);
expect(response.body).to.have.keys(['users']);
expect(response.body.users.list).to.have.length(6);
return response.body.users.list;
}
it('List records', async function () {
// retrieve inserted records
insertedRecords = await listRow({ base, table });
// verify length of unfiltered records to be 400
expect(insertedRecords.length).to.equal(400);
expect(insertedRecords[0].userFieldSingle[0]).to.have.keys([
'email',
'id',
'display_name',
]);
expect(insertedRecords[0].userFieldMulti[0]).to.have.keys([
'email',
'id',
'display_name',
]);
});
it('List: sort, ascending', async function () {
const sortColumn = columns.find((c) => c.title === 'userFieldSingle');
const rsp = await ncAxiosGet({
query: { sort: 'userFieldSingle', limit: 400 },
});
expect(verifyColumnsInRsp(rsp.body.list[0], columns)).to.equal(true);
const sortedArray = rsp.body.list.map((r) => r[sortColumn.title]);
expect(sortedArray).to.deep.equal(
sortedArray.sort((a, b) => {
const emailA = a ? a[0]?.email?.toLowerCase() : '';
const emailB = b ? b[0]?.email?.toLowerCase() : '';
if (emailA < emailB) {
return -1;
}
if (emailA > emailB) {
return 1;
}
// Emails are equal, no change in order
return 0;
}),
);
});
it('List: filter, single', async function () {
const userList = await getUsers();
const rsp = await ncAxiosGet({
query: {
where: `(userFieldSingle,eq,${userList[2].id})`,
limit: 400,
},
});
expect(verifyColumnsInRsp(rsp.body.list[0], columns)).to.equal(true);
const filteredArray = rsp.body.list.map((r) => r.userFieldSingle);
expect(filteredArray).to.deep.equal(filteredArray.fill(userList[2]));
});
it('List: sort, ascending for user multi field', async function () {
const sortColumn = columns.find((c) => c.title === 'userFieldMulti');
const rsp = await ncAxiosGet({
query: { sort: 'userFieldMulti', limit: 400 },
});
expect(verifyColumnsInRsp(rsp.body.list[0], columns)).to.equal(true);
const sortedArray = rsp.body.list.map((r) => r[sortColumn.title]);
expect(sortedArray).to.deep.equal(
sortedArray.sort((a, b) => {
const emailA = a ? a[0]?.email?.toLowerCase() : '';
const emailB = b ? b[0]?.email?.toLowerCase() : '';
if (emailA < emailB) {
return -1;
}
if (emailA > emailB) {
return 1;
}
// Emails are equal, no change in order
return 0;
}),
);
});
it('List: filter, user multi field', async function () {
const userList = await getUsers();
const rsp = await ncAxiosGet({
query: {
where: `(userFieldMulti,anyof,${userList[2].id})`,
limit: 400,
},
});
expect(verifyColumnsInRsp(rsp.body.list[0], columns)).to.equal(true);
expect(rsp.body.list.length).to.equal(100);
});
it('Create record : using email', async function () {
const newRecord = {
userFieldSingle: 'a@nocodb.com',
userFieldMulti: 'a@nocodb.com,b@nocodb.com',
};
const rsp = await ncAxiosPost({ body: newRecord });
expect(rsp.body).to.deep.equal({ Id: 401 });
const record = await ncAxiosGet({
url: `/api/v2/tables/${table.id}/records/401`,
});
expect(record.body.Id).to.equal(401);
expect(record.body.userFieldSingle[0].email).to.equal('a@nocodb.com');
expect(record.body.userFieldMulti[0].email).to.equal('a@nocodb.com');
expect(record.body.userFieldMulti[1].email).to.equal('b@nocodb.com');
});
it('Create record : using ID', async function () {
const userList = await getUsers();
const id0 = userList.find((u) => u.email === 'test@example.com').id;
const id1 = userList.find((u) => u.email === 'a@nocodb.com').id;
const newRecord = {
userFieldSingle: id0,
userFieldMulti: `${id0},${id1}`,
};
const rsp = await ncAxiosPost({ body: newRecord });
expect(rsp.body).to.deep.equal({ Id: 401 });
const record = await ncAxiosGet({
url: `/api/v2/tables/${table.id}/records/401`,
});
expect(record.body.Id).to.equal(401);
expect(record.body.userFieldSingle[0].email).to.equal('test@example.com');
expect(record.body.userFieldMulti[0].email).to.equal('test@example.com');
expect(record.body.userFieldMulti[1].email).to.equal('a@nocodb.com');
});
it('Create record : duplicate ID', async function () {
const userList = await getUsers();
const newRecord1 = {
userFieldSingle: userList[0].id,
userFieldMulti: `${userList[0].id},${userList[0].id}`,
};
const rsp = await ncAxiosPost({ body: newRecord1, status: 422 });
expect(rsp.body.msg).to.equal('Duplicate users not allowed for user field');
const newRecord2 = {
userFieldSingle: `${userList[0].id},${userList[1].id}`,
userFieldMulti: `${userList[0].id},${userList[1].id}`,
};
const rsp2 = await ncAxiosPost({ body: newRecord2, status: 422 });
expect(rsp2.body.msg).to.equal(
"Multiple users not allowed for 'userFieldSingle'",
);
});
}
///////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////
@ -2742,8 +2985,9 @@ export default function () {
describe('Select based', selectBased);
describe('Date based', dateBased);
describe('Link based', linkBased);
describe('User field based', userFieldBased);
// based out of sakila db, for link based tests
// based out of Sakila db, for link based tests
describe('General', generalDb);
}

117
tests/playwright/pages/Dashboard/Grid/Column/UserOptionColumn.ts

@ -0,0 +1,117 @@
import { ColumnPageObject } from '.';
import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class UserOptionColumnPageObject extends BasePage {
readonly column: ColumnPageObject;
constructor(column: ColumnPageObject) {
super(column.rootPage);
this.column = column;
}
get() {
return this.column.get();
}
async allowMultipleUser({
columnTitle,
allowMultiple = false,
}: {
columnTitle: string;
allowMultiple?: boolean;
}): Promise<void> {
await this.column.openEdit({ title: columnTitle });
const checkbox = this.get().getByTestId('user-column-allow-multiple');
const isChecked = await checkbox.isChecked();
if ((isChecked && !allowMultiple) || (!isChecked && allowMultiple)) {
await checkbox.click();
}
await this.column.save({ isUpdated: true });
}
async selectDefaultValueOption({
columnTitle,
option,
multiSelect,
}: {
columnTitle: string;
option: string | string[];
multiSelect?: boolean;
}): Promise<void> {
// Verify allow multiple checkbox before selecting default value
await this.allowMultipleUser({ columnTitle, allowMultiple: multiSelect });
await this.column.openEdit({ title: columnTitle });
// Clear previous default value
await this.clearDefaultValue();
const selector = this.column.get().locator('.nc-user-select >> .ant-select-selector');
await selector.click();
if (multiSelect) {
const optionsToSelect = Array.isArray(option) ? option : [option];
for (const op of optionsToSelect) {
await this.selectOption({ option: op });
}
} else if (!Array.isArray(option)) {
await this.selectOption({ option });
}
// Press `Escape` to close the dropdown
await this.rootPage.keyboard.press('Escape');
await this.rootPage.locator('.nc-dropdown-user-select-cell').waitFor({ state: 'hidden' });
await this.column.save({ isUpdated: true });
}
async selectOption({ option }: { option: string }) {
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').fill(option);
// Select user option
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${option}"`).click();
}
async clearDefaultValue(): Promise<void> {
await this.column.get().locator('.nc-cell-user + svg.nc-icon').click();
}
async verifyDefaultValueOptionCount({
columnTitle,
totalCount,
}: {
columnTitle: string;
totalCount: number;
}): Promise<void> {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator('.nc-cell-user > .nc-user-select').click();
expect(await this.rootPage.getByTestId(`select-option-${columnTitle}-undefined`).count()).toEqual(totalCount);
await this.column.get().locator('.nc-cell-user').click();
// Press `Cancel` to close edit modal
await this.column.get().locator('button:has-text("Cancel")').click();
await this.get().waitFor({ state: 'hidden' });
}
async verifySelectedOptions({ options, columnHeader }: { columnHeader: string; options: string[] }) {
await this.column.openEdit({ title: columnHeader });
const defaultValueSelector = this.get().locator('.nc-user-select >> .ant-select-selector');
let counter = 0;
for (const option of options) {
await expect(defaultValueSelector.locator(`.nc-selected-option`).nth(counter)).toHaveText(option);
counter++;
}
// Press `Cancel` to close edit modal
await this.column.get().locator('button:has-text("Cancel")').click();
await this.get().waitFor({ state: 'hidden' });
}
}

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

@ -4,17 +4,20 @@ import BasePage from '../../../Base';
import { SelectOptionColumnPageObject } from './SelectOptionColumn';
import { AttachmentColumnPageObject } from './Attachment';
import { getTextExcludeIconText } from '../../../../tests/utils/general';
import { UserOptionColumnPageObject } from './UserOptionColumn';
export class ColumnPageObject extends BasePage {
readonly grid: GridPage;
readonly selectOption: SelectOptionColumnPageObject;
readonly attachmentColumnPageObject: AttachmentColumnPageObject;
readonly userOption: UserOptionColumnPageObject;
constructor(grid: GridPage) {
super(grid.rootPage);
this.grid = grid;
this.selectOption = new SelectOptionColumnPageObject(this);
this.attachmentColumnPageObject = new AttachmentColumnPageObject(this);
this.userOption = new UserOptionColumnPageObject(this);
}
get() {
@ -185,6 +188,8 @@ export class ColumnPageObject extends BasePage {
.nth(0)
.click();
break;
case 'User':
break;
default:
break;
}

5
tests/playwright/pages/Dashboard/Grid/index.ts

@ -467,4 +467,9 @@ export class GridPage extends BasePage {
// kludge: wait for paste to complete
await this.rootPage.waitForTimeout(1000);
}
async clearWithMouse({ index, columnHeader }: CellProps) {
await this.cell.get({ index, columnHeader }).click({ button: 'right' });
await this.get().page().getByTestId('context-menu-item-clear').click();
}
}

154
tests/playwright/pages/Dashboard/common/Cell/UserOptionCell.ts

@ -0,0 +1,154 @@
import { expect } from '@playwright/test';
import { CellPageObject } from '.';
import BasePage from '../../../Base';
export class UserOptionCellPageObject extends BasePage {
readonly cell: CellPageObject;
constructor(cell: CellPageObject) {
super(cell.rootPage);
this.cell = cell;
}
get({ index, columnHeader }: { index: number; columnHeader: string }) {
return this.cell.get({ index, columnHeader });
}
async select({
index,
columnHeader,
option,
multiSelect,
}: {
index: number;
columnHeader: string;
option: string;
multiSelect?: boolean;
}) {
const selectCell = this.get({ index, columnHeader });
// check if cell active
if (
!(await selectCell.getAttribute('class')).includes('active') &&
(await selectCell.locator('.nc-selected-option').count()) === 0
) {
await selectCell.click();
}
await selectCell.click();
if (index === -1)
await this.rootPage.getByTestId(`select-option-${columnHeader}-undefined`).getByText(option).click();
else await this.rootPage.getByTestId(`select-option-${columnHeader}-${index}`).getByText(option).click();
if (multiSelect) await this.get({ index, columnHeader }).click();
await this.rootPage
.getByTestId(`select-option-${columnHeader}-${index}`)
.getByText(option)
.waitFor({ state: 'hidden' });
}
async clear({ index, columnHeader, multiSelect }: { index: number; columnHeader: string; multiSelect?: boolean }) {
if (multiSelect) {
await this.cell.get({ index, columnHeader }).click();
await this.cell.get({ index, columnHeader }).click();
const optionCount = await this.cell.get({ index, columnHeader }).locator('.ant-tag').count();
for (let i = 0; i < optionCount; i++) {
await this.cell.get({ index, columnHeader }).locator('.ant-tag > .ant-tag-close-icon').first().click();
// wait till number of options is less than before
await this.cell
.get({ index, columnHeader })
.locator('.ant-tag')
.nth(optionCount - i - 1)
.waitFor({ state: 'hidden' });
}
return;
}
await this.get({ index, columnHeader }).click();
await this.rootPage.locator('.ant-tag > .ant-tag-close-icon').click();
// Press `Escape` to close the dropdown
await this.rootPage.keyboard.press('Escape');
await this.rootPage.locator('.nc-dropdown-user-select-cell').waitFor({ state: 'hidden' });
}
async verify({
index = 0,
columnHeader,
option,
multiSelect,
}: {
index?: number;
columnHeader: string;
option: string;
multiSelect?: boolean;
}) {
if (multiSelect) {
return await expect(this.cell.get({ index, columnHeader })).toContainText(option, { useInnerText: true });
}
const locator = this.cell.get({ index, columnHeader }).locator('.ant-tag');
await locator.waitFor({ state: 'visible' });
const text = await locator.allInnerTexts();
return expect(text).toContain(option);
}
async verifyNoOptionsSelected({ index, columnHeader }: { index: number; columnHeader: string }) {
return await expect(
this.cell.get({ index, columnHeader }).locator('.ant-select-selection-overflow-item >> .ant-tag')
).toBeHidden();
}
async verifyOptions({
index = 0,
columnHeader,
options,
}: {
index?: number;
columnHeader: string;
options: string[];
}) {
const selectCell = this.get({ index, columnHeader });
// check if cell active
// drag based non-primary cell will have 'active' attribute
// primary cell with blue border will have 'active-cell' attribute
if (!(await selectCell.getAttribute('class')).includes('active-cell')) {
await selectCell.click();
}
await this.get({ index, columnHeader }).click();
await this.rootPage.waitForTimeout(500);
let counter = 0;
for (const option of options) {
await expect(this.rootPage.locator(`div.ant-select-item-option`).nth(counter)).toHaveText(option);
counter++;
}
await this.rootPage.keyboard.press('Escape');
await this.rootPage.locator(`.nc-dropdown-user-select-cell`).nth(index).waitFor({ state: 'hidden' });
}
async verifySelectedOptions({
index,
options,
columnHeader,
}: {
columnHeader: string;
options: string[];
index: number;
}) {
const selectCell = this.get({ index, columnHeader });
await selectCell.click();
let counter = 0;
for (const option of options) {
await expect(selectCell.locator(`.nc-selected-option`).nth(counter)).toHaveText(option);
counter++;
}
}
}

3
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -13,6 +13,7 @@ import { getTextExcludeIconText } from '../../../../tests/utils/general';
import { YearCellPageObject } from './YearCell';
import { TimeCellPageObject } from './TimeCell';
import { GroupPageObject } from '../../Grid/Group';
import { UserOptionCellPageObject } from './UserOptionCell';
export interface CellProps {
indexMap?: Array<number>;
@ -31,6 +32,7 @@ export class CellPageObject extends BasePage {
readonly geoData: GeoDataCellPageObject;
readonly date: DateCellPageObject;
readonly dateTime: DateTimeCellPageObject;
readonly userOption: UserOptionCellPageObject;
constructor(parent: GridPage | SharedFormPage | GroupPageObject) {
super(parent.rootPage);
@ -44,6 +46,7 @@ export class CellPageObject extends BasePage {
this.geoData = new GeoDataCellPageObject(this);
this.date = new DateCellPageObject(this);
this.dateTime = new DateTimeCellPageObject(this);
this.userOption = new UserOptionCellPageObject(this);
}
get({ indexMap, index, columnHeader }: CellProps): Locator {

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

@ -331,6 +331,28 @@ export class ToolbarFilterPage extends BasePage {
.click();
}
break;
case UITypes.User:
if (!['is blank', 'is not blank'].includes(operation)) {
await this.get()
.locator('.nc-filter-value-select')
.click({
position: {
x: 1,
y: 1,
},
});
const v = value.split(',');
for (let i = 0; i < v.length; i++) {
await this.rootPage
.locator(`.nc-dropdown-user-select-cell`)
.getByTestId('select-option-User-filter')
.getByText(v[i])
.click();
}
}
break;
default:
fillFilter = () => this.rootPage.locator('.nc-filter-value-select > input').last().fill(value);
await this.waitForResponse({

14
tests/playwright/pages/Dashboard/common/Toolbar/Groupby.ts

@ -118,11 +118,15 @@ export class ToolbarGroupByPage extends BasePage {
.nth(ascending ? 0 : 1)
.click();
await this.waitForResponse({
uiAction: selectSortDirection,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
const selectedSortDirection = await this.rootPage.locator('.nc-sort-dir-select').last().textContent();
if ((ascending && selectedSortDirection != 'A → Z') || (!ascending && selectedSortDirection != 'Z → A')) {
await this.waitForResponse({
uiAction: selectSortDirection,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
}
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();

2
tests/playwright/pages/WorkspacePage/CollaborationPage.ts

@ -39,7 +39,7 @@ export class CollaborationPage extends BasePage {
await this.rootPage.keyboard.press('Enter');
// role
await this.selector_role.click();
await this.selector_role.first().click();
const menu = this.rootPage.locator('.nc-role-select-dropdown:visible');
await menu.locator(`.nc-role-select-workspace-level-${role.toLowerCase()}:visible`).first().click();

747
tests/playwright/tests/db/columns/columnUserSelect.spec.ts

@ -0,0 +1,747 @@
import { test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import { GridPage } from '../../../pages/Dashboard/Grid';
import setup, { unsetup } from '../../../setup';
import { TopbarPage } from '../../../pages/Dashboard/common/Topbar';
import { ToolbarPage } from '../../../pages/Dashboard/common/Toolbar';
import { WorkspacePage } from '../../../pages/WorkspacePage';
import { CollaborationPage } from '../../../pages/WorkspacePage/CollaborationPage';
import { Api } from 'nocodb-sdk';
import { isEE } from '../../../setup/db';
import { getDefaultPwd } from '../../utils/general';
const users: string[] = isEE()
? ['useree@nocodb.com', 'useree-0@nocodb.com', 'useree-1@nocodb.com', 'useree-2@nocodb.com', 'useree-3@nocodb.com']
: ['user@nocodb.com', 'user-0@nocodb.com', 'user-1@nocodb.com', 'user-2@nocodb.com', 'user-3@nocodb.com'];
const roleDb = [
{ email: 'useree@nocodb.com', role: 'editor' },
{ email: 'useree-0@nocodb.com', role: 'editor' },
{ email: 'useree-1@nocodb.com', role: 'editor' },
{ email: 'useree-2@nocodb.com', role: 'editor' },
{ email: 'useree-3@nocodb.com', role: 'editor' },
];
async function beforeEachInit({ page }: { page: any }) {
let workspacePage: WorkspacePage;
let collaborationPage: CollaborationPage;
let api: Api<any>;
const context: any = await setup({ page, isEmptyProject: true });
const dashboard: DashboardPage = new DashboardPage(page, context.base);
if (isEE()) {
workspacePage = new WorkspacePage(page);
collaborationPage = workspacePage.collaboration;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
for (let i = 0; i < roleDb.length; i++) {
try {
await api.auth.signup({
email: roleDb[i].email,
password: getDefaultPwd(),
});
} catch (e) {
// ignore error even if user already exists
}
}
await dashboard.leftSidebar.clickTeamAndSettings();
for (const user of roleDb) {
await collaborationPage.addUsers(user.email, user.role);
}
}
return { dashboard, context };
}
test.describe('User single select', () => {
let dashboard: DashboardPage, grid: GridPage, topbar: TopbarPage;
let context: any;
test.beforeEach(async ({ page }) => {
const initRsp = await beforeEachInit({ page: page });
context = initRsp.context;
dashboard = initRsp.dashboard;
grid = dashboard.grid;
topbar = dashboard.grid.topbar;
await dashboard.treeView.createTable({ title: 'Sheet1', baseTitle: context.base.title });
await grid.column.create({ title: 'User', type: 'User' });
await grid.addNewRow({ index: 0, value: 'Row 0' });
});
test.afterEach(async () => {
await unsetup(context);
});
test('Verify the default option count, select default value and verify', async () => {
if (!isEE()) {
await grid.column.userOption.verifyDefaultValueOptionCount({ columnTitle: 'User', totalCount: 5 });
}
await grid.column.userOption.selectDefaultValueOption({
columnTitle: 'User',
option: users[0],
multiSelect: false,
});
// Verify default value is set
await grid.column.userOption.verifySelectedOptions({
columnHeader: 'User',
options: [users[0]],
});
// Add new row and verify default value is added in new cell
await grid.addNewRow({ index: 1, value: 'Row 1' });
await grid.cell.userOption.verify({
index: 1,
columnHeader: 'User',
option: users[0],
multiSelect: false,
});
});
test('Rename column title and delete the column', async () => {
// Rename column title, reload page and verify
await grid.column.openEdit({ title: 'User' });
await grid.column.fillTitle({ title: 'UserField' });
await grid.column.save({
isUpdated: true,
});
// reload page
await dashboard.rootPage.reload();
await grid.column.verify({ title: 'UserField', isVisible: true });
// delete column and verify
await grid.column.delete({ title: 'UserField' });
await grid.column.verify({ title: 'UserField', isVisible: false });
});
test('Field operations - duplicate column, convert to SingleLineText', async () => {
for (let i = 0; i <= 4; i++) {
await grid.cell.userOption.select({ index: i, columnHeader: 'User', option: users[i], multiSelect: false });
await grid.addNewRow({ index: i + 1, value: `Row ${i + 1}` });
}
await grid.column.duplicateColumn({
title: 'User',
expectedTitle: 'User copy',
});
// Verify duplicate column content
for (let i = 0; i <= 4; i++) {
await grid.cell.userOption.verify({ index: i, columnHeader: 'User copy', option: users[i], multiSelect: false });
}
// Convert User field column to SingleLineText
await grid.column.openEdit({ title: 'User copy' });
await grid.column.selectType({ type: 'SingleLineText' });
await grid.column.save({ isUpdated: true });
// Verify converted column content
for (let i = 0; i <= 4; i++) {
await grid.cell.verify({ index: i, columnHeader: 'User copy', value: users[i] });
}
});
test('Cell Operation - edit, copy-paste and delete', async () => {
// set default user
await grid.column.userOption.selectDefaultValueOption({
columnTitle: 'User',
option: users[0],
multiSelect: false,
});
// Edit, refresh and verify
for (let i = 0; i <= 4; i++) {
await grid.cell.userOption.select({ index: i, columnHeader: 'User', option: users[i], multiSelect: false });
await grid.addNewRow({ index: i + 1, value: `Row ${i + 1}` });
}
// refresh page
await topbar.clickRefresh();
for (let i = 0; i <= 4; i++) {
await grid.cell.userOption.verify({
index: i,
columnHeader: 'User',
option: users[i],
multiSelect: false,
});
}
// Delete/clear cell, refresh and verify
// #1 Using `Delete` keyboard button
await grid.cell.click({ index: 0, columnHeader: 'User' });
// trigger delete button key
await dashboard.rootPage.keyboard.press('Delete');
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifyNoOptionsSelected({ index: 0, columnHeader: 'user' });
// #2 Using mouse click
await grid.cell.userOption.clear({ index: 1, columnHeader: 'User', multiSelect: false });
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifyNoOptionsSelected({ index: 1, columnHeader: 'user' });
// #3 Using `Cell Context Menu` right click `Clear` option
await grid.clearWithMouse({ index: 2, columnHeader: 'User' });
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifyNoOptionsSelected({ index: 2, columnHeader: 'user' });
// Copy-paste
// #1 Using keyboard
await grid.cell.click({ index: 3, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press('Shift+ArrowDown');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await grid.cell.click({ index: 0, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
// refresh
await topbar.clickRefresh();
let counter = 3;
for (let i = 0; i <= 1; i++) {
await grid.cell.userOption.verify({
index: i,
columnHeader: 'User',
option: users[counter],
multiSelect: false,
});
counter++;
}
// #2 Using cell context menu copy paste option
await grid.copyWithMouse({ index: 4, columnHeader: 'User' });
await grid.pasteWithMouse({ index: 0, columnHeader: 'User' });
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verify({
index: 0,
columnHeader: 'User',
option: users[4],
multiSelect: false,
});
});
});
test.describe('User single select - filter, sort & GroupBy', () => {
// Row values
// only user@nocodb.com (row 0)
// only user-0@nocodb.com (row 1)
// only user-1@nocodb.com (row 2)
// only user-2@nocodb.com (row 3)
// only user-3@nocodb.com (row 4)
// Example filters:
//
// where tags contains all of [user@nocodb.com]
// result: rows 0
// where tags contains any of [user@nocodb.com, user-0@nocodb.com]
// result: rows 0,1
// where tags does not contain any of [user@nocodb.com, user-0@nocodb.com]
// result: rows 2,3,4
// where tags does not contain all of [user-0@nocodb.com]
// result: rows 0,2,3,4
// where tags is not blank
// result: rows 0,1,2,3,4
// where tags is blank
// result: null
let dashboard: DashboardPage, grid: GridPage, toolbar: ToolbarPage;
let context: any;
test.beforeEach(async ({ page }) => {
const initRsp = await beforeEachInit({ page: page });
context = initRsp.context;
dashboard = initRsp.dashboard;
grid = dashboard.grid;
toolbar = dashboard.grid.toolbar;
await dashboard.treeView.createTable({ title: 'sheet1', baseTitle: context.base.title });
await grid.column.create({ title: 'User', type: 'User' });
for (let i = 0; i <= 4; i++) {
await grid.addNewRow({ index: i, value: `${i}` });
await grid.cell.userOption.select({ index: i, columnHeader: 'User', option: users[i], multiSelect: false });
}
});
test.afterEach(async () => {
await unsetup(context);
});
// define validateRowArray function
async function validateRowArray(value: string[]) {
const length = value.length;
for (let i = 0; i < length; i++) {
await dashboard.grid.cell.verify({
index: i,
columnHeader: 'Title',
value: value[i],
});
}
}
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
title: 'User',
operation: param.opType,
value: param.value,
locallySaved: false,
dataType: 'User',
});
await toolbar.clickFilter();
// verify filtered rows
await validateRowArray(param.result);
// Reset filter
await toolbar.filter.reset();
}
test('User sort & validate, filter & validate', async () => {
const ascendingOrderRowTitle = ['1', '2', '3', '4', '0'];
const descendingOrderRowTitle = ['0', '4', '3', '2', '1'];
// Sort ascending and validate
await toolbar.sort.add({
title: 'User',
ascending: true,
locallySaved: false,
});
await validateRowArray(ascendingOrderRowTitle);
await toolbar.sort.reset();
// sort descending and validate
await toolbar.sort.add({
title: 'User',
ascending: false,
locallySaved: false,
});
await validateRowArray(descendingOrderRowTitle);
await toolbar.sort.reset();
// filter
await verifyFilter({ opType: 'contains all of', value: users[0], result: ['0'] });
await verifyFilter({
opType: 'contains any of',
value: `${users[0]},${users[1]}`,
result: ['0', '1'],
});
await verifyFilter({
opType: 'does not contain any of',
value: `${users[0]},${users[1]}`,
result: ['2', '3', '4'],
});
await verifyFilter({ opType: 'does not contain all of', value: users[1], result: ['0', '2', '3', '4'] });
await verifyFilter({ opType: 'is not blank', result: ['0', '1', '2', '3', '4'] });
await verifyFilter({ opType: 'is blank', result: [] });
//GroupBy
// ascending order
await toolbar.groupBy.add({ title: 'User', ascending: true, locallySaved: false });
for (let i = 0; i <= 4; i++) {
await dashboard.grid.groupPage.openGroup({ indexMap: [i] });
await dashboard.grid.groupPage.validateFirstRow({
indexMap: [i],
rowIndex: 0,
columnHeader: 'Title',
value: ascendingOrderRowTitle[i],
});
}
// descending order
await toolbar.groupBy.update({ title: 'User', ascending: false, index: 0 });
for (let i = 0; i <= 4; i++) {
await dashboard.grid.groupPage.openGroup({ indexMap: [i] });
await dashboard.grid.groupPage.validateFirstRow({
indexMap: [i],
rowIndex: 0,
columnHeader: 'Title',
value: descendingOrderRowTitle[i],
});
}
await toolbar.groupBy.remove({ index: 0 });
});
});
test.describe('User multiple select', () => {
let dashboard: DashboardPage, grid: GridPage, topbar: TopbarPage;
let context: any;
test.beforeEach(async ({ page }) => {
const initRsp = await beforeEachInit({ page: page });
context = initRsp.context;
dashboard = initRsp.dashboard;
grid = dashboard.grid;
topbar = dashboard.grid.topbar;
await dashboard.treeView.createTable({ title: 'Sheet1', baseTitle: context.base.title });
await grid.column.create({ title: 'User', type: 'User' });
await grid.column.userOption.allowMultipleUser({ columnTitle: 'User', allowMultiple: true });
});
test.afterEach(async () => {
await unsetup(context);
});
test('Verify the default option count, select default value and verify', async () => {
await grid.addNewRow({ index: 0, value: 'Row 0' });
if (!isEE()) {
await grid.column.userOption.verifyDefaultValueOptionCount({ columnTitle: 'User', totalCount: 5 });
}
await grid.column.userOption.selectDefaultValueOption({
columnTitle: 'User',
option: [users[0], users[1]],
multiSelect: true,
});
// Verify default value is set
await grid.column.userOption.verifySelectedOptions({
columnHeader: 'User',
options: [users[0], users[1]],
});
// Add new row and verify default value is added in new cell
await grid.addNewRow({ index: 1, value: 'Row 1' });
await grid.cell.userOption.verify({
index: 1,
columnHeader: 'User',
option: users[0],
multiSelect: true,
});
await grid.cell.userOption.verify({
index: 1,
columnHeader: 'User',
option: users[1],
multiSelect: true,
});
});
test('Field operations - duplicate column, convert to SingleLineText', async () => {
let counter = 1;
for (let i = 0; i <= 4; i++) {
await grid.addNewRow({ index: i, value: `Row ${i}` });
await grid.cell.userOption.select({ index: i, columnHeader: 'User', option: users[i], multiSelect: true });
await grid.cell.userOption.select({ index: i, columnHeader: 'User', option: users[counter], multiSelect: true });
if (counter === 4) counter = 0;
else counter++;
}
await grid.column.duplicateColumn({
title: 'User',
expectedTitle: 'User copy',
});
// Verify duplicate column content
counter = 1;
for (let i = 0; i <= 4; i++) {
await grid.cell.userOption.verifySelectedOptions({
index: i,
columnHeader: 'User copy',
options: [users[i], users[counter]],
});
if (counter === 4) counter = 0;
else counter++;
}
// Convert User field column to SingleLineText
await grid.column.openEdit({ title: 'User copy' });
await grid.column.selectType({ type: 'SingleLineText' });
await grid.column.save({ isUpdated: true });
// Verify converted column content
counter = 1;
for (let i = 0; i <= 4; i++) {
await grid.cell.verify({ index: i, columnHeader: 'User copy', value: `${users[i]},${users[counter]}` });
if (counter === 4) counter = 0;
else counter++;
}
});
test('Cell Operation - edit, copy-paste and delete', async () => {
// Edit, refresh and verify
let counter = 1;
for (let i = 0; i <= 4; i++) {
await grid.addNewRow({ index: i, value: `Row ${i}` });
await grid.cell.userOption.select({
index: i,
columnHeader: 'User',
option: users[i],
multiSelect: true,
});
await grid.cell.userOption.select({
index: i,
columnHeader: 'User',
option: users[counter],
multiSelect: true,
});
if (counter === 4) counter = 0;
else counter++;
}
// reload page
await dashboard.rootPage.reload();
counter = 1;
for (let i = 0; i <= 4; i++) {
await grid.cell.userOption.verifySelectedOptions({
index: i,
columnHeader: 'User',
options: [users[i], users[counter]],
});
if (counter === 4) counter = 0;
else counter++;
}
// Delete/clear cell, refresh and verify
// #1 Using `Delete` keyboard button
await grid.cell.click({ index: 0, columnHeader: 'User' });
// trigger delete button key
await dashboard.rootPage.keyboard.press('Delete');
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifyNoOptionsSelected({ index: 0, columnHeader: 'user' });
// #2 Using mouse click
await grid.cell.userOption.clear({ index: 1, columnHeader: 'User', multiSelect: true });
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifyNoOptionsSelected({ index: 1, columnHeader: 'user' });
// #3 Using `Cell Context Menu` right click `Clear` option
await grid.clearWithMouse({ index: 2, columnHeader: 'User' });
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifyNoOptionsSelected({ index: 2, columnHeader: 'user' });
// Copy-paste
// #1 Using keyboard
await grid.cell.click({ index: 3, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await grid.cell.click({ index: 0, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifySelectedOptions({
index: 0,
columnHeader: 'User',
options: [users[3], users[4]],
});
// #2 Using cell context menu copy paste option
await grid.copyWithMouse({ index: 4, columnHeader: 'User' });
await grid.pasteWithMouse({ index: 1, columnHeader: 'User' });
// refresh
await topbar.clickRefresh();
await grid.cell.userOption.verifySelectedOptions({
index: 1,
columnHeader: 'User',
options: [users[4], users[0]],
});
});
});
test.describe('User multiple select - filter, sort & GroupBy', () => {
// Row values
// only user@nocodb.com (row 0)
// user-0@nocodb.com and user-1@nocodb.com (row 1)
// user-1@nocodb.com and user-2@nocodb.com (row 2)
// user-2@nocodb.com and user-3@nocodb.com (row 3)
// user-3@nocodb.com and user@nocodb.com (row 4)
// Example filters:
//
// where tags contains all of [user-0@nocodb.com, user-1@nocodb.com]
// result: rows 1
// where tags contains any of [user@nocodb.com, user-0@nocodb.com]
// result: rows 0,1,4
// where tags does not contain any of [user@nocodb.com, user-0@nocodb.com]
// result: rows 2,3
// where tags does not contain all of [user-0@nocodb.com]
// result: rows 0,2,3,4
// where tags is not blank
// result: rows 0,1,2,3,4
// where tags is blank
// result: null
let dashboard: DashboardPage, grid: GridPage, toolbar: ToolbarPage;
let context: any;
test.beforeEach(async ({ page }) => {
const initRsp = await beforeEachInit({ page: page });
context = initRsp.context;
dashboard = initRsp.dashboard;
grid = dashboard.grid;
toolbar = dashboard.grid.toolbar;
await dashboard.treeView.createTable({ title: 'sheet1', baseTitle: context.base.title });
await grid.column.create({ title: 'User', type: 'User' });
await grid.column.userOption.allowMultipleUser({ columnTitle: 'User', allowMultiple: true });
let counter = 2;
for (let i = 0; i <= 4; i++) {
await grid.addNewRow({ index: i, value: `${i}` });
await grid.cell.userOption.select({ index: i, columnHeader: 'User', option: users[i], multiSelect: true });
if (i !== 0) {
await grid.cell.userOption.select({
index: i,
columnHeader: 'User',
option: users[counter],
multiSelect: true,
});
if (counter === 4) counter = 0;
else counter++;
}
}
});
test.afterEach(async () => {
await unsetup(context);
});
// define validateRowArray function
async function validateRowArray(value: string[]) {
const length = value.length;
for (let i = 0; i < length; i++) {
await dashboard.grid.cell.verify({
index: i,
columnHeader: 'Title',
value: value[i],
});
}
}
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
title: 'User',
operation: param.opType,
value: param.value,
locallySaved: false,
dataType: 'User',
});
await toolbar.clickFilter();
// verify filtered rows
await validateRowArray(param.result);
// Reset filter
await toolbar.filter.reset();
}
test('User sort & validate, filter & validate', async () => {
const ascendingOrderRowTitle = ['1', '2', '3', '4', '0'];
const descendingOrderRowTitle = ['0', '4', '3', '2', '1'];
// Sort ascending and validate
await toolbar.sort.add({
title: 'User',
ascending: true,
locallySaved: false,
});
await validateRowArray(ascendingOrderRowTitle);
await toolbar.sort.reset();
// sort descending and validate
await toolbar.sort.add({
title: 'User',
ascending: false,
locallySaved: false,
});
await validateRowArray(descendingOrderRowTitle);
await toolbar.sort.reset();
// filter
await verifyFilter({ opType: 'contains all of', value: `${(users[1], users[2])}`, result: ['1'] });
await verifyFilter({
opType: 'contains any of',
value: `${users[0]},${users[1]}`,
result: ['0', '1', '4'],
});
await verifyFilter({
opType: 'does not contain any of',
value: `${users[0]},${users[1]}`,
result: ['2', '3'],
});
await verifyFilter({ opType: 'does not contain all of', value: users[1], result: ['0', '2', '3', '4'] });
await verifyFilter({ opType: 'is not blank', result: ['0', '1', '2', '3', '4'] });
await verifyFilter({ opType: 'is blank', result: [] });
//GroupBy
// ascending order
await toolbar.groupBy.add({ title: 'User', ascending: true, locallySaved: false });
for (let i = 0; i <= 4; i++) {
await dashboard.grid.groupPage.openGroup({ indexMap: [i] });
await dashboard.grid.groupPage.validateFirstRow({
indexMap: [i],
rowIndex: 0,
columnHeader: 'Title',
value: ascendingOrderRowTitle[i],
});
}
// descending order
await toolbar.groupBy.update({ title: 'User', ascending: false, index: 0 });
for (let i = 0; i <= 4; i++) {
await dashboard.grid.groupPage.openGroup({ indexMap: [i] });
await dashboard.grid.groupPage.validateFirstRow({
indexMap: [i],
rowIndex: 0,
columnHeader: 'Title',
value: descendingOrderRowTitle[i],
});
}
await toolbar.groupBy.remove({ index: 0 });
});
});
Loading…
Cancel
Save