Browse Source

Nc fix: replace ant design table (#9113)

* fix: replace ant design table

* feat(nc-gui): custom table component

* fix(nc-gui): udpate UIAcl table

* fix(nc-gui): table scrolling issue

* feat(nc-gui): sticky first column of custom table component

* fix(nc-gui): update meta sync ant table with new table

* fix(nc-gui): update import data table

* fix(nc-gui): update import & upload data modal table

* chore(nc-gui): lint

* fix(nc-gui): update all table tab table

* fix(nc-gui): update project members table

* fix(nc-gui): update collaborators list table

* fix(nc-gui): table list search section alignment issue

* fix(nc-gui): collaborators list overflow issue

* fix(nc-gui): small changes

* fix(nc-gui): update project home page tables height

* fix(nc-gui): update oss user table

* fix(nc-gui): small changes

* test(nc-gui): update ant table related test cases

* test(nc-gui): update oss user list test cases

* chore(nc-gui): lint

* chore(nc-gui): cleanup unused css

* fix(nc-gui): add missing invite team image state

* fix(nc-gui): user management test fail issue

* fix(test): oss user management test fails issue

* fix(nc-gui): some pr review changes

* fix(nc-gui): handle empty object entries destructuring case

* fix(nc-gui): pr review changes

* fix(nc-gui): disable ui acl header checkbox is list is empty

* fix(nc-gui): update oss user management pw test

---------

Co-authored-by: Pranav C <pranavxc@gmail.com>
pull/9139/head
Ramesh Mane 4 months ago committed by GitHub
parent
commit
388b9d9590
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 294
      packages/nc-gui/components/account/UserList.vue
  2. 83
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  3. 208
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  4. 2
      packages/nc-gui/components/general/MaintenanceAlert.vue
  5. 387
      packages/nc-gui/components/nc/Table.vue
  6. 299
      packages/nc-gui/components/project/AccessSettings.vue
  7. 124
      packages/nc-gui/components/project/AllTables.vue
  8. 324
      packages/nc-gui/components/template/Editor.vue
  9. 38
      packages/nc-gui/components/template/utils.ts
  10. 2
      packages/nc-gui/components/workspace/AuditLogs.vue
  11. 332
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  12. 26
      packages/nc-gui/lib/types.ts
  13. 7
      tests/playwright/pages/Account/Users.ts
  14. 2
      tests/playwright/pages/Dashboard/Import/ImportTemplate.ts
  15. 4
      tests/playwright/pages/Dashboard/ProjectView/Metadata.ts

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

@ -17,7 +17,7 @@ const { user: loggedInUser } = useGlobal()
const { copy } = useCopy()
const { sorts, loadSorts, handleGetSortedData, toggleSort } = useUserSorts('Org')
const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: saveOrUpdateUserSort } = useUserSorts('Org')
const users = ref<UserType[]>([])
@ -163,10 +163,55 @@ const openDeleteModal = (user: UserType) => {
deleteModalInfo.value = user
isOpen.value = true
}
const orderBy = computed<Record<string, SordDirectionType>>({
get: () => {
return sortDirection.value
},
set: (value: Record<string, SordDirectionType>) => {
// Check if value is an empty object
if (Object.keys(value).length === 0) {
saveOrUpdateUserSort({})
return
}
const [field, direction] = Object.entries(value)[0]
saveOrUpdateUserSort({
field,
direction,
})
},
})
const columns = [
{
key: 'email',
title: t('objects.users'),
minWidth: 220,
dataIndex: 'email',
showOrderBy: true,
},
{
key: 'role',
title: t('general.access'),
basis: '30%',
minWidth: 272,
dataIndex: 'roles',
showOrderBy: true,
},
{
key: 'action',
title: t('labels.actions'),
width: 110,
minWidth: 110,
justify: 'justify-end',
},
] as NcTableColumnProps[]
</script>
<template>
<div data-testid="nc-super-user-list" class="h-full">
<div data-testid="nc-super-user-list" class="h-full px-6">
<div class="max-w-195 mx-auto h-full">
<div class="text-2xl text-left font-weight-bold mb-4" data-rec="true">{{ $t('title.userMgmt') }}</div>
<div class="py-2 flex gap-4 items-center justify-between">
@ -190,159 +235,134 @@ const openDeleteModal = (user: UserType) => {
</NcButton>
</div>
</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">
<LazyAccountHeaderWithSorter
class="py-3.5 text-gray-500 font-medium text-3.5 w-2/3 text-start pl-6"
:header="$t('objects.users')"
:active-sort="sorts"
field="email"
:toggle-sort="toggleSort"
/>
<LazyAccountHeaderWithSorter
class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start"
:header="$t('general.access')"
:active-sort="sorts"
field="roles"
:toggle-sort="toggleSort"
/>
<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>
</div>
<div v-if="isLoading" class="flex items-center justify-center text-center h-[513px]">
<GeneralLoader size="xlarge" />
</div>
<!-- if users are empty -->
<div v-else-if="!users.length" class="flex items-center justify-center text-center h-full">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</div>
<section v-else class="tbody h-[calc(100%-4rem)] nc-scrollbar-md border-t-0 !overflow-auto">
<div
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"
:class="{
'py-4': el.roles?.includes('super'),
}"
>
<div class="text-3.5 text-start w-2/3 pl-5 flex items-center">
<NcTooltip v-if="el.display_name">
<template #title>
{{ el.email }}
</template>
<GeneralTruncateText :length="29">
{{ el.display_name }}
</GeneralTruncateText>
</NcTooltip>
<GeneralTruncateText v-else :length="29">
<NcTable
v-model:order-by="orderBy"
:columns="columns"
:data="sortedUsers"
:is-data-loading="isLoading"
class="h-[calc(100%-140px)] max-w-250 mt-4"
>
<template #bodyCell="{ column, record: el }">
<div v-if="column.key === 'email'" class="w-full">
<NcTooltip v-if="el.display_name" class="truncate max-w-full">
<template #title>
{{ el.email }}
</GeneralTruncateText>
</template>
{{ el.display_name }}
</NcTooltip>
<NcTooltip v-else class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ el.email }}
</template>
{{ el.email }}
</NcTooltip>
</div>
<template v-if="column.key === 'role'">
<div v-if="el?.roles?.includes('super')" class="font-weight-bold" data-rec="true">
{{ $t('labels.superAdmin') }}
</div>
<div class="text-3.5 text-start w-1/3">
<div v-if="el?.roles?.includes('super')" class="font-weight-bold" data-rec="true">
{{ $t('labels.superAdmin') }}
</div>
<NcSelect
v-else-if="el.id !== loggedInUser?.id"
v-model:value="el.roles"
class="w-55 nc-user-roles"
:dropdown-match-select-width="false"
dropdown-class-name="max-w-64"
@change="updateRole(el.id, el.roles as string)"
<NcSelect
v-else-if="el.id !== loggedInUser?.id"
v-model:value="el.roles"
class="w-55 nc-user-roles"
:dropdown-match-select-width="false"
dropdown-class-name="max-w-64"
@change="updateRole(el.id, el.roles as string)"
>
<a-select-option
class="nc-users-list-role-option"
:value="OrgUserRoles.CREATOR"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<a-select-option
class="nc-users-list-role-option"
:value="OrgUserRoles.CREATOR"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div class="w-full">
<div class="flex items-center gap-1 justify-between">
<div data-rec="true">{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<GeneralIcon
v-if="el?.roles === OrgUserRoles.CREATOR"
id="nc-selected-item-icon"
icon="check"
class="w-4 h-4 text-primary"
class="flex-none w-4 h-4 text-primary"
/>
</div>
<div class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgCreator') }}
</div>
</a-select-option>
</div>
</a-select-option>
<a-select-option
class="nc-users-list-role-option"
:value="OrgUserRoles.VIEWER"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<a-select-option
class="nc-users-list-role-option"
:value="OrgUserRoles.VIEWER"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div class="w-full">
<div class="flex items-center gap-1 justify-between">
<div data-rec="true">{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<GeneralIcon
v-if="el.roles === OrgUserRoles.VIEWER"
id="nc-selected-item-icon"
icon="check"
class="w-4 h-4 text-primary"
class="flex-none w-4 h-4 text-primary"
/>
</div>
<div class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgViewer') }}
</div>
</a-select-option>
</NcSelect>
<div v-else class="font-weight-bold" data-rec="true">
{{ $t(`objects.roleType.orgLevelCreator`) }}
</div>
</div>
</a-select-option>
</NcSelect>
<div v-else class="font-weight-bold" data-rec="true">
{{ $t(`objects.roleType.orgLevelCreator`) }}
</div>
<span class="w-26 flex items-center justify-end mr-4">
<div
class="flex items-center gap-2"
:class="{
'opacity-0 pointer-events-none': el.roles?.includes('super'),
}"
>
<NcDropdown :trigger="['click']">
<NcButton size="xsmall" type="ghost">
<MdiDotsVertical
class="text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/>
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="!el.roles?.includes('super')">
<!-- Resend invite Email -->
<NcMenuItem @click="resendInvite(el)">
<component :is="iconMap.email" class="flex text-gray-600" />
<div data-rec="true">{{ $t('activity.resendInvite') }}</div>
</NcMenuItem>
<NcMenuItem @click="copyInviteUrl(el)">
<component :is="iconMap.copy" class="flex text-gray-600" />
<div data-rec="true">{{ $t('activity.copyInviteURL') }}</div>
</NcMenuItem>
<NcMenuItem @click="copyPasswordResetUrl(el)">
<component :is="iconMap.copy" class="flex text-gray-600" />
<div>{{ $t('activity.copyPasswordResetURL') }}</div>
</NcMenuItem>
</template>
<template v-if="el.id !== loggedInUser?.id">
<NcDivider v-if="!el.roles?.includes('super')" />
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="openDeleteModal(el)">
<MaterialSymbolsDeleteOutlineRounded />
{{ $t('general.remove') }} {{ $t('objects.user') }}
</NcMenuItem>
</template>
</NcMenu>
</template>
<div
v-if="column.key === 'action'"
class="flex items-center gap-2"
:class="{
'opacity-0 pointer-events-none': el.roles?.includes('super'),
}"
>
<NcDropdown :trigger="['click']">
<NcButton size="xsmall" type="ghost">
<MdiDotsVertical
class="text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/>
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="!el.roles?.includes('super')">
<!-- Resend invite Email -->
<NcMenuItem @click="resendInvite(el)">
<component :is="iconMap.email" class="flex text-gray-600" />
<div data-rec="true">{{ $t('activity.resendInvite') }}</div>
</NcMenuItem>
<NcMenuItem @click="copyInviteUrl(el)">
<component :is="iconMap.copy" class="flex text-gray-600" />
<div data-rec="true">{{ $t('activity.copyInviteURL') }}</div>
</NcMenuItem>
<NcMenuItem @click="copyPasswordResetUrl(el)">
<component :is="iconMap.copy" class="flex text-gray-600" />
<div>{{ $t('activity.copyPasswordResetURL') }}</div>
</NcMenuItem>
</template>
</NcDropdown>
</div>
</span>
<template v-if="el.id !== loggedInUser?.id">
<NcDivider v-if="!el.roles?.includes('super')" />
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="openDeleteModal(el)">
<MaterialSymbolsDeleteOutlineRounded />
{{ $t('general.remove') }} {{ $t('objects.user') }}
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
</div>
</template>
<template #extraRow>
<div
v-if="sortedUsers.length === 1"
class="user pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center border-b-1 border-l-1 border-r-1"
v-if="pagination.total === 1 && sortedUsers.length === 1"
class="w-full pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center"
>
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
@ -352,16 +372,20 @@ const openDeleteModal = (user: UserType) => {
</div>
<img src="~assets/img/placeholder/invite-team.png" class="!w-[30rem] flex-none" />
</div>
</section>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-4">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
show-less-items
@change="loadUsers(currentPage, currentLimit)"
/>
</div>
</template>
<template #tableFooter>
<div v-if="pagination.total > 10" class="px-4 py-2 flex items-center justify-center">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
show-less-items
@change="loadUsers(currentPage, currentLimit)"
/>
</div>
</template>
</NcTable>
<GeneralDeleteModal v-model:visible="isOpen" entity-name="User" :on-delete="() => deleteUser()">
<template #entity-preview>
<span>

83
packages/nc-gui/components/dashboard/settings/Metadata.vue

@ -87,24 +87,28 @@ onMounted(async () => {
}
})
const tableHeaderRenderer = (label: string) => () => h('div', { class: 'text-gray-500' }, label)
const columns = [
{
// Models
title: tableHeaderRenderer(t('labels.models')),
title: t('labels.models'),
key: 'table_name',
name: 'table_name',
minWidth: 200,
padding: '0px 12px',
},
{
// Sync state
title: tableHeaderRenderer(t('labels.syncState')),
title: t('labels.syncState'),
dataIndex: 'syncState',
key: 'syncState',
// No change identified
customRender: (value: { text: string }) =>
h('div', { style: { color: value.text ? 'red' : 'gray' } }, value.text || t('msg.info.metaNoChange')),
minWidth: 200,
padding: '0px 12px',
},
]
const customRow = (record: Record<string, any>) => ({
class: `nc-metasync-row nc-metasync-row-${record.table_name}`,
})
</script>
<template>
@ -140,38 +144,45 @@ const columns = [
</div>
</a-button>
</div>
<div class="h-auto max-h-[calc(100%_-_72px)] overflow-y-auto nc-scrollbar-thin">
<a-table
class="nc-metasync-table w-full"
size="small"
:custom-row="
(record) => ({
class: `nc-metasync-row nc-metasync-row-${record.table_name}`,
})
"
:data-source="metadiff ?? []"
:columns="columns"
:pagination="false"
:loading="isLoading"
sticky
bordered
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #bodyCell="{ record, column }">
<div v-if="column.key === 'table_name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="record" class="text-gray-500" />
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record.title || record.table_name }}</span>
<NcTable
:columns="columns"
:data="metadiff ?? []"
row-height="44px"
header-row-height="44px"
:is-data-loading="isLoading"
:custom-row="customRow"
class="nc-metasync-table h-[calc(100%_-_72px)] w-full"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'table_name'">
<div class="flex items-center gap-2 max-w-full">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="record" class="text-gray-500" />
</div>
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>{{ record.title || record.table_name }}</template>
{{ record.title || record.table_name }}
</NcTooltip>
</div>
</template>
</a-table>
</div>
<template v-if="column.key === 'syncState'">
<div class="flex items-center gap-2 max-w-full">
<NcTooltip class="truncate" show-on-truncate-only>
<template #title> {{ record?.syncState || $t('msg.info.metaNoChange') }} </template>
<span
:class="{
'text-red-500': record?.syncState,
'text-gray-500': !record?.syncState,
}"
>
{{ record?.syncState || $t('msg.info.metaNoChange') }}
</span>
</NcTooltip>
</div>
</template>
</template>
</NcTable>
</div>
<div class="flex place-content-center item-center">

