Browse Source

Nc feat: header revamp (#9204)

* fix(nc-gui): update topbar breadcrumb divider

* feat(nc-gui): custom list component setup

* fix(nc-gui): update reload view data tooltip

* feat(nc-gui): custom list component

* feat(nc-gui): add table list menu

* fix(nc-gui): small changes

* fix(nc-gui): add bases list dropdown

* fix(nc-gui): show chevron icon in mobile view

* feat(nc-gui): add view list dropdown in topbar

* fix(nc-gui): auto scroll selected list option on open dropdown

* feat(nc-gui): add typedocs for each fun from custom list component

* chore(nc-gui): add typedocs for new functions

* fix(nc-gui): view search issue on default view

* fix(nc-gui): reset selected option hover state on search input

* fix(nc-gui): font weight issue

* fix(nc-gui): show reload data topbar option

* fix(nc-gui): change view action menu position

* fix(nc-gui): font weight issue

* feat(nc-gui): create new table/view from topbar

* fix(nc-gui): update other page headers

* fix(nc-gui): project view header

* fix(nc-gui): update admin panel workspaces page header

* fix(nc-gui): admin panel base/workspace user page header

* fix(nc-gui): admin panel scroll issue

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

* fix(nc-gui): table list scroll issue

* chore(nc-gui): lint

* fix(nc-gui): reset breadcrumb btn hover state on open dropdown

* fix(nc-gui): review changes

* fix(nc-gui): use slash icon instead of text

* fix(nc-gui): pr review changes

* fix(nc-gui): details tab height issue

* fix(nc-gui): add user account pages breadcrumb

* fix(nc-gui): hide rename view option

* fix(nc-gui): disable scrollIntoView on base rename

* fix(nc-gui): on rename view select text

* fix(nc-gui): user menu overflow issue if sidebar baselist is scrollable

* feat(nc-gui): use virtual scrolling for NcList component

* fix(nc-gui): reduce chevron icon opacity

* chore(nc-gui): lint

* fix(nc-gui): ai review changes

* fix(nc-gui): view rename input focus issue

* fix(nc-gui): topbar width issue

* fix(nc-gui): udpate toolbar height

* fix(nc-gui): update chevron icon from breadcrumb

* fix(nc-gui): update breadcrumb icon size

* fix(nc-gui): add min width for breadcrumb

* fix(nc-gui): add topbar bottom border

* fix(nc-gui): details tab heigth and alignment issue

* fix(nc-gui): hide basename and show only icon

* fix(nc-gui): update NcList component

* fix(nc-gui): update admin panel header

* fix(nc-gui): add header in account settings pages

* fix(nc-gui): add account pages header oss

* fix(nc-gui): udpate max width

* chore(nc-gui): lint

* fix(nc-gui(: reduce topbar top padding

* fix(nc-gui): typo error

* fix(nc-gui): review changes

* fix(nc-gui): review changes

* fix(nc-gui): slash icon conflict

* fix(nc-gui): review changes

* fix(nc-gui): remove chevron icon & add list wrapper div to control height

* fix(nc-gui): ncList keyboard navigation issue

* chore(nc-gui): lint
pull/9230/head
Ramesh Mane 3 months ago committed by GitHub
parent
commit
57c36d9d71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      packages/nc-gui/assets/nc-icons/chevron-down-small.svg
  2. 5
      packages/nc-gui/assets/nc-icons/chevron-up-down-small.svg
  3. 3
      packages/nc-gui/assets/nc-icons/chevron-up-small.svg
  4. 3
      packages/nc-gui/assets/nc-icons/home1.svg
  5. 10
      packages/nc-gui/assets/nc-icons/slash1.svg
  6. 27
      packages/nc-gui/assets/style.scss
  7. 19
      packages/nc-gui/components/account/AppStore.vue
  8. 112
      packages/nc-gui/components/account/Breadcrumb.vue
  9. 5
      packages/nc-gui/components/account/Integration.vue
  10. 107
      packages/nc-gui/components/account/Profile.vue
  11. 163
      packages/nc-gui/components/account/ResetPassword.vue
  12. 43
      packages/nc-gui/components/account/SignupSettings.vue
  13. 353
      packages/nc-gui/components/account/Token.vue
  14. 375
      packages/nc-gui/components/account/UserList.vue
  15. 2
      packages/nc-gui/components/dashboard/Sidebar.vue
  16. 21
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  17. 17
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  18. 22
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  19. 6
      packages/nc-gui/components/dashboard/settings/AppStore.vue
  20. 6
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  21. 2
      packages/nc-gui/components/general/ApiLoader.vue
  22. 2
      packages/nc-gui/components/general/OpenLeftSidebarBtn.vue
  23. 2
      packages/nc-gui/components/general/ShareProject.vue
  24. 2
      packages/nc-gui/components/general/language/index.vue
  25. 338
      packages/nc-gui/components/nc/List.vue
  26. 36
      packages/nc-gui/components/nc/PageHeader.vue
  27. 222
      packages/nc-gui/components/project/AccessSettings.vue
  28. 8
      packages/nc-gui/components/project/AllTables.vue
  29. 22
      packages/nc-gui/components/project/View.vue
  30. 11
      packages/nc-gui/components/smartsheet/Details.vue
  31. 4
      packages/nc-gui/components/smartsheet/Form.vue
  32. 5
      packages/nc-gui/components/smartsheet/Toolbar.vue
  33. 72
      packages/nc-gui/components/smartsheet/Topbar.vue
  34. 2
      packages/nc-gui/components/smartsheet/grid/index.vue
  35. 2
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  36. 2
      packages/nc-gui/components/smartsheet/toolbar/LockType.vue
  37. 37
      packages/nc-gui/components/smartsheet/toolbar/OpenedViewAction.vue
  38. 4
      packages/nc-gui/components/smartsheet/toolbar/Reload.vue
  39. 29
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  40. 253
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  41. 80
      packages/nc-gui/components/smartsheet/topbar/ProjectListDropdown.vue
  42. 12
      packages/nc-gui/components/smartsheet/topbar/SelectMode.vue
  43. 159
      packages/nc-gui/components/smartsheet/topbar/TableListDropdown.vue
  44. 303
      packages/nc-gui/components/smartsheet/topbar/ViewListDropdown.vue
  45. 679
      packages/nc-gui/components/workspace/AuditLogs.vue
  46. 8
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  47. 76
      packages/nc-gui/components/workspace/View.vue
  48. 18
      packages/nc-gui/components/workspace/integrations/view.vue
  49. 54
      packages/nc-gui/pages/account/index.vue
  50. 4
      packages/nc-gui/pages/account/index/[page].vue
  51. 2
      packages/nc-gui/pages/account/index/users/[[nestedPage]].vue
  52. 3
      packages/nc-gui/store/config.ts
  53. 12
      packages/nc-gui/utils/iconUtils.ts
  54. 2
      packages/nc-gui/utils/syncDataUtils.ts

3
packages/nc-gui/assets/nc-icons/chevron-down-small.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3.75 4.5L6 6.75L8.25 4.5" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

5
packages/nc-gui/assets/nc-icons/chevron-up-down-small.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M11 6L8 3L5 6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 10L8 13L11 10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

3
packages/nc-gui/assets/nc-icons/chevron-up-small.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M8.25 6.75L6 4.5L3.75 6.75" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

3
packages/nc-gui/assets/nc-icons/home1.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 1.6665L2.5 7.49984V16.6665C2.5 17.1085 2.67559 17.5325 2.98816 17.845C3.30072 18.1576 3.72464 18.3332 4.16667 18.3332L7.5 18.3332V12.4999H12.5V18.3332L15.8333 18.3332C16.2754 18.3332 16.6993 18.1576 17.0118 17.845C17.3244 17.5325 17.5 17.1085 17.5 16.6665V7.49984L10 1.6665Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

10
packages/nc-gui/assets/nc-icons/slash1.svg

@ -0,0 +1,10 @@
<svg width="6" height="16" viewBox="0 0 6 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_242_24839)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.01007 0.499643C5.36572 0.594938 5.57677 0.960496 5.48148 1.31614L1.80722 15.0287C1.71192 15.3843 1.34637 15.5954 0.990723 15.5001C0.635078 15.4048 0.424023 15.0392 0.519318 14.6836L4.19358 0.971048C4.28887 0.615404 4.65443 0.404349 5.01007 0.499643Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_242_24839">
<rect width="6" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 571 B

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

@ -25,8 +25,8 @@ body {
}
:root {
--toolbar-height: 2.25rem;
--topbar-height: 3.1rem;
--toolbar-height: 2.75rem;
--topbar-height: 3rem;
--sidebar-bottom-height: 8.5rem;
--new-header-height: 3.5rem;
--tw-text-opacity: 1;
@ -935,3 +935,26 @@ svg.nc-virtual-cell-icon {
.nc-edit-or-add-provider-wrapper {
@apply overflow-auto max-h-[max(80vh,500px)] min-w-[384px] rounded-xl shadow-lg shadow-gray-300;
}
.nc-breadcrumb {
@apply text-sm text-gray-700 font-weight-500 flex items-center;
&:not(.nc-no-negative-margin) {
@apply -ml-1;
}
.nc-breadcrumb-item {
@apply h-8 px-2 flex items-center gap-2 my-0;
&.nc-clickable {
@apply cursor-pointer select-none rounded-lg hover:(bg-gray-100 text-gray-900);
}
&.active {
@apply font-bold text-gray-800;
}
}
}
.nc-breadcrumb-divider {
@apply flex-none text-gray-500 !stroke-transparent;
}

19
packages/nc-gui/components/account/AppStore.vue

@ -1,8 +1,19 @@
<template>
<div class="h-full overflow-y-auto scrollbar-thin-dull pt-2 px-5">
<div class="text-xl mt-4 mb-8 text-left font-weight-bold">{{ $t('title.appStore') }}</div>
<div>
<LazyDashboardSettingsAppStore />
<div class="flex flex-col">
<NcPageHeader>
<template #icon>
<GeneralIcon icon="appStore" class="flex-none text-gray-700 text-[20px] h-5 w-5" />
</template>
<template #title>
<span data-rec="true">
{{ $t('title.appStore') }}
</span>
</template>
</NcPageHeader>
<div class="p-6 h-[calc(100vh_-_100px)] border-t-1 border-gray-200 flex flex-col gap-6 overflow-auto nc-scrollbar-thin">
<div>
<LazyDashboardSettingsAppStore />
</div>
</div>
</div>
</template>

112
packages/nc-gui/components/account/Breadcrumb.vue

@ -0,0 +1,112 @@
<script lang="ts" setup>
const route = useRoute()
interface BreadcrumbType {
title: string
active?: boolean
}
const { t } = useI18n()
const breadcrumb = computed<BreadcrumbType[]>(() => {
const payload: BreadcrumbType[] = [
{
title: 'Account',
},
]
switch (route.params.page) {
case 'profile': {
payload.push({
title: t('labels.profile'),
active: true,
})
break
}
case 'tokens': {
payload.push({
title: t('title.tokens'),
active: true,
})
break
}
case 'audit': {
payload.push({
title: t('title.auditLogs'),
active: true,
})
break
}
case 'apps': {
payload.push({
title: t('title.appStore'),
active: true,
})
break
}
}
switch (route.params.nestedPage) {
case 'password-reset': {
payload.push(
...[
{
title: t('objects.users'),
},
{
title: t('title.resetPasswordMenu'),
active: true,
},
],
)
break
}
case 'settings': {
payload.push(
...[
{
title: t('objects.users'),
},
{
title: t('activity.settings'),
active: true,
},
],
)
break
}
}
if ((route.params.page === undefined && route.params.nestedPage === '') || route.params.nestedPage === 'list') {
payload.push(
...[
{
title: t('objects.users'),
},
{
title: t('title.userManagement'),
active: true,
},
],
)
}
return payload
})
</script>
<template>
<div class="nc-breadcrumb">
<template v-for="(item, i) of breadcrumb" :key="i">
<div
class="nc-breadcrumb-item"
:class="{
active: item.active,
}"
>
{{ item.title }}
</div>
<GeneralIcon v-if="i !== breadcrumb.length - 1" icon="ncSlash1" class="nc-breadcrumb-divider" />
</template>
</div>
</template>

5
packages/nc-gui/components/account/Integration.vue

@ -1,5 +0,0 @@
<script lang="ts" setup></script>
<template>
<WorkspaceIntegrationsView />
</template>

107
packages/nc-gui/components/account/Profile.vue

@ -61,57 +61,68 @@ const onValidate = async (_: any, valid: boolean) => {
</script>
<template>
<div class="flex flex-col items-center">
<div class="flex flex-col w-150">
<div class="flex font-bold text-xl" data-rec="true">{{ $t('labels.profile') }}</div>
<div class="mt-5 flex flex-col border-1 rounded-2xl border-gray-200 p-6 gap-y-2">
<div class="flex font-medium text-base" data-rec="true">{{ $t('labels.accountDetails') }}</div>
<div class="flex text-gray-500" data-rec="true">{{ $t('labels.controlAppearance') }}</div>
<div class="flex flex-row mt-4">
<div class="flex h-20 mt-1.5">
<GeneralUserIcon size="xlarge" :email="user?.email" :name="user?.display_name" />
</div>
<div class="flex w-10"></div>
<a-form
ref="formValidator"
layout="vertical"
no-style
:model="form"
class="flex flex-col w-full"
@finish="onSubmit"
@validate="onValidate"
>
<div class="text-gray-800 mb-1.5" data-rec="true">{{ $t('general.name') }}</div>
<a-form-item name="title" :rules="formRules.title">
<div class="flex flex-col">
<NcPageHeader>
<template #icon>
<GeneralIcon class="flex-none !h-5 !w-5" icon="user" />
</template>
<template #title>
<span data-rec="true">
{{ $t('labels.profile') }}
</span>
</template>
</NcPageHeader>
<div class="p-6 h-[calc(100vh_-_100px)] border-t-1 border-gray-200 flex flex-col gap-6 overflow-auto nc-scrollbar-thin">
<div class="flex flex-col w-150 mx-auto">
<div class="mt-5 flex flex-col border-1 rounded-2xl border-gray-200 p-6 gap-y-2">
<div class="flex font-medium text-base" data-rec="true">{{ $t('labels.accountDetails') }}</div>
<div class="flex text-gray-500" data-rec="true">{{ $t('labels.controlAppearance') }}</div>
<div class="flex flex-row mt-4">
<div class="flex h-20 mt-1.5">
<GeneralUserIcon size="xlarge" :email="user?.email" :name="user?.display_name" />
</div>
<div class="flex w-10"></div>
<a-form
ref="formValidator"
layout="vertical"
no-style
:model="form"
class="flex flex-col w-full"
@finish="onSubmit"
@validate="onValidate"
>
<div class="text-gray-800 mb-1.5" data-rec="true">{{ $t('general.name') }}</div>
<a-form-item name="title" :rules="formRules.title">
<a-input
v-model:value="form.title"
class="w-full !rounded-md !py-1.5"
:placeholder="$t('general.name')"
data-testid="nc-account-settings-rename-input"
/>
</a-form-item>
<div class="text-gray-800 mb-1.5" data-rec="true">{{ $t('labels.accountEmailID') }}</div>
<a-input
v-model:value="form.title"
v-model:value="email"
class="w-full !rounded-md !py-1.5"
:placeholder="$t('general.name')"
data-testid="nc-account-settings-rename-input"
:placeholder="$t('labels.email')"
disabled
data-testid="nc-account-settings-email-input"
/>
</a-form-item>
<div class="text-gray-800 mb-1.5" data-rec="true">{{ $t('labels.accountEmailID') }}</div>
<a-input
v-model:value="email"
class="w-full !rounded-md !py-1.5"
:placeholder="$t('labels.email')"
disabled
data-testid="nc-account-settings-email-input"
/>
<div class="flex flex-row w-full justify-end mt-8" data-rec="true">
<NcButton
type="primary"
html-type="submit"
:disabled="isErrored || (form?.title && form?.title === user?.display_name)"
:loading="isTitleUpdating"
data-testid="nc-account-settings-save"
@click="onSubmit"
>
<template #loading> {{ $t('general.saving') }} </template>
{{ $t('general.save') }}
</NcButton>
</div>
</a-form>
<div class="flex flex-row w-full justify-end mt-8" data-rec="true">
<NcButton
type="primary"
html-type="submit"
:disabled="isErrored || (form?.title && form?.title === user?.display_name)"
:loading="isTitleUpdating"
data-testid="nc-account-settings-save"
@click="onSubmit"
>
<template #loading> {{ $t('general.saving') }} </template>
{{ $t('general.save') }}
</NcButton>
</div>
</a-form>
</div>
</div>
</div>
</div>

163
packages/nc-gui/components/account/ResetPassword.vue

@ -57,84 +57,97 @@ const resetError = () => {
</script>
<template>
<div class="mx-auto relative flex flex-col justify-start gap-2 w-full px-8 md:(bg-white) max-w-[900px]">
<div class="text-xl my-4 text-left font-weight-bold">{{ $t('activity.changePwd') }}</div>
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full"
no-style
:model="form"
@finish="passwordChange"
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center" data-rec="true">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item
:label="$t('placeholder.password.current')"
data-rec="true"
name="currentPassword"
:rules="formRules.currentPassword"
>
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" data-rec="true" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item
:label="$t('placeholder.password.confirm')"
data-rec="true"
name="passwordRepeat"
:rules="formRules.passwordRepeat"
>
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-right">
<a-button
size="middle"
data-testid="nc-user-settings-form__submit"
class="!rounded-md !h-[2.5rem]"
type="primary"
html-type="submit"
<div class="flex flex-col">
<NcPageHeader>
<template #icon>
<GeneralIcon icon="passwordChange" class="flex-none text-gray-700 text-[20px] h-5 w-5" />
</template>
<template #title>
<span data-rec="true">
{{ $t('activity.changePwd') }}
</span>
</template>
</NcPageHeader>
<div class="p-6 h-[calc(100vh_-_100px)] border-t-1 border-gray-200 flex flex-col gap-6 overflow-auto nc-scrollbar-thin">
<div class="mx-auto relative flex flex-col justify-start gap-2 w-full md:(bg-white) max-w-[900px]">
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full"
no-style
:model="form"
@finish="passwordChange"
>
<div class="flex justify-center items-center gap-2" data-rec="true">
<component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }}
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center" data-rec="true">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item
:label="$t('placeholder.password.current')"
data-rec="true"
name="currentPassword"
:rules="formRules.currentPassword"
>
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" data-rec="true" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item
:label="$t('placeholder.password.confirm')"
data-rec="true"
name="passwordRepeat"
:rules="formRules.passwordRepeat"
>
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-right">
<a-button
size="middle"
data-testid="nc-user-settings-form__submit"
class="!rounded-md !h-[2.5rem]"
type="primary"
html-type="submit"
>
<div class="flex justify-center items-center gap-2" data-rec="true">
<component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }}
</div>
</a-button>
</div>
</a-button>
</a-form>
</div>
</a-form>
</div>
</div>
</template>

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

@ -29,21 +29,34 @@ loadSettings()
</script>
<template>
<div class="p-7 flex flex-col items-center">
<h1 class="text-2xl mt-4 mb-5 pl-3.5 font-bold">{{ t('activity.settings') }}</h1>
<div class="flex items-center gap-2">
<a-form-item>
<a-checkbox
v-model:checked="settings.invite_only_signup"
v-e="['c:account:enable-signup']"
class="nc-checkbox nc-invite-only-signup-checkbox !mt-6"
name="virtual"
@change="saveSettings"
/>
</a-form-item>
<span data-rec="true">
{{ $t('labels.inviteOnlySignup') }}
</span>
<div class="flex flex-col">
<NcPageHeader>
<template #icon>
<GeneralIcon icon="settings" class="flex-none text-[20px] text-gray-700 h-5 w-5" />
</template>
<template #title>
<span data-rec="true">
{{ $t('activity.settings') }}
</span>
</template>
</NcPageHeader>
<div class="p-6 h-[calc(100vh_-_100px)] border-t-1 border-gray-200 flex flex-col gap-6 overflow-auto nc-scrollbar-thin">
<div class="flex flex-col items-center">
<div class="flex items-center gap-2">
<a-form-item>
<a-checkbox
v-model:checked="settings.invite_only_signup"
v-e="['c:account:enable-signup']"
class="nc-checkbox nc-invite-only-signup-checkbox !mt-6"
name="virtual"
@change="saveSettings"
/>
</a-form-item>
<span data-rec="true">
{{ $t('labels.inviteOnlySignup') }}
</span>
</div>
</div>
</div>
</div>
</template>

353
packages/nc-gui/components/account/Token.vue

@ -224,189 +224,212 @@ const handleCancel = () => {
</script>
<template>
<div class="h-full pt-2">
<div class="max-w-202 mx-auto px-4 h-full" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-baseline justify-between">
<h6 class="text-2xl text-left font-bold" data-rec="true">{{ $t('title.apiTokens') }}</h6>
<NcTooltip v-if="tokens.length" :disabled="!(isEeUI && tokens.length)">
<template #title>{{ $t('labels.tokenLimit') }}</template>
<div class="flex flex-col">
<NcPageHeader>
<template #icon>
<MdiShieldKeyOutline class="flex-none text-gray-700 h-5 w-5" />
</template>
<template #title>
<span data-rec="true">
{{ $t('title.tokens') }}
</span>
</template>
</NcPageHeader>
<div class="p-6 h-[calc(100vh_-_100px)] border-t-1 border-gray-200 flex flex-col gap-6 overflow-auto nc-scrollbar-thin">
<div class="max-w-202 mx-auto h-full w-full" data-testid="nc-token-list">
<div class="flex gap-4 items-baseline justify-between">
<h6 class="text-xl text-left font-bold my-0" data-rec="true">{{ $t('title.apiTokens') }}</h6>
<NcTooltip v-if="tokens.length" :disabled="!(isEeUI && tokens.length)">
<template #title>{{ $t('labels.tokenLimit') }}</template>
<NcButton
:disabled="showNewTokenModal || (isEeUI && tokens.length)"
class="!rounded-md"
data-testid="nc-token-create"
size="middle"
type="primary"
tooltip="bottom"
@click="showNewTokenModal = true"
>
<span class="hidden md:block" data-rec="true">
{{ $t('title.addNewToken') }}
</span>
<span class="flex items-center justify-center md:hidden" data-rec="true">
<component :is="iconMap.plus" />
</span>
</NcButton>
</NcTooltip>
</div>
<span data-rec="true">{{ $t('msg.apiTokenCreate') }}</span>
<div v-if="!isLoadingAllTokens && (tokens.length || showNewTokenModal)" class="mt-6 h-full max-h-[calc(100%-80px)]">
<div class="h-full w-full overflow-y-auto rounded-md">
<div class="flex w-full pl-5 bg-gray-50 border-1 rounded-t-md">
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9" data-rec="true">{{ $t('title.tokenName') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9 text-start" data-rec="true">{{
$t('title.creator')
}}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-3/9 text-start" data-rec="true">{{
$t('labels.token')
}}</span>
<span class="py-3.5 pl-19 text-gray-500 font-medium text-3.5 w-2/9 text-start" data-rec="true">{{
$t('labels.actions')
}}</span>
</div>
<div class="nc-scrollbar-md !overflow-y-auto flex flex-col h-[calc(100%-52px)]">
<div v-if="showNewTokenModal">
<div
class="flex gap-5 px-3 py-2.5 text-gray-500 font-medium text-3.5 w-full nc-token-generate border-b-1 border-l-1 border-r-1"
:class="{
'rounded-b-md': !tokens.length,
}"
>
<div class="flex w-full">
<a-input
:ref="selectInputOnMount"
v-model:value="selectedTokenData.description"
:default-value="defaultTokenName"
type="text"
class="!rounded-lg !py-1"
placeholder="Token Name"
data-testid="nc-token-input"
:disabled="isLoading"
@press-enter="generateToken"
/>
<span v-if="!isValidTokenName" class="text-red-500 text-xs font-light mt-1.5 ml-1" data-rec="true"
>{{ errorMessage }}
</span>
</div>
<div class="flex gap-2 justify-start">
<NcButton v-if="!isLoading" type="secondary" size="small" @click="handleCancel">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
type="primary"
size="sm"
:loading="isLoading"
data-testid="nc-token-save-btn"
@click="generateToken"
>
{{ $t('general.save') }}
</NcButton>
</div>
</div>
</div>
<div
v-if="!tokens.length && !showNewTokenModal"
class="border-l-1 border-r-1 border-b-1 rounded-b-md justify-center flex items-center"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noToken')" />
</div>
<div
v-for="el of tokens"
:key="el.id"
data-testid="nc-token-list"
class="flex pl-5 py-3 justify-between token items-center border-l-1 border-r-1 border-b-1"
>
<span class="text-black font-bold text-3.5 text-start w-2/9">
<GeneralTruncateText placement="top" :length="20">
{{ el.description }}
</GeneralTruncateText>
</span>
<span class="text-gray-500 font-medium text-3.5 text-start w-2/9">
<GeneralTruncateText placement="top" :length="20">
{{ el.created_by }}
</GeneralTruncateText>
</span>
<span class="text-gray-500 font-medium text-3.5 text-start w-3/9">
<GeneralTruncateText v-if="el.token === selectedToken.id && selectedToken.isShow" placement="top" :length="29">
{{ el.token }}
</GeneralTruncateText>
<span v-else>************************************</span>
</span>
<!-- ACTIONS -->
<div class="flex justify-end items-center gap-3 pr-5 text-gray-500 font-medium text-3.5 w-2/9">
<NcTooltip placement="top">
<template #title>{{ $t('labels.showOrHide') }}</template>
<component
:is="iconMap.eye"
class="nc-toggle-token-visibility hover::cursor-pointer w-h-4 mb-[1.8px]"
@click="hideOrShowToken(el.token as string)"
/>
</NcTooltip>
<NcTooltip placement="top">
<template #title>{{ $t('general.copy') }}</template>
<component
:is="iconMap.copy"
class="hover::cursor-pointer w-4 h-4 text-gray-600"
@click="copyToken(el.token)"
/>
</NcTooltip>
<NcTooltip placement="top">
<template #title>{{ $t('general.delete') }}</template>
<component
:is="iconMap.delete"
data-testid="nc-token-row-action-icon"
class="nc-delete-icon hover::cursor-pointer w-4 h-4"
@click="triggerDeleteModal(el.token as string, el.description as string)"
/>
</NcTooltip>
</div>
</div>
</div>
</div>
</div>
<div
v-else-if="!isLoadingAllTokens && !tokens.length && !showNewTokenModal"
class="max-w-[40rem] border px-3 py-6 flex flex-col items-center justify-center gap-6 text-center"
>
<img src="~assets/img/placeholder/api-tokens.png" class="!w-[22rem] flex-none" />
<div class="text-2xl text-gray-800 font-bold">{{ $t('placeholder.noTokenCreated') }}</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.noTokenCreatedLabel') }}
</div>
<NcButton
:disabled="showNewTokenModal || (isEeUI && tokens.length)"
class="!rounded-md"
class="!rounded-lg !py-3 !h-10"
data-testid="nc-token-create"
size="middle"
type="primary"
tooltip="bottom"
@click="showNewTokenModal = true"
>
<span class="hidden md:block" data-rec="true">
{{ $t('title.addNewToken') }}
{{ $t('title.createNewToken') }}
</span>
<span class="flex items-center justify-center md:hidden" data-rec="true">
<component :is="iconMap.plus" />
</span>
</NcButton>
</NcTooltip>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-5">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
show-less-items
@change="loadTokens(currentPage, currentLimit)"
/>
</div>
</div>
<span data-rec="true">{{ $t('msg.apiTokenCreate') }}</span>
<div v-if="!isLoadingAllTokens && (tokens.length || showNewTokenModal)" class="mt-5 h-[calc(100%-13rem)]">
<div class="h-full w-full !overflow-hidden rounded-md">
<div class="flex w-full pl-5 bg-gray-50 border-1 rounded-t-md">
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9" data-rec="true">{{ $t('title.tokenName') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9 text-start" data-rec="true">{{
$t('title.creator')
}}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-3/9 text-start" data-rec="true">{{
$t('labels.token')
}}</span>
<span class="py-3.5 pl-19 text-gray-500 font-medium text-3.5 w-2/9 text-start" data-rec="true">{{
$t('labels.actions')
}}</span>
</div>
<div class="nc-scrollbar-md !overflow-y-auto flex flex-col h-[calc(100%-5rem)]">
<div v-if="showNewTokenModal">
<GeneralDeleteModal
v-model:visible="isModalOpen"
:entity-name="$t('labels.token')"
:on-delete="() => deleteToken(tokenToCopy)"
>
<template #entity-preview>
<span>
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="key" class="nc-view-icon"></GeneralIcon>
<div
class="flex gap-5 px-3 py-2.5 text-gray-500 font-medium text-3.5 w-full nc-token-generate border-b-1 border-l-1 border-r-1"
:class="{
'rounded-b-md': !tokens.length,
}"
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
<div class="flex w-full">
<a-input
:ref="selectInputOnMount"
v-model:value="selectedTokenData.description"
:default-value="defaultTokenName"
type="text"
class="!rounded-lg !py-1"
placeholder="Token Name"
data-testid="nc-token-input"
:disabled="isLoading"
@press-enter="generateToken"
/>
<span v-if="!isValidTokenName" class="text-red-500 text-xs font-light mt-1.5 ml-1" data-rec="true"
>{{ errorMessage }}
</span>
</div>
<div class="flex gap-2 justify-start">
<NcButton v-if="!isLoading" type="secondary" size="small" @click="handleCancel">
{{ $t('general.cancel') }}
</NcButton>
<NcButton type="primary" size="sm" :loading="isLoading" data-testid="nc-token-save-btn" @click="generateToken">
{{ $t('general.save') }}
</NcButton>
</div>
</div>
</div>
<div
v-if="!tokens.length && !showNewTokenModal"
class="border-l-1 border-r-1 border-b-1 rounded-b-md justify-center flex items-center"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noToken')" />
</div>
<div
v-for="el of tokens"
:key="el.id"
data-testid="nc-token-list"
class="flex pl-5 py-3 justify-between token items-center border-l-1 border-r-1 border-b-1"
>
<span class="text-black font-bold text-3.5 text-start w-2/9">
<GeneralTruncateText placement="top" :length="20">
{{ el.description }}
</GeneralTruncateText>
</span>
<span class="text-gray-500 font-medium text-3.5 text-start w-2/9">
<GeneralTruncateText placement="top" :length="20">
{{ el.created_by }}
</GeneralTruncateText>
</span>
<span class="text-gray-500 font-medium text-3.5 text-start w-3/9">
<GeneralTruncateText v-if="el.token === selectedToken.id && selectedToken.isShow" placement="top" :length="29">
{{ el.token }}
</GeneralTruncateText>
<span v-else>************************************</span>
</span>
<!-- ACTIONS -->
<div class="flex justify-end items-center gap-3 pr-5 text-gray-500 font-medium text-3.5 w-2/9">
<NcTooltip placement="top">
<template #title>{{ $t('labels.showOrHide') }}</template>
<component
:is="iconMap.eye"
class="nc-toggle-token-visibility hover::cursor-pointer w-h-4 mb-[1.8px]"
@click="hideOrShowToken(el.token as string)"
/>
</NcTooltip>
<NcTooltip placement="top">
<template #title>{{ $t('general.copy') }}</template>
<component
:is="iconMap.copy"
class="hover::cursor-pointer w-4 h-4 text-gray-600"
@click="copyToken(el.token)"
/>
</NcTooltip>
<NcTooltip placement="top">
<template #title>{{ $t('general.delete') }}</template>
<component
:is="iconMap.delete"
data-testid="nc-token-row-action-icon"
class="nc-delete-icon hover::cursor-pointer w-4 h-4"
@click="triggerDeleteModal(el.token as string, el.description as string)"
/>
</NcTooltip>
{{ tokenDesc }}
</div>
</div>
</div>
</div>
</div>
<div
v-else-if="!isLoadingAllTokens && !tokens.length && !showNewTokenModal"
class="max-w-[40rem] border px-3 py-6 flex flex-col items-center justify-center gap-6 text-center"
>
<img src="~assets/img/placeholder/api-tokens.png" class="!w-[22rem] flex-none" />
<div class="text-2xl text-gray-800 font-bold">{{ $t('placeholder.noTokenCreated') }}</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.noTokenCreatedLabel') }}
</div>
<NcButton class="!rounded-lg !py-3 !h-10" data-testid="nc-token-create" type="primary" @click="showNewTokenModal = true">
<span class="hidden md:block" data-rec="true">
{{ $t('title.createNewToken') }}
</span>
<span class="flex items-center justify-center md:hidden" data-rec="true">
<component :is="iconMap.plus" />
</span>
</NcButton>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-5">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
show-less-items
@change="loadTokens(currentPage, currentLimit)"
/>
</div>
</template>
</GeneralDeleteModal>
</div>
<GeneralDeleteModal
v-model:visible="isModalOpen"
:entity-name="$t('labels.token')"
:on-delete="() => deleteToken(tokenToCopy)"
>
<template #entity-preview>
<span>
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="key" class="nc-view-icon"></GeneralIcon>
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ tokenDesc }}
</div>
</div>
</span>
</template>
</GeneralDeleteModal>
</div>
</template>

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

