Browse Source

Merge pull request #7276 from nocodb/feat/sort-for-user-management

🔦 Feature: Sort for user management
pull/7341/head
Ramesh Mane 9 months ago committed by GitHub
parent
commit
3ad21a142c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 43
      packages/nc-gui/components/account/UserList.vue
  2. 79
      packages/nc-gui/components/account/UserMenu.vue
  3. 59
      packages/nc-gui/components/project/AccessSettings.vue
  4. 177
      packages/nc-gui/composables/useUserSorts.ts
  5. 1
      packages/nc-gui/lang/en.json
  6. 6
      packages/nc-gui/lib/types.ts

43
packages/nc-gui/components/account/UserList.vue

@ -2,7 +2,16 @@
import { OrgUserRoles } from 'nocodb-sdk'
import type { OrgUserReqType, RequestParams, UserType } from 'nocodb-sdk'
import type { User } from '#imports'
import { extractSdkResponseErrorMsg, iconMap, useApi, useCopy, useDashboard, useDebounceFn, useNuxtApp } from '#imports'
import {
extractSdkResponseErrorMsg,
iconMap,
useApi,
useCopy,
useDashboard,
useDebounceFn,
useNuxtApp,
useUserSorts,
} from '#imports'
const { api, isLoading } = useApi()
@ -19,8 +28,14 @@ const { user: loggedInUser } = useGlobal()
const { copy } = useCopy()
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Org')
const users = ref<UserType[]>([])
const sortedUsers = computed(() => {
return handleGetSortedData(users.value, sorts.value) as UserType[]
})
const currentPage = ref(1)
const currentLimit = ref(10)
@ -64,6 +79,7 @@ const loadUsers = useDebounceFn(async (page = currentPage.value, limit = current
onMounted(() => {
loadUsers()
loadSorts()
})
const updateRole = async (userId: string, roles: string) => {
@ -73,6 +89,12 @@ const updateRole = async (userId: string, roles: string) => {
} as OrgUserReqType)
message.success(t('msg.success.roleUpdated'))
users.value.forEach((user) => {
if (user.id === userId) {
user.roles = roles
}
})
$e('a:org-user:role-updated', { role: roles })
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -176,10 +198,21 @@ const openDeleteModal = (user: UserType) => {
</div>
<div class="w-full rounded-md max-w-250 h-[calc(100%-12rem)] rounded-md overflow-hidden mt-5">
<div class="flex w-full bg-gray-50 border-1 rounded-t-md">
<div class="py-3.5 text-gray-500 font-medium text-3.5 w-2/3 text-start pl-6" data-rec="true">
{{ $t('labels.email') }}
<div
class="py-3.5 text-gray-500 font-medium text-3.5 w-2/3 text-start pl-6 flex items-center space-x-2"
data-rec="true"
>
<span>
{{ $t('labels.email') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" />
</div>
<div class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start flex items-center space-x-2" data-rec="true">
<span>
{{ $t('objects.role') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
<div class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start" data-rec="true">{{ $t('objects.role') }}</div>
<div class="flex py-3.5 text-gray-500 font-medium text-3.5 w-28 justify-end mr-4" data-rec="true">
{{ $t('labels.action') }}
</div>
@ -193,7 +226,7 @@ const openDeleteModal = (user: UserType) => {
</div>
<section v-else class="tbody h-[calc(100%-4rem)] nc-scrollbar-md border-t-0 !overflow-auto">
<div
v-for="el of users"
v-for="el of sortedUsers"
:key="el.id"
data-testid="nc-token-list"
class="user flex py-3 justify-around px-1 border-b-1 border-l-1 border-r-1"

79
packages/nc-gui/components/account/UserMenu.vue

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { iconMap } from '#imports'
import type { UsersSortType } from '~/lib'
const { field, direction, handleUserSort } = defineProps<{
field: UsersSortType['field']
direction: UsersSortType['direction']
handleUserSort: Function
}>()
const isOpen = ref(false)
const sortUserBy = (direction?: UsersSortType['direction']) => {
handleUserSort({
field,
direction,
})
isOpen.value = false
}
</script>
<template>
<a-dropdown
v-model:visible="isOpen"
:trigger="['click']"
placement="bottomLeft"
overlay-class-name="nc-user-menu-column-operations !border-1 rounded-lg !shadow-xl"
@click.stop="isOpen = !isOpen"
>
<div>
<GeneralIcon
:icon="direction === 'asc' || direction === 'desc' ? 'sortDesc' : 'arrowDown'"
class="text-grey h-full text-grey nc-user-menu-trigger cursor-pointer outline-0 mr-2 transition-none"
:style="{ transform: direction === 'asc' ? 'rotate(180deg)' : undefined }"
/>
</div>
<template #overlay>
<NcMenu class="flex flex-col gap-1 border-gray-200 nc-user-menu-column-options">
<NcMenuItem @click="sortUserBy('asc')">
<div class="nc-column-insert-after nc-user-menu-item">
<component
:is="iconMap.sortDesc"
class="text-gray-700 !rotate-180 !w-4.25 !h-4.25"
:style="{
transform: 'rotate(180deg)',
}"
/>
<!-- Sort Ascending -->
{{ $t('general.sortAsc') }}
</div>
</NcMenuItem>
<NcMenuItem @click="sortUserBy('desc')">
<div class="nc-column-insert-before nc-user-menu-item">
<component :is="iconMap.sortDesc" class="text-gray-700 !w-4.25 !h-4.25 ml-0.5 mr-0.25" />
<!-- Sort Descending -->
{{ $t('general.sortDesc') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</a-dropdown>
</template>
<style scoped>
.nc-user-menu-item {
@apply flex items-center gap-2;
}
.nc-user-menu-column-options {
.nc-icons {
@apply !w-5 !h-5;
}
}
:deep(.ant-dropdown-menu-item) {
@apply !hover:text-black text-gray-700;
}
</style>

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

@ -8,8 +8,8 @@ import {
parseStringDateTime,
timeAgo,
} from 'nocodb-sdk'
import type { WorkspaceUserRoles } from 'nocodb-sdk'
import { isEeUI, storeToRefs } from '#imports'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const basesStore = useBases()
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
@ -17,6 +17,8 @@ const { activeProjectId } = storeToRefs(basesStore)
const { orgRoles, baseRoles } = useRoles()
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Project')
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
interface Collaborators {
@ -24,6 +26,7 @@ interface Collaborators {
email: string
main_roles: OrgUserRoles
roles: ProjectRoles
base_roles: Roles
workspace_roles: WorkspaceUserRoles
created_at: string
}
@ -35,6 +38,14 @@ const isLoading = ref(false)
const isSearching = ref(false)
const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>([])
const filteredCollaborators = computed(() =>
collaborators.value.filter((collab) => collab.email.toLowerCase().includes(userSearchText.value.toLowerCase())),
)
const sortedCollaborators = computed(() => {
return handleGetSortedData(filteredCollaborators.value, sorts.value)
})
const loadCollaborators = async () => {
try {
const { users, totalRows } = await getBaseUsers({
@ -64,28 +75,32 @@ const loadCollaborators = async () => {
}
const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
const currentCollaborator = collaborators.value.find((coll) => coll.id === collab.id)!
try {
if (
!roles ||
(roles === ProjectRoles.NO_ACCESS && !isEeUI) ||
(collab.workspace_roles && WorkspaceRolesToProjectRoles[collab.workspace_roles as WorkspaceUserRoles] === roles && isEeUI)
(currentCollaborator.workspace_roles &&
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI)
) {
await removeProjectUser(activeProjectId.value!, collab)
if (
collab.workspace_roles &&
WorkspaceRolesToProjectRoles[collab.workspace_roles as WorkspaceUserRoles] === roles &&
currentCollaborator.workspace_roles &&
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI
) {
collab.roles = WorkspaceRolesToProjectRoles[collab.workspace_roles as WorkspaceUserRoles]
currentCollaborator.roles = WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles]
} else {
collab.roles = ProjectRoles.NO_ACCESS
currentCollaborator.roles = ProjectRoles.NO_ACCESS
}
} else if (collab.base_roles) {
collab.roles = roles
} else if (currentCollaborator.base_roles) {
currentCollaborator.roles = roles
await updateProjectUser(activeProjectId.value!, collab)
} else {
collab.roles = roles
collab.base_roles = roles
currentCollaborator.roles = roles
currentCollaborator.base_roles = roles
await createProjectUser(activeProjectId.value!, collab)
}
} catch (e: any) {
@ -106,16 +121,13 @@ onMounted(async () => {
} else if (currentRoleIndex !== -1) {
accessibleRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1)
}
loadSorts()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
}
})
const filteredCollaborators = computed(() =>
collaborators.value.filter((collab) => collab.email.toLowerCase().includes(userSearchText.value.toLowerCase())),
)
</script>
<template>
@ -145,14 +157,25 @@ const filteredCollaborators = computed(() =>
<div v-else class="nc-collaborators-list mt-6 h-full">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]">
<div class="flex flex-row bg-gray-50 min-h-12 items-center border-b-1">
<div class="text-gray-700 users-email-grid">{{ $t('objects.users') }}</div>
<div class="text-gray-700 user-access-grid">{{ $t('general.access') }}</div>
<div class="text-gray-700 users-email-grid flex items-center space-x-2">
<span>
{{ $t('objects.users') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" />
</div>
<div class="text-gray-700 user-access-grid flex items-center space-x-2">
<span>
{{ $t('general.access') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of filteredCollaborators"
v-for="(collab, i) of sortedCollaborators"
:key="i"
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center"
>

177
packages/nc-gui/composables/useUserSorts.ts

@ -0,0 +1,177 @@
import rfdc from 'rfdc'
import { OrgUserRoles, ProjectRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import type { UsersSortType } from '~/lib'
import { useGlobal } from '#imports'
/**
* Hook for managing user sorts and sort configurations.
*
* @param {string} roleType - The type of role for which user sorts are managed ('Workspace', 'Org', or 'Project').
* @returns {object} An object containing reactive values and functions related to user sorts.
*/
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
const clone = rfdc()
const { user } = useGlobal()
const sorts = ref<UsersSortType>({})
// Key for storing user sort configurations in local storage
const userSortConfigKey = 'userSortConfig'
// Default user ID if no user found (fallback)
const defaultUserId = 'default'
/**
* Computed property that returns a record of sort directions based on the current sort configurations.
* @type {ComputedRef<Record<string, UsersSortType['direction']>>}
*/
const sortDirection: ComputedRef<Record<string, UsersSortType['direction']>> = computed(() => {
if (sorts.value.field) {
return { [sorts.value.field]: sorts.value.direction } as Record<string, UsersSortType['direction']>
}
return {} as Record<string, UsersSortType['direction']>
})
/**
* Loads user sort configurations from local storage based on the current user ID.
*/
function loadSorts(): void {
try {
// Retrieve sort configuration from local storage
const storedConfig = localStorage.getItem(userSortConfigKey)
const sortConfig = storedConfig ? JSON.parse(storedConfig) : ({} as Record<string, UsersSortType>)
if (sortConfig && isValidSortConfig(sortConfig)) {
// Load user-specific sort configurations or default configurations
sorts.value = user.value?.id ? sortConfig[user.value.id] || {} : sortConfig[defaultUserId] || {}
} else {
throw new Error('Invalid sort config stored in local storage')
}
} catch (error) {
console.error(error)
// remove sortConfig from localStorage in case of error
localStorage.removeItem(userSortConfigKey)
// Set sorts to an empty obj in case of an error
sorts.value = {}
}
}
/**
* Saves or updates a user sort configuration and updates local storage.
* @param {UsersSortType} newSortConfig - The new sort configuration to save or update.
*/
function saveOrUpdate(newSortConfig: UsersSortType): void {
try {
if (newSortConfig.field && newSortConfig.direction) {
sorts.value = { ...newSortConfig }
} else {
sorts.value = {}
}
// Update local storage with the new sort configurations
const storedConfig = localStorage.getItem(userSortConfigKey)
const sortConfig = storedConfig ? JSON.parse(storedConfig) : {}
if (user.value?.id) {
// Save or delete user-specific sort configurations
if (sorts.value.field) {
sortConfig[user.value.id] = sorts.value
} else {
delete sortConfig[user.value.id]
}
} else {
// Save or delete default user sort configurations
sortConfig[defaultUserId] = sorts.value
}
localStorage.setItem(userSortConfigKey, JSON.stringify(sortConfig))
} catch (error) {
console.error('Error while saving sort configuration into local storage:', error)
}
}
/**
* Sorts and returns a deep copy of an array of objects based on the provided sort configurations.
*
* @param data - The array of objects to be sorted.
* @param sortsConfig - The object of sort configurations.
* @returns A new array containing sorted objects.
* @template T - The type of objects in the input array.
*/
function handleGetSortedData<T extends Record<string, any>>(data: T[], sortsConfig: UsersSortType = sorts.value): T[] {
let userRoleOrder: string[] = []
if (roleType === 'Workspace') {
userRoleOrder = Object.values(WorkspaceUserRoles)
} else if (roleType === 'Org') {
userRoleOrder = Object.values(OrgUserRoles)
} else if (roleType === 'Project') {
userRoleOrder = Object.values(ProjectRoles)
}
data = clone(data)
const superUserIndex = data.findIndex((user) => user?.roles?.includes('super'))
const superUser = sortsConfig.field === 'roles' && superUserIndex !== -1 ? data.splice(superUserIndex, 1) : null
let sortedData = data.sort((a, b) => {
switch (sortsConfig.field) {
case 'roles': {
const roleA = a?.roles?.split(',')[0]
const roleB = b?.roles?.split(',')[0]
if (sortsConfig.direction === 'asc') {
return userRoleOrder.indexOf(roleA) - userRoleOrder.indexOf(roleB)
} else {
return userRoleOrder.indexOf(roleB) - userRoleOrder.indexOf(roleA)
}
}
case 'email': {
if (sortsConfig.direction === 'asc') {
return a[sortsConfig.field]?.localeCompare(b[sortsConfig.field])
} else {
return b[sortsConfig.field]?.localeCompare(a[sortsConfig.field])
}
}
}
return 0
})
if (superUser && superUser.length) {
if (sortsConfig.direction === 'desc') {
sortedData = [...sortedData, superUser[0]]
} else {
sortedData = [superUser[0], ...sortedData]
}
}
return sortedData
}
/**
* Checks if the provided sort configuration has the expected structure.
* @param sortConfig - The sort configuration to validate.
* @param expectedStructure - The expected structure for the sort configuration.
* Defaults to { field: 'email', direction: 'asc' }.
* @returns `true` if the sort configuration is valid, otherwise `false`.
*/
function isValidSortConfig(
sortConfig: Record<string, any>,
expectedStructure: UsersSortType = { field: 'email', direction: 'asc' },
): boolean {
// Check if the sortConfig has the expected keys
for (const key in sortConfig) {
const isValidConfig = Object.keys(sortConfig[key]).every((key) =>
Object.prototype.hasOwnProperty.call(expectedStructure, key),
)
if (!isValidConfig) return false
}
return true
}
return { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData }
}

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

@ -1212,7 +1212,6 @@
"thankYou": "Thank you!",
"submittedFormData": "You have successfully submitted the form data.",
"editingSystemKeyNotSupported": "Editing system key not supported"
},
"error": {
"nameRequired": "Name Required",

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

@ -177,6 +177,11 @@ interface SidebarTableNode extends TableType {
isViewsLoading?: boolean
}
interface UsersSortType {
field?: 'email' | 'roles'
direction?: 'asc' | 'desc'
}
export type {
User,
ProjectMetaInfo,
@ -202,4 +207,5 @@ export type {
ViewPageType,
NcButtonSize,
SidebarTableNode,
UsersSortType,
}

Loading…
Cancel
Save