Browse Source

Merge pull request #9807 from nocodb/nc-feat/personal-view

Nc feat/personal view
pull/9816/head
Pranav C 2 weeks ago committed by GitHub
parent
commit
e1b2eb29ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      packages/nc-gui/assets/nc-icons/personal.svg
  2. 13
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  3. 29
      packages/nc-gui/components/dlg/ReAssign/UserItem.vue
  4. 186
      packages/nc-gui/components/dlg/ReAssign/index.vue
  5. 2
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  6. 39
      packages/nc-gui/components/smartsheet/toolbar/LockType.vue
  7. 30
      packages/nc-gui/components/smartsheet/toolbar/NotAllowedTooltip.vue
  8. 75
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  9. 2
      packages/nc-gui/composables/useExtensionHelper.ts
  10. 10
      packages/nc-gui/composables/useSmartsheetStore.ts
  11. 7
      packages/nc-gui/lang/en.json
  12. 8
      packages/nc-gui/lib/enums.ts
  13. 21
      packages/nc-gui/utils/viewUtils.ts
  14. 3
      packages/noco-docs/docs/090.views/010.views-overview.md
  15. 6
      packages/nocodb-sdk/src/lib/enums.ts
  16. 6
      packages/nocodb/src/controllers/meta-diffs.controller.ts
  17. 19
      packages/nocodb/src/helpers/populateMeta.ts
  18. 8
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  19. 20
      packages/nocodb/src/meta/migrations/v2/nc_066_ai_button.ts
  20. 18
      packages/nocodb/src/meta/migrations/v2/nc_067_personal_view.ts
  21. 5
      packages/nocodb/src/models/Model.ts
  22. 11
      packages/nocodb/src/models/View.ts
  23. 1
      packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
  24. 1
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  25. 5
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  26. 4
      packages/nocodb/src/modules/jobs/jobs/source-create/source-create.processor.ts
  27. 1
      packages/nocodb/src/modules/jobs/migration-jobs/nc_job_003_recover_links.ts
  28. 4
      packages/nocodb/src/schema/swagger-v2.json
  29. 8
      packages/nocodb/src/schema/swagger.json
  30. 6
      packages/nocodb/src/services/bases.service.ts
  31. 3
      packages/nocodb/src/services/calendars.service.ts
  32. 2
      packages/nocodb/src/services/columns.service.ts
  33. 3
      packages/nocodb/src/services/forms.service.ts
  34. 4
      packages/nocodb/src/services/galleries.service.ts
  35. 3
      packages/nocodb/src/services/grids.service.ts
  36. 3
      packages/nocodb/src/services/kanbans.service.ts
  37. 2
      packages/nocodb/src/services/maps.service.ts
  38. 26
      packages/nocodb/src/services/meta-diffs.service.ts
  39. 7
      packages/nocodb/src/services/sources.service.ts
  40. 6
      packages/nocodb/src/services/tables.service.ts
  41. 88
      packages/nocodb/src/services/views.service.ts
  42. 8
      tests/playwright/pages/Dashboard/Grid/columnHeader.ts
  43. 10
      tests/playwright/pages/Dashboard/Grid/index.ts
  44. 5
      tests/playwright/pages/Dashboard/common/Footbar/index.ts
  45. 8
      tests/playwright/pages/Dashboard/common/Toolbar/index.ts

5
packages/nc-gui/assets/nc-icons/personal.svg

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3333 14V12.6667C13.3333 11.9594 13.0523 11.2811 12.5522 10.781C12.0521 10.281 11.3739 10 10.6666 10H5.33329C4.62605 10 3.94777 10.281 3.44767 10.781C2.94758 11.2811 2.66663 11.9594 2.66663 12.6667V14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.00004 7.33333C9.4728 7.33333 10.6667 6.13943 10.6667 4.66667C10.6667 3.19391 9.4728 2 8.00004 2C6.52728 2 5.33337 3.19391 5.33337 4.66667C5.33337 6.13943 6.52728 7.33333 8.00004 7.33333Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

13
packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { TableType, ViewType, ViewTypes } from 'nocodb-sdk'
import { type TableType, ViewLockType, type ViewType, type ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import { isDefaultBase as _isDefaultBase } from '#imports'
@ -285,15 +285,22 @@ watch(isDropdownOpen, async () => {
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ vModel.alias || vModel.title }}
<component
:is="viewLockIcons[view.lock_type].icon"
v-if="view.lock_type === ViewLockType.Locked || view.lock_type === ViewLockType.Personal"
class="text-gray-400 ml-1 -mt-[2px]"
:class="{
'w-3.2 h-3.2': view.lock_type === ViewLockType.Locked,
'w-3.5 h-3.5': view.lock_type !== ViewLockType.Locked,
}"
/>
</div>
</NcTooltip>
<template v-if="!isEditing && !isLocked">
<NcTooltip v-if="vModel.description?.length" placement="bottom">
<template #title>
{{ vModel.description }}
</template>
<NcButton type="text" class="!hover:bg-transparent" size="xsmall">
<GeneralIcon icon="info" class="!w-3.5 !h-3.5 nc-info-icon group-hover:opacity-100 text-gray-600 opacity-0" />
</NcButton>

29
packages/nc-gui/components/dlg/ReAssign/UserItem.vue

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { UserType } from 'nocodb-sdk'
const { user } = defineProps<{
user: UserType
}>()
const displayName = computed(() => {
return user?.display_name?.trim() ? user?.display_name?.trim() : user?.email?.split('@')[0]
})
</script>
<template>
<div class="flex flex-row items-center gap-x-2 h-12.5 p-2">
<GeneralUserIcon
size="auto"
:name="user.display_name?.trim() ? user.display_name?.trim() : ''"
:email="user.email"
class="!text-[0.65rem]"
/>
<div class="flex flex-col justify-center flex-grow">
<div class="flex flex-col">
<span class="capitalize font-weight-medium">{{ displayName }}</span>
<span class="text-xs">{{ user.email }}</span>
</div>
</div>
<slot name="append"></slot>
</div>
</template>