@ -211,198 +211,211 @@ const columns = [
</script>
<template>
<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">
<a-input
v-model:value="searchText"
class="!max-w-90 !rounded-md"
:placeholder="$t('title.searchMembers')"
@change="loadUsers()"
>
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<div class="flex gap-3 items-center justify-center">
<component :is="iconMap.reload" class="cursor-pointer" @click="loadUsers(currentPage, currentLimit)" />
<NcButton data-testid="nc-super-user-invite" size="small" type="primary" @click="openInviteModal">
<div class="flex items-center gap-1" data-rec="true">
<component :is="iconMap.plus" />
{{ $t('activity.inviteUser') }}
</div>
</NcButton>
</div>
</div>
<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 }}
</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>
<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)"
<div class="flex flex-col" data-testid="nc-super-user-list">
<NcPageHeader>
<template #icon>
<GeneralIcon icon="users" class="flex-none text-gray-700 h-5 w-5" />
</template>
<template #title>
<span data-rec="true">
{{ $t('title.userManagement') }}
</span>
</template>
</NcPageHeader>
<div class="p-6 h-[calc(100vh_-_100px)] border-t-1 border-gray-200 flex flex-col gap-6 overflow-auto nc-scrollbar-thin">
<div class="h-full">
<div class="max-w-195 mx-auto h-full">
<div class="flex gap-4 items-center justify-between">
<a-input
v-model:value="searchText"
class="!max-w-90 !rounded-md"
:placeholder="$t('title.searchMembers')"
@change="loadUsers()"
>
<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="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>
</div>
</a-select-option>
<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="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>
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<div class="flex gap-3 items-center justify-center">
<component :is="iconMap.reload" class="cursor-pointer" @click="loadUsers(currentPage, currentLimit)" />
<NcButton data-testid="nc-super-user-invite" size="small" type="primary" @click="openInviteModal">
<div class="flex items-center gap-1" data-rec="true">
<component :is="iconMap.plus" />
{{ $t('activity.inviteUser') }}
</div>
</a-select-option>
</NcSelect>
<div v-else class="font-weight-bold" data-rec="true">
{{ $t(`objects.roleType.orgLevelCreator`) }}
</NcButton>
</div>
</template>
<div
v-if="column.key === 'action'"
class="flex items-center gap-2"
:class="{
'opacity-0 pointer-events-none': el.roles?.includes('super'),
}"
</div>
<NcTable
v-model:order-by="orderBy"
:columns="columns"
:data="sortedUsers"
:is-data-loading="isLoading"
class="h-[calc(100%-58px)] max-w-250 mt-6"
>
<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 #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 }}
</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>
{{ el.display_name }}
</NcTooltip>
<NcTooltip v-else class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ el.email }}
</template>
</NcMenu>
{{ 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>
<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`)"
>
<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="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>
</div>
</a-select-option>
<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="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>
</div>
</a-select-option>
</NcSelect>
<div v-else class="font-weight-bold" data-rec="true">
{{ $t(`objects.roleType.orgLevelCreator`) }}
</div>
</template>
</NcDropdown>
</div>
</template>
<template #extraRow>
<div
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') }}
</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</div>
<img src="~assets/img/placeholder/invite-team.png" class="!w-[30rem] flex-none" />
</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>
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="account" class="nc-view-icon"></GeneralIcon>
<div
class="text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
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>
<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="pagination.total === 1 && sortedUsers.length === 1"
class="w-full pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center"
>
{{ deleteModalInfo?.email }}
<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" class="!w-[30rem] flex-none" />
</div>
</div>
</span>
</template>
</GeneralDeleteModal>
</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>
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="account" class="nc-view-icon"></GeneralIcon>
<div
class="text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ deleteModalInfo?.email }}
</div>
</div>
</span>
</template>
</GeneralDeleteModal>
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" />
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" />
</div>
</div>
</div>
</div>
</template>

2
packages/nc-gui/components/dashboard/Sidebar.vue

@ -56,7 +56,7 @@ onUnmounted(() => {
>
<DashboardTreeView v-if="!isWorkspaceLoading" />
</div>
<div v-if="!isSharedBase" class="overflow-auto">
<div v-if="!isSharedBase" class="flex-none overflow-auto">
<GeneralGift v-if="!isEeUI" />
<div class="border-t-1 w-full"></div>
<DashboardSidebarBeforeUserInfo />

21
packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue

@ -32,6 +32,27 @@ const overlayClassName = computed(() => {
return 'nc-view-create-dropdown'
})
/**
* Opens a modal for creating or editing a view.
*
* @param options - The options for opening the modal.
* @param options.title - The title of the modal. Default is an empty string.
* @param options.type - The type of view to create or edit.
* @param options.copyViewId - The ID of the view to copy, if creating a copy.
* @param options.groupingFieldColumnId - The ID of the column to use for grouping, if applicable.
* @param options.calendarRange - The date range for calendar views.
* @param options.coverImageColumnId - The ID of the column to use for cover images, if applicable.
*
* @returns A Promise that resolves when the modal operation is complete.
*
* @remarks
* This function opens a modal dialog for creating or editing a view.
* It handles the dialog state, view creation, and navigation to the newly created view.
* After creating a view, it refreshes the command palette and reloads the views.
*
* @see {@link packages/nc-gui/components/smartsheet/topbar/ViewListDropdown.vue} for a similar implementation of view creation dialog.
* If this function is updated, consider updating the other implementations as well.
*/
async function onOpenModal({
title = '',
type,

17
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -112,7 +112,7 @@ const enableEditMode = () => {
nextTick(() => {
input.value?.focus()
input.value?.select()
input.value?.scrollIntoView()
// input.value?.scrollIntoView()
})
}
@ -128,7 +128,7 @@ const enableEditModeForSource = (sourceId: string) => {
if (!input) return
input?.focus()
input?.select()
input?.scrollIntoView()
// input?.scrollIntoView()
})
}
@ -225,6 +225,19 @@ const setColor = async (color: string, base: BaseType) => {
}
}
/**
* Opens a dialog to create a new table.
*
* @returns {void}
*
* @remarks
* This function is triggered when the user initiates the table creation process.
* It opens a dialog for table creation, handles the dialog closure,
* and potentially scrolls to the newly created table.
*
* @see {@link packages/nc-gui/components/smartsheet/topbar/TableListDropdown.vue} for a similar implementation
* of table creation dialog. If this function is updated, consider updating the other implementation as well.
*/
function openTableCreateDialog(sourceIndex?: number | undefined) {
const isOpen = ref(true)
let sourceId = base.value!.sources?.[0].id

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

@ -1,5 +1,4 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import type { TableType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import { isDefaultBase as _isDefaultBase } from '#imports'
@ -64,6 +63,8 @@ const isDefaultBase = computed(() => {
return _isDefaultBase(source)
})
const input = ref<HTMLInputElement>()
const isDropdownOpen = ref(false)
const isEditing = ref(false)
@ -92,6 +93,13 @@ const handleOnClick = () => {
}
}
const focusInput = () => {
setTimeout(() => {
input.value?.focus()
input.value?.select()
})
}
/** Enable editing view name on dbl click */
function onDblClick() {
if (isMobileMode.value) return
@ -101,6 +109,10 @@ function onDblClick() {
isEditing.value = true
_title.value = vModel.value.title
$e('c:view:rename', { view: vModel.value?.type })
nextTick(() => {
focusInput()
})
}
}
@ -142,11 +154,13 @@ const onRenameMenuClick = () => {
isEditing.value = true
_title.value = vModel.value.title
$e('c:view:rename', { view: vModel.value?.type })
nextTick(() => {
focusInput()
})
}
}
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
/** Rename a view */
async function onRename() {
isDropdownOpen.value = false
@ -244,7 +258,7 @@ watch(isDropdownOpen, async () => {
<a-input
v-if="isEditing"
:ref="focusInput"
ref="input"
v-model:value="_title"
class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent !pl-0 !flex-1 mr-4"
:class="{

6
packages/nc-gui/components/dashboard/settings/AppStore.vue

@ -106,11 +106,11 @@ onMounted(async () => {
</div>
</a-modal>
<div class="flex flex-wrap mt-4 w-full gap-5 mb-10">
<div class="flex flex-wrap w-full gap-5">
<a-card
v-for="(app, i) in apps"
:key="i"
class="sm:w-100 md:w-138.1"
class="sm:w-100 md:w-130"
:class="`relative flex overflow-x-hidden app-item-card !shadow-sm rounded-md w-full nc-app-store-card-${app.title}`"
>
<div class="install-btn flex flex-row justify-end space-x-1">
@ -137,7 +137,7 @@ onMounted(async () => {
</div>
<div class="flex flex-row space-x-2 items-center justify-start w-full">
<div class="flex w-20 pl-3">
<div class="flex w-[68px]">
<img
v-if="app.title !== 'SMTP'"
class="avatar"

6
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -294,8 +294,8 @@ const handleClickRow = (source: SourceType, tab?: string) => {
</script>
<template>
<div class="flex flex-col h-full" data-testid="nc-settings-datasources-tab">
<div class="px-1 pt-3 mb-6 flex items-center justify-between gap-3">
<div class="flex flex-col h-full p-6" data-testid="nc-settings-datasources-tab">
<div class="mb-6 flex items-center justify-between gap-3">
<a-input
v-model:value="searchQuery"
type="text"
@ -429,7 +429,7 @@ const handleClickRow = (source: SourceType, tab?: string) => {
@source-created="loadBases(true)"
/>
</template>
<div v-else class="ds-table overflow-y-auto nc-scrollbar-thin relative max-h-full mx-1 mb-4">
<div v-else class="ds-table overflow-y-auto nc-scrollbar-thin relative max-h-full mb-4">
<div class="ds-table-head sticky top-0 bg-white z-10">
<div class="ds-table-row !border-0">
<div class="ds-table-col ds-table-enabled cursor-pointer">{{ $t('general.visibility') }}</div>

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

@ -4,7 +4,7 @@ const { isLoading } = useGlobal()
<template>
<div class="flex justify-center self-center min-w-6">
<div v-if="isLoading" class="flex items-center gap-2 ml-3 text-gray-200 text-xs" data-testid="nc-loading">
<div v-if="isLoading" class="flex items-center gap-2 text-gray-200 text-xs" data-testid="nc-loading">
<component :is="iconMap.loading" class="animate-infinite animate-spin" />
</div>
</div>

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

@ -11,7 +11,7 @@ const onClick = () => {
</script>
<template>
<div v-e="['c:leftSidebar:hideToggle']">
<div v-if="isMobileMode || !isLeftSidebarOpen" v-e="['c:leftSidebar:hideToggle']">
<NcTooltip
placement="topLeft"
hide-on-click

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

@ -45,7 +45,7 @@ const copySharedBase = async () => {
<template>
<div
v-if="!isSharedBase && isUIAllowed('baseShare') && visibility !== 'hidden' && (activeTable || base)"
class="nc-share-base-button flex flex-col justify-center h-full"
class="nc-share-base-button flex flex-col justify-center"
data-testid="share-base-button"
:data-sharetype="visibility"
>

2
packages/nc-gui/components/general/language/index.vue

@ -5,7 +5,7 @@
overlay-class-name="nc-dropdown-menu-translate"
>
<div v-bind="$attrs" class="flex items-center justify-center">
<MaterialSymbolsTranslate class="md:text-xl nc-menu-translate" />
<MaterialSymbolsTranslate class="text-base nc-menu-translate" />
</div>
<template #overlay>

338
packages/nc-gui/components/nc/List.vue

@ -0,0 +1,338 @@
<script lang="ts" setup>
import { useVirtualList } from '@vueuse/core'
export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'
export type RawValueType = string | number
interface ListItem {
value?: RawValueType
label?: string
[key: string]: any
}
/**
* Props interface for the List component
*/
interface Props {
/** The currently selected value */
value: RawValueType
/** The list of items to display */
list: ListItem[]
/**
* The key to use for accessing the value from a list item
* @default 'value'
*/
optionValueKey?: string
/**
* The key to use for accessing the label from a list item
* @default 'label'
*/
optionLabelKey?: string
/** Whether the list is open or closed */
open?: boolean
/** Whether to close the list after an item is selected */
closeOnSelect?: boolean
/** Placeholder text for the search input */
searchInputPlaceholder?: string
/** Whether to show the currently selected option */
showSelectedOption?: boolean
/** Custom filter function for list items */
filterOption?: (input: string, option: ListItem, index: Number) => boolean
}
interface Emits {
(e: 'update:value', value: RawValueType): void
(e: 'update:open', open: boolean): void
(e: 'change', option: ListItem): void
}
const props = withDefaults(defineProps<Props>(), {
open: false,
closeOnSelect: true,
showSelectedOption: true,
optionValueKey: 'value',
optionLabelKey: 'label',
})
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'value', emits)
const vOpen = useVModel(props, 'open', emits)
const { optionValueKey, optionLabelKey } = props
const { closeOnSelect, showSelectedOption } = toRefs(props)
const listRef = ref<HTMLDivElement>()
const searchQuery = ref('')
const inputRef = ref()
const activeOptionIndex = ref(-1)
const showHoverEffectOnSelectedOption = ref(true)
const isSearchEnabled = computed(() => props.list.length > 4)
/**
* Computed property that filters the list of options based on the search query.
* If a custom filter function is provided via props.filterOption, it will be used instead of the default filtering logic.
*
* @returns Filtered list of options
*
* @typeparam ListItem - The type of items in the list
*/
const list = computed(() => {
const query = searchQuery.value.toLowerCase()
return props.list.filter((item, i) => {
if (props?.filterOption) {
return props.filterOption(query, item, i)
} else {
return item[optionLabelKey]?.toLowerCase()?.includes(query)
}
})
})
const {
list: virtualList,
containerProps,
wrapperProps,
scrollTo,
} = useVirtualList(list, {
itemHeight: 38,
})
/**
* Resets the hover effect on the selected option
* @param clearActiveOption - Whether to clear the active option index
*/
const handleResetHoverEffect = (clearActiveOption = false, newActiveIndex?: number) => {
if (clearActiveOption && showHoverEffectOnSelectedOption.value) {
activeOptionIndex.value = -1
}
if (newActiveIndex !== undefined) {
activeOptionIndex.value = newActiveIndex
}
if (!showHoverEffectOnSelectedOption.value) return
showHoverEffectOnSelectedOption.value = false
}
/**
* Handles the selection of an option from the list
*
* @param option - The selected list item
*
* This function is responsible for handling the selection of an option from the list.
* It updates the model value, emits a change event, and optionally closes the dropdown.
*/
const handleSelectOption = (option: ListItem) => {
if (!option?.[optionValueKey]) return
vModel.value = option[optionValueKey] as RawValueType
emits('change', option)
if (closeOnSelect.value) {
vOpen.value = false
}
}
/**
* Automatically scrolls to the active option in the list
*/
const handleAutoScrollOption = (useDelay = false) => {
if (activeOptionIndex.value === -1) return
if (!useDelay) {
scrollTo(activeOptionIndex.value)
return
}
setTimeout(() => {
scrollTo(activeOptionIndex.value)
}, 150)
}
const onArrowDown = () => {
handleResetHoverEffect()
if (activeOptionIndex.value === list.value.length - 1) return
activeOptionIndex.value = Math.min(activeOptionIndex.value + 1, list.value.length - 1)
handleAutoScrollOption()
}
const onArrowUp = () => {
handleResetHoverEffect()
if (activeOptionIndex.value === 0) return
activeOptionIndex.value = Math.max(activeOptionIndex.value - 1, 0)
handleAutoScrollOption()
}
const handleKeydownEnter = () => {
if (list.value[activeOptionIndex.value]) {
handleSelectOption(list.value[activeOptionIndex.value])
} else if (list.value[0]) {
handleSelectOption(list.value[activeOptionIndex.value])
}
}
/**
* Focuses the input box when the list is opened
*/
const focusInputBox = () => {
if (!vOpen.value) return
setTimeout(() => {
inputRef.value?.focus()
}, 100)
}
/**
* Focuses the list wrapper when the list is opened
*
* This function is called when the list is opened and search is not enabled.
* It sets a timeout to focus the list wrapper element after a short delay.
* This allows for proper rendering and improves accessibility.
*/
const focusListWrapper = () => {
if (!vOpen.value || isSearchEnabled.value) return
setTimeout(() => {
listRef.value?.focus()
}, 100)
}
watch(
vOpen,
() => {
if (!vOpen.value) return
searchQuery.value = ''
showHoverEffectOnSelectedOption.value = true
if (vModel.value) {
activeOptionIndex.value = list.value.findIndex((o) => o?.[optionValueKey] === vModel.value)
nextTick(() => {
handleAutoScrollOption(true)
})
} else {
activeOptionIndex.value = -1
}
if (isSearchEnabled.value) {
focusInputBox()
} else {
focusListWrapper()
}
},
{
immediate: true,
},
)
</script>
<template>
<div
ref="listRef"
tabindex="0"
class="flex flex-col pt-2 w-64 !focus:(shadow-none outline-none)"
@keydown.arrow-down.prevent="onArrowDown"
@keydown.arrow-up.prevent="onArrowUp"
@keydown.enter.prevent="handleSelectOption(list[activeOptionIndex])"
>
<template v-if="isSearchEnabled">
<div class="w-full px-2" @click.stop>
<a-input
ref="inputRef"
v-model:value="searchQuery"
:placeholder="searchInputPlaceholder || $t('placeholder.searchFields')"
class="nc-toolbar-dropdown-search-field-input !pl-2 !pr-1.5"
allow-clear
:bordered="false"
@keydown.enter.stop="handleKeydownEnter"
@change="handleResetHoverEffect(false, 0)"
>
<template #prefix> <GeneralIcon icon="search" class="nc-search-icon h-3.5 w-3.5 mr-1" /> </template
></a-input>
</div>
<NcDivider />
</template>
<slot name="listHeader"></slot>
<div class="nc-list-wrapper">
<template v-if="list.length">
<div class="h-auto !max-h-[247px]">
<div
v-bind="containerProps"
class="nc-list !h-auto w-full nc-scrollbar-thin px-2 pb-2"
:style="{
maxHeight: '247px !important',
}"
>
<div v-bind="wrapperProps">
<div
v-for="{ data: option, index: idx } in virtualList"
:key="idx"
class="flex items-center gap-2 w-full py-2 px-2 hover:bg-gray-100 cursor-pointer rounded-md"
:class="[
`nc-list-option-${idx}`,
{
'nc-list-option-selected': option[optionValueKey] === vModel,
'bg-gray-100 ': showHoverEffectOnSelectedOption && option[optionValueKey] === vModel,
'bg-gray-100 nc-list-option-active': activeOptionIndex === idx,
},
]"
@mouseover="handleResetHoverEffect(true)"
@click="handleSelectOption(option)"
>
<slot name="listItem" :option="option" :index="idx">
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ option[optionLabelKey] }}
</template>
{{ option[optionLabelKey] }}
</NcTooltip>
<GeneralIcon
v-if="showSelectedOption && option[optionValueKey] === vModel"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</slot>
</div>
</div>
</div>
</div>
</template>
<template v-if="!list.length">
<slot name="emptyState">
<div class="h-full text-center flex items-center justify-center gap-3 mt-4">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
</div>
</slot>
</template>
</div>
<slot name="listFooter"></slot>
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-toolbar-dropdown-search-field-input) {
&.ant-input-affix-wrapper-focused {
.ant-input-prefix svg {
@apply text-brand-500;
}
}
.ant-input {
@apply placeholder-gray-500;
}
}
</style>

36
packages/nc-gui/components/nc/PageHeader.vue

@ -0,0 +1,36 @@
<script lang="ts" setup></script>
<template>
<div class="nc-page-header">
<div class="flex-1 flex items-start gap-3">
<div v-if="$slots.icon" class="h-7 flex items-center children:flex-none">
<slot name="icon"></slot>
</div>
<div class="flex flex-col gap-3">
<h1 class="nc-page-header-title truncate">
<slot name="title"></slot>
</h1>
<p v-if="$slots.subtitle" class="nc-page-header-subtitle">
<slot name="subtitle"></slot>
</p>
</div>
</div>
<div v-if="$slots.action">
<slot name="action"></slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.nc-page-header {
@apply h-12 flex items-center gap-3 px-3 py-2;
.nc-page-header-title {
@apply text-xl font-semibold text-gray-800 my-0;
}
.nc-page-header-subtitle {
@apply text-sm font-weight-500 text-gray-700;
}
}
</style>

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

@ -17,7 +17,7 @@ const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: save
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
const orgStore = useOrg()
const { orgId } = storeToRefs(orgStore)
const { orgId, org } = storeToRefs(orgStore)
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
@ -255,129 +255,149 @@ const customRow = (record: Record<string, any>) => ({
<template>
<div
class="nc-collaborator-table-container nc-access-settings-view flex flex-col"
:class="{
'px-6': isAdminPanel,
'px-1': !isAdminPanel,
'h-[calc(100vh_-_100px)]': !isAdminPanel,
}"
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 text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<div v-if="isAdminPanel">
<div class="nc-breadcrumb px-2">
<div class="nc-breadcrumb-item">
{{ org.title }}
</div>
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
<NuxtLink
:href="`/admin/${orgId}/bases`"
class="!hover:(text-black underline-gray-600) flex items-center !text-black !underline-transparent ml-0.75 max-w-1/4"
class="!hover:(text-gray-800 underline-gray-600) flex items-center !text-gray-700 !underline-transparent ml-0.75 max-w-1/4"
>
<component :is="iconMap.arrowLeft" class="text-3xl" />
{{ $t('objects.projects') }}
<div class="nc-breadcrumb-item">
{{ $t('objects.projects') }}
</div>
</NuxtLink>
<span class="text-2xl"> / </span>
<GeneralBaseIconColorPicker readonly />
<span class="text-base">
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
<div class="nc-breadcrumb-item active truncate capitalize">
{{ currentBase?.title }}
</span>
</div>
</div>
</div>
<div v-else 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-input-border-on-value !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
<NcPageHeader>
<template #icon>
<div class="nc-page-header-icon flex justify-center items-center h-5 w-5">
<GeneralBaseIconColorPicker readonly />
</div>
</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>
<template #title>
<span data-rec="true" class="capitalize">
{{ currentBase?.title }}
</span>
</template>
</NcPageHeader>
</div>
<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"
<div
class="h-full flex flex-col items-center gap-6 px-6 pt-6"
:class="{
'border-t-1 border-gray-200': isAdminPanel,
}"
>
<template #emptyText>
<a-empty :description="$t('title.noMembersFound')" />
</template>
<div v-if="!isAdminPanel" 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-input-border-on-value !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
<template #headerCell="{ column }">
<template v-if="column.key === 'select'">
<NcCheckbox v-model:checked="selectAll" :disabled="!sortedCollaborators.length" />
</template>
<template v-else>
{{ column.title }}
<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>
<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>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'select'">
<NcCheckbox v-model:checked="selected[record.id]" />
<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>
<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>
<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>
{{ 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.display_name || record.email.slice(0, record.email.indexOf('@')) }}
{{ record.email }}
</NcTooltip>
</div>
<NcTooltip class="truncate max-w-full text-xs text-gray-600" show-on-truncate-only>
</div>
<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
"
show-inherit
: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>
{{ record.email }}
{{ parseStringDateTime(record.created_at) }}
</template>
{{ record.email }}
<span>
{{ timeAgo(record.created_at) }}
</span>
</NcTooltip>
</div>
</div>
<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
"
show-inherit
: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>
</template>
</NcTable>
</div>
<LazyDlgInviteDlg v-model:model-value="isInviteModalVisible" :base-id="currentBase?.id" type="base" />
</div>
@ -395,4 +415,10 @@ const customRow = (record: Record<string, any>) => ({
:deep(.nc-collaborator-role-select .ant-select-selector) {
@apply !rounded;
}
.nc-page-header-icon {
:deep(svg) {
@apply h-4.5 w-4.5;
}
}
</style>

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

@ -111,9 +111,9 @@ const onCreateBaseClick = () => {
</script>
<template>
<div class="nc-all-tables-view">
<div class="nc-all-tables-view px-6 pt-6">
<div
class="flex flex-row gap-x-6 pt-6 pb-2 overflow-x-auto nc-scrollbar-thin"
class="flex flex-row gap-x-6 pb-2 overflow-x-auto nc-scrollbar-thin"
:class="{
'pointer-events-none': base?.isLoading,
}"
@ -199,7 +199,7 @@ const onCreateBaseClick = () => {
v-else-if="activeTables.length"
class="flex mt-4"
:style="{
height: 'calc(100vh - var(--topbar-height) - 15.2rem)',
height: 'calc(100vh - var(--topbar-height) - 218px)',
}"
>
<NcTable
@ -219,7 +219,7 @@ const onCreateBaseClick = () => {
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">
<div class="min-w-6 flex items-center justify-center">
<GeneralTableIcon :meta="record" class="flex-none text-gray-600" />
</div>
<NcTooltip class="truncate max-w-[calc(100%_-_28px)]" show-on-truncate-only>

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

@ -112,14 +112,14 @@ watch(
<div class="h-full nc-base-view">
<div
v-if="!isAdminPanel"
class="flex flex-row pl-2 pr-2 gap-1 border-b-1 border-gray-200 justify-between w-full"
class="flex flex-row px-2 py-2 gap-1 justify-between w-full border-b-1 border-gray-200"
:class="{ 'nc-table-toolbar-mobile': isMobileMode, 'h-[var(--topbar-height)]': !isMobileMode }"
>
<div class="flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn />
<div class="flex flex-row items-center h-full gap-x-2.5">
<div class="flex flex-row items-center h-full gap-x-2 px-2">
<GeneralProjectIcon :color="parseProp(currentBase?.meta).iconColor" :type="currentBase?.type" />
<NcTooltip class="flex font-medium text-sm capitalize truncate max-w-150" show-on-truncate-only>
<NcTooltip class="flex font-bold text-sm capitalize truncate max-w-150 text-gray-800" show-on-truncate-only>
<template #title> {{ currentBase?.title }}</template>
<span class="truncate">
{{ currentBase?.title }}
@ -130,12 +130,15 @@ watch(
<LazyGeneralShareProject />
</div>
<div
class="flex mx-12 my-8 nc-base-view-tab"
class="flex nc-base-view-tab container"
:style="{
height: 'calc(100% - var(--topbar-height))',
}"
>
<a-tabs v-model:activeKey="projectPageTab" class="w-full">
<template #leftExtra>
<div class="w-3"></div>
</template>
<a-tab-pane v-if="!isAdminPanel" key="allTable">
<template #tab>
<div class="tab-title" data-testid="proj-view-tab__all-tables">
@ -193,11 +196,7 @@ watch(
</div>
</div>
</template>
<DashboardSettingsDataSources
v-model:state="baseSettingsState"
:base-id="base.id"
class="max-h-[calc(100%_-_36px)] pt-3"
/>
<DashboardSettingsDataSources v-model:state="baseSettingsState" :base-id="base.id" class="max-h-full" />
</a-tab-pane>
</a-tabs>
</div>
@ -213,7 +212,10 @@ watch(
}
.tab-title {
@apply flex flex-row items-center gap-x-2 px-2;
@apply flex flex-row items-center gap-x-2 px-2 py-[1px];
}
:deep(.ant-tabs-tab) {
@apply pt-2 pb-3;
}
:deep(.ant-tabs-tab .tab-title) {
@apply text-gray-500;

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

@ -104,23 +104,28 @@ watch(openedSubTab, () => {
}
:deep(.ant-tabs-nav) {
min-height: calc(var(--topbar-height) - 1.75px);
min-height: calc(var(--toolbar-height) - 1px);
}
:deep(.ant-tabs-tab) {
@apply pt-2 pb-3;
}
</style>
<style lang="scss">
.nc-details-tab.nc-tabs.centered {
> .ant-tabs-nav {
@apply px-3;
.ant-tabs-nav-wrap {
@apply absolute mx-auto -left-9.5;
@apply absolute mx-auto;
}
}
}
.nc-details-tab-left-sidebar-close > .nc-details-tab.nc-tabs.centered {
> .ant-tabs-nav {
@apply px-3;
.ant-tabs-nav-wrap {
@apply absolute mx-auto left-0;
@apply absolute mx-auto;
}
}
}

4
packages/nc-gui/components/smartsheet/Form.vue

@ -1254,7 +1254,7 @@ useEventListener(
:trigger="['click']"
overlay-class-name="nc-dropdown-form-edit-column"
:disabled="!isUIAllowed('fieldEdit')"
@visibleChange="onVisibilityChange('showEditColumn')"
@visible-change="onVisibilityChange('showEditColumn')"
>
<NcButton type="secondary" size="small" class="nc-form-add-field" data-testid="nc-form-add-field">
{{ $t('general.edit') }} {{ $t('objects.field') }}
@ -1340,7 +1340,7 @@ useEventListener(
v-model:visible="dropdownStates.showAddColumn"
:trigger="['click']"
overlay-class-name="nc-dropdown-form-add-column"
@visibleChange="onVisibilityChange('showAddColumn')"
@visible-change="onVisibilityChange('showAddColumn')"
>
<NcButton type="secondary" size="small" class="nc-form-add-field" data-testid="nc-form-add-field">
<div class="flex gap-2 items-center">

5
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -34,7 +34,7 @@ provide(IsToolbarIconMode, isToolbarIconMode)
:class="{
'px-4': isMobileMode,
}"
class="nc-table-toolbar relative px-3 flex gap-2 items-center border-b border-gray-200 overflow-hidden xs:(min-h-14) min-h-9 max-h-9 z-7"
class="nc-table-toolbar relative px-3 flex gap-2 items-center border-b border-gray-200 overflow-hidden xs:(min-h-14) min-h-[var(--toolbar-height)] max-h-[var(--toolbar-height)] z-7"
>
<template v-if="isViewsLoading">
<a-skeleton-input :active="true" class="!w-44 !h-4 ml-2 !rounded overflow-hidden" />
@ -63,6 +63,8 @@ provide(IsToolbarIconMode, isToolbarIconMode)
<LazySmartsheetToolbarGroupByMenu v-if="isGrid && !isLocalMode" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarOpenedViewAction v-if="isCalendar" />
</div>
<LazySmartsheetToolbarCalendarMode v-if="isCalendar && isTab" :tab="isTab" />
@ -70,6 +72,7 @@ provide(IsToolbarIconMode, isToolbarIconMode)
<template v-if="!isMobileMode">
<LazySmartsheetToolbarRowHeight v-if="isGrid" />
<LazySmartsheetToolbarOpenedViewAction v-if="!isCalendar" />
<!-- <LazySmartsheetToolbarQrScannerButton v-if="isMobileMode && (isGrid || isKanban || isGallery)" /> -->
<div class="flex-1" />

72
packages/nc-gui/components/smartsheet/Topbar.vue

@ -15,52 +15,70 @@ const { appInfo } = useGlobal()
const { toggleExtensionPanel, isPanelExpanded, extensionsEgg, onEggClick } = useExtensions()
const isSharedBase = computed(() => route.value.params.typeOrId === 'base')
const topbarBreadcrumbItemWidth = computed(() => {
if (!isSharedBase.value && !isMobileMode.value) {
return 'calc(\(100% - 167px - 24px\) / 2)'
} else if (isMobileMode.value) {
return 'calc(75% - 12px)'
} else {
return 'calc(\(100% - 12px\) / 2)'
}
})
</script>
<template>
<div
class="nc-table-topbar h-20 py-1 flex gap-2 items-center border-b border-gray-200 overflow-hidden relative max-h-[var(--topbar-height)] min-h-[var(--topbar-height)] md:(pr-2 pl-2) xs:(px-1)"
class="nc-table-topbar py-2 border-b-1 border-gray-200 flex gap-3 items-center justify-between overflow-hidden relative h-[var(--topbar-height)] max-h-[var(--topbar-height)] min-h-[var(--topbar-height)] md:(px-2) xs:(px-1)"
style="z-index: 7"
>
<template v-if="isViewsLoading">
<a-skeleton-input :active="true" class="!w-44 !h-4 ml-2 !rounded overflow-hidden" />
</template>
<template v-else>
<GeneralOpenLeftSidebarBtn />
<LazySmartsheetToolbarViewInfo v-if="!isPublic" />
<div
class="flex items-center gap-3 min-w-[300px]"
:style="{
width: topbarBreadcrumbItemWidth,
}"
>
<GeneralOpenLeftSidebarBtn />
<LazySmartsheetToolbarViewInfo v-if="!isPublic" />
</div>
<div v-if="!isSharedBase && !isMobileMode">
<SmartsheetTopbarSelectMode />
</div>
<div class="flex-1" />
<GeneralApiLoader v-if="!isMobileMode" />
<div class="flex items-center justify-end gap-3 flex-1">
<GeneralApiLoader v-if="!isMobileMode" />
<div
v-if="extensionsEgg"
class="flex items-center px-2 py-1 border-1 rounded-lg h-8 xs:(h-10 ml-0) ml-1 border-gray-200 cursor-pointer font-weight-600 text-sm select-none"
:class="{ 'bg-brand-50 text-brand-500': isPanelExpanded }"
@click="toggleExtensionPanel"
>
<GeneralIcon icon="puzzle" class="w-4 h-4" :class="{ 'border-l-1 border-transparent': isPanelExpanded }" />
<span
class="overflow-hidden trasition-all duration-200"
:class="{ 'w-[0px] invisible': isPanelExpanded, 'ml-2 w-[74px]': !isPanelExpanded }"
<div
v-if="extensionsEgg"
class="flex items-center px-2 py-1 border-1 rounded-lg h-8 xs:(h-10 ml-0) ml-1 border-gray-200 cursor-pointer font-weight-600 text-sm select-none"
:class="{ 'bg-brand-50 text-brand-500': isPanelExpanded }"
@click="toggleExtensionPanel"
>
Extensions
</span>
</div>
<div v-else-if="!extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<GeneralIcon icon="puzzle" class="w-4 h-4" :class="{ 'border-l-1 border-transparent': isPanelExpanded }" />
<span
class="overflow-hidden trasition-all duration-200"
:class="{ 'w-[0px] invisible': isPanelExpanded, 'ml-2 w-[74px]': !isPanelExpanded }"
>
Extensions
</span>
</div>
<div v-else-if="!extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<LazyGeneralShareProject
v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode"
is-view-toolbar
/>
<LazyGeneralShareProject
v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode"
is-view-toolbar
/>
<LazyGeneralLanguage
v-if="isSharedBase && !appInfo.ee"
class="cursor-pointer text-lg hover:(text-black bg-gray-200) mr-0 p-1.5 rounded-md"
/>
<LazyGeneralLanguage
v-if="isSharedBase && !appInfo.ee"
class="cursor-pointer text-lg hover:(text-black bg-gray-200) mr-0 p-1.5 rounded-md"
/>
</div>
</template>
</div>
</template>

2
packages/nc-gui/components/smartsheet/grid/index.vue

@ -94,7 +94,7 @@ function expandForm(row: Row, state?: Record<string, any>, fromToolbar = false)
expandedFormRowState.value = state
if (rowId && !isPublic.value) {
expandedFormRow.value = undefined
router.push({
query: {
...routeQuery.value,

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

@ -241,7 +241,7 @@ const onClick = (e: Event) => {
:trigger="['click']"
:placement="isExpandedForm && !isExpandedBulkUpdateForm ? 'bottomLeft' : 'bottomRight'"
:overlay-class-name="`nc-dropdown-edit-column ${editColumnDropdown ? 'active' : ''}`"
@visibleChange="onVisibleChange"
@visible-change="onVisibleChange"
>
<div v-if="isExpandedForm && !isExpandedBulkUpdateForm" class="h-[1px]" @dblclick.stop>&nbsp;</div>
<div v-else />

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

@ -29,7 +29,7 @@ const selectedView = inject(ActiveViewInj)
</script>
<template>
<div class="nc-locked-menu-item !px-1 text-gray-800" @click="emit('select', type)">
<div class="nc-locked-menu-item !px-1 text-gray-800 max-w-[312px]" @click="emit('select', type)">
<div :class="{ 'show-tick': !hideTick }">
<div class="flex flex-col gap-y-1">
<div class="flex items-center gap-2 flex-grow">

37
packages/nc-gui/components/smartsheet/toolbar/OpenedViewAction.vue

@ -11,7 +11,7 @@ const { $api } = useNuxtApp()
const { refreshCommandPalette } = useCommandPalette()
const { activeView, views, openedViewsTab, viewsByTable } = storeToRefs(useViewsStore())
const { activeView, views, viewsByTable } = storeToRefs(useViewsStore())
const { loadViews, removeFromRecentViews } = useViewsStore()
const { navigateToTable } = useTablesStore()
@ -171,28 +171,17 @@ function openDeleteDialog() {
class="!xs:pointer-events-none nc-actions-menu-btn nc-view-context-btn"
overlay-class-name="nc-dropdown-actions-menu"
>
<div
v-e="['c:breadcrumb:view-actions']"
class="truncate nc-active-view-title flex gap-1 items-center !hover:(bg-gray-100 text-gray-800) ml-1 pl-1 pr-0.25 rounded-md py-1 cursor-pointer"
:class="{
'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default,
'max-w-3/5': !isSharedBase && !isMobileMode && !activeView?.is_default,
'max-w-1/2': isMobileMode,
'text-gray-500': activeView?.is_default,
'text-gray-800 font-medium': !activeView?.is_default,
}"
>
<NcTooltip class="truncate xs:pl-1.25 flex-1 text-inherit" show-on-truncate-only>
<template #title>{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }} </template>
<span
:class="{
'max-w-28/100': !isMobileMode,
}"
>
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</span>
</NcTooltip>
<GeneralIcon icon="chevronDown" class="!text-gray-500 mt-0.5" />
<div>
<NcButton
v-e="['c:toolbar:view-actions']"
class="nc-view-action-menu-btn nc-toolbar-btn !border-0 !h-7 !px-1.5 !min-w-7"
size="small"
type="secondary"
>
<div class="flex items-center gap-0.5">
<GeneralIcon icon="threeDotVertical" class="!h-4 !w-4" />
</div>
</NcButton>
</div>
<template #overlay>
<SmartsheetToolbarViewActionMenu
@ -204,6 +193,4 @@ function openDeleteDialog() {
/>
</template>
</NcDropdown>
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view' && !isMobileMode && !isRenaming" />
</template>

4
packages/nc-gui/components/smartsheet/toolbar/Reload.vue

@ -25,7 +25,7 @@ watch(isReloading, () => {
</script>
<template>
<a-tooltip placement="bottom">
<NcTooltip placement="bottom">
<template #title> {{ $t('general.reload') }} </template>
<div
@ -38,5 +38,5 @@ watch(isReloading, () => {
@click="onClick"
/>
</div>
</a-tooltip>
</NcTooltip>
</template>

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

@ -174,17 +174,8 @@ const onDelete = async () => {
<template v-if="!view?.is_default && isUIAllowed('viewCreateOrEdit')">
<NcDivider />
<NcMenuItem v-if="lockType !== LockType.Locked" @click="onRenameMenuClick">
<GeneralIcon icon="rename" />
{{
$t('general.renameEntity', {
entity: view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>
<NcTooltip v-else>
<template #title> {{ $t('msg.info.disabledAsViewLocked') }} </template>
<NcMenuItem class="!cursor-not-allowed !text-gray-400">
<template v-if="inSidebar">
<NcMenuItem v-if="lockType !== LockType.Locked" @click="onRenameMenuClick">
<GeneralIcon icon="rename" />
{{
$t('general.renameEntity', {
@ -192,7 +183,19 @@ const onDelete = async () => {
})
}}
</NcMenuItem>
</NcTooltip>
<NcTooltip v-else>
<template #title> {{ $t('msg.info.disabledAsViewLocked') }} </template>
<NcMenuItem class="!cursor-not-allowed !text-gray-400">
<GeneralIcon icon="rename" />
{{
$t('general.renameEntity', {
entity:
view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>
</NcTooltip>
</template>
<NcMenuItem @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
{{
@ -296,7 +299,7 @@ const onDelete = async () => {
<template #expandIcon></template>
<div class="flex py-3 px-4 font-bold uppercase text-xs text-gray-500">{{ $t('labels.viewMode') }}</div>
<a-menu-item class="!mx-1 !py-2 !rounded-md nc-view-action-lock-subaction">
<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>

253
packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue

@ -1,137 +1,180 @@
<script setup lang="ts">
const { isMobileMode } = useGlobal()
const { activeView } = storeToRefs(useViewsStore())
const { activeView, openedViewsTab } = storeToRefs(useViewsStore())
const { base, isSharedBase } = storeToRefs(useBase())
const { baseUrl } = useBase()
const { activeTable } = storeToRefs(useTablesStore())
const { tableUrl } = useTablesStore()
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const openedBaseUrl = computed(() => {
if (!base.value) return ''
return `${window.location.origin}/#${baseUrl({
id: base.value.id!,
type: 'database',
isSharedBase: isSharedBase.value,
})}`
})
</script>
<template>
<div
class="ml-0.25 flex flex-row font-medium items-center border-gray-50 transition-all duration-100"
class="flex flex-row items-center border-gray-50 transition-all duration-100 select-none"
:class="{
'min-w-36/100 max-w-36/100': !isMobileMode && isLeftSidebarOpen,
'min-w-39/100 max-w-39/100': !isMobileMode && !isLeftSidebarOpen,
'w-2/3 text-base ml-1.5': isMobileMode,
'!max-w-3/4': isSharedBase && !isMobileMode,
'text-base w-[calc(100%_-_52px)]': isMobileMode,
'w-[calc(100%_-_44px)]': !isMobileMode && !isLeftSidebarOpen,
'w-full': !isMobileMode && isLeftSidebarOpen,
}"
>
<template v-if="!isMobileMode">
<NuxtLink
class="!hover:(text-black underline-gray-600) !underline-transparent ml-0.75 max-w-1/4"
:class="{
'!max-w-none': isSharedBase && !isMobileMode,
'!text-gray-500': activeTable,
'!text-gray-700': !activeTable,
}"
:to="openedBaseUrl"
>
<NcTooltip class="!text-inherit">
<template #title>
<span class="capitalize">
{{ base?.title }}
</span>
</template>
<div class="flex flex-row items-center gap-x-1.5">
<GeneralProjectIcon
:type="base?.type"
class="!grayscale min-w-4"
:style="{
filter: 'grayscale(100%) brightness(115%)',
}"
/>
<div
class="hidden !2xl:(flex truncate ml-1)"
:class="{
'!flex': isSharedBase && !isMobileMode,
}"
>
<span class="truncate !text-inherit capitalize">
{{ base?.title }}
</span>
</div>
</div>
</NcTooltip>
</NuxtLink>
<div class="px-1.75 text-gray-500">></div>
</template>
<template v-if="!(isMobileMode && !activeView?.is_default)">
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeTable?.meta?.icon" readonly size="xsmall">
<template #default>
<MdiTable
class="min-w-5"
<SmartsheetTopbarProjectListDropdown v-if="activeTable">
<template #default="{ isOpen }">
<div
class="rounded-lg h-8 px-2 text-gray-700 font-weight-500 hover:(bg-gray-100 text-gray-900) flex items-center gap-1 cursor-pointer max-w-1/3"
:class="{
'!text-gray-500': !isMobileMode,
'!text-gray-700': isMobileMode,
'!max-w-none': isSharedBase && !isMobileMode,
'': !isMobileMode && isLeftSidebarOpen,
}"
/>
>
<NcTooltip :disabled="isSharedBase || isOpen">
<template #title>
<span class="capitalize">
{{ base?.title }}
</span>
</template>
<GeneralProjectIcon
:type="base?.type"
class="!grayscale min-w-4"
:style="{
filter: 'grayscale(100%) brightness(115%)',
}"
/>
</NcTooltip>
<template v-if="isSharedBase">
<NcTooltip
class="ml-1 truncate nc-active-base-title max-w-full !leading-5"
show-on-truncate-only
:disabled="isOpen"
>
<template #title>
<span class="capitalize">
{{ base?.title }}
</span>
</template>
<span
class="text-ellipsis capitalize"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ base?.title }}
</span>
</NcTooltip>
<GeneralIcon
icon="chevronDown"
class="!text-current opacity-70 flex-none transform transition-transform duration-25 w-3.5 h-3.5"
:class="{ '!rotate-180': isOpen }"
/>
</template>
</div>
</template>
</LazyGeneralEmojiPicker>
<div
v-if="activeTable"
:class="{
'max-w-1/2': isMobileMode || activeView?.is_default,
'max-w-20/100': !isSharedBase && !isMobileMode && !activeView?.is_default,
'max-w-none': isSharedBase && !isMobileMode,
}"
>
<NcTooltip class="truncate nc-active-table-title max-w-full" show-on-truncate-only>
<template #title>
{{ activeTable?.title }}
</template>
<span
class="text-ellipsis overflow-hidden text-gray-500 xs:ml-2"
</SmartsheetTopbarProjectListDropdown>
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
</template>
<template v-if="!(isMobileMode && !activeView?.is_default)">
<SmartsheetTopbarTableListDropdown v-if="activeTable">
<template #default="{ isOpen }">
<div
class="rounded-lg h-8 px-2 text-gray-700 font-weight-500 hover:(bg-gray-100 text-gray-900) flex items-center gap-1 cursor-pointer"
:class="{
'text-gray-500': !isMobileMode,
'text-gray-800 font-medium': isMobileMode || activeView?.is_default,
}"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
'max-w-full': isMobileMode,
'max-w-1/2': activeView?.is_default,
'max-w-1/4': !isSharedBase && !isMobileMode && !activeView?.is_default,
'max-w-none': isSharedBase && !isMobileMode,
}"
>
<template v-if="activeView?.is_default">
{{ activeTable?.title }}
</template>
<NuxtLink
v-else
class="!text-inherit !underline-transparent !hover:(text-black underline-gray-600)"
:to="tableUrl({ table: activeTable, completeUrl: true, isSharedBase })"
>
{{ activeTable?.title }}
</NuxtLink>
</span>
</NcTooltip>
</div>
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeTable?.meta?.icon" readonly size="xsmall" class="mr-1">
<template #default>
<GeneralIcon
icon="table"
class="min-w-5"
:class="{
'!text-gray-500': !isMobileMode,
'!text-gray-700': isMobileMode,
}"
/>
</template>
</LazyGeneralEmojiPicker>
<NcTooltip class="truncate nc-active-table-title max-w-full !leading-5" show-on-truncate-only :disabled="isOpen">
<template #title>
{{ activeTable?.title }}
</template>
<span
class="text-ellipsis"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ activeTable?.title }}
</span>
</NcTooltip>
<GeneralIcon
icon="chevronDown"
class="!text-current opacity-70 flex-none transform transition-transform duration-25 w-3.5 h-3.5"
:class="{ '!rotate-180': isOpen }"
/>
</div>
</template>
</SmartsheetTopbarTableListDropdown>
</template>
<div v-if="!isMobileMode" class="pl-1.25 text-gray-500">></div>
<GeneralIcon v-if="!isMobileMode" icon="ncSlash1" class="nc-breadcrumb-divider" />
<template v-if="!(isMobileMode && activeView?.is_default)">
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeView?.meta?.icon" readonly size="xsmall">
<template #default>
<GeneralViewIcon :meta="{ type: activeView?.type }" class="min-w-4.5 text-lg flex" />
<!-- <SmartsheetToolbarOpenedViewAction /> -->
<SmartsheetTopbarViewListDropdown>
<template #default="{ isOpen }">
<div
class="rounded-lg h-8 px-2 text-gray-800 font-semibold hover:(bg-gray-100 text-gray-900) flex items-center gap-1 cursor-pointer"
:class="{
'max-w-full': isMobileMode,
'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default,
'max-w-1/2': !isSharedBase && !isMobileMode && !activeView?.is_default,
'max-w-none': isSharedBase && !isMobileMode,
}"
>
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeView?.meta?.icon" readonly size="xsmall" class="mr-1">
<template #default>
<GeneralViewIcon :meta="{ type: activeView?.type }" class="min-w-4.5 text-lg flex" />
</template>
</LazyGeneralEmojiPicker>
<NcTooltip class="truncate nc-active-view-title max-w-full !leading-5" show-on-truncate-only :disabled="isOpen">
<template #title>
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</template>
<span
class="text-ellipsis"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</span>
</NcTooltip>
<GeneralIcon
icon="chevronDown"
class="!text-current opacity-70 flex-none transform transition-transform duration-25 w-3.5 h-3.5"
:class="{ '!rotate-180': isOpen }"
/>
</div>
</template>
</LazyGeneralEmojiPicker>
</SmartsheetTopbarViewListDropdown>
<SmartsheetToolbarOpenedViewAction />
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view' && !isMobileMode" />
</template>
</div>
</template>

80
packages/nc-gui/components/smartsheet/topbar/ProjectListDropdown.vue

@ -0,0 +1,80 @@
<script lang="ts" setup>
const basesStore = useBases()
const { basesList } = storeToRefs(basesStore)
const baseStore = useBase()
const { base: activeBase, isSharedBase } = storeToRefs(baseStore)
const { loadProjectTables } = useTablesStore()
const isOpen = ref<boolean>(false)
/**
* Handles navigation to a selected project/base.
*
* @param base - The project/base to navigate to.
* @returns A Promise that resolves when the navigation is complete.
*
* @remarks
* This function is called when a user selects a project from the dropdown list.
* It performs the following steps:
* 1. Checks if the base has a valid ID.
* 2. Determines if the project data is already populated.
* 3. Navigates to the selected project's URL.
* 4. If the project data isn't populated, it loads the project tables.
*/
const handleNavigateToProject = async (base: NcProject) => {
if (!base?.id) return
const isProjectPopulated = basesStore.isProjectPopulated(base.id!)
await navigateTo(
baseStore.baseUrl({
id: base.id!,
type: 'database',
isSharedBase: isSharedBase.value,
}),
)
if (!isProjectPopulated) {
await loadProjectTables(base.id)
}
}
</script>
<template>
<NcDropdown v-model:visible="isOpen">
<slot name="default" :is-open="isOpen"></slot>
<template #overlay>
<LazyNcList
v-if="activeBase.id"
v-model:open="isOpen"
:value="activeBase.id"
:list="basesList"
option-value-key="id"
option-label-key="title"
search-input-placeholder="Search bases"
@change="handleNavigateToProject"
>
<template #listItem="{ option }">
<GeneralBaseIconColorPicker :type="option?.type" :model-value="parseProp(option.meta).iconColor" size="xsmall" readonly>
</GeneralBaseIconColorPicker>
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ option?.title }}
</template>
{{ option?.title }}
</NcTooltip>
<GeneralIcon
v-if="option.id === activeBase.id"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</template>
</LazyNcList>
</template>
</NcDropdown>
</template>
<style lang="scss" scoped></style>

12
packages/nc-gui/components/smartsheet/topbar/SelectMode.vue

@ -15,7 +15,7 @@ const onClickDetails = () => {
</script>
<template>
<div class="flex flex-row p-1 mx-3 mt-3 mb-3 bg-gray-100 rounded-lg gap-x-0.5 nc-view-sidebar-tab">
<div class="flex flex-row p-1 bg-gray-200 rounded-lg gap-x-0.5 nc-view-sidebar-tab">
<div
v-e="['c:project:mode:data']"
class="tab"
@ -51,12 +51,12 @@ const onClickDetails = () => {
<style scoped>
.tab {
@apply flex flex-row items-center h-7.5 justify-center px-2 py-1 bg-gray-100 rounded-lg gap-x-1.5 text-gray-500 hover:text-black cursor-pointer transition-all duration-300 select-none;
@apply flex flex-row items-center h-6 justify-center px-2 py-1 rounded-md gap-x-2 text-gray-600 hover:text-black cursor-pointer transition-all duration-300 select-none;
}
.tab-icon {
font-size: 1.1rem !important;
@apply w-4.5;
font-size: 1rem !important;
@apply w-4;
}
.tab .tab-title {
@apply min-w-0;
@ -67,6 +67,8 @@ const onClickDetails = () => {
}
.active {
@apply bg-white shadow text-brand-500 hover:text-brand-500;
@apply bg-white text-brand-600 hover:text-brand-600;
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
</style>

159
packages/nc-gui/components/smartsheet/topbar/TableListDropdown.vue

@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { TableType } from 'nocodb-sdk'
const { isMobileMode } = useGlobal()
const { $e } = useNuxtApp()
const { isUIAllowed } = useRoles()
const { base } = storeToRefs(useBase())
const { activeTable, activeTables } = storeToRefs(useTablesStore())
const { openTable } = useTablesStore()
const isOpen = ref<boolean>(false)
const activeTableSourceIndex = computed(() => base.value?.sources?.findIndex((s) => s.id === activeTable.value?.source_id) ?? -1)
const filteredTableList = computed(() => {
return activeTables.value.filter((t: TableType) => t?.source_id === activeTable.value?.source_id) || []
})
/**
* Handles navigation to a selected table.
*
* @param table - The table to navigate to.
*
* @remarks
* This function is called when a user selects a table from the dropdown list.
* It checks if the table has a valid ID and then opens the selected table.
*/
const handleNavigateToTable = (table: TableType) => {
if (table?.id) {
openTable(table)
}
}
/**
* Opens a dialog to create a new table.
*
* @returns void
*
* @remarks
* This function is triggered when the user initiates the table creation process from the topbar.
* It emits a tracking event, checks for a valid source, and opens a dialog for table creation.
* The function also handles the dialog closure and potential scrolling to the newly created table.
*
* @see {@link packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue} for a similar implementation
* of table creation dialog. If this function is updated, consider updating the other implementation as well.
*/
function openTableCreateDialog() {
$e('c:table:create:topbar')
if (activeTableSourceIndex.value === -1) return
isOpen.value = false
const isCreateTableOpen = ref(true)
const sourceId = base.value!.sources?.[activeTableSourceIndex.value].id
if (!sourceId || !base.value?.id) return
const { close } = useDialog(resolveComponent('DlgTableCreate'), {
'modelValue': isCreateTableOpen,
sourceId, // || sources.value[0].id,
'baseId': base.value!.id,
'onCreate': closeDialog,
'onUpdate:modelValue': () => closeDialog(),
})
function closeDialog(table?: TableType) {
isCreateTableOpen.value = false
if (!table) return
// TODO: Better way to know when the table node dom is available
setTimeout(() => {
const newTableDom = document.querySelector(`[data-table-id="${table.id}"]`)
if (!newTableDom) return
// Scroll to the table node
newTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 1000)
close(1000)
}
}
</script>
<template>
<NcDropdown v-model:visible="isOpen">
<slot name="default" :is-open="isOpen"></slot>
<template #overlay>
<LazyNcList
v-model:open="isOpen"
:value="activeTable.id"
:list="filteredTableList"
option-value-key="id"
option-label-key="title"
search-input-placeholder="Search tables"
@change="handleNavigateToTable"
>
<template #listItem="{ option }">
<div>
<LazyGeneralEmojiPicker :emoji="option?.meta?.icon" readonly size="xsmall">
<template #default>
<GeneralIcon icon="table" class="min-w-4 !text-gray-500" />
</template>
</LazyGeneralEmojiPicker>
</div>
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ option?.title }}
</template>
{{ option?.title }}
</NcTooltip>
<GeneralIcon
v-if="option.id === activeTable.id"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</template>
<template
v-if="
!isMobileMode &&
isUIAllowed('tableCreate', {
roles: base?.project_role || base?.workspace_role,
source: base?.sources?.[activeTableSourceIndex] || {},
})
"
#listFooter
>
<NcDivider class="!mt-0 !mb-2" />
<div class="px-2 mb-2" @click="openTableCreateDialog()">
<div
class="px-2 py-1.5 flex items-center justify-between gap-2 text-sm font-weight-500 !text-brand-500 hover:bg-gray-100 rounded-md cursor-pointer"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" />
<div>
{{
$t('general.createEntity', {
entity: $t('objects.table'),
})
}}
</div>
</div>
</div>
</div>
</template>
</LazyNcList>
</template>
</NcDropdown>
</template>
<style lang="scss" scoped></style>

303
packages/nc-gui/components/smartsheet/topbar/ViewListDropdown.vue

@ -0,0 +1,303 @@
<script lang="ts" setup>
import { type ViewType, ViewTypes } from 'nocodb-sdk'
const { isMobileMode } = useGlobal()
const { t } = useI18n()
const { $e } = useNuxtApp()
const { isUIAllowed } = useRoles()
const { base } = storeToRefs(useBase())
const { activeTable } = storeToRefs(useTablesStore())
const viewsStore = useViewsStore()
const { activeView, views } = storeToRefs(viewsStore)
const { loadViews, navigateToView } = viewsStore
const { refreshCommandPalette } = useCommandPalette()
const isOpen = ref<boolean>(false)
const activeSource = computed(() => {
return base.value.sources?.find((s) => s.id === activeView.value?.source_id)
})
/**
* Handles navigation to a selected view.
*
* @param view - The view to navigate to.
* @returns A Promise that resolves when the navigation is complete.
*
* @remarks
* This function is called when a user selects a view from the dropdown list.
* It checks if the view has a valid ID and then navigates to the selected view.
* If the view is a form and it's already active, it performs a hard reload.
*/
const handleNavigateToView = async (view: ViewType) => {
if (!view?.id) return
await navigateToView({
view,
tableId: activeTable.value.id!,
baseId: base.value.id!,
hardReload: view.type === ViewTypes.FORM && activeView.value?.id === view.id,
doNotSwitchTab: true,
})
}
/**
* Filters the view options based on the input string.
*
* @param input - The search input string.
* @param view - The view object to be filtered.
* @returns True if the view matches the filter criteria, false otherwise.
*
* @remarks
* This function is used to filter the list of views in the dropdown.
* It checks if the input string matches either the default view title (translated) or the view's title.
* The matching is case-insensitive.
*/
const filterOption = (input: string = '', view: ViewType) => {
if (view.is_default && t('title.defaultView').toLowerCase().includes(input)) {
return true
}
return view.title?.toLowerCase()?.includes(input.toLowerCase())
}
/**
* Opens a modal for creating or editing a view.
*
* @param options - The options for opening the modal.
* @param options.title - The title of the modal. Default is an empty string.
* @param options.type - The type of view to create or edit.
* @param options.copyViewId - The ID of the view to copy, if creating a copy.
* @param options.groupingFieldColumnId - The ID of the column to use for grouping, if applicable.
* @param options.calendarRange - The date range for calendar views.
* @param options.coverImageColumnId - The ID of the column to use for cover images, if applicable.
*
* @returns A Promise that resolves when the modal operation is complete.
*
* @remarks
* This function opens a modal dialog for creating or editing a view.
* It handles the dialog state, view creation, and navigation to the newly created view.
* After creating a view, it refreshes the command palette and reloads the views.
*
* @see {@link packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue} for a similar implementation of view creation dialog.
* If this function is updated, consider updating the other implementations as well.
*/
async function onOpenModal({
title = '',
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
coverImageColumnId,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
calendarRange?: Array<{
fk_from_column_id: string
fk_to_column_id: string | null
}>
coverImageColumnId?: string
}) {
isOpen.value = false
const isDlgOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewCreate'), {
'modelValue': isDlgOpen,
title,
type,
'tableId': activeTable.value.id,
'selectedViewId': copyViewId,
calendarRange,
groupingFieldColumnId,
coverImageColumnId,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
refreshCommandPalette()
await loadViews({
tableId: activeTable.value.id!,
force: true,
})
activeTable.value.meta = {
...(activeTable.value.meta as object),
hasNonDefaultViews: true,
}
navigateToView({
view,
tableId: activeTable.value.id!,
baseId: base.value.id!,
doNotSwitchTab: true,
})
$e('a:view:create', { view: view.type })
},
})
function closeDialog() {
isOpen.value = false
isDlgOpen.value = false
close(1000)
}
}
</script>
<template>
<NcDropdown v-if="activeView" v-model:visible="isOpen">
<slot name="default" :is-open="isOpen"></slot>
<template #overlay>
<LazyNcList
v-model:open="isOpen"
:value="activeView.id"
:list="views"
option-value-key="id"
option-label-key="title"
search-input-placeholder="Search views"
:filter-option="filterOption"
@change="handleNavigateToView"
>
<template #listItem="{ option }">
<div>
<LazyGeneralEmojiPicker :emoji="option?.meta?.icon" readonly size="xsmall">
<template #default>
<GeneralViewIcon :meta="{ type: option?.type }" class="min-w-4 text-lg flex" />
</template>
</LazyGeneralEmojiPicker>
</div>
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ option?.is_default ? $t('title.defaultView') : option?.title }}
</template>
{{ option?.is_default ? $t('title.defaultView') : option?.title }}
</NcTooltip>
<GeneralIcon
v-if="option.id === activeView.id"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</template>
<template v-if="!isMobileMode && isUIAllowed('viewCreateOrEdit')" #listFooter>
<NcDivider class="!mt-0 !mb-2" />
<div class="overflow-hidden mb-2">
<a-menu class="nc-viewlist-menu">
<a-sub-menu popup-class-name="nc-viewlist-submenu-popup ">
<template #title>
<div class="flex items-center justify-between gap-2 text-sm font-weight-500 !text-brand-500">
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" />
<div>
{{
$t('general.createEntity', {
entity: $t('objects.view'),
})
}}
</div>
</div>
<GeneralIcon icon="arrowRight" class="text-base text-gray-600 group-hover:text-gray-800" />
</div>
</template>
<template #expandIcon> </template>
<a-menu-item @click.stop="onOpenModal({ type: ViewTypes.GRID })">
<div class="nc-viewlist-submenu-popup-item" data-testid="topbar-view-create-grid">
<GeneralViewIcon :meta="{ type: ViewTypes.GRID }" />
Grid
</div>
</a-menu-item>
<a-menu-item v-if="!activeSource?.is_schema_readonly" @click="onOpenModal({ type: ViewTypes.FORM })">
<div class="nc-viewlist-submenu-popup-item" data-testid="topbar-view-create-form">
<GeneralViewIcon :meta="{ type: ViewTypes.FORM }" />
Form
</div>
</a-menu-item>
<a-menu-item @click="onOpenModal({ type: ViewTypes.GALLERY })">
<div class="nc-viewlist-submenu-popup-item" data-testid="topbar-view-create-gallery">
<GeneralViewIcon :meta="{ type: ViewTypes.GALLERY }" />
Gallery
</div>
</a-menu-item>
<a-menu-item data-testid="topbar-view-create-kanban" @click="onOpenModal({ type: ViewTypes.KANBAN })">
<div class="nc-viewlist-submenu-popup-item">
<GeneralViewIcon :meta="{ type: ViewTypes.KANBAN }" />
Kanban
</div>
</a-menu-item>
<a-menu-item data-testid="topbar-view-create-calendar" @click="onOpenModal({ type: ViewTypes.CALENDAR })">
<div class="nc-viewlist-submenu-popup-item">
<GeneralViewIcon :meta="{ type: ViewTypes.CALENDAR }" class="!w-4 !h-4" />
{{ $t('objects.viewType.calendar') }}
</div>
</a-menu-item>
</a-sub-menu>
</a-menu>
</div>
</template>
</LazyNcList>
</template>
</NcDropdown>
</template>
<style lang="scss">
.nc-viewlist-menu {
@apply !border-r-0;
.ant-menu-submenu {
@apply !mx-2;
.ant-menu-submenu-title {
@apply flex items-center gap-2 py-1.5 px-2 my-0 h-auto hover:bg-gray-100 cursor-pointer rounded-md;
.ant-menu-title-content {
@apply w-full;
}
}
}
}
.nc-viewlist-submenu-popup {
@apply !rounded-lg border-1 border-gray-50;
.ant-menu.ant-menu-sub {
@apply p-2 !rounded-lg !shadow-lg shadow-gray-200;
}
.ant-menu-item {
@apply h-auto !my-0 text-sm !leading-5 py-2 px-2 hover:!bg-gray-100 cursor-pointer rounded-md;
.ant-menu-title-content {
@apply w-full px-0;
}
.nc-viewlist-submenu-popup-item {
@apply flex items-center gap-2 !text-gray-800;
}
&.ant-menu-item-selected {
@apply bg-transparent;
}
}
}
.nc-viewlist-submenu-popup .ant-dropdown-menu.ant-dropdown-menu-sub {
@apply !rounded-lg !shadow-lg shadow-gray-200;
}
</style>

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

@ -198,388 +198,403 @@ onKeyStroke('ArrowDown', onDown)
</script>
<template>
<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 }">
<div class="flex flex-row items-center gap-3 justify-between">
<div
:class="{
'flex-1 max-w-[75%]': baseId,
}"
>
<h6
class="font-semibold text-gray-900 !my-0 flex items-center gap-1"
:style="{
'word-break': 'keep-all',
}"
<div class="h-full flex flex-col" :class="{ 'gap-4 px-1': baseId }">
<NcPageHeader v-if="!baseId">
<template #icon>
<GeneralIcon icon="audit" class="flex-none text-gray-700 h-5 w-5" />
</template>
<template #title>
<span data-rec="true">
{{ $t('title.auditLogs') }}
</span>
</template>
</NcPageHeader>
<div
class="flex flex-col"
:class="{ 'gap-6 p-6 border-t-1 border-gray-200 h-[calc(100vh_-_100px)]': !baseId, 'gap-4 h-full': 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 }">
<div class="flex flex-row items-center gap-3 justify-between">
<div
:class="{
'text-xl': baseId,
'text-2xl': !baseId,
'flex-1 max-w-[75%]': baseId,
}"
>
<span class="keep-word min-w-[100px]"> {{ $t('title.auditLogs') }} </span>
<NcTooltip
<h6
v-if="baseId"
class="max-w-[80%] truncate"
class="font-semibold text-gray-900 !my-0 flex items-center gap-1"
:style="{
'word-break': 'keep-all',
}"
:class="{
'!leading-7': baseId,
'!leading-8': !baseId,
'text-xl': baseId,
'text-2xl': !baseId,
}"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{ bases.get(baseId)?.title }}
</template>
: {{ bases.get(baseId)?.title }}
</NcTooltip>
</h6>
</div>
<div v-if="appInfo.auditEnabled" class="flex items-center gap-3 justify-end flex-wrap">
<div class="flex items-center gap-3">
<NcButton type="text" size="small" :disabled="isLoading" @click="loadAudits()">
<!-- Refresh -->
<div class="flex items-center gap-2">
{{ $t('general.refresh') }}
<component :is="iconMap.refresh" class="h-3.5 w-3.5" :class="{ 'animate-infinite animate-spin': isLoading }" />
</div>
</NcButton>
<span class="keep-word min-w-[100px]"> {{ $t('title.auditLogs') }} </span>
<NcTooltip
class="max-w-[80%] truncate"
:class="{
'!leading-7': baseId,
'!leading-8': !baseId,
}"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{ bases.get(baseId)?.title }}
</template>
: {{ bases.get(baseId)?.title }}
</NcTooltip>
</h6>
</div>
<div v-if="appInfo.auditEnabled" class="flex items-center gap-3 justify-end flex-wrap">
<div class="flex items-center gap-3">
<NcButton type="text" size="small" :disabled="isLoading" @click="loadAudits()">
<!-- Refresh -->
<div class="flex items-center gap-2">
{{ $t('general.refresh') }}
<component :is="iconMap.refresh" class="h-3.5 w-3.5" :class="{ 'animate-infinite animate-spin': isLoading }" />
</div>
</NcButton>
</div>
</div>
</div>
</div>
</div>
<template v-if="appInfo.auditEnabled">
<div
class="table-container relative"
:class="{
'h-[calc(100%_-_48px)] ': baseId,
'h-[calc(100%_-_56px)]': !baseId,
'bordered': bordered,
}"
>
<template v-if="appInfo.auditEnabled">
<div
ref="tableWrapper"
class="nc-audit-logs-table h-full max-h-[calc(100%_-_40px)] relative nc-scrollbar-thin !overflow-auto"
class="table-container relative"
:class="{
'h-[calc(100%_-_48px)] ': baseId,
'h-[calc(100%_-_56px)]': !baseId,
'bordered': bordered,
}"
>
<table class="!sticky top-0 z-5">
<thead>
<tr>
<th
class="cell-user !hover:bg-gray-100 select-none cursor-pointer"
:class="{
'cursor-not-allowed': !audits?.length,
}"
@click="updateOrderBy('user')"
>
<div class="flex items-center gap-3">
<div>{{ $t('objects.user') }}</div>
<GeneralIcon
v-if="auditLogsQuery.orderBy?.user"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': auditLogsQuery.orderBy?.user === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</div>
</th>
<th
class="cell-timestamp !hover:bg-gray-100 select-none cursor-pointer"
:class="{
'cursor-not-allowed': !audits?.length,
}"
@click="updateOrderBy('created_at')"
>
<div class="flex items-center gap-3">
<div>Time</div>
<GeneralIcon
v-if="auditLogsQuery.orderBy?.created_at"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': auditLogsQuery.orderBy?.created_at === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</div>
</th>
<th class="cell-base">
<div>{{ $t('objects.project') }}</div>
</th>
<th class="cell-type">
<div>{{ $t('general.type') }}</div>
</th>
<th class="cell-sub-type">
<div>{{ $t('general.subType') }}</div>
</th>
<th class="cell-description">
<div>{{ $t('labels.description') }}</div>
</th>
<th class="cell-ip">
<div>{{ $t('general.ipAddress') }}</div>
</th>
</tr>
</thead>
</table>
<template v-if="audits?.length">
<table>
<tbody>
<tr
v-for="(audit, i) of audits"
:key="i"
:class="{
selected: selectedAudit?.id === audit.id && isRowExpanded,
}"
@click="handleRowClick(audit)"
>
<td class="cell-user">
<div>
<div v-if="audit.user && collaboratorsMap.get(audit.user)?.email" class="w-full flex gap-3 items-center">
<GeneralUserIcon :email="collaboratorsMap.get(audit.user)?.email" size="base" class="flex-none" />
<div class="flex-1 flex flex-col max-w-[calc(100%_-_44px)]">
<div class="w-full flex gap-3">
<NcTooltip
class="text-sm !leading-5 text-gray-800 capitalize font-semibold truncate"
show-on-truncate-only
placement="bottom"
>
<template #title>
<div
ref="tableWrapper"
class="nc-audit-logs-table h-full max-h-[calc(100%_-_40px)] relative nc-scrollbar-thin !overflow-auto"
>
<table class="!sticky top-0 z-5">
<thead>
<tr>
<th
class="cell-user !hover:bg-gray-100 select-none cursor-pointer"
:class="{
'cursor-not-allowed': !audits?.length,
}"
@click="updateOrderBy('user')"
>
<div class="flex items-center gap-3">
<div>{{ $t('objects.user') }}</div>
<GeneralIcon
v-if="auditLogsQuery.orderBy?.user"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': auditLogsQuery.orderBy?.user === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</div>
</th>
<th
class="cell-timestamp !hover:bg-gray-100 select-none cursor-pointer"
:class="{
'cursor-not-allowed': !audits?.length,
}"
@click="updateOrderBy('created_at')"
>
<div class="flex items-center gap-3">
<div>Time</div>
<GeneralIcon
v-if="auditLogsQuery.orderBy?.created_at"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': auditLogsQuery.orderBy?.created_at === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</div>
</th>
<th class="cell-base">
<div>{{ $t('objects.project') }}</div>
</th>
<th class="cell-type">
<div>{{ $t('general.type') }}</div>
</th>
<th class="cell-sub-type">
<div>{{ $t('general.subType') }}</div>
</th>
<th class="cell-description">
<div>{{ $t('labels.description') }}</div>
</th>
<th class="cell-ip">
<div>{{ $t('general.ipAddress') }}</div>
</th>
</tr>
</thead>
</table>
<template v-if="audits?.length">
<table>
<tbody>
<tr
v-for="(audit, i) of audits"
:key="i"
:class="{
selected: selectedAudit?.id === audit.id && isRowExpanded,
}"
@click="handleRowClick(audit)"
>
<td class="cell-user">
<div>
<div v-if="audit.user && collaboratorsMap.get(audit.user)?.email" class="w-full flex gap-3 items-center">
<GeneralUserIcon :email="collaboratorsMap.get(audit.user)?.email" size="base" class="flex-none" />
<div class="flex-1 flex flex-col max-w-[calc(100%_-_44px)]">
<div class="w-full flex gap-3">
<NcTooltip
class="text-sm !leading-5 text-gray-800 capitalize font-semibold truncate"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{
collaboratorsMap.get(audit.user)?.display_name ||
collaboratorsMap
.get(audit.user)
?.email?.slice(0, collaboratorsMap.get(audit.user)?.email.indexOf('@'))
}}
</template>
{{
collaboratorsMap.get(audit.user)?.display_name ||
collaboratorsMap
.get(audit.user)
?.email?.slice(0, collaboratorsMap.get(audit.user)?.email.indexOf('@'))
}}
</NcTooltip>
</div>
<NcTooltip class="text-xs !leading-4 text-gray-600 truncate" show-on-truncate-only placement="bottom">
<template #title>
{{ collaboratorsMap.get(audit.user)?.email }}
</template>
{{
collaboratorsMap.get(audit.user)?.display_name ||
collaboratorsMap
.get(audit.user)
?.email?.slice(0, collaboratorsMap.get(audit.user)?.email.indexOf('@'))
}}
{{ collaboratorsMap.get(audit.user)?.email }}
</NcTooltip>
</div>
<NcTooltip class="text-xs !leading-4 text-gray-600 truncate" show-on-truncate-only placement="bottom">
<template #title>
{{ collaboratorsMap.get(audit.user)?.email }}
</template>
{{ collaboratorsMap.get(audit.user)?.email }}
</NcTooltip>
</div>
<template v-else>{{ audit.user }} </template>
</div>
<template v-else>{{ audit.user }} </template>
</div>
</td>
<td class="cell-timestamp">
<div>
<NcTooltip placement="bottom">
<template #title> {{ parseStringDateTime(audit.created_at, 'D MMMM YYYY HH:mm') }}</template>
</td>
<td class="cell-timestamp">
<div>
<NcTooltip placement="bottom">
<template #title> {{ parseStringDateTime(audit.created_at, 'D MMMM YYYY HH:mm') }}</template>
{{ timeAgo(audit.created_at) }}
</NcTooltip>
</div>
</td>
<td class="cell-base">
<div>
<div v-if="audit.base_id" class="w-full">
<NcTooltip
class="truncate text-sm !leading-5 text-gray-800 font-semibold"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{ bases.get(audit.base_id)?.title }}
</template>
{{ bases.get(audit.base_id)?.title }}
{{ timeAgo(audit.created_at) }}
</NcTooltip>
<div class="text-gray-600 text-xs">ID: {{ audit.base_id }}</div>
</div>
<template v-else>
{{ audit.base_id }}
</template>
</div>
</td>
<td class="cell-type">
<div>
<div class="truncate bg-gray-200 px-2 py-1 rounded-lg">
<NcTooltip class="truncate" placement="bottom" show-on-truncate-only>
<template #title> {{ auditOperationTypeLabels[audit.op_type] }}</template>
</td>
<td class="cell-base">
<div>
<div v-if="audit.base_id" class="w-full">
<NcTooltip
class="truncate text-sm !leading-5 text-gray-800 font-semibold"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{ bases.get(audit.base_id)?.title }}
</template>
{{ bases.get(audit.base_id)?.title }}
</NcTooltip>
<span class="truncate"> {{ auditOperationTypeLabels[audit.op_type] }} </span>
</NcTooltip>
<div class="text-gray-600 text-xs">ID: {{ audit.base_id }}</div>
</div>
<template v-else>
{{ audit.base_id }}
</template>
</div>
</div>
</td>
<td class="cell-sub-type">
<div>
<div class="truncate">
<NcTooltip class="truncate" placement="bottom" show-on-truncate-only>
<template #title> {{ auditOperationSubTypeLabels[audit.op_sub_type] }}</template>
<span class="truncate"> {{ auditOperationSubTypeLabels[audit.op_sub_type] }} </span>
</NcTooltip>
</td>
<td class="cell-type">
<div>
<div class="truncate bg-gray-200 px-2 py-1 rounded-lg">
<NcTooltip class="truncate" placement="bottom" show-on-truncate-only>
<template #title> {{ auditOperationTypeLabels[audit.op_type] }}</template>
<span class="truncate"> {{ auditOperationTypeLabels[audit.op_type] }} </span>
</NcTooltip>
</div>
</div>
</div>
</td>
<td class="cell-description">
<div>
<div class="truncate">
{{ audit.description }}
</td>
<td class="cell-sub-type">
<div>
<div class="truncate">
<NcTooltip class="truncate" placement="bottom" show-on-truncate-only>
<template #title> {{ auditOperationSubTypeLabels[audit.op_sub_type] }}</template>
<span class="truncate"> {{ auditOperationSubTypeLabels[audit.op_sub_type] }} </span>
</NcTooltip>
</div>
</div>
</div>
</td>
<td class="cell-ip">
<div>
<div class="truncate">
{{ audit.ip }}
</td>
<td class="cell-description">
<div>
<div class="truncate">
{{ audit.description }}
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</template>
</div>
<div
v-show="isLoading"
class="flex items-center justify-center absolute left-0 top-0 w-full h-full z-10 pb-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>
</td>
<td class="cell-ip">
<div>
<div class="truncate">
{{ audit.ip }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</template>
</div>
</div>
<div
v-if="!isLoading && !audits?.length"
class="flex items-center justify-center absolute left-0 top-0 w-full h-full pb-10 flex items-center justify-center text-gray-500"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
</div>
<div
v-if="auditPaginationData.totalRows"
class="flex flex-row justify-center items-center bg-gray-50 min-h-10"
:class="{
'pointer-events-none': isLoading,
}"
>
<div class="flex justify-between items-center w-full px-6">
<div>&nbsp;</div>
<NcPagination
v-model:current="auditPaginationData.page"
v-model:page-size="auditPaginationData.pageSize"
:total="+auditPaginationData.totalRows"
show-size-changer
:use-stored-page-size="false"
:prev-page-tooltip="`${renderAltOrOptlKey()}+←`"
:next-page-tooltip="`${renderAltOrOptlKey()}+→`"
:first-page-tooltip="`${renderAltOrOptlKey()}+↓`"
:last-page-tooltip="`${renderAltOrOptlKey()}+↑`"
@update:current="loadAudits(undefined, undefined, false)"
@update:page-size="loadAudits(currentPage, $event, false)"
/>
<div class="text-gray-500 text-xs">
{{ auditPaginationData.totalRows }} {{ auditPaginationData.totalRows === 1 ? 'record' : 'records' }}
<div
v-show="isLoading"
class="flex items-center justify-center absolute left-0 top-0 w-full h-full z-10 pb-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>
</div>
<NcModal v-model:visible="isRowExpanded" size="medium" :show-separator="false" @keydown.esc="isRowExpanded = false">
<template #header>
<div class="flex items-center justify-between gap-x-2 w-full">
<div class="flex-1 text-base font-weight-700 text-gray-900">Audit Details</div>
<div class="flex items-center gap-2">
<NcTooltip placement="bottom" class="text-gray-600 text-small leading-[18px]">
<template #title> {{ parseStringDateTime(selectedAudit.created_at, 'D MMMM YYYY HH:mm') }}</template>
{{ timeAgo(selectedAudit.created_at) }}
</NcTooltip>
<div
v-if="!isLoading && !audits?.length"
class="flex items-center justify-center absolute left-0 top-0 w-full h-full pb-10 flex items-center justify-center text-gray-500"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
</div>
<div
v-if="auditPaginationData.totalRows"
class="flex flex-row justify-center items-center bg-gray-50 min-h-10"
:class="{
'pointer-events-none': isLoading,
}"
>
<div class="flex justify-between items-center w-full px-6">
<div>&nbsp;</div>
<NcPagination
v-model:current="auditPaginationData.page"
v-model:page-size="auditPaginationData.pageSize"
:total="+auditPaginationData.totalRows"
show-size-changer
:use-stored-page-size="false"
:prev-page-tooltip="`${renderAltOrOptlKey()}+←`"
:next-page-tooltip="`${renderAltOrOptlKey()}+→`"
:first-page-tooltip="`${renderAltOrOptlKey()}+↓`"
:last-page-tooltip="`${renderAltOrOptlKey()}+↑`"
@update:current="loadAudits(undefined, undefined, false)"
@update:page-size="loadAudits(currentPage, $event, false)"
/>
<div class="text-gray-500 text-xs">
{{ auditPaginationData.totalRows }} {{ auditPaginationData.totalRows === 1 ? 'record' : 'records' }}
</div>
</div>
</div>
</template>
<div v-if="selectedAudit" class="nc-expanded-audit flex flex-col gap-4">
<div class="bg-gray-50 rounded-lg border-1 border-gray-200">
<div class="flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">Performed by</div>
<div
v-if="selectedAudit?.user && collaboratorsMap.get(selectedAudit.user)?.email"
class="w-full flex gap-3 items-center"
>
<GeneralUserIcon :email="collaboratorsMap.get(selectedAudit.user)?.email" size="base" class="flex-none" />
<div class="flex-1 flex flex-col">
<div class="w-full flex gap-3">
<span class="text-sm text-gray-800 capitalize font-semibold">
{{
collaboratorsMap.get(selectedAudit.user)?.display_name ||
collaboratorsMap
.get(selectedAudit.user)
?.email?.slice(0, collaboratorsMap.get(selectedAudit.user)?.email.indexOf('@'))
}}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collaboratorsMap.get(selectedAudit.user)?.email }}
</span>
</div>
</div>
</div>
<NcModal v-model:visible="isRowExpanded" size="medium" :show-separator="false" @keydown.esc="isRowExpanded = false">
<template #header>
<div class="flex items-center justify-between gap-x-2 w-full">
<div class="flex-1 text-base font-weight-700 text-gray-900">Audit Details</div>
<div class="flex items-center gap-2">
<NcTooltip placement="bottom" class="text-gray-600 text-small leading-[18px]">
<template #title> {{ parseStringDateTime(selectedAudit.created_at, 'D MMMM YYYY HH:mm') }}</template>
<div v-else class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.user }}</div>
</div>
<div class="w-1/2 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.ipAddress') }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.ip }}</div>
{{ timeAgo(selectedAudit.created_at) }}
</NcTooltip>
</div>
</div>
<div class="border-t-1 border-gray-200 flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">{{ $t('objects.project') }}</div>
<div v-if="selectedAudit?.base_id && bases.get(selectedAudit?.base_id)" class="flex items-stretch gap-3">
<div class="flex items-center">
<GeneralProjectIcon
:color="bases.get(selectedAudit?.base_id)?.meta?.iconColor"
:type="bases.get(selectedAudit?.base_id)?.type || 'database'"
class="nc-view-icon w-5 h-5"
/>
</div>
<div>
<div class="text-sm font-weight-500 text-gray-900">{{ bases.get(selectedAudit?.base_id)?.title }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.base_id }}</div>
</template>
<div v-if="selectedAudit" class="nc-expanded-audit flex flex-col gap-4">
<div class="bg-gray-50 rounded-lg border-1 border-gray-200">
<div class="flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">Performed by</div>
<div
v-if="selectedAudit?.user && collaboratorsMap.get(selectedAudit.user)?.email"
class="w-full flex gap-3 items-center"
>
<GeneralUserIcon :email="collaboratorsMap.get(selectedAudit.user)?.email" size="base" class="flex-none" />
<div class="flex-1 flex flex-col">
<div class="w-full flex gap-3">
<span class="text-sm text-gray-800 capitalize font-semibold">
{{
collaboratorsMap.get(selectedAudit.user)?.display_name ||
collaboratorsMap
.get(selectedAudit.user)
?.email?.slice(0, collaboratorsMap.get(selectedAudit.user)?.email.indexOf('@'))
}}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collaboratorsMap.get(selectedAudit.user)?.email }}
</span>
</div>
</div>
<div v-else class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.user }}</div>
</div>
<div class="w-1/2 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.ipAddress') }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.ip }}</div>
</div>
<template v-else>
{{ selectedAudit.base_id }}
</template>
</div>
<div class="w-1/2">
<div class="h-1/2 border-b border-gray-200 flex items-center gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.type') }}</div>
<div class="text-small leading-[18px] text-gray-600 bg-gray-200 px-1 rounded-md">
{{ auditOperationTypeLabels[selectedAudit?.op_type] }}
<div class="border-t-1 border-gray-200 flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">{{ $t('objects.project') }}</div>
<div v-if="selectedAudit?.base_id && bases.get(selectedAudit?.base_id)" class="flex items-stretch gap-3">
<div class="flex items-center">
<GeneralProjectIcon
:color="bases.get(selectedAudit?.base_id)?.meta?.iconColor"
:type="bases.get(selectedAudit?.base_id)?.type || 'database'"
class="nc-view-icon w-5 h-5"
/>
</div>
<div>
<div class="text-sm font-weight-500 text-gray-900">{{ bases.get(selectedAudit?.base_id)?.title }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.base_id }}</div>
</div>
</div>
<template v-else>
{{ selectedAudit.base_id }}
</template>
</div>
<div class="h-1/2 flex items-center gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.subType') }}</div>
<div class="text-small leading-[18px] text-gray-600">
{{ auditOperationSubTypeLabels[selectedAudit?.op_sub_type] }}
<div class="w-1/2">
<div class="h-1/2 border-b border-gray-200 flex items-center gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.type') }}</div>
<div class="text-small leading-[18px] text-gray-600 bg-gray-200 px-1 rounded-md">
{{ auditOperationTypeLabels[selectedAudit?.op_type] }}
</div>
</div>
<div class="h-1/2 flex items-center gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.subType') }}</div>
<div class="text-small leading-[18px] text-gray-600">
{{ auditOperationSubTypeLabels[selectedAudit?.op_sub_type] }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="cell-header">{{ $t('labels.description') }}</div>
<div>
<pre class="!text-small !leading-[18px] !text-gray-600 mb-0">{{ selectedAudit?.description }}</pre>
<div class="flex flex-col gap-2">
<div class="cell-header">{{ $t('labels.description') }}</div>
<div>
<pre class="!text-small !leading-[18px] !text-gray-600 mb-0">{{ selectedAudit?.description }}</pre>
</div>
</div>
</div>
</div>
</NcModal>
</template>
</NcModal>
</template>
</div>
</div>
</template>

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

@ -166,7 +166,13 @@ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
</script>
<template>
<div class="nc-collaborator-table-container py-6 h-[calc(100vh-92px)] max-w-350 px-1 flex flex-col gap-6">
<div
class="nc-collaborator-table-container py-6 max-w-350 px-6 flex flex-col gap-6"
:class="{
'h-[calc(100vh-144px)]': isAdminPanel,
'h-[calc(100vh-92px)]': !isAdminPanel,
}"
>
<div class="w-full flex items-center justify-between gap-3">
<a-input
v-model:value="userSearchText"

76
packages/nc-gui/components/workspace/View.vue

@ -17,7 +17,7 @@ const { activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceS
const { loadCollaborators, loadWorkspace } = workspaceStore
const orgStore = useOrg()
const { orgId } = storeToRefs(orgStore)
const { orgId, org } = storeToRefs(orgStore)
const currentWorkspace = computedAsync(async () => {
let ws
@ -68,38 +68,62 @@ onMounted(() => {
</script>
<template>
<div v-if="currentWorkspace" class="flex w-full px-6 max-w-[97.5rem] flex-col nc-workspace-settings">
<div v-if="!props.workspaceId" class="flex gap-2 items-center min-w-0 py-4">
<h1 class="text-base capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
<span class="text-gray-500">{{ currentWorkspace?.title }} ></span> {{ $t('title.teamAndSettings') }}
</h1>
<div v-if="currentWorkspace" class="flex w-full max-w-[97.5rem] flex-col nc-workspace-settings">
<div v-if="!props.workspaceId" class="min-w-0 pt-2 px-2">
<div class="nc-breadcrumb nc-workspace-title">
<div class="nc-breadcrumb-item capitalize">
{{ currentWorkspace?.title }}
</div>
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
<h1 class="nc-breadcrumb-item active">
{{ $t('title.teamAndSettings') }}
</h1>
</div>
</div>
<div v-else>
<div class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<NuxtLink
:href="`/admin/${orgId}/workspaces`"
class="!hover:(text-black underline-gray-600) flex items-center !text-black !underline-transparent ml-0.75 max-w-1/4"
>
<component :is="iconMap.arrowLeft" class="text-3xl" />
<template v-else>
<div class="nc-breadcrumb px-2">
<div class="nc-breadcrumb-item">
{{ org.title }}
</div>
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
<NuxtLink
:href="`/admin/${orgId}/workspaces`"
class="!hover:(text-gray-800 underline-gray-600) flex items-center !text-gray-700 !underline-transparent ml-0.75 max-w-1/4"
>
<div class="nc-breadcrumb-item">
{{ $t('labels.workspaces') }}
</NuxtLink>
</div>
</NuxtLink>
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
<span class="text-2xl"> / </span>
<GeneralWorkspaceIcon :workspace="currentWorkspace" hide-label />
<span class="text-base capitalize">
{{ currentWorkspace?.title }}
</span>
<div class="nc-breadcrumb-item active truncate capitalize">
{{ currentWorkspace?.title }}
</div>
</div>
</div>
<NcPageHeader>
<template #icon>
<div class="flex justify-center items-center h-6 w-6">
<GeneralWorkspaceIcon :workspace="currentWorkspace" hide-label size="small" />
</div>
</template>
<template #title>
<span data-rec="true" class="capitalize">
{{ currentWorkspace?.title }}
</span>
</template>
</NcPageHeader>
</template>
<NcTabs v-model:activeKey="tab">
<template #leftExtra>
<div class="w-3"></div>
</template>
<template v-if="isUIAllowed('workspaceSettings')">
<a-tab-pane key="collaborators" class="w-full">
<template #tab>
<div class="flex flex-row items-center pb-1 gap-x-1.5">
<div class="flex flex-row items-center pb-1 pt-2 gap-x-1.5">
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" />
Members
</div>
@ -111,7 +135,7 @@ onMounted(() => {
<template v-if="isUIAllowed('workspaceManage')">
<a-tab-pane key="settings" class="w-full">
<template #tab>
<div class="flex flex-row items-center pb-1 gap-x-1.5" data-testid="nc-workspace-settings-tab-settings">
<div class="flex flex-row items-center pb-1 pt-2 gap-x-1.5" data-testid="nc-workspace-settings-tab-settings">
<GeneralIcon icon="settings" />
Settings
</div>
@ -123,12 +147,12 @@ onMounted(() => {
<template v-if="isUIAllowed('workspaceAuditList') && !props.workspaceId">
<a-tab-pane key="audit" class="w-full">
<template #tab>
<div class="flex flex-row items-center pb-1 gap-x-1.5">
<div class="flex flex-row items-center pb-1 pt-2 gap-x-1.5">
<GeneralIcon icon="audit" class="!h-3.5 !w-3.5" />
Audit Logs
</div>
</template>
<div class="h-[calc(100vh-92px)]">
<div class="h-[calc(100vh-92px)] px-6">
<WorkspaceAuditLogs :workspace-id="currentWorkspace.id" />
</div>
</a-tab-pane>
@ -139,7 +163,7 @@ onMounted(() => {
<style lang="scss" scoped>
.nc-workspace-avatar {
@apply min-w-6 h-6 rounded-[6px] flex items-center justify-center text-white font-weight-bold uppercase;
@apply min-w-5 h-5 w-5 rounded-[6px] flex items-center justify-center text-white font-weight-bold uppercase;
font-size: 0.7rem;
}

18
packages/nc-gui/components/workspace/integrations/view.vue

@ -47,10 +47,16 @@ onBeforeMount(() => {
<template>
<div v-if="currentWorkspace" class="flex w-full max-w-[97.5rem] flex-col nc-workspace-integrations">
<div class="flex gap-2 items-center min-w-0 py-4 px-6">
<h1 class="text-base capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
<span class="text-gray-500"> {{ currentWorkspace?.title }} ></span> {{ $t('general.integrations') }}
</h1>
<div class="flex gap-2 items-center min-w-0 pt-2 px-2">
<div class="nc-breadcrumb">
<div class="nc-breadcrumb-item capitalize">
{{ currentWorkspace?.title }}
</div>
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" />
<h1 class="nc-breadcrumb-item active">
{{ $t('general.integrations') }}
</h1>
</div>
</div>
<NcTabs v-model:activeKey="activeViewTab">
@ -60,7 +66,7 @@ onBeforeMount(() => {
<template v-if="isUIAllowed('workspaceIntegrations')">
<a-tab-pane key="integrations" class="w-full">
<template #tab>
<div class="flex flex-row items-center pb-1 gap-x-1.5" data-testid="nc-workspace-settings-tab-integrations">
<div class="flex flex-row items-center pb-1 pt-2 gap-x-1.5" data-testid="nc-workspace-settings-tab-integrations">
<GeneralIcon icon="integration" />
{{ $t('general.integrations') }}
</div>
@ -73,7 +79,7 @@ onBeforeMount(() => {
<template v-if="isUIAllowed('workspaceIntegrations')">
<a-tab-pane key="connections" class="w-full">
<template #tab>
<div class="flex flex-row items-center pb-1 gap-x-1.5" data-testid="nc-workspace-settings-tab-integrations">
<div class="flex flex-row items-center pb-1 pt-2 gap-x-1.5" data-testid="nc-workspace-settings-tab-integrations">
<GeneralIcon icon="gitCommit" />
{{ $t('general.connections') }}
<div

54
packages/nc-gui/pages/account/index.vue

@ -29,10 +29,10 @@ const logout = async () => {
<div>
<NuxtLayout name="empty">
<div class="mx-auto h-full">
<div class="h-full overflow-y-auto flex">
<div class="h-full flex">
<!-- Side tabs -->
<div class="h-full bg-white nc-user-sidebar fixed">
<div class="h-full bg-white nc-user-sidebar overflow-y-auto nc-scrollbar-thin min-w-[312px]">
<NcMenu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
@ -40,20 +40,23 @@ const logout = async () => {
class="tabs-menu h-full"
mode="inline"
>
<div
<NcButton
v-if="!$route.params.baseType"
v-e="['c:navbar:home']"
type="text"
size="small"
class="transition-all duration-200 mx-2 my-2.5 cursor-pointer transform hover:bg-gray-100 nc-noco-brand-icon"
data-testid="nc-noco-brand-icon"
class="transition-all duration-200 px-2 mx-2 mt-1.5 cursor-pointer transform hover:bg-gray-100 my-1 nc-noco-brand-icon h-8 rounded-md min-w-60"
@click="navigateTo('/')"
>
<div class="flex flex-row gap-x-2 items-center h-8.5">
<GeneralIcon icon="arrowLeft" class="-mt-0.1" />
<div class="flex text-sm font-medium text-gray-800">{{ $t('labels.backToWorkspace') }}</div>
<div class="flex flex-row gap-x-2 items-center">
<GeneralIcon icon="ncArrowLeft" />
<div class="flex text-small leading-[18px] font-semibold">{{ $t('labels.back') }}</div>
</div>
</div>
</NcButton>
<NcDivider class="!mt-0" />
<div class="text-sm text-gray-600 ml-4 p-2 mt-3 gray-600 font-medium">{{ $t('labels.account') }}</div>
<div class="text-sm text-gray-500 font-semibold ml-4 py-1.5 mt-2">{{ $t('labels.account') }}</div>
<NcMenuItem
key="profile"
@ -78,9 +81,9 @@ const logout = async () => {
@click="navigateTo('/account/tokens')"
>
<div class="flex items-center space-x-2">
<component :is="iconMap.code" />
<MdiShieldKeyOutline />
<div class="select-none">API {{ $t('title.tokens') }}</div>
<div class="select-none">{{ $t('title.tokens') }}</div>
</div>
</NcMenuItem>
<NcMenuItem
@ -156,9 +159,11 @@ const logout = async () => {
<!-- Sub Tabs -->
<div class="flex flex-col w-full pl-65">
<div class="flex flex-row p-3 items-center h-14">
<div class="flex-1" />
<div class="h-full flex-1 flex flex-col overflow-y-auto nc-scrollbar-thin">
<div class="flex flex-row pt-2 px-2 items-center">
<div class="flex-1">
<LazyAccountBreadcrumb />
</div>
<LazyGeneralReleaseInfo />
@ -198,12 +203,12 @@ const logout = async () => {
</template>
</div>
<div
class="flex flex-col container mx-auto"
class="flex flex-col w-full max-w-[97.5rem]"
:style="{
height: 'calc(100vh - 3.5rem)',
}"
>
<div class="mt-2 h-full">
<div class="h-full">
<NuxtPage />
</div>
</div>
@ -231,14 +236,15 @@ const logout = async () => {
:deep(.ant-menu-submenu-selected .ant-menu-submenu-arrow) {
@apply !text-inherit;
}
:deep(.item) {
@apply select-none mx-2 !px-3 !text-sm !rounded-md !mb-1 !hover:(bg-brand-50 text-brand-500);
width: calc(100% - 1rem);
}
:deep(.active) {
@apply !bg-brand-50 !text-brand-500;
.tabs-menu {
:deep(.item) {
@apply select-none mx-2 !px-3 !text-sm !rounded-md !mb-1 !hover:(bg-brand-50 text-brand-500);
width: calc(100% - 1rem);
}
:deep(.active) {
@apply !bg-brand-50 !text-brand-500;
}
}
:deep(.ant-menu-submenu-title) {

4
packages/nc-gui/pages/account/index/[page].vue

@ -5,9 +5,7 @@ const { appInfo } = useGlobal()
<template>
<div>
<AccountToken v-if="$route.params.page === 'tokens'" />
<div v-else-if="$route.params.page === 'audit'" class="h-[calc(100vh_-_4rem)] w-full px-6">
<WorkspaceAuditLogs />
</div>
<WorkspaceAuditLogs v-else-if="$route.params.page === 'audit'" />
<AccountProfile v-else-if="$route.params.page === 'profile'" />
<AccountAppStore v-else-if="$route.params.page === 'apps' && !appInfo.isCloud" />
<span v-else></span>

2
packages/nc-gui/pages/account/index/users/[[nestedPage]].vue

@ -3,7 +3,7 @@ const { isUIAllowed } = useRoles()
</script>
<template>
<div class="h-full overflow-y-auto scrollbar-thin-dull pt-2">
<div class="h-full">
<template
v-if="
$route.params.nestedPage === 'password-reset' ||

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

@ -1,3 +1,4 @@
import { ViewTypes } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { MAX_WIDTH_FOR_MOBILE_MODE } from '~/lib/constants'
@ -33,7 +34,7 @@ export const useConfigStore = defineStore('configStore', () => {
globalIsMobile.value = isMobileMode.value
// Change --topbar-height css variable
document.documentElement.style.setProperty('--topbar-height', isMobileMode.value ? '3.25rem' : '3.1rem')
document.documentElement.style.setProperty('--topbar-height', isMobileMode.value ? '3.875rem' : '3rem')
// Set .mobile-mode class on body
if (isMobileMode.value) {

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

@ -150,6 +150,7 @@ import NcStrike from '~icons/nc-icons/strike-through'
import NcCrop from '~icons/nc-icons/crop'
import NcControlPanel from '~icons/nc-icons/control-panel'
import NcHome from '~icons/nc-icons/home'
import NcHome1 from '~icons/nc-icons/home1'
import NcWorkspace from '~icons/nc-icons/workspace'
import NcCellBarcode from '~icons/nc-icons/cell-barcode'
@ -528,6 +529,12 @@ import NcAlignLeftIcon from '~icons/nc-icons-v2/align-left.svg'
import NcHeartIcon from '~icons/nc-icons-v2/heart.svg'
import NcTrendingUpIcon from '~icons/nc-icons-v2/trending-up.svg'
import NcSlash1 from '~icons/nc-icons/slash1'
import NcChevronUpSmall from '~icons/nc-icons/chevron-up-small'
import NcChevronDownSmall from '~icons/nc-icons/chevron-down-small'
import NcChevronUpDownSmall from '~icons/nc-icons/chevron-up-down-small'
// keep it for reference
// todo: remove it after all icons are migrated
/* export const iconMapOld = {
@ -679,10 +686,12 @@ export const iconMap = {
strike: NcStrike,
atSign: NcAtSign,
slash: NcSlash,
ncSlash1: NcSlash1,
arrowUpRight: NcArrowUpRight,
ncWorkspace: NcWorkspace,
controlPanel: NcControlPanel,
home: NcHome,
home1: NcHome1,
cellBarcode: NcCellBarcode,
cellCheckbox: NcCellCheckbox,
cellDate: NcCellDate,
@ -1275,6 +1284,9 @@ export const iconMap = {
ncAlignLeft: NcAlignLeftIcon,
ncHeart: NcHeartIcon,
ncTrendingUp: NcTrendingUpIcon,
chevronUpSmall: NcChevronUpSmall,
chevronDownSmall: NcChevronDownSmall,
chevronUpDownSmall: NcChevronUpDownSmall,
}
export const getMdiIcon = (type: string): any => {

2
packages/nc-gui/utils/syncDataUtils.ts

@ -466,7 +466,7 @@ export const allIntegrations: IntegrationItemType[] = [
categories: [IntegrationCategoryType.STORAGE],
},
// Spreadsheet
// Spreadsheet
{
title: 'objects.syncData.appleNumbers',
value: SyncDataType.APPLE_NUMBERS,

Loading…
Cancel
Save