208
packages/nc-gui/components/dashboard/settings/UIAcl.vue

@ -15,7 +15,7 @@ const { base: activeBase } = storeToRefs(useBase())
const _projectId = inject(ProjectIdInj, ref())
const baseId = computed(() => _projectId.value ?? activeBase.value?.id!)
const baseId = computed(() => _projectId.value ?? (activeBase.value?.id as string))
const { bases } = storeToRefs(useBases())
@ -103,29 +103,46 @@ onMounted(async () => {
const columns = [
{
key: 'name',
title: t('labels.tableName'),
name: 'Table Name',
minWidth: 220,
padding: '0px 12px',
dataIndex: '_ptn',
},
{
key: 'name',
title: t('labels.viewName'),
name: 'View Name',
minWidth: 220,
padding: '0px 12px',
dataIndex: 'title',
},
{
key: 'action',
title: t('objects.roleType.editor'),
name: 'editor',
width: 120,
minWidth: 120,
padding: '0px 12px',
},
{
key: 'action',
title: t('objects.roleType.commenter'),
name: 'commenter',
width: 120,
width: 135,
minWidth: 135,
padding: '0px 12px',
},
{
key: 'action',
title: t('objects.roleType.viewer'),
name: 'viewer',
width: 120,
minWidth: 120,
padding: '0px 12px',
},
]
] as NcTableColumnProps[]
</script>
<template>
@ -158,122 +175,87 @@ const columns = [
</div>
</div>
<div class="h-auto max-h-[calc(100%_-_102px)] overflow-y-auto nc-scrollbar-thin">
<div class="w-full" size="small">
<div class="table-header">
<template v-for="column in columns" :key="column.name">
<template v-if="['editor', 'commenter', 'viewer'].includes(column.name)">
<div class="table-header-col" :style="`width: ${column.width}px`">
<div class="flex flex-row gap-x-1">
<NcCheckbox
v-model:checked="allSelected[column.name as Role]"
@change="toggleSelectAll(column.name as Role)"
/>
<div class="flex capitalize">
{{ column.name }}
</div>
</div>
</div>
</template>
<template v-else>
<div class="table-header-col flex-1">
<div class="flex capitalize">{{ column.title }}</div>
</div>
</template>
</template>
</div>
<template v-if="filteredTables.length === 0">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
<NcTable
:columns="columns"
:data="filteredTables"
row-height="44px"
header-row-height="44px"
class="h-[calc(100%_-_102px)] w-full"
>
<template #headerCell="{ column }">
<template v-if="column.key === 'name'">
{{ column.title }}
</template>
<template v-if="column.key === 'action'">
<div class="flex flex-row gap-x-2">
<NcCheckbox
v-model:checked="allSelected[column.name as Role]"
:disabled="!filteredTables.length"
class="!m-0 !top-0"
@change="toggleSelectAll(column.name as Role)"
/>
<div class="flex">
{{ column.title }}
</div>
</div>
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.name === 'Table Name'">
<div class="flex items-center gap-2 max-w-full">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="{ meta: record.table_meta, type: record.ptype }" class="text-gray-500" />
</div>
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>{{ record._ptn }}</template>
{{ record._ptn }}
</NcTooltip>
</div>
</template>
<template v-else-if="column.name === 'View Name'">
<div class="flex items-center gap-2 max-w-full">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon
v-if="record?.meta?.icon"
:meta="{ meta: record.meta, type: 'view' }"
class="text-gray-500 !text-sm children:(!w-5 !h-5)"
/>
<GeneralViewIcon v-else :meta="record" class="text-gray-500"></GeneralViewIcon>
</div>
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>{{ record.is_default ? $t('title.defaultView') : record.title }}</template>
{{ record.is_default ? $t('title.defaultView') : record.title }}
</NcTooltip>
</div>
</template>
<template v-else>
<div
v-for="record in filteredTables"
:key="record.id"
:class="`table-body-row nc-acl-table-row nc-acl-table-row-${record.title}`"
>
<template v-for="column in columns" :key="column.name">
<template v-if="column.name === 'Table Name'">
<div class="table-body-row-col flex-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="{ meta: record.table_meta, type: record.ptype }" class="text-gray-500" />
</div>
<NcTooltip class="overflow-ellipsis min-w-0 shrink-1 truncate" show-on-truncate-only>
<template #title>{{ record._ptn }}</template>
<span>{{ record._ptn }}</span>
</NcTooltip>
</div>
</template>
<template v-else-if="column.name === 'View Name'">
<div class="table-body-row-col flex-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon
v-if="record?.meta?.icon"
:meta="{ meta: record.meta, type: 'view' }"
class="text-gray-500 !text-sm children:(!w-5 !h-5)"
/>
<GeneralViewIcon v-else :meta="record" class="text-gray-500"></GeneralViewIcon>
</div>
<NcTooltip class="overflow-ellipsis min-w-0 shrink-1 truncate" show-on-truncate-only>
<template #title>{{ record.is_default ? $t('title.defaultView') : record.title }}</template>
<span>{{ record.is_default ? $t('title.defaultView') : record.title }}</span>
</NcTooltip>
</div>
<div>
<NcTooltip>
<template #title>
<span v-if="record.disabled[column.name]">
{{ $t('labels.clickToMake') }} '{{ record.title }}' {{ $t('labels.visibleForRole') }} {{ column.name }}
{{ $t('labels.inUI') }} dashboard</span
>
<span v-else
>{{ $t('labels.clickToHide') }} '{{ record.title }}' {{ $t('labels.forRole') }}:{{ column.name }}
{{ $t('labels.inUI') }}</span
>
</template>
<template v-else>
<div class="table-body-row-col" :style="`width: ${column.width}px`">
<NcTooltip>
<template #title>
<span v-if="record.disabled[column.name]">
{{ $t('labels.clickToMake') }} '{{ record.title }}' {{ $t('labels.visibleForRole') }} {{ column.name }}
{{ $t('labels.inUI') }} dashboard</span
>
<span v-else
>{{ $t('labels.clickToHide') }} '{{ record.title }}' {{ $t('labels.forRole') }}:{{ column.name }}
{{ $t('labels.inUI') }}</span
>
</template>
<NcCheckbox
:checked="!record.disabled[column.name]"
:class="`nc-acl-${record.title}-${column.name}-chkbox !ml-0.25`"
@change="onRoleCheck(record, column.name as Role)"
/>
</NcTooltip>
</div>
</template>
</template>
<NcCheckbox
:checked="!record.disabled[column.name]"
:class="`nc-acl-${record.title}-${column.name}-chkbox !ml-0.25`"
@change="onRoleCheck(record, column.name as Role)"
/>
</NcTooltip>
</div>
</template>
</div>
</div>
</template>
</NcTable>
</div>
</div>
</template>
<style scoped lang="scss">
.table-header {
@apply flex items-center bg-gray-100 border-1 border-gray-200;
}
.table-header-col {
@apply flex items-center p-2 border-r-1 border-gray-200;
}
.table-header-col:last-child {
@apply border-r-0;
}
.table-body-row {
@apply flex items-center bg-white border-r-1 border-l-1 border-b-1 border-gray-200;
}
.table-body-row-col {
@apply flex items-center p-2 border-r-1 border-gray-200;
}
.table-body-row-col:last-child {
@apply border-r-0;
}
</style>
<style scoped lang="scss"></style>

2
packages/nc-gui/components/general/MaintenanceAlert.vue

@ -1,5 +1,5 @@
<script setup lang="ts"></script>
<template></template>
<template><span class="hidden"></span></template>
<style scoped lang="scss"></style>

387
packages/nc-gui/components/nc/Table.vue

@ -0,0 +1,387 @@
<script lang="ts" setup>
import type { CSSProperties } from '@vue/runtime-dom'
interface Props {
columns: NcTableColumnProps[]
data: Record<string, any>[]
headerRowHeight?: CSSProperties['height']
rowHeight?: CSSProperties['height']
orderBy?: Record<string, SordDirectionType>
multiFieldOrderBy?: boolean
bordered?: boolean
isDataLoading?: boolean
stickyHeader?: boolean
stickyFirstColumn?: boolean
headerRowClassName?: string
bodyRowClassName?: string
headerCellClassName?: string
bodyCellClassName?: string
customHeaderRow?: (columns: NcTableColumnProps[]) => Record<string, any>
customRow?: (record: Record<string, any>, recordIndex: number) => Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
columns: () => [] as NcTableColumnProps[],
data: () => [] as Record<string, any>[],
headerRowHeight: '54px',
rowHeight: '54px',
orderBy: () => ({} as Record<string, SordDirectionType>),
multiFieldOrderBy: false,
bordered: true,
isDataLoading: false,
stickyHeader: true,
headerRowClassName: '',
bodyRowClassName: '',
headerCellClassName: '',
bodyCellClassName: '',
customHeaderRow: () => ({}),
customRow: () => ({}),
})
const emit = defineEmits(['update:orderBy'])
const tableWrapper = ref<HTMLDivElement>()
const tableHeader = ref<HTMLTableElement>()
const tableFooterRef = ref<HTMLDivElement>()
const { height: tableHeadHeight, width: tableHeadWidth } = useElementBounding(tableHeader)
const { height: tableFooterHeight } = useElementBounding(tableFooterRef)
const orderBy = useVModel(props, 'orderBy', emit)
const { columns, data, isDataLoading, customHeaderRow, customRow } = toRefs(props)
const headerRowClassName = computed(() => `nc-table-header-row ${props.headerRowClassName}`)
const bodyRowClassName = computed(() => `nc-table-row ${props.headerRowClassName}`)
const slots = useSlots()
const headerCellWidth = ref<(number | undefined)[]>([])
const updateOrderBy = (field: string) => {
if (!data.value.length || !field) return
const orderCycle = { undefined: 'asc', asc: 'desc', desc: undefined }
if (props.multiFieldOrderBy) {
orderBy.value[field] = orderCycle[`${orderBy.value[field]}`] as SordDirectionType
} else {
orderBy.value = { [field]: orderCycle[`${orderBy.value[field]}`] as SordDirectionType }
}
}
/**
* We are using 2 different table tag to make header sticky,
* so it's imp to keep header cell and body cell width same
*/
watch(
tableHeadWidth,
() => {
if (!tableHeader.value || !tableHeadWidth.value) return
nextTick(() => {
const headerCells = tableHeader.value?.querySelectorAll('th > div')
if (headerCells && headerCells.length) {
headerCells.forEach((el, i) => {
headerCellWidth.value[i] = el.getBoundingClientRect().width || undefined
})
}
})
},
{
immediate: true,
},
)
useEventListener(tableWrapper, 'scroll', () => {
const stickyHeaderCell = tableWrapper.value?.querySelector('th:nth-of-type(1)')
const nonStickyHeaderFirstCell = tableWrapper.value?.querySelector('th:nth-of-type(2)')
if (
!stickyHeaderCell ||
!nonStickyHeaderFirstCell ||
!stickyHeaderCell?.getBoundingClientRect()?.right ||
!nonStickyHeaderFirstCell?.getBoundingClientRect()?.left
) {
return
}
if (nonStickyHeaderFirstCell?.getBoundingClientRect().left < stickyHeaderCell?.getBoundingClientRect().right) {
tableWrapper.value?.classList.add('sticky-border')
} else {
tableWrapper.value?.classList.remove('sticky-border')
}
})
</script>
<template>
<div
class="nc-table-container relative"
:class="{
bordered,
'min-h-120': isDataLoading,
}"
>
<div
ref="tableWrapper"
class="nc-table-wrapper max-h-full relative nc-scrollbar-thin !overflow-auto"
:class="{
'sticky-first-column': stickyFirstColumn,
'h-full': data.length,
}"
:style="{
maxHeight: `calc(100% - ${tableFooterHeight}px)`,
}"
>
<table
ref="tableHeader"
class="w-full max-w-full"
:class="{
'!sticky top-0 z-5': stickyHeader,
}"
>
<thead>
<tr
:style="{
height: headerRowHeight,
}"
:class="[`${headerRowClassName}`]"
v-bind="customHeaderRow ? customHeaderRow(columns) : {}"
>
<th
v-for="(col, index) in columns"
:key="index"
class="nc-table-header-cell"
:class="[
`${headerCellClassName}`,
`nc-table-header-cell-${index}`,
{
'!hover:bg-gray-100 select-none cursor-pointer': col.showOrderBy,
'cursor-not-allowed': col.showOrderBy && !data?.length,
'!text-gray-700': col.showOrderBy && col?.dataIndex && orderBy[col.dataIndex],
'flex-1': !col.width && !col.basis,
},
]"
:style="{
width: col.width,
flexBasis: !col.width ? col.basis : undefined,
maxWidth: col.width ? col.width : undefined,
}"
:data-test-id="`nc-table-header-cell-${col.name || col.key}`"
@click="col.showOrderBy && col?.dataIndex ? updateOrderBy(col.dataIndex) : undefined"
>
<div
class="gap-3"
:class="[`${col.justify || ''}`]"
:style="{
padding: col.padding || '0px 24px',
minWidth: `calc(${col.minWidth}px - 2px)`,
}"
>
<slot name="headerCell" :column="col">
<div>{{ col.title || col.name || '' }}</div>
</slot>
<template v-if="col.showOrderBy && col?.dataIndex">
<GeneralIcon
v-if="orderBy[col.dataIndex]"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': orderBy[col.dataIndex] === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</template>
</div>
</th>
</tr>
</thead>
</table>
<template v-if="data.length">
<table
class="w-full h-full"
:style="{
maxHeight: `calc(100% - ${tableHeadHeight}px)`,
}"
>
<tbody>
<tr
v-for="(record, recordIndex) of data"
:key="recordIndex"
:style="{
height: rowHeight,
}"
:class="[`${bodyRowClassName}`, `nc-table-row-${recordIndex}`]"
v-bind="customRow ? customRow(record, recordIndex) : {}"
>
<td
v-for="(col, colIndex) of columns"
:key="colIndex"
class="nc-table-cell"
:class="[
`${bodyCellClassName}`,
`nc-table-cell-${recordIndex}`,
{
'flex-1': !col.width && !col.basis,
},
]"
:style="{
width: col.width,
flexBasis: !col.width ? col.basis : undefined,
maxWidth: col.width ? col.width : undefined,
}"
:data-test-id="`nc-table-cell-${col.name || col.key}`"
>
<div
:class="[`${col.align || 'items-center'} ${col.justify || ''}`]"
:style="{
padding: col.padding || '0px 24px',
minWidth: `calc(${col.minWidth}px - 2px)`,
maxWidth: headerCellWidth[colIndex] ? `${headerCellWidth[colIndex]}px` : undefined,
}"
>
<slot name="bodyCell" :column="col" :record="record" :record-index="recordIndex">
{{ col?.dataIndex && col.key !== 'action' ? record[col.dataIndex] : '' }}
</slot>
</div>
</td>
</tr>
<template v-if="slots.extraRow">
<tr class="nc-table-extra-row">
<slot name="extraRow" />
</tr>
</template>
</tbody>
</table>
</template>
</div>
<div
v-show="isDataLoading"
class="flex items-center justify-center absolute left-0 top-0 w-full h-full z-10 pointer-events-none"
>
<div class="flex flex-col justify-center items-center gap-2">
<GeneralLoader size="xlarge" />
<span class="text-center">{{ $t('general.loading') }}</span>
</div>
</div>
<div
v-if="!isDataLoading && !data?.length"
class="flex-none nc-table-empty flex items-center justify-center py-8 px-6 h-full"
:style="{
maxHeight: `calc(100% - ${headerRowHeight} - ${tableFooterHeight}px)`,
}"
>
<div class="flex-none text-center flex flex-col items-center gap-3">
<slot name="emptyText">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
</slot>
</div>
</div>
<!-- Not scrollable footer -->
<template v-if="slots.tableFooter">
<div ref="tableFooterRef">
<slot name="tableFooter" />
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.nc-table-container {
&.bordered {
@apply border-1 border-gray-200 rounded-lg overflow-hidden w-full;
}
&:not(.bordered) {
@apply overflow-hidden w-full;
}
.nc-table-wrapper {
@apply w-full;
&.sticky-first-column {
th {
&:first-of-type {
@apply bg-gray-50;
}
}
td {
&:first-of-type {
@apply bg-white;
}
}
th,
td {
&:first-of-type {
@apply border-r-1 border-transparent sticky left-0 z-4;
}
}
&.sticky-border {
th,
td {
&:first-of-type {
@apply !border-gray-200;
}
}
}
}
thead {
@apply w-full max-w-full;
th {
@apply bg-gray-50 text-sm text-gray-500 font-weight-500;
&.cell-title {
@apply sticky left-0 z-4 bg-gray-50;
}
}
}
tbody {
@apply w-full max-w-full;
tr {
&:not(.nc-table-extra-row) {
@apply cursor-pointer;
}
td {
@apply text-sm text-gray-600;
}
}
}
tr {
@apply flex w-full max-w-full;
&:not(.nc-table-extra-row) {
@apply border-b-1 border-gray-200;
}
&.selected td {
@apply !bg-[#F0F3FF];
}
&:not(.selected):hover td {
@apply !bg-gray-50;
}
th,
td {
@apply h-full flex;
& > div {
@apply h-full flex-1 flex items-center;
}
}
}
}
}
</style>

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

@ -12,7 +12,7 @@ const { activeProjectId, bases, basesUser } = storeToRefs(basesStore)
const { orgRoles, baseRoles, loadRoles } = useRoles()
const { sorts, loadSorts, handleGetSortedData, toggleSort } = useUserSorts('Project')
const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: saveOrUpdateUserSort } = useUserSorts('Project')
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
@ -23,6 +23,8 @@ const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { $api } = useNuxtApp()
const { t } = useI18n()
const currentBase = computedAsync(async () => {
let base
if (props.baseId) {
@ -54,7 +56,6 @@ const totalCollaborators = ref(0)
const userSearchText = ref('')
const isLoading = ref(false)
const isSearching = ref(false)
const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>([])
const filteredCollaborators = computed(() =>
@ -196,16 +197,71 @@ watch(isInviteModalVisible, () => {
watch(currentBase, () => {
loadCollaborators()
})
const orderBy = computed<Record<string, SordDirectionType>>({
get: () => {
return sortDirection.value
},
set: (value: Record<string, SordDirectionType>) => {
// Check if value is an empty object
if (Object.keys(value).length === 0) {
saveOrUpdateUserSort({})
return
}
const [field, direction] = Object.entries(value)[0]
saveOrUpdateUserSort({
field,
direction,
})
},
})
const columns = [
{
key: 'select',
title: '',
width: 70,
minWidth: 70,
},
{
key: 'email',
title: t('objects.users'),
minWidth: 220,
dataIndex: 'email',
showOrderBy: true,
},
{
key: 'role',
title: t('general.role'),
basis: '30%',
minWidth: 272,
dataIndex: 'roles',
showOrderBy: true,
},
{
key: 'created_at',
title: t('title.dateJoined'),
basis: '25%',
minWidth: 200,
},
] as NcTableColumnProps[]
const customRow = (record: Record<string, any>) => ({
class: `${selected[record.id] ? 'selected' : ''} user-row`,
})
</script>
<template>
<div
:class="{
'px-6 ': isAdminPanel,
'px-6': isAdminPanel,
'px-1': !isAdminPanel,
}"
class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]"
class="nc-collaborator-table-container pt-6 nc-access-settings-view h-[calc(100vh-8rem)] flex flex-col gap-6"
>
<div v-if="isAdminPanel" class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div v-if="isAdminPanel" class="font-bold w-full text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<NuxtLink
:href="`/admin/${orgId}/bases`"
@ -223,132 +279,118 @@ watch(currentBase, () => {
</span>
</div>
</div>
<LazyDlgInviteDlg v-model:model-value="isInviteModalVisible" :base-id="currentBase?.id" type="base" />
<div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" />
</div>
<template v-else>
<div class="w-full flex flex-row justify-between items-center max-w-350 mt-6.5 mb-2 pr-0.25">
<a-input v-model:value="userSearchText" :placeholder="$t('title.searchMembers')" class="!max-w-90 !rounded-md mr-4">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<NcButton size="small" @click="isInviteModalVisible = true">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }}
</div>
</NcButton>
</div>
<div v-if="isSearching" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" />
</div>
<div
v-else-if="!filteredCollaborators?.length"
class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center"
<div class="w-full flex justify-between items-center max-w-350 gap-3">
<a-input
v-model:value="userSearchText"
:placeholder="$t('title.searchMembers')"
:disabled="isLoading"
allow-clear
class="nc-project-collaborator-list-search-input !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
>
<a-empty :description="$t('title.noMembersFound')" />
</div>
<div v-else class="nc-collaborators-list mt-6 h-full">
<div class="flex flex-col overflow-hidden 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="py-3 px-6"><NcCheckbox v-model:checked="selectAll" /></div>
<LazyAccountHeaderWithSorter
class="users-email-grid"
:header="$t('objects.users')"
:active-sort="sorts"
field="email"
:toggle-sort="toggleSort"
/>
<LazyAccountHeaderWithSorter
class="user-access-grid"
:header="$t('general.role')"
:active-sort="sorts"
field="roles"
:toggle-sort="toggleSort"
/>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
</div>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
<NcButton :disabled="isLoading" size="small" @click="isInviteModalVisible = true">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }}
</div>
</NcButton>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of sortedCollaborators"
:key="i"
:class="{
'bg-[#F0F3FF]': selected[collab.id],
}"
class="user-row flex hover:bg-[#F0F3FF] flex-row border-b-1 py-1 min-h-14 items-center"
>
<div class="py-3 px-6">
<NcCheckbox v-model:checked="selected[collab.id]" />
</div>
<div class="flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :email="collab.email" />
<div class="flex flex-col">
<div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collab.email }}
</span>
</div>
</div>
<div class="user-access-grid">
<template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector
:role="collab.roles"
:roles="accessibleRoles"
:inherit="
isEeUI && collab.workspace_roles && WorkspaceRolesToProjectRoles[collab.workspace_roles]
? WorkspaceRolesToProjectRoles[collab.workspace_roles]
: null
"
:description="false"
:on-role-change="(role) => updateCollaborator(collab, role as ProjectRoles)"
/>
</template>
<template v-else>
<RolesBadge :border="false" :role="collab.roles" />
<NcTable
v-model:order-by="orderBy"
:is-data-loading="isLoading"
:columns="columns"
:data="sortedCollaborators"
:bordered="false"
:custom-row="customRow"
class="flex-1 nc-collaborators-list max-w-350"
>
<template #emptyText>
<a-empty :description="$t('title.noMembersFound')" />
</template>
<template #headerCell="{ column }">
<template v-if="column.key === 'select'">
<NcCheckbox v-model:checked="selectAll" :disabled="!sortedCollaborators.length" />
</template>
<template v-else>
{{ column.title }}
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'select'">
<NcCheckbox v-model:checked="selected[record.id]" />
</template>
<div v-if="column.key === 'email'" class="w-full flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :email="record.email" class="flex-none" />
<div class="flex flex-col flex-1 max-w-[calc(100%_-_44px)]">
<div class="flex gap-3">
<NcTooltip class="truncate max-w-full text-gray-800 capitalize font-semibold" show-on-truncate-only>
<template #title>
{{ record.display_name || record.email.slice(0, record.email.indexOf('@')) }}
</template>
</div>
<div class="date-joined-grid">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(collab.created_at) }}
</template>
<span>
{{ timeAgo(collab.created_at) }}
</span>
</NcTooltip>
</div>
{{ record.display_name || record.email.slice(0, record.email.indexOf('@')) }}
</NcTooltip>
</div>
<NcTooltip class="truncate max-w-full text-xs text-gray-600" show-on-truncate-only>
<template #title>
{{ record.email }}
</template>
{{ record.email }}
</NcTooltip>
</div>
</div>
</div>
</template>
<div v-if="column.key === 'role'">
<template v-if="accessibleRoles.includes(record.roles)">
<RolesSelector
:role="record.roles"
:roles="accessibleRoles"
:inherit="
isEeUI && record.workspace_roles && WorkspaceRolesToProjectRoles[record.workspace_roles]
? WorkspaceRolesToProjectRoles[record.workspace_roles]
: null
"
:description="false"
:on-role-change="(role) => updateCollaborator(record, role as ProjectRoles)"
/>
</template>
<template v-else>
<RolesBadge :border="false" :role="record.roles" />
</template>
</div>
<div v-if="column.key === 'created_at'">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(record.created_at) }}
</template>
<span>
{{ timeAgo(record.created_at) }}
</span>
</NcTooltip>
</div>
</template>
</NcTable>
<LazyDlgInviteDlg v-model:model-value="isInviteModalVisible" :base-id="currentBase?.id" type="base" />
</div>
</template>
<style scoped lang="scss">
.ant-input::placeholder {
:deep(.ant-input::placeholder) {
@apply text-gray-500;
}
.ant-input:placeholder-shown {
@apply text-gray-500 !text-md;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
:deep(.ant-input-affix-wrapper.nc-project-collaborator-list-search-input) {
&:not(:has(.ant-input-clear-icon-hidden)):has(.ant-input-clear-icon) {
@apply border-[var(--ant-primary-5)];
}
}
.color-band {
@ -358,27 +400,4 @@ watch(currentBase, () => {
:deep(.nc-collaborator-role-select .ant-select-selector) {
@apply !rounded;
}
:deep(.ant-select-selection-item) {
@apply mt-0.75;
}
.users-email-grid {
@apply flex-grow ml-4 w-1/2;
}
.date-joined-grid {
@apply w-1/4 flex items-start;
}
.user-access-grid {
@apply w-1/4 flex justify-start;
}
.user-row {
@apply w-full;
}
.user-row:last-child {
@apply border-b-0;
}
</style>

124
packages/nc-gui/components/project/AllTables.vue

@ -12,6 +12,8 @@ const { isUIAllowed } = useRoles()
const { $e } = useNuxtApp()
const { t } = useI18n()
const isImportModalOpen = ref(false)
const defaultBase = computed(() => {
@ -65,6 +67,38 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
close(1000)
}
}
const columns = [
{
key: 'tableName',
title: t('objects.table'),
name: 'Table Name',
basis: '40%',
minWidth: 220,
padding: '0px 12px',
},
{
key: 'sourceName',
title: t('general.source'),
name: 'View Name',
basis: '25%',
minWidth: 220,
padding: '0px 12px',
},
{
key: 'created_at',
title: t('labels.createdOn'),
name: 'editor',
minWidth: 120,
padding: '0px 12px',
},
] as NcTableColumnProps[]
const customRow = (record: Record<string, any>) => ({
onclick: () => {
openTable(record as TableType)
},
})
</script>
<template>
@ -121,7 +155,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
v-if="base?.isLoading"
class="flex items-center justify-center text-center"
:style="{
height: 'calc(100vh - var(--topbar-height) - 18rem)',
height: 'calc(100vh - var(--topbar-height) - 15.2rem)',
}"
>
<div>
@ -131,46 +165,68 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
</div>
</div>
</div>
<template v-else-if="activeTables.length">
<div class="flex flex-row w-full text-gray-400 border-b-1 border-gray-50 py-3 px-2.5">
<div class="w-2/5">{{ $t('objects.table') }}</div>
<div class="w-1/5">{{ $t('general.source') }}</div>
<div class="w-1/5">{{ $t('labels.createdOn') }}</div>
</div>
<div
class="nc-base-view-all-table-list nc-scrollbar-md"
:style="{
height: 'calc(100vh - var(--topbar-height) - 18rem)',
}"
>
<div
v-for="table in [...activeTables].sort(
<div
v-else-if="activeTables.length"
class="flex mt-2"
:style="{
height: 'calc(100vh - var(--topbar-height) - 15.2rem)',
}"
>
<NcTable
:is-data-loading="base?.isLoading"
:columns="columns"
sticky-first-column
:data="[...activeTables].sort(
(a, b) => a.source_id!.localeCompare(b.source_id!) * 20
)"
:key="table.id"
class="py-4 flex flex-row w-full cursor-pointer hover:bg-gray-100 border-b-1 border-gray-100 px-2.25"
data-testid="proj-view-list__item"
@click="openTable(table)"
>
<div class="flex flex-row w-2/5 items-center gap-x-2" data-testid="proj-view-list__item-title">
:custom-row="customRow"
:bordered="false"
class="nc-base-view-all-table-list flex-1"
>
<template #bodyCell="{ column, record }">
<div
v-if="column.key === 'tableName'"
class="w-full flex items-center gap-3 max-w-full text-gray-800 font-semibold"
data-testid="proj-view-list__item-title"
>
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="table" class="text-gray-500" />
<GeneralTableIcon :meta="record" class="flex-none text-gray-600" />
</div>
{{ table?.title }}
<NcTooltip class="truncate max-w-[calc(100%_-_28px)]" show-on-truncate-only>
<template #title>
{{ record?.title }}
</template>
{{ record?.title }}
</NcTooltip>
</div>
<div class="w-1/5 text-gray-600" data-testid="proj-view-list__item-type">
<div v-if="table.source_id === defaultBase?.id" class="ml-0.75">-</div>
<div v-else class="capitalize flex flex-row items-center gap-x-0.5">
<GeneralBaseLogo class="w-4 mr-1" />
{{ sources.get(table.source_id!)?.alias }}
</div>
<div
v-if="column.key === 'sourceName'"
class="capitalize w-full flex items-center gap-3 max-w-full"
data-testid="proj-view-list__item-type"
>
<div v-if="record.source_id === defaultBase?.id" class="ml-0.75">-</div>
<template v-else>
<GeneralBaseLogo class="flex-none w-4" />
<NcTooltip class="truncate max-w-[calc(100%_-_28px)]" show-on-truncate-only>
<template #title>
{{ sources.get(record.source_id!)?.alias }}
</template>
{{ sources.get(record.source_id!)?.alias }}
</NcTooltip>
</template>
</div>
<div class="w-1/5 text-gray-400 ml-0.25" data-testid="proj-view-list__item-created-at">
{{ dayjs(table?.created_at).fromNow() }}
<div
v-if="column.key === 'created_at'"
class="capitalize flex items-center gap-2 max-w-full"
data-testid="proj-view-list__item-created-at"
>
{{ dayjs(record?.created_at).fromNow() }}
</div>
</div>
</div>
</template>
</template>
</NcTable>
</div>
<div v-else class="py-3 flex items-center gap-6 <lg:flex-col">
<img src="~assets/img/placeholder/table.png" class="!w-[23rem] flex-none" />
<div class="text-center lg:text-left">

324
packages/nc-gui/components/template/Editor.vue

@ -801,62 +801,71 @@ watch(modelRef, async () => {
</NcTooltip>
</template>
<a-table
v-if="srcDestMapping"
class="template-form"
row-class-name="template-form-row"
:data-source="srcDestMapping[table.table_name]"
:columns="srcDestMappingColumns"
:pagination="false"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #headerCell="{ column }">
<span v-if="column.key === 'source_column' || column.key === 'destination_column'">
{{ column.name }}
</span>
<span v-if="column.key === 'action'">
<a-checkbox
v-model:checked="checkAllRecord[table.table_name]"
@change="handleCheckAllRecord($event, table.table_name)"
/>
</span>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'source_column'">
<NcTooltip class="truncate inline-block">
<template #title>{{ record.srcTitle }}</template>
{{ record.srcTitle }}
</NcTooltip>
<div v-if="srcDestMapping" class="flex w-full max-h-[calc(80vh_-_200px)]">
<NcTable
class="template-form flex-1"
body-row-class-name="template-form-row"
:data="srcDestMapping[table.table_name]"
:columns="srcDestMappingColumns"
:bordered="false"
>
<template #headerCell="{ column }">
<span v-if="column.key !== 'action'">
{{ column.title }}
</span>
<span v-if="column.key === 'action'">
<a-checkbox
v-model:checked="checkAllRecord[table.table_name]"
@change="handleCheckAllRecord($event, table.table_name)"
/>
</span>
</template>
<template v-else-if="column.key === 'destination_column'">
<a-select
v-model:value="record.destCn"
class="w-52"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-filter-field"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-current" />
</template>
<a-select-option v-for="(col, i) of columns" :key="i" :value="col.title">
<div class="flex items-center gap-2">
<component :is="getUIDTIcon(col.uidt)" class="w-3.5 h-3.5" />
<span>{{ col.title }}</span>
</div>
</a-select-option>
</a-select>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'source_column'">
<NcTooltip class="truncate inline-block">
<template #title>{{ record.srcTitle }}</template>
{{ record.srcTitle }}
</NcTooltip>
</template>
<template v-if="column.key === 'action'">
<a-checkbox v-model:checked="record.enabled" />
<template v-else-if="column.key === 'destination_column'">
<a-select
v-model:value="record.destCn"
class="w-full"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-filter-field"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-current" />
</template>
<a-select-option v-for="(col, i) of columns" :key="i" :value="col.title">
<div class="flex items-center gap-2 w-full">
<component :is="getUIDTIcon(col.uidt)" class="flex-none w-3.5 h-3.5" />
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ col.title }}
</template>
{{ col.title }}
</NcTooltip>
<component
:is="iconMap.check"
v-if="record.destCn === col.title"
id="nc-selected-item-icon"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</a-select>
</template>
<template v-if="column.key === 'action'">
<a-checkbox v-model:checked="record.enabled" />
</template>
</template>
</template>
</a-table>
</NcTable>
</div>
</a-collapse-panel>
</a-collapse>
</a-card>
@ -909,118 +918,116 @@ watch(modelRef, async () => {
/>
</NcTooltip>
</template>
<a-table
v-if="table.columns && table.columns.length"
class="template-form"
row-class-name="template-form-row"
:data-source="table.columns"
:columns="tableColumns"
:pagination="table.columns.length > 50 ? { defaultPageSize: 50, position: ['bottomCenter'] } : false"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #headerCell="{ column }">
<template v-if="column.key === 'column_name'">
<span>
{{ $t('labels.columnName') }}
</span>
</template>
<div v-if="table.columns && table.columns.length" class="flex w-full max-h-[calc(80vh_-_200px)]">
<NcTable
class="template-form flex-1"
body-row-class-name="template-form-row"
:data="table.columns"
:columns="tableColumns"
:bordered="false"
:pagination="table.columns.length > 50 ? { defaultPageSize: 50, position: ['bottomCenter'] } : false"
>
<template #headerCell="{ column }">
<template v-if="column.key === 'column_name'">
<span>
{{ $t('labels.columnName') }}
</span>
</template>
<template v-else-if="column.key === 'uidt'">
<span>
{{ $t('labels.columnType') }}
</span>
</template>
<template v-else-if="column.key === 'uidt'">
<span>
{{ $t('labels.columnType') }}
</span>
</template>
<template v-else-if="column.key === 'dtxp' && hasSelectColumn[tableIdx]">
<span>
{{ $t('general.options') }}
</span>
<template v-else-if="column.key === 'dtxp' && hasSelectColumn[tableIdx]">
<span>
{{ $t('general.options') }}
</span>
</template>
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'column_name'">
<a-form-item
v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.title`]"
class="nc-table-field-name"
>
<a-input
:ref="(el: HTMLInputElement) => (inputRefs[record.key] = el)"
v-model:value="record.title"
class="!rounded-md"
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'column_name'">
<a-form-item
v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.title`]"
class="nc-table-field-name !mb-0 w-full"
>
<template #suffix>
<NcTooltip v-if="formError?.[`tables.${tableIdx}.columns.${record.key}.title`]" class="flex">
<template #title
>{{ formError?.[`tables.${tableIdx}.columns.${record.key}.title`].join('\n') }}
<a-input
:ref="(el: HTMLInputElement) => (inputRefs[record.key] = el)"
v-model:value="record.title"
class="!rounded-md"
>
<template #suffix>
<NcTooltip v-if="formError?.[`tables.${tableIdx}.columns.${record.key}.title`]" class="flex">
<template #title
>{{ formError?.[`tables.${tableIdx}.columns.${record.key}.title`].join('\n') }}
</template>
<GeneralIcon icon="info" class="h-4 w-4 text-red-500 flex-none" />
</NcTooltip>
</template>
</a-input>
</a-form-item>
</template>
<template v-else-if="column.key === 'uidt'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.uidt`]" class="!mb-0 w-full">
<NcTooltip :disabled="importDataOnly">
<template #title>
{{ $t('tooltip.useFieldEditMenuToConfigFieldType') }}
</template>
<a-select
v-model:value="record.uidt"
class="w-52"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-template-uidt"
:disabled="!importDataOnly"
@change="handleUIDTChange(record, table)"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-current" />
</template>
<GeneralIcon icon="info" class="h-4 w-4 text-red-500 flex-none" />
</NcTooltip>
</template>
</a-input>
</a-form-item>
</template>
<template v-else-if="column.key === 'uidt'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.uidt`]">
<NcTooltip :disabled="importDataOnly">
<a-select-option v-for="(option, i) of uiTypeOptions" :key="i" :value="option.value">
<div class="flex items-center gap-2">
<component :is="getUIDTIcon(UITypes[option.value])" class="h-3.5 w-3.5" />
<NcTooltip placement="right" :disabled="!importDataOnly" show-on-truncate-only>
<template v-if="isSelectDisabled(option.label, table.columns[record.key]?._disableSelect)" #title>
{{
$t('msg.tooLargeFieldEntity', {
entity: option.label,
})
}}
</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</a-select>
</NcTooltip>
</a-form-item>
</template>
<template v-if="column.key === 'action'">
<NcTooltip class="inline-block">
<template #title>
{{ $t('tooltip.useFieldEditMenuToConfigFieldType') }}
<span>{{ $t('activity.column.delete') }}</span>
</template>
<a-select
v-model:value="record.uidt"
class="w-52"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-template-uidt"
:disabled="!importDataOnly"
@change="handleUIDTChange(record, table)"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-current" />
</template>
<a-select-option v-for="(option, i) of uiTypeOptions" :key="i" :value="option.value">
<div class="flex items-center gap-2">
<component :is="getUIDTIcon(UITypes[option.value])" class="h-3.5 w-3.5" />
<NcTooltip placement="right" :disabled="!importDataOnly" show-on-truncate-only>
<template v-if="isSelectDisabled(option.label, table.columns[record.key]?._disableSelect)" #title>
{{
$t('msg.tooLargeFieldEntity', {
entity: option.label,
})
}}
</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</a-select>
<NcButton
type="text"
size="small"
:disabled="table.columns.length === 1"
@click="deleteTableColumn(tableIdx, record.key)"
>
<component :is="iconMap.deleteListItem" />
</NcButton>
</NcTooltip>
</a-form-item>
</template>
<template v-if="column.key === 'action'">
<NcTooltip class="inline-block">
<template #title>
<span>{{ $t('activity.column.delete') }}</span>
</template>
<NcButton
type="text"
size="small"
:disabled="table.columns.length === 1"
@click="deleteTableColumn(tableIdx, record.key)"
>
<component :is="iconMap.deleteListItem" />
</NcButton>
</NcTooltip>
</template>
</template>
</template>
</a-table>
</NcTable>
</div>
</a-collapse-panel>
</a-collapse>
</a-form>
@ -1033,19 +1040,6 @@ watch(modelRef, async () => {
@apply bg-white;
}
.template-form {
:deep(.ant-table-thead) > tr > th {
@apply bg-white;
}
:deep(.template-form-row) > td {
@apply p-1 mb-0 truncate max-w-50;
.ant-form-item {
@apply mb-0;
}
}
}
:deep(.ant-collapse-header) {
@apply !items-center;
& > div {

38
packages/nc-gui/components/template/utils.ts

@ -1,47 +1,55 @@
import type { ColumnGroupType } from 'ant-design-vue/es/table'
export const tableColumns: (Omit<ColumnGroupType<any>, 'children'> & { dataIndex?: string; name: string })[] = [
export const tableColumns: NcTableColumnProps[] = [
{
title: 'Column Name',
name: 'Column Name',
dataIndex: 'column_name',
key: 'column_name',
width: 250,
minWidth: 200,
padding: '0px 12px',
},
{
title: 'Column Type',
name: 'Column Type',
dataIndex: 'column_type',
key: 'uidt',
width: 250,
minWidth: 200,
padding: '0px 12px',
},
// {
// name: 'Select Option',
// key: 'dtxp',
// },
{
title: '',
name: 'Action',
key: 'action',
align: 'center',
width: 40,
width: 60,
minWidth: 60,
padding: '0px 12px',
},
]
export const srcDestMappingColumns: (Omit<ColumnGroupType<any>, 'children'> & { dataIndex?: string; name: string })[] = [
export const srcDestMappingColumns: NcTableColumnProps[] = [
{
name: 'Source column',
title: 'Source column',
dataIndex: 'source_column',
key: 'source_column',
width: 400,
minWidth: 200,
padding: '0px 12px',
},
{
name: 'Destination column',
title: 'Destination column',
dataIndex: 'destination_column',
key: 'destination_column',
width: 400,
minWidth: 200,
padding: '0px 12px',
},
{
name: 'Action',
title: 'Action',
key: 'action',
align: 'center',
width: 50,
justify: 'justify-center',
width: 60,
minWidth: 60,
padding: '0px 12px',
},
]

2
packages/nc-gui/components/workspace/AuditLogs.vue

@ -198,7 +198,7 @@ onKeyStroke('ArrowDown', onDown)
</script>
<template>
<div class="h-full flex flex-col" :class="{ 'gap-6 pb-6': !baseId, 'gap-4': baseId }">
<div class="h-full flex flex-col px-1" :class="{ 'gap-6 pb-6': !baseId, 'gap-4': baseId }">
<div v-if="!appInfo.auditEnabled" class="text-red-500">Audit logs are currently disabled by administrators.</div>
<div class="flex flex-col" :class="{ 'gap-6': !baseId, 'gap-4': baseId }">

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

@ -11,7 +11,7 @@ const workspaceStore = useWorkspace()
const { removeCollaborator, updateCollaborator: _updateCollaborator, loadWorkspace } = workspaceStore
const { collaborators, activeWorkspace, workspacesList } = storeToRefs(workspaceStore)
const { collaborators, activeWorkspace, workspacesList, isCollaboratorsLoading } = storeToRefs(workspaceStore)
const currentWorkspace = computedAsync(async () => {
if (props.workspaceId) {
@ -25,7 +25,7 @@ const currentWorkspace = computedAsync(async () => {
return activeWorkspace.value ?? workspacesList.value[0]
})
const { sorts, loadSorts, handleGetSortedData, toggleSort } = useUserSorts('Workspace')
const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: saveOrUpdateUserSort } = useUserSorts('Workspace')
const userSearchText = ref('')
@ -33,6 +33,8 @@ const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { isUIAllowed } = useRoles()
const { t } = useI18n()
const inviteDlg = ref(false)
const filterCollaborators = computed(() => {
@ -73,7 +75,7 @@ const selectAll = computed({
const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
if (!currentWorkspace.value || !currentWorkspace.value.id) return
console.log(WorkspaceUserRoles.OWNER)
try {
await _updateCollaborator(collab.id, roles, currentWorkspace.value.id)
message.success('Successfully updated user role')
@ -99,176 +101,226 @@ const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
onMounted(async () => {
loadSorts()
})
const orderBy = computed<Record<string, SordDirectionType>>({
get: () => {
return sortDirection.value
},
set: (value: Record<string, SordDirectionType>) => {
// Check if value is an empty object
if (Object.keys(value).length === 0) {
saveOrUpdateUserSort({})
return
}
const [field, direction] = Object.entries(value)[0]
saveOrUpdateUserSort({
field,
direction,
})
},
})
const columns = [
{
key: 'select',
title: '',
width: 70,
minWidth: 70,
},
{
key: 'email',
title: t('objects.users'),
minWidth: 220,
dataIndex: 'email',
showOrderBy: true,
},
{
key: 'role',
title: t('general.access'),
basis: '25%',
minWidth: 252,
dataIndex: 'roles',
showOrderBy: true,
},
{
key: 'created_at',
title: t('title.dateJoined'),
basis: '25%',
minWidth: 200,
},
{
key: 'action',
title: t('labels.actions'),
width: 110,
minWidth: 110,
justify: 'justify-end',
},
] as NcTableColumnProps[]
const customRow = (_record: Record<string, any>, recordIndex: number) => ({
class: `${selected[recordIndex] ? 'selected' : ''} last:!border-b-0`,
})
</script>
<template>
<DlgInviteDlg v-if="currentWorkspace" v-model:model-value="inviteDlg" :workspace-id="currentWorkspace?.id" type="workspace" />
<div class="nc-collaborator-table-container mt-4 h-[calc(100vh-10rem)] max-w-350">
<div class="w-full flex justify-between mt-6.5 mb-2">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
<div class="nc-collaborator-table-container py-6 h-[calc(100vh-92px)] max-w-350 px-1 flex flex-col gap-6">
<div class="w-full flex items-center justify-between gap-3">
<a-input
v-model:value="userSearchText"
allow-clear
:disabled="isCollaboratorsLoading"
class="nc-collaborator-list-search-input !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
placeholder="Search members"
>
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
<NcButton data-testid="nc-add-member-btn" @click="inviteDlg = true">
<NcButton size="small" :disabled="isCollaboratorsLoading" data-testid="nc-add-member-btn" @click="inviteDlg = true">
<div class="flex items-center gap-2">
<component :is="iconMap.plus" class="!h-4 !w-4" />
{{ $t('labels.addMember') }}
</div>
</NcButton>
</div>
<div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center">
<a-empty description="No members found" />
</div>
<div v-else class="nc-collaborators-list mt-6 h-full">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-h-[calc(100%-4rem)]">
<div class="flex flex-row bg-gray-50 min-h-11 items-center border-b-1">
<div class="py-3 px-6"><NcCheckbox v-model:checked="selectAll" /></div>
<LazyAccountHeaderWithSorter
class="text-gray-700 w-[30rem] users-email-grid"
:header="$t('objects.users')"
:active-sort="sorts"
field="email"
:toggle-sort="toggleSort"
/>
<LazyAccountHeaderWithSorter
class="text-gray-700 w-full flex-1 px-6 py-3"
:header="$t('general.access')"
:active-sort="sorts"
field="roles"
:toggle-sort="toggleSort"
/>
<div class="text-gray-700 w-full flex-1 px-6 py-3">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 w-full text-right flex-1 px-6 py-3">{{ $t('labels.actions') }}</div>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of sortedCollaborators"
:key="i"
:class="{
'bg-[#F0F3FF]': selected[i],
}"
class="user-row flex hover:bg-[#F0F3FF] flex-row last:border-b-0 border-b-1 py-1 min-h-14 items-center"
>
<div class="py-3 px-6">
<NcCheckbox v-model:checked="selected[i]" />
</div>
<div class="flex h-[calc(100%-4rem)]">
<NcTable
v-model:order-by="orderBy"
:columns="columns"
:data="sortedCollaborators"
:is-data-loading="isCollaboratorsLoading"
:custom-row="customRow"
:bordered="false"
class="flex-1 nc-collaborators-list"
>
<template #emptyText>
<a-empty :description="$t('title.noMembersFound')" />
</template>
<div class="flex gap-3 w-[30rem] items-center users-email-grid">
<GeneralUserIcon :email="collab.email" size="base" />
<div class="flex flex-col">
<div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab?.email?.slice(0, collab.email.indexOf('@')) }}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collab.email }}
</span>
</div>
</div>
<div class="w-full flex-1 px-6 py-3">
<div class="w-[30px]">
<template v-if="accessibleRoles.includes(collab.roles as WorkspaceUserRoles)">
<RolesSelector
:description="false"
:on-role-change="(role) => updateCollaborator(collab, role as WorkspaceUserRoles)"
:role="collab.roles"
:roles="accessibleRoles"
class="cursor-pointer"
/>
</template>
<template v-else>
<RolesBadge :border="false" :role="collab.roles" class="cursor-default" />
</template>
<template #headerCell="{ column }">
<template v-if="column.key === 'select'">
<NcCheckbox v-model:checked="selectAll" :disabled="!sortedCollaborators.length" />
</template>
<template v-else>
{{ column.title }}
</template>
</template>
<template #bodyCell="{ column, record, recordIndex }">
<template v-if="column.key === 'select'">
<NcCheckbox v-model:checked="selected[recordIndex]" />
</template>
<div v-if="column.key === 'email'" class="w-full flex gap-3 items-center">
<GeneralUserIcon size="base" :email="record.email" class="flex-none" />
<div class="flex flex-col flex-1 max-w-[calc(100%_-_44px)]">
<div class="flex gap-3">
<NcTooltip class="truncate max-w-full text-gray-800 capitalize font-semibold" show-on-truncate-only>
<template #title>
{{ record.display_name || record.email.slice(0, record.email.indexOf('@')) }}
</template>
{{ record.display_name || record.email.slice(0, record.email.indexOf('@')) }}
</NcTooltip>
</div>
</div>
<div class="w-full flex-1 px-6 py-3">
<NcTooltip class="max-w-full">
<NcTooltip class="truncate max-w-full text-xs text-gray-600" show-on-truncate-only>
<template #title>
{{ parseStringDateTime(collab.created_at) }}
{{ record.email }}
</template>
<span>
{{ timeAgo(collab.created_at) }}
</span>
{{ record.email }}
</NcTooltip>
</div>
<div class="w-full justify-end flex-1 flex px-6 py-3">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER">
<NcButton size="small" type="secondary">
<component :is="iconMap.threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="isAdminPanel">
<NcMenuItem data-testid="nc-admin-org-user-delete">
<GeneralIcon class="text-gray-800" icon="signout" />
<span>{{ $t('labels.signOutUser') }}</span>
</NcMenuItem>
<a-menu-divider class="my-1.5" />
</template>
<NcMenuItem
v-if="isUIAllowed('transferWorkspaceOwnership')"
data-testid="nc-admin-org-user-assign-admin"
@click="updateCollaborator(collab, WorkspaceUserRoles.OWNER)"
>
<GeneralIcon class="text-gray-800" icon="user" />
<span>{{ $t('labels.assignAs') }}</span>
<RolesBadge :border="false" :show-icon="false" role="owner" />
</NcMenuItem>
</div>
<div v-if="column.key === 'role'">
<template v-if="accessibleRoles.includes(record.roles as WorkspaceUserRoles)">
<RolesSelector
:description="false"
:on-role-change="(role) => updateCollaborator(record, role as WorkspaceUserRoles)"
:role="record.roles"
:roles="accessibleRoles"
class="cursor-pointer"
/>
</template>
<template v-else>
<RolesBadge :border="false" :role="record.roles" class="cursor-default" />
</template>
</div>
<div v-if="column.key === 'created_at'">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(record.created_at) }}
</template>
<span>
{{ timeAgo(record.created_at) }}
</span>
</NcTooltip>
</div>
<NcMenuItem
class="!text-red-500 !hover:bg-red-50"
@click="removeCollaborator(collab.id, currentWorkspace?.id)"
>
<MaterialSymbolsDeleteOutlineRounded />
Remove user
<div v-if="column.key === 'action'">
<NcDropdown v-if="record.roles !== WorkspaceUserRoles.OWNER">
<NcButton size="small" type="secondary">
<component :is="iconMap.threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="isAdminPanel">
<NcMenuItem data-testid="nc-admin-org-user-delete">
<GeneralIcon class="text-gray-800" icon="signout" />
<span>{{ $t('labels.signOutUser') }}</span>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
</div>
</div>
<div v-if="sortedCollaborators.length === 1" class="pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center">
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
<a-menu-divider class="my-1.5" />
</template>
<NcMenuItem
v-if="isUIAllowed('transferWorkspaceOwnership')"
data-testid="nc-admin-org-user-assign-admin"
@click="updateCollaborator(record, WorkspaceUserRoles.OWNER)"
>
<GeneralIcon class="text-gray-800" icon="user" />
<span>{{ $t('labels.assignAs') }}</span>
<RolesBadge :border="false" :show-icon="false" role="owner" />
</NcMenuItem>
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(record.id, currentWorkspace?.id)">
<MaterialSymbolsDeleteOutlineRounded />
Remove user
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</template>
<template #extraRow>
<div v-if="collaborators?.length === 1" class="w-full pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center">
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</div>
<img src="~assets/img/placeholder/invite-team.png" alt="Invite Team" class="!w-[30rem] flex-none" />
</div>
<img alt="Invite Team" class="!w-[30rem] flex-none" src="~assets/img/placeholder/invite-team.png" />
</div>
</div>
</template>
</NcTable>
</div>
<DlgInviteDlg v-if="currentWorkspace" v-model:model-value="inviteDlg" :workspace-id="currentWorkspace?.id" type="workspace" />
</div>
</template>
<style scoped lang="scss">
.ant-input::placeholder {
:deep(.ant-input::placeholder) {
@apply text-gray-500;
}
.ant-input:placeholder-shown {
@apply text-gray-500 !text-md;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
:deep(.ant-input-affix-wrapper.nc-collaborator-list-search-input) {
&:not(:has(.ant-input-clear-icon-hidden)):has(.ant-input-clear-icon) {
@apply border-[var(--ant-primary-5)];
}
}
.badge-text {
@apply text-[14px] pt-1 text-center;
}
.nc-collaborators-list-table {
@apply min-w-[700px] !w-full border-gray-100 mt-1;
}
.last:last-child {
border-bottom: none;
}
</style>

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

@ -1,3 +1,5 @@
import type { CSSProperties } from '@vue/runtime-dom'
import type {
AuditOperationTypes,
BaseType,
@ -255,6 +257,28 @@ interface AuditLogsQuery {
}
}
interface NcTableColumnProps {
key: 'name' | 'action' | string
// title is column header cell value and we can also pass i18n value as this is just used to render in UI
title: string
// minWidth is required to fix overflow col issue
minWidth: number
// provide width if we want col to be fixed width or provide basis value
width?: number
basis?: CSSProperties['flex-basis']
padding?: CSSProperties['padding']
align?: 'items-center' | 'items-start' | 'items-end'
justify?: 'justify-center' | 'justify-start' | 'justify-end'
showOrderBy?: boolean
// dataIndex is used as key to extract data from row object
dataIndex?: string
// name can be used as value, which will be used to display in header if title is absent and in data-test-id
name?: string
[key: string]: any
}
type SordDirectionType = 'asc' | 'desc' | undefined
export type {
User,
ProjectMetaInfo,
@ -286,4 +310,6 @@ export type {
FormFieldsLimitOptionsType,
ImageCropperConfig,
AuditLogsQuery,
NcTableColumnProps,
SordDirectionType,
}

7
tests/playwright/pages/Account/Users.ts

@ -76,8 +76,9 @@ export class AccountUsersPage extends BasePage {
// ensure page is loaded
email = this.prefixEmail(email);
await this.get().waitFor();
return this.get().locator(`[data-testid="nc-token-list"]:has-text("${email}")`);
await this.get().locator('.nc-table-row').first().waitFor({ state: 'visible' });
return this.get().locator(`.nc-table-row:has-text("${email}")`).first();
}
async updateRole({ email, role }: { email: string; role: string }) {
@ -99,7 +100,7 @@ export class AccountUsersPage extends BasePage {
async deleteUser({ email }: { email: string }) {
await this.openRowActionMenu({ email });
await this.rootPage.locator('.nc-menu-item:has-text("Remove user")').click();
await this.rootPage.locator('.nc-menu-item:visible:has-text("Remove user")').click();
await this.rootPage.locator('.ant-modal.active button:has-text("Delete User")').click();
await this.verifyToast({ message: 'User deleted successfully' });
}

2
tests/playwright/pages/Dashboard/Import/ImportTemplate.ts

@ -36,7 +36,7 @@ export class ImportTemplatePage extends BasePage {
async getImportColumnList() {
// return an array
const columnList: { type: string; name: string }[] = [];
const tr = this.get().locator(`tr.ant-table-row-level-0:visible`);
const tr = this.get().locator(`tr.nc-table-row:visible`);
const rowCount = await tr.count();
for (let i = 0; i < rowCount; i++) {
// replace \n and \t from innerText

4
tests/playwright/pages/Dashboard/ProjectView/Metadata.ts

@ -38,11 +38,11 @@ export class MetaDataPage extends BasePage {
}
async verifyRow({ index, model, state }: { index: number; model: string; state: string }) {
const fieldLocator = this.get().locator(`tr.ant-table-row`).nth(index).locator(`td.ant-table-cell`).nth(0);
const fieldLocator = this.get().locator(`tr.nc-table-row`).nth(index).locator(`td.nc-table-cell`).nth(0);
const fieldText = await getTextExcludeIconText(fieldLocator);
expect(fieldText).toBe(model);
await expect(this.get().locator(`tr.ant-table-row`).nth(index).locator(`td.ant-table-cell`).nth(1)).toHaveText(
await expect(this.get().locator(`tr.nc-table-row`).nth(index).locator(`td.nc-table-cell`).nth(1)).toHaveText(
state,
{
ignoreCase: true,

Loading…
Cancel
Save