186
packages/nc-gui/components/dlg/ReAssign/index.vue

@ -0,0 +1,186 @@
<script lang="ts" setup>
import { ProjectRoles, ViewLockType } from 'nocodb-sdk'
import UserItem from './UserItem.vue'
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const { loadUsers, users } = useManageUsers()
interface Props {
modelValue: boolean
view?: Record<string, any>
}
interface Emits {
(event: 'update:modelValue', data: boolean): void
}
const vModel = useVModel(props, 'modelValue', emits)
onMounted(async () => {
if (!users.value) {
await loadUsers()
}
})
const basesStore = useBases()
const viewsStore = useViewsStore()
const { basesUser } = storeToRefs(basesStore)
const searchQuery = ref('')
const selectedUser = ref()
const userSelectMenu = ref(false)
const currentOwner = computed(() => {
return (
(props.view && basesUser.value.get(props.view.base_id)?.find((u) => u.id === props.view.owned_by)) || {
id: props.view.owned_by,
display_name: 'Unknown User',
}
)
})
const filterdBaseUsers = computed(() => {
let users = props.view.base_id ? basesUser.value.get(props.view.base_id) || [] : []
if (searchQuery.value) {
const keyword = searchQuery.value.toLowerCase()
users = users.filter((u) => {
return u.display_name?.toLowerCase().includes(keyword) || u.email.toLowerCase().includes(keyword)
})
}
// exclude current owner from the list
return users.filter(
(u) => u.id !== currentOwner.value?.id && u.roles !== ProjectRoles.NO_ACCESS && u.roles !== ProjectRoles.VIEWER,
)
})
const { api, isLoading } = useApi()
const assignView = async () => {
try {
if (!selectedUser.value) return
await api.dbView.update(props.view.id, {
owned_by: selectedUser.value.id,
})
vModel.value = false
message.success('View reassigned successfully')
// if personal view then redirect to default view and reload view list
if (props.view.lock_type === ViewLockType.Personal) {
// then reload the view list
viewsStore
.loadViews({
ignoreLoading: true,
tableId: props.view.fk_model_id,
force: true,
})
.catch(() => {
// ignore
})
}
} catch (e) {
await message.error(await extractSdkResponseErrorMsg(e))
}
}
const selectUser = (user) => {
selectedUser.value = user
userSelectMenu.value = false
}
const inputEl = (el: HTMLInputElement) => {
setTimeout(() => el?.focus(), 100)
}
</script>
<template>
<NcModal v-model:visible="vModel" wrap-class-name="nc-modal-re-assign" width="448px">
<div class="mb-5">
<div class="flex text-base font-bold mb-2">Re-assign this view</div>
<div class="flex">Once reassigned, current owner will no longer be able to edit the view configuration.</div>
</div>
<div class="mb-5">
<div class="mb-1">Current owner</div>
<UserItem :user="currentOwner" class="bg-gray-100 rounded-lg" />
</div>
<div class="mb-5">
<div class="mb-1">New owner</div>
<div
class="rounded-lg border-1"
:class="{
'shadow-sm': selectedUser && !userSelectMenu,
}"
>
<UserItem
v-if="selectedUser && !userSelectMenu"
:user="selectedUser"
class="cursor-pointer"
@click="userSelectMenu = true"
>
<template #append>
<GeneralIcon icon="arrowDown" class="text-gray-500" />
</template>
</UserItem>
<div v-else class="flex flex-row items-center gap-x-2 h-12.5 p-2 nc-list-user-item">
<GeneralIcon icon="search" class="text-gray-500 ml-2" />
<input
:ref="inputEl"
v-model="searchQuery"
placeholder="Search User to assign..."
class="border-0 px-2.5 outline-none nc-search-input"
/>
</div>
<div v-if="!selectedUser || userSelectMenu" class="max-h-65 overflow-auto">
<UserItem
v-for="user of filterdBaseUsers"
:key="user.id"
class="cursor-pointer hover:(bg-gray-100) nc-list-user-item"
:class="{ 'bg-gray-100': selectedUser === user }"
:user="user"
@click="selectUser(user)"
>
</UserItem>
</div>
<div v-if="!filterdBaseUsers?.length" class="h-12.5 p-2 text-gray-400 text-sm flex items-center justify-center">
No base users found
</div>
</div>
</div>
<div class="flex justify-end">
<div class="flex gap-2">
<NcButton size="small" type="secondary" @click="vModel = false"> {{ $t('labels.cancel') }} </NcButton>
<NcButton
size="small"
type="primary"
class="nc-invite-btn"
:disabled="!selectedUser"
:loading="isLoading"
@click="assignView"
>
{{ $t('activity.assignView') }}
</NcButton>
</div>
</div>
</NcModal>
</template>
<style scoped lang="scss">
.nc-modal-re-assign {
.nc-search-input::placeholder {
@apply text-gray-400;
}
.nc-list-user-item:not(:last-of-type) {
border-bottom: 1px solid;
border-color: inherit;
}
}
</style>

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

@ -235,7 +235,7 @@ const onClick = (e: Event) => {
</template>
<NcTooltip placement="bottom" class="truncate name pl-1" :show-on-truncate-only="!showTooltipAlways">
<template #title>
<template v-for="msg in tooltipMsg.split('\n')">
<template v-for="(msg, i) in tooltipMsg.split('\n')" :key="i">
<div>{{ msg }}</div>
</template>
</template>

39
packages/nc-gui/components/smartsheet/toolbar/LockType.vue

@ -1,29 +1,11 @@
<script setup lang="ts">
import UsersIcon from '~icons/nc-icons/users'
import LockIcon from '~icons/nc-icons/lock'
import { LockType } from '#imports'
import type { LockType } from '#imports'
const { type, hideTick } = defineProps<{ hideTick?: boolean; type: LockType }>()
const { type, hideTick } = defineProps<{ hideTick?: boolean; type: LockType; disabled?: boolean }>()
const emit = defineEmits(['select'])
const types = {
[LockType.Personal]: {
title: 'title.personal',
icon: iconMap.account,
subtitle: 'msg.info.personalView',
},
[LockType.Collaborative]: {
title: 'title.collaborative',
icon: UsersIcon,
subtitle: 'msg.info.collabView',
},
[LockType.Locked]: {
title: 'title.locked',
icon: LockIcon,
subtitle: 'msg.info.lockedView',
},
}
const types = viewLockIcons
const selectedView = inject(ActiveViewInj)
</script>
@ -35,27 +17,36 @@ const selectedView = inject(ActiveViewInj)
<div class="flex items-center gap-2 flex-grow">
<component
:is="types[type].icon"
class="flex-none"
class="flex-none w-6"
:class="{
'!w-3 h-3': hideTick,
'!w-4 h-4': !hideTick,
'text-gray-400': disabled,
}"
/>
<div
class="flex"
:class="{
'text-xs py-0.7': hideTick,
'text-gray-400': disabled,
}"
>
{{ $t(types[type].title) }}
</div>
<div v-if="!hideTick" class="flex flex-grow"></div>
<template v-if="!hideTick">
<GeneralIcon v-if="selectedView?.lock_type === type" icon="check" class="!text-brand-500" />
<GeneralIcon v-if="selectedView?.lock_type === type" icon="check" :class="{ '!text-brand-5s00': !disabled }" />
<span v-else />
</template>
</div>
<div v-if="!hideTick" class="nc-subtitle max-w-120 text-xs text-gray-500 whitespace-normal ml-6">
<div
v-if="!hideTick"
class="nc-subtitle max-w-120 text-xs whitespace-normal ml-6"
:class="{
'text-gray-400': disabled,
'text-gray-500': !disabled,
}"
>
{{ $t(types[type].subtitle) }}
</div>
</div>

30
packages/nc-gui/components/smartsheet/toolbar/NotAllowedTooltip.vue

@ -0,0 +1,30 @@
<script setup lang="ts">
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip'
import type { CSSProperties } from '@vue/runtime-dom'
defineProps<{
tooltipStyle?: CSSProperties
overlayInnerStyle?: CSSProperties
mouseLeaveDelay?: number
placement?: TooltipPlacement
trigger?: 'hover' | 'click'
message?: string
enabled?: boolean
}>()
</script>
<template>
<NcTooltip
:disabled="!enabled"
:tooltip-style="{ 'min-width': 'max-content' }"
:overlay-inner-style="{ 'min-width': 'max-content' }"
:mouse-leave-delay="0.3"
placement="left"
trigger="hover"
>
<template #title>
{{ message }}
</template>
<slot />
</NcTooltip>
</template>

75
packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { TableType, ViewType } from 'nocodb-sdk'
import { ProjectRoles, type TableType, type ViewType, WorkspaceUserRoles } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import { resolveComponent } from '@vue/runtime-core'
import { LockType } from '#imports'
const props = withDefaults(
@ -30,6 +31,8 @@ const table = computed(() => props.table)
const { loadViews, navigateToView, duplicateView } = useViewsStore()
const { user } = useGlobal()
const { base } = storeToRefs(useBase())
const { refreshCommandPalette } = useCommandPalette()
@ -70,9 +73,9 @@ async function changeLockType(type: LockType) {
if (!view.value) return
if (type === 'personal') {
// Coming soon
return message.info(t('msg.toast.futureRelease'))
// if default view block the change since it's not allowed
if (type === 'personal' && view.value.is_default) {
return message.info(t('msg.toast.notAllowedToChangeDefaultView'))
}
try {
view.value.lock_type = type
@ -129,6 +132,27 @@ const onDelete = async () => {
emits('delete')
}
const openReAssignDlg = () => {
const { close } = useDialog(resolveComponent('DlgReAssign'), {
'modelValue': ref(true),
'onUpdate:modelValue': () => {
close()
},
view,
})
emits('closeModal')
}
const isViewOwner = computed(() => {
return (
view.value?.owned_by === user.value?.id ||
(!view.value?.owned_by &&
(user.value.base_roles?.[ProjectRoles.OWNER] || user.value.workspace_roles?.[WorkspaceUserRoles.OWNER]))
)
})
const isDefaultView = computed(() => view.value?.is_default)
/**
* ## Known Issue and Fix
* - **Issue**: When conditionally rendering `NcMenuItem` using `v-if` without a corresponding `v-else` fallback,
@ -274,8 +298,11 @@ const onDelete = async () => {
<template v-if="isUIAllowed('viewCreateOrEdit')">
<NcDivider />
<NcSubMenu key="lock-type" class="scrollbar-thin-dull max-h-90vh overflow-auto !py-0">
<NcSubMenu
key="lock-type"
:disabled="!isViewOwner && !isUIAllowed('reAssignViewOwner') && view.lock_type === LockType.Personal"
class="scrollbar-thin-dull max-h-90vh overflow-auto !py-0"
>
<template #title>
<div
v-e="[
@ -305,11 +332,45 @@ const onDelete = async () => {
<a-menu-item class="!mx-1 !py-2 !rounded-md nc-view-action-lock-subaction max-w-[100px]">
<LazySmartsheetToolbarLockType :type="LockType.Collaborative" @click="changeLockType(LockType.Collaborative)" />
</a-menu-item>
<SmartsheetToolbarNotAllowedTooltip
v-if="isEeUI"
:enabled="!isViewOwner || isDefaultView"
:message="isDefaultView ? 'Default view can\'t be made personal' : 'Only view owner can change to personal view'"
>
<a-menu-item
:disabled="!isViewOwner || isDefaultView"
class="!mx-1 !py-2 !rounded-md nc-view-action-lock-subaction max-w-[100px]"
@click="changeLockType(LockType.Personal)"
>
<LazySmartsheetToolbarLockType :type="LockType.Personal" :disabled="!isViewOwner || isDefaultView" />
</a-menu-item>
</SmartsheetToolbarNotAllowedTooltip>
<a-menu-item class="!mx-1 !py-2 !rounded-md nc-view-action-lock-subaction">
<LazySmartsheetToolbarLockType :type="LockType.Locked" @click="changeLockType(LockType.Locked)" />
</a-menu-item>
</NcSubMenu>
<SmartsheetToolbarNotAllowedTooltip
v-if="isEeUI && !isDefaultView"
:enabled="!(isViewOwner || isUIAllowed('reAssignViewOwner'))"
message="Only owner or creator can re-assign"
>
<NcMenuItem :disabled="!(isViewOwner || isUIAllowed('reAssignViewOwner'))" @click="openReAssignDlg">
<div
v-e="[
'c:navdraw:preview-as',
{
sidebar: props.inSidebar,
},
]"
class="flex flex-row items-center gap-x-3"
>
<div>
{{ $t('labels.reAssignView') }}
</div>
<div class="flex flex-grow"></div>
</div>
</NcMenuItem>
</SmartsheetToolbarNotAllowedTooltip>
</template>
<template v-if="!view.is_default && isUIAllowed('viewCreateOrEdit')">

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

@ -4,7 +4,7 @@ import type { ExtensionManifest, ExtensionType } from '#imports'
const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState(
(extension: Ref<ExtensionType>, extensionManifest: ComputedRef<ExtensionManifest | undefined>, activeError: Ref<any>) => {
const { $api } = useNuxtApp()
const route = useRoute();
const route = useRoute()
const basesStore = useBases()

10
packages/nc-gui/composables/useSmartsheetStore.ts

@ -1,4 +1,4 @@
import { ViewTypes } from 'nocodb-sdk'
import { ViewLockType, ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { SmartsheetStoreEvents } from '#imports'
@ -14,6 +14,8 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
) => {
const { $api } = useNuxtApp()
const { user } = useGlobal()
const { activeView: view, activeNestedFilters, activeSorts } = storeToRefs(useViewsStore())
const baseStore = useBase()
@ -28,7 +30,11 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
const isLocked = computed(() => view.value?.lock_type === 'locked')
const isLocked = computed(
() =>
(view.value?.lock_type === ViewLockType.Personal && user.value?.id !== view.value?.owned_by) ||
view.value?.lock_type === ViewLockType.Locked,
)
const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk))
const isGrid = computed(() => view.value?.type === ViewTypes.GRID)
const isForm = computed(() => view.value?.type === ViewTypes.FORM)

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

@ -799,6 +799,7 @@
"clickToCopyTableID": "Click to copy Table ID",
"clickToCopyViewID": "Click to copy View ID",
"viewMode": "View mode",
"reAssignView": "Re-assign view",
"searchUsers": "Search Users",
"superAdmin": "Super Admin",
"allTables": "All Tables",
@ -1008,6 +1009,7 @@
"redirectToUrl": "Redirect to URL"
},
"activity": {
"assignView": "Assign view",
"webhookDetails": "Webhook Details",
"hideWeekends": "Hide weekends",
"renameBase": "Rename Base",
@ -1576,7 +1578,7 @@
"showAllViews": "Show all shared views of this table",
"collabView": "Collaborators with edit permissions or higher can change the view configuration.",
"lockedView": "No one can edit the view configuration until it is unlocked.",
"personalView": "Only you can edit the view configuration. Other collaborators’ personal views are hidden by default.",
"personalView": "Only you can edit the view configuration. For other collaborators, this view will be in locked state.",
"ownerDesc": "Can add/remove creators. And full edit database structures & fields.",
"creatorDesc": "Can fully edit database structure & values.",
"editorDesc": "Can edit records but cannot change structure of database/fields.",
@ -1806,7 +1808,8 @@
"formEmailSMTP": "Please activate SMTP plugin in App store for enabling email notification",
"collabView": "Successfully Switched to collaborative view",
"lockedView": "Successfully Switched to locked view",
"futureRelease": "Coming soon!"
"futureRelease": "Coming soon!",
"notAllowedToChangeDefaultView": "You are not allowed to change the default view"
},
"success": {
"licenseKeyUpdated": "License Key Updated",

8
packages/nc-gui/lib/enums.ts

@ -1,3 +1,5 @@
import { ViewLockType } from 'nocodb-sdk'
export { ClientType, IntegrationCategoryType, SyncDataType } from 'nocodb-sdk'
export enum Language {
@ -50,11 +52,7 @@ export enum NavigateDir {
PREV,
}
export enum LockType {
Personal = 'personal',
Locked = 'locked',
Collaborative = 'collaborative',
}
export { ViewLockType as LockType }
export enum TabType {
TABLE = 'table',

21
packages/nc-gui/utils/viewUtils.ts

@ -1,6 +1,9 @@
import { ViewTypes } from 'nocodb-sdk'
import { iconMap } from './iconUtils'
import type { Language } from '~/lib/types'
import UsersIcon from '~icons/nc-icons/users'
import LockIcon from '~icons/nc-icons/lock'
import PersonalIcon from '~icons/nc-icons/personal'
export const viewIcons: Record<number | string, { icon: any; color: string }> = {
[ViewTypes.GRID]: { icon: iconMap.grid, color: '#36BFFF' },
@ -43,3 +46,21 @@ export const getViewIcon = (key?: string | number) => {
export function applyNonSelectable() {
document.body.classList.add('non-selectable')
}
export const viewLockIcons = {
[LockType.Personal]: {
title: 'title.personal',
icon: PersonalIcon,
subtitle: 'msg.info.personalView',
},
[LockType.Collaborative]: {
title: 'title.collaborative',
icon: UsersIcon,
subtitle: 'msg.info.collabView',
},
[LockType.Locked]: {
title: 'title.locked',
icon: LockIcon,
subtitle: 'msg.info.lockedView',
},
}

3
packages/noco-docs/docs/090.views/010.views-overview.md

@ -39,4 +39,5 @@ By default, views are set to "Collaborative," allowing members with edit permiss
### Locked Views
With "Locked Views," no one can edit the view configurations until they are unlocked. In this mode, all members are restricted to reading data from the view and are unable to make any changes to its settings or content. This mode is useful when you want to share a view with others but don't want them to make any changes to it.
### Personal Views
With "Personal Views" only the view owner can edit view configurations, while all other members have read-only access. This mode is similar to "Locked Views" but provides exclusive control to the owner. Only the view owner can set a view as "Personal" and they can reassign the view to another user if necessary. The base owner or creator also has the authority to reassign the view to another user and change the view’s lock status, switching it back to "Collaborative" or "Locked" as needed. If no specific owner is assigned(older views), the base owner automatically assumes ownership of the view.

6
packages/nocodb-sdk/src/lib/enums.ts

@ -434,3 +434,9 @@ export enum IntegrationCategoryType {
STORAGE = 'storage',
OTHERS = 'others',
}
export enum ViewLockType {
Personal = 'personal',
Locked = 'locked',
Collaborative = 'collaborative',
}

6
packages/nocodb/src/controllers/meta-diffs.controller.ts

@ -1,10 +1,10 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard';
import { MetaDiffsService } from '~/services/meta-diffs.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { TenantContext } from '~/decorators/tenant-context.decorator';
import { NcContext } from '~/interface/config';
import { NcContext, NcRequest } from '~/interface/config';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@ -32,10 +32,12 @@ export class MetaDiffsController {
@TenantContext() context: NcContext,
@Param('baseId') baseId: string,
@Param('sourceId') sourceId: string,
@Req() req: NcRequest,
) {
return await this.metaDiffsService.baseMetaDiff(context, {
sourceId,
baseId,
user: req.user,
});
}
}

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

@ -3,6 +3,7 @@ import { isVirtualCol, RelationTypes } from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection';
import { isLinksOrLTAR } from 'nocodb-sdk';
import { getUniqueColumnAliasName, getUniqueColumnName } from './getUniqueName';
import type { UserType } from 'nocodb-sdk';
import type { RollupColumn } from '~/models';
import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn';
import type Source from '~/models/Source';
@ -205,9 +206,17 @@ export async function extractAndGenerateManyToManyRelations(
export async function populateMeta(
context: NcContext,
source: Source,
base: Base,
logger?: (message: string) => void,
{
source,
base,
logger,
user,
}: {
source: Source;
base: Base;
logger?: (message: string) => void;
user: UserType;
},
): Promise<any> {
const info = {
type: 'rest',
@ -271,6 +280,8 @@ export async function populateMeta(
};
}
const userId = user?.id;
// await this.syncRelations();
const tableMetasInsert = tables.map((table) => {
@ -347,6 +358,7 @@ export async function populateMeta(
title: table.title,
type: table.type || 'table',
order: table.order,
user_id: userId,
},
);
@ -489,6 +501,7 @@ export async function populateMeta(
// todo: sanitize
type: ModelTypes.VIEW,
order: table.order,
user_id: userId,
},
);

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

@ -52,6 +52,8 @@ import * as nc_062_integration_store from '~/meta/migrations/v2/nc_062_integrati
import * as nc_063_form_field_filter from '~/meta/migrations/v2/nc_063_form_field_filter';
import * as nc_064_pg_minimal_dbs from '~/meta/migrations/v2/nc_064_pg_minimal_dbs';
import * as nc_065_encrypt_flag from '~/meta/migrations/v2/nc_065_encrypt_flag';
import * as nc_066_ai_button from '~/meta/migrations/v2/nc_066_ai_button';
import * as nc_067_personal_view from '~/meta/migrations/v2/nc_067_personal_view';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -115,6 +117,8 @@ export default class XcMigrationSourcev2 {
'nc_063_form_field_filter',
'nc_064_pg_minimal_dbs',
'nc_065_encrypt_flag',
'nc_066_ai_button',
'nc_067_personal_view',
]);
}
@ -232,6 +236,10 @@ export default class XcMigrationSourcev2 {
return nc_064_pg_minimal_dbs;
case 'nc_065_encrypt_flag':
return nc_065_encrypt_flag;
case 'nc_066_ai_button':
return nc_066_ai_button;
case 'nc_067_personal_view':
return nc_067_personal_view;
}
}
}

20
packages/nocodb/src/meta/migrations/v2/nc_066_ai_button.ts

@ -0,0 +1,20 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_BUTTON, (table) => {
table.string('fk_integration_id', 20);
table.string('model', 255);
table.text('output_column_ids');
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_BUTTON, (table) => {
table.dropColumn('fk_integration_id');
table.dropColumn('model');
table.dropColumn('output_column_ids');
});
};
export { up, down };

18
packages/nocodb/src/meta/migrations/v2/nc_067_personal_view.ts

@ -0,0 +1,18 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.VIEWS, (table) => {
table.string('created_by', 20).index();
table.string('owned_by', 20).index();
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.VIEWS, (table) => {
table.dropColumn('created_by');
table.dropColumn('owned_by');
});
};
export { up, down };

5
packages/nocodb/src/models/Model.ts

@ -140,6 +140,7 @@ export default class Model implements TableType {
mm?: BoolType;
type?: ModelTypes;
source_id?: string;
user_id: string;
},
ncMeta = Noco.ncMeta,
) {
@ -199,6 +200,8 @@ export default class Model implements TableType {
type: ViewTypes.GRID,
base_id: baseId,
source_id: sourceId,
created_by: model.user_id,
owned_by: model.user_id,
},
{
getColumns: async () => insertedColumns,
@ -1154,8 +1157,10 @@ export default class Model implements TableType {
context: NcContext,
{
modelId,
userId,
}: {
modelId: string;
userId?: string;
},
ncMeta = Noco.ncMeta,
) {

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

@ -73,6 +73,8 @@ export default class View implements ViewType {
order: number;
type: ViewTypes;
lock_type?: ViewType['lock_type'];
created_by?: string;
owned_by?: string;
fk_model_id: string;
model?: Model;
@ -1261,7 +1263,10 @@ export default class View implements ViewType {
password?: string;
uuid?: string;
meta?: any;
owned_by?: string;
created_by?: string;
},
includeCreatedByAndUpdateBy = false,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(body, [
@ -1273,6 +1278,7 @@ export default class View implements ViewType {
'password',
'meta',
'uuid',
...(includeCreatedByAndUpdateBy ? ['owned_by', 'created_by'] : []),
]);
const oldView = await this.get(context, viewId, ncMeta);
@ -1984,6 +1990,8 @@ export default class View implements ViewType {
copy_from_id?: string;
fk_grp_col_id?: string;
calendar_range?: Partial<CalendarRange>[];
created_by: string;
owned_by: string;
},
model: {
getColumns: (context: NcContext, ncMeta?) => Promise<Column[]>;
@ -2000,6 +2008,9 @@ export default class View implements ViewType {
'base_id',
'source_id',
'meta',
'created_by',
'owned_by',
'lock_type',
]);
if (!insertObj.order) {

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

@ -687,6 +687,7 @@ export class AtImportProcessor {
base_roles: {
owner: true,
},
id: syncDB.user.id,
},
});
recordPerfStats(_perfStart, 'dbView.list');

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

@ -422,6 +422,7 @@ export class ExportService {
filter: view.filter,
sorts: view.sorts,
lock_type: view.lock_type,
owned_by: view.owned_by,
columns: view.columns.map((column) => {
const {
id,

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

@ -1521,6 +1521,7 @@ export class ImportService {
const gview = await this.gridsService.gridViewCreate(context, {
tableId: md.id,
grid: vw as ViewCreateReqType,
ownedBy: vw.owned_by,
req,
});
const gridData = withoutNull(vw.view);
@ -1538,6 +1539,7 @@ export class ImportService {
tableId: md.id,
body: vw as ViewCreateReqType,
user,
ownedBy: vw.owned_by,
req,
});
const formData = withoutNull(vw.view);
@ -1553,6 +1555,7 @@ export class ImportService {
case ViewTypes.CALENDAR: {
return await this.calendarsService.calendarViewCreate(context, {
tableId: md.id,
ownedBy: vw.owned_by,
calendar: {
...vw,
calendar_range: (vw.view as CalendarView).calendar_range.map(
@ -1569,6 +1572,7 @@ export class ImportService {
case ViewTypes.GALLERY: {
const glview = await this.galleriesService.galleryViewCreate(context, {
tableId: md.id,
ownedBy: vw.owned_by,
gallery: vw as ViewCreateReqType,
user,
req,
@ -1593,6 +1597,7 @@ export class ImportService {
case ViewTypes.KANBAN: {
const kview = await this.kanbansService.kanbanViewCreate(context, {
tableId: md.id,
ownedBy: vw.owned_by,
kanban: vw as ViewCreateReqType,
user,
req,

4
packages/nocodb/src/modules/jobs/jobs/source-create/source-create.processor.ts

@ -16,7 +16,7 @@ export class SourceCreateProcessor {
async job(job: Job) {
this.debugLog(`job started for ${job.id}`);
const { context, baseId, source, req } = job.data;
const { context, baseId, source, req, user } = job.data;
const logBasic = (log) => {
this.jobsLogService.sendLog(job, { message: log });
@ -34,7 +34,7 @@ export class SourceCreateProcessor {
if (error) {
await this.sourcesService.baseDelete(context, {
sourceId: createdSource.id,
req: {},
req: { user: user || req.user || {} },
});
throw error;
}

1
packages/nocodb/src/modules/jobs/migration-jobs/nc_job_003_recover_links.ts

@ -15,7 +15,6 @@ import { Column } from '~/models';
export class RecoverLinksMigration {
private readonly debugLog = debug('nc:migration-jobs:recover-links');
log = (...msgs: string[]) => {
console.log('[nc_job_003_recover_links]: ', ...msgs);
};

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

@ -21438,6 +21438,10 @@
"show_system_fields": {
"$ref": "#/components/schemas/Bool",
"description": "Should this view show system fields?"
},
"owned_by": {
"$ref": "#/components/schemas/Id",
"description": "ID of view owner user"
}
}
},

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

@ -26546,6 +26546,10 @@
}
],
"description": "Associated View Model"
},
"owned_by": {
"$ref": "#/components/schemas/Id",
"description": "ID of view owner user"
}
},
"required": [
@ -26848,6 +26852,10 @@
"show_system_fields": {
"$ref": "#/components/schemas/Bool",
"description": "Should this view show system fields?"
},
"owned_by": {
"$ref": "#/components/schemas/Id",
"description": "ID of view owner user"
}
}
},

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

@ -282,7 +282,11 @@ export class BasesService {
// populate metadata if existing table
for (const source of await base.getSources()) {
if (process.env.NC_CLOUD !== 'true' && !base.is_meta) {
const info = await populateMeta(context, source, base);
const info = await populateMeta(context, {
source,
base,
user: param.user,
});
this.appHooksService.emit(AppEvents.APIS_CREATED, {
info,

3
packages/nocodb/src/services/calendars.service.ts

@ -28,6 +28,7 @@ export class CalendarsService {
calendar: ViewCreateReqType;
user: UserType;
req: NcRequest;
ownedBy?: string;
},
) {
validatePayload(
@ -45,6 +46,8 @@ export class CalendarsService {
type: ViewTypes.CALENDAR,
base_id: model.base_id,
source_id: model.source_id,
created_by: param.user.id,
owned_by: param.ownedBy || param.user.id,
},
model,
);

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

@ -3067,6 +3067,7 @@ export class ColumnsService {
base: Base;
reuse?: ReusableParams;
colExtra?: any;
user: UserType;
},
) {
validateParams(['parentId', 'childId', 'type'], param.column);
@ -3369,6 +3370,7 @@ export class ColumnsService {
// todo: sanitize
mm: true,
columns: associateTableCols,
user_id: param.user.id,
},
);

3
packages/nocodb/src/services/forms.service.ts

@ -29,6 +29,7 @@ export class FormsService {
body: ViewCreateReqType;
user: UserType;
req: NcRequest;
ownedBy?: string;
},
) {
validatePayload(
@ -53,6 +54,8 @@ export class FormsService {
type: ViewTypes.FORM,
base_id: model.base_id,
source_id: model.source_id,
created_by: param.user.id,
owned_by: param.ownedBy || param.user.id,
},
model,
);

4
packages/nocodb/src/services/galleries.service.ts

@ -27,7 +27,7 @@ export class GalleriesService {
tableId: string;
gallery: ViewCreateReqType;
user: UserType;
ownedBy?: string;
req: NcRequest;
},
) {
@ -47,6 +47,8 @@ export class GalleriesService {
type: ViewTypes.GALLERY,
base_id: model.base_id,
source_id: model.source_id,
created_by: param.user.id,
owned_by: param.ownedBy || param.user.id,
},
model,
);

3
packages/nocodb/src/services/grids.service.ts

@ -19,6 +19,7 @@ export class GridsService {
tableId: string;
grid: ViewCreateReqType;
req: NcRequest;
ownedBy?: string;
},
) {
validatePayload(
@ -37,6 +38,8 @@ export class GridsService {
type: ViewTypes.GRID,
base_id: model.base_id,
source_id: model.source_id,
created_by: param.req.user?.id,
owned_by: param.ownedBy || param.req.user?.id,
},
model,
);

3
packages/nocodb/src/services/kanbans.service.ts

@ -28,6 +28,7 @@ export class KanbansService {
kanban: ViewCreateReqType;
user: UserType;
req: NcRequest;
ownedBy?: string;
},
) {
validatePayload(
@ -46,6 +47,8 @@ export class KanbansService {
type: ViewTypes.KANBAN,
base_id: model.base_id,
source_id: model.source_id,
created_by: param.user?.id,
owned_by: param.ownedBy || param.user?.id,
},
model,
);

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

@ -42,6 +42,8 @@ export class MapsService {
type: ViewTypes.MAP,
base_id: model.base_id,
source_id: model.source_id,
created_by: param.user.id,
owned_by: param.user.id,
},
model,
);

26
packages/nocodb/src/services/meta-diffs.service.ts

@ -8,6 +8,7 @@ import {
UITypes,
} from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection';
import type { UserType } from 'nocodb-sdk';
import type { LinksColumn, LinkToAnotherRecordColumn } from '~/models';
import type { NcContext } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
@ -647,7 +648,7 @@ export class MetaDiffsService {
async baseMetaDiff(
context: NcContext,
param: { baseId: string; sourceId: string },
param: { baseId: string; sourceId: string; user: UserType },
) {
const base = await Base.getWithInfo(context, param.baseId);
const source = await Source.get(context, param.sourceId);
@ -662,9 +663,17 @@ export class MetaDiffsService {
async syncBaseMeta(
context: NcContext,
base: Base,
source: Source,
{
base,
source,
throwOnFail = false,
user,
}: {
base: Base;
source: Source;
throwOnFail?: boolean;
user: UserType;
},
) {
if (source.is_meta) {
if (throwOnFail) NcError.badRequest('Cannot sync meta source');
@ -711,6 +720,7 @@ export class MetaDiffsService {
source,
),
type: ModelTypes.TABLE,
user_id: user.id,
});
for (const column of columns) {
@ -738,6 +748,7 @@ export class MetaDiffsService {
table_name: table_name,
title: getTableNameAlias(table_name, base.prefix, source),
type: ModelTypes.VIEW,
user_id: user.id,
});
for (const column of columns) {
@ -909,7 +920,7 @@ export class MetaDiffsService {
async metaDiffSync(context: NcContext, param: { baseId: string; req: any }) {
const base = await Base.getWithInfo(context, param.baseId);
for (const source of base.sources) {
await this.syncBaseMeta(context, base, source);
await this.syncBaseMeta(context, { base, source, user: param.req.user });
}
this.appHooksService.emit(AppEvents.META_DIFF_SYNC, {
@ -931,7 +942,12 @@ export class MetaDiffsService {
const base = await Base.getWithInfo(context, param.baseId);
const source = await Source.get(context, param.sourceId);
await this.syncBaseMeta(context, base, source, true);
await this.syncBaseMeta(context, {
base,
source,
throwOnFail: true,
user: param.req.user,
});
this.appHooksService.emit(AppEvents.META_DIFF_SYNC, {
base,

7
packages/nocodb/src/services/sources.service.ts

@ -173,7 +173,12 @@ export class SourcesService {
param.logger?.('Populating meta');
const info = await populateMeta(context, source, base, param.logger);
const info = await populateMeta(context, {
source,
base,
logger: param.logger,
user: param.req.user,
});
await populateRollupColumnAndHideLTAR(context, source, base);

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

@ -10,7 +10,9 @@ import {
ProjectRoles,
RelationTypes,
UITypes,
ViewLockType,
} from 'nocodb-sdk';
import { LockType } from 'nc-gui/lib/enums';
import { MetaDiffsService } from './meta-diffs.service';
import { ColumnsService } from './columns.service';
import type {
@ -353,9 +355,9 @@ export class TablesService {
);
//await View.list(param.tableId)
table.views = viewList.filter((table: any) => {
table.views = viewList.filter((view: any) => {
return Object.keys(param.user?.roles).some(
(role) => param.user?.roles[role] && !table.disabled[role],
(role) => param.user?.roles[role] && !view.disabled[role],
);
});

88
packages/nocodb/src/services/views.service.ts

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AppEvents, ProjectRoles } from 'nocodb-sdk';
import { AppEvents, ProjectRoles, ViewLockType } from 'nocodb-sdk';
import type {
SharedViewReqType,
UserType,
@ -9,7 +9,7 @@ import type { NcContext, NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { validatePayload } from '~/helpers';
import { NcError } from '~/helpers/catchError';
import { Model, ModelRoleVisibility, View } from '~/models';
import { BaseUser, Model, ModelRoleVisibility, View } from '~/models';
// todo: move
async function xcVisibilityMetaGet(
@ -77,6 +77,7 @@ export class ViewsService {
user: {
roles?: Record<string, boolean> | string;
base_roles?: Record<string, boolean>;
id: string;
};
},
) {
@ -94,6 +95,14 @@ export class ViewsService {
// todo: user roles
//await View.list(param.tableId)
const filteredViewList = viewList.filter((view: any) => {
// if (
// view.lock_type === ViewLockType.Personal &&
// view.owned_by !== param.user.id &&
// !(!view.owned_by && !param.user.base_roles?.[ProjectRoles.OWNER])
// ) {
// return false;
// }
return Object.values(ProjectRoles).some(
(role) => param?.user?.['base_roles']?.[role] && !view.disabled[role],
);
@ -136,18 +145,83 @@ export class ViewsService {
'swagger.json#/components/schemas/ViewUpdateReq',
param.view,
);
const oldView = await View.get(context, param.viewId);
const view = await View.get(context, param.viewId);
if (!view) {
if (!oldView) {
NcError.viewNotFound(param.viewId);
}
const result = await View.update(context, param.viewId, param.view);
let ownedBy = oldView.owned_by;
let createdBy = oldView.created_by;
let includeCreatedByAndUpdateBy = false;
// check if the lock_type changing to `personal` and only allow if user is the owner
// if the owned_by is not the same as the user, then throw error
// if owned_by is empty, then only allow owner of project to change
if (
param.view.lock_type === 'personal' &&
param.view.lock_type !== oldView.lock_type
) {
// if owned_by is not empty then check if the user is the owner of the project
if (ownedBy && ownedBy !== param.user.id) {
NcError.unauthorized('Only owner/creator can change to personal view');
}
// if empty then check if current user is the owner of the project then allow and update the owned_by
if (!ownedBy && (param.user as any).base_roles?.[ProjectRoles.OWNER]) {
includeCreatedByAndUpdateBy = true;
ownedBy = param.user.id;
if (!createdBy) {
createdBy = param.user.id;
}
} else if (!ownedBy) {
// todo: move to catchError
NcError.unauthorized('Only owner can change to personal view');
}
}
// handle view ownership transfer
if (ownedBy && param.view.owned_by && ownedBy !== param.view.owned_by) {
// extract user roles and allow creator and owner to change to personal view
if (
param.user.id !== ownedBy &&
!(param.user as any).base_roles?.[ProjectRoles.OWNER] &&
!(param.user as any).base_roles?.[ProjectRoles.CREATOR]
) {
NcError.unauthorized('Only owner/creator can transfer view ownership');
}
ownedBy = param.view.owned_by;
// verify if the new owned_by is a valid user who have access to the base/workspace
// if not then throw error
const baseUser = await BaseUser.get(
context,
context.base_id,
param.view.owned_by,
);
if (!baseUser) {
NcError.badRequest('Invalid user');
}
includeCreatedByAndUpdateBy = true;
}
const result = await View.update(
context,
param.viewId,
{
...param.view,
owned_by: ownedBy,
created_by: createdBy,
},
includeCreatedByAndUpdateBy,
);
this.appHooksService.emit(AppEvents.VIEW_UPDATE, {
view: {
...view,
...oldView,
...param.view,
},
user: param.user,

8
tests/playwright/pages/Dashboard/Grid/columnHeader.ts

@ -36,6 +36,14 @@ export class ColumnHeaderPageObject extends BasePage {
expect(await this.get().locator('.nc-ui-dt-dropdown').count()).toBe(0);
}
async verifyPersonalMode() {
// add column button
await expect(this.btn_addColumn).toBeVisible({ visible: false });
// column header context menu
expect(await this.get().locator('.nc-ui-dt-dropdown').count()).toBe(0);
}
async verifyCollaborativeMode() {
// add column button
await expect(this.btn_addColumn).toBeVisible({ visible: true });

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

@ -77,6 +77,16 @@ export class GridPage extends BasePage {
await this.columnHeader.verifyCollaborativeMode();
}
async verifyPersonalMode() {
// add new row button
expect(await this.btn_addNewRow.count()).toBe(1);
// the behaviour is same as lock mode
await this.toolbar.verifyPersonalMode();
await this.footbar.verifyPersonalMode();
await this.columnHeader.verifyPersonalMode();
}
get() {
return this.dashboard.get().locator('[data-testid="nc-grid-wrapper"]');
}

5
tests/playwright/pages/Dashboard/common/Footbar/index.ts

@ -49,6 +49,11 @@ export class FootbarPage extends BasePage {
await expect(this.btn_addNewRow).toBeVisible({ visible: true });
}
async verifyPersonalMode() {
// add record button
await expect(this.btn_addNewRow).toBeVisible({ visible: true });
}
async verifyCollaborativeMode() {
// add record button
await expect(this.btn_addNewRow).toBeVisible({ visible: true });

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

@ -256,6 +256,14 @@ export class ToolbarPage extends BasePage {
await expect(this.btn_rowHeight).toBeDisabled();
}
async verifyPersonalMode() {
await expect(this.btn_fields).toBeDisabled();
await expect(this.btn_filter).toBeDisabled();
await expect(this.btn_sort).toBeDisabled();
await expect(this.btn_groupBy).toBeDisabled();
await expect(this.btn_rowHeight).toBeDisabled();
}
async verifyCollaborativeMode() {
await expect(this.btn_fields).toBeEnabled();
await expect(this.btn_filter).toBeEnabled();

Loading…
Cancel
Save