Unify sidebar workspace and account menus (#240)
* Unify sidebar workspace and account menus. Merge workspace switching, profile, billing, and language into one header dropdown, move notifications beside it, pin support links to the footer, and add Workspace Settings to the nav — without MCP or onboarding residual. Co-authored-by: Cursor <cursoragent@cursor.com> * Remove Workspace Settings from the sidebar nav. Settings already lives in the unified account menu; keep the workspace group free for items like MCP later. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover sidebar menu role gates in browser tests. Assert account/workspace settings visibility for owner, admin, member, and self-hosted so the unified menu stays aligned with WorkspacePolicy. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename sidebar wait helper to avoid Pest browser collision. waitForTestId was already declared in MobileEditorTest, which fataled the e2e suite when listing Browser tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Add sidebar menu browser coverage for workspace viewers. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1af705f3fd
commit
f62b4bb4a5
22 changed files with 592 additions and 252 deletions
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'إنشاء مساحة عمل',
|
'create_workspace' => 'إنشاء مساحة عمل',
|
||||||
'create_post' => 'إنشاء منشور',
|
'create_post' => 'إنشاء منشور',
|
||||||
'profile' => 'الملف الشخصي',
|
'profile' => 'الملف الشخصي',
|
||||||
|
'my_account' => 'حسابي',
|
||||||
|
'account_settings' => 'الحساب والفوترة',
|
||||||
|
'workspace_settings' => 'إعدادات مساحة العمل',
|
||||||
'log_out' => 'تسجيل الخروج',
|
'log_out' => 'تسجيل الخروج',
|
||||||
|
|
||||||
'workspace' => 'مساحة العمل: :name',
|
'workspace' => 'مساحة العمل: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Workspace erstellen',
|
'create_workspace' => 'Workspace erstellen',
|
||||||
'create_post' => 'Beitrag erstellen',
|
'create_post' => 'Beitrag erstellen',
|
||||||
'profile' => 'Profil',
|
'profile' => 'Profil',
|
||||||
|
'my_account' => 'Mein Konto',
|
||||||
|
'account_settings' => 'Konto & Abrechnung',
|
||||||
|
'workspace_settings' => 'Workspace-Einstellungen',
|
||||||
'log_out' => 'Abmelden',
|
'log_out' => 'Abmelden',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Δημιουργία workspace',
|
'create_workspace' => 'Δημιουργία workspace',
|
||||||
'create_post' => 'Δημιουργία δημοσίευσης',
|
'create_post' => 'Δημιουργία δημοσίευσης',
|
||||||
'profile' => 'Προφίλ',
|
'profile' => 'Προφίλ',
|
||||||
|
'my_account' => 'Ο λογαριασμός μου',
|
||||||
|
'account_settings' => 'Λογαριασμός και χρέωση',
|
||||||
|
'workspace_settings' => 'Ρυθμίσεις workspace',
|
||||||
'log_out' => 'Αποσύνδεση',
|
'log_out' => 'Αποσύνδεση',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Create workspace',
|
'create_workspace' => 'Create workspace',
|
||||||
'create_post' => 'Create post',
|
'create_post' => 'Create post',
|
||||||
'profile' => 'Profile',
|
'profile' => 'Profile',
|
||||||
|
'my_account' => 'My account',
|
||||||
|
'account_settings' => 'Account & billing',
|
||||||
|
'workspace_settings' => 'Workspace settings',
|
||||||
'log_out' => 'Log out',
|
'log_out' => 'Log out',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Crear workspace',
|
'create_workspace' => 'Crear workspace',
|
||||||
'create_post' => 'Crear post',
|
'create_post' => 'Crear post',
|
||||||
'profile' => 'Perfil',
|
'profile' => 'Perfil',
|
||||||
|
'my_account' => 'Mi cuenta',
|
||||||
|
'account_settings' => 'Cuenta y facturación',
|
||||||
|
'workspace_settings' => 'Configuración del workspace',
|
||||||
'log_out' => 'Cerrar sesión',
|
'log_out' => 'Cerrar sesión',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Créer un espace de travail',
|
'create_workspace' => 'Créer un espace de travail',
|
||||||
'create_post' => 'Créer une publication',
|
'create_post' => 'Créer une publication',
|
||||||
'profile' => 'Profil',
|
'profile' => 'Profil',
|
||||||
|
'my_account' => 'Mon compte',
|
||||||
|
'account_settings' => 'Compte et facturation',
|
||||||
|
'workspace_settings' => 'Paramètres de l\'espace de travail',
|
||||||
'log_out' => 'Se déconnecter',
|
'log_out' => 'Se déconnecter',
|
||||||
|
|
||||||
'workspace' => 'Espace de travail : :name',
|
'workspace' => 'Espace de travail : :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Crea workspace',
|
'create_workspace' => 'Crea workspace',
|
||||||
'create_post' => 'Crea post',
|
'create_post' => 'Crea post',
|
||||||
'profile' => 'Profilo',
|
'profile' => 'Profilo',
|
||||||
|
'my_account' => 'Il mio account',
|
||||||
|
'account_settings' => 'Account e fatturazione',
|
||||||
|
'workspace_settings' => 'Impostazioni workspace',
|
||||||
'log_out' => 'Esci',
|
'log_out' => 'Esci',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'ワークスペースを作成',
|
'create_workspace' => 'ワークスペースを作成',
|
||||||
'create_post' => '投稿を作成',
|
'create_post' => '投稿を作成',
|
||||||
'profile' => 'プロフィール',
|
'profile' => 'プロフィール',
|
||||||
|
'my_account' => 'マイアカウント',
|
||||||
|
'account_settings' => 'アカウントと請求',
|
||||||
|
'workspace_settings' => 'ワークスペース設定',
|
||||||
'log_out' => 'ログアウト',
|
'log_out' => 'ログアウト',
|
||||||
|
|
||||||
'workspace' => 'ワークスペース: :name',
|
'workspace' => 'ワークスペース: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => '워크스페이스 만들기',
|
'create_workspace' => '워크스페이스 만들기',
|
||||||
'create_post' => '게시물 만들기',
|
'create_post' => '게시물 만들기',
|
||||||
'profile' => '프로필',
|
'profile' => '프로필',
|
||||||
|
'my_account' => '내 계정',
|
||||||
|
'account_settings' => '계정 및 결제',
|
||||||
|
'workspace_settings' => '워크스페이스 설정',
|
||||||
'log_out' => '로그아웃',
|
'log_out' => '로그아웃',
|
||||||
|
|
||||||
'workspace' => '워크스페이스: :name',
|
'workspace' => '워크스페이스: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Workspace aanmaken',
|
'create_workspace' => 'Workspace aanmaken',
|
||||||
'create_post' => 'Post aanmaken',
|
'create_post' => 'Post aanmaken',
|
||||||
'profile' => 'Profiel',
|
'profile' => 'Profiel',
|
||||||
|
'my_account' => 'Mijn account',
|
||||||
|
'account_settings' => 'Account en facturatie',
|
||||||
|
'workspace_settings' => 'Workspace-instellingen',
|
||||||
'log_out' => 'Uitloggen',
|
'log_out' => 'Uitloggen',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Utwórz przestrzeń roboczą',
|
'create_workspace' => 'Utwórz przestrzeń roboczą',
|
||||||
'create_post' => 'Utwórz post',
|
'create_post' => 'Utwórz post',
|
||||||
'profile' => 'Profil',
|
'profile' => 'Profil',
|
||||||
|
'my_account' => 'Moje konto',
|
||||||
|
'account_settings' => 'Konto i płatności',
|
||||||
|
'workspace_settings' => 'Ustawienia workspace',
|
||||||
'log_out' => 'Wyloguj się',
|
'log_out' => 'Wyloguj się',
|
||||||
|
|
||||||
'workspace' => 'Przestrzeń robocza: :name',
|
'workspace' => 'Przestrzeń robocza: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Criar workspace',
|
'create_workspace' => 'Criar workspace',
|
||||||
'create_post' => 'Novo post',
|
'create_post' => 'Novo post',
|
||||||
'profile' => 'Perfil',
|
'profile' => 'Perfil',
|
||||||
|
'my_account' => 'Minha conta',
|
||||||
|
'account_settings' => 'Conta e cobrança',
|
||||||
|
'workspace_settings' => 'Configurações do workspace',
|
||||||
'log_out' => 'Sair',
|
'log_out' => 'Sair',
|
||||||
|
|
||||||
'workspace' => 'Workspace: :name',
|
'workspace' => 'Workspace: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Создать рабочее пространство',
|
'create_workspace' => 'Создать рабочее пространство',
|
||||||
'create_post' => 'Создать пост',
|
'create_post' => 'Создать пост',
|
||||||
'profile' => 'Профиль',
|
'profile' => 'Профиль',
|
||||||
|
'my_account' => 'Мой аккаунт',
|
||||||
|
'account_settings' => 'Аккаунт и оплата',
|
||||||
|
'workspace_settings' => 'Настройки workspace',
|
||||||
'log_out' => 'Выйти',
|
'log_out' => 'Выйти',
|
||||||
|
|
||||||
'workspace' => 'Рабочее пространство: :name',
|
'workspace' => 'Рабочее пространство: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Çalışma alanı oluştur',
|
'create_workspace' => 'Çalışma alanı oluştur',
|
||||||
'create_post' => 'Gönderi oluştur',
|
'create_post' => 'Gönderi oluştur',
|
||||||
'profile' => 'Profil',
|
'profile' => 'Profil',
|
||||||
|
'my_account' => 'Hesabım',
|
||||||
|
'account_settings' => 'Hesap ve faturalandırma',
|
||||||
|
'workspace_settings' => 'Çalışma alanı ayarları',
|
||||||
'log_out' => 'Çıkış yap',
|
'log_out' => 'Çıkış yap',
|
||||||
|
|
||||||
'workspace' => 'Çalışma alanı: :name',
|
'workspace' => 'Çalışma alanı: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => 'Створити робочий простір',
|
'create_workspace' => 'Створити робочий простір',
|
||||||
'create_post' => 'Створити пост',
|
'create_post' => 'Створити пост',
|
||||||
'profile' => 'Профіль',
|
'profile' => 'Профіль',
|
||||||
|
'my_account' => 'Мій акаунт',
|
||||||
|
'account_settings' => 'Акаунт і оплата',
|
||||||
|
'workspace_settings' => 'Налаштування робочого простору',
|
||||||
'log_out' => 'Вийти',
|
'log_out' => 'Вийти',
|
||||||
|
|
||||||
'workspace' => 'Робочий простір: :name',
|
'workspace' => 'Робочий простір: :name',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
'create_workspace' => '创建工作区',
|
'create_workspace' => '创建工作区',
|
||||||
'create_post' => '创建帖子',
|
'create_post' => '创建帖子',
|
||||||
'profile' => '个人资料',
|
'profile' => '个人资料',
|
||||||
|
'my_account' => '我的账户',
|
||||||
|
'account_settings' => '账户与账单',
|
||||||
|
'workspace_settings' => '工作区设置',
|
||||||
'log_out' => '退出登录',
|
'log_out' => '退出登录',
|
||||||
|
|
||||||
'workspace' => '工作区::name',
|
'workspace' => '工作区::name',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
IconAffiliate,
|
IconAffiliate,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
|
@ -14,27 +14,26 @@ import {
|
||||||
IconGift,
|
IconGift,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconLifebuoy,
|
IconLifebuoy,
|
||||||
IconPhoto,
|
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPlus,
|
IconPhoto,
|
||||||
IconSelector,
|
IconSelector,
|
||||||
IconTag,
|
IconTag,
|
||||||
} from '@tabler/icons-vue';
|
} from '@tabler/icons-vue';
|
||||||
import { trans } from 'laravel-vue-i18n';
|
import { trans } from 'laravel-vue-i18n';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { create as createPost, index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
import {
|
||||||
|
create as createPost,
|
||||||
|
index as postsIndex,
|
||||||
|
} from '@/actions/App/Http/Controllers/App/PostController';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import NavSupport from '@/components/NavSupport.vue';
|
import NavSupport from '@/components/NavSupport.vue';
|
||||||
import NavUser from '@/components/NavUser.vue';
|
import NotificationBell from '@/components/NotificationBell.vue';
|
||||||
import { Avatar } from '@/components/ui/avatar';
|
import { Avatar } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
|
|
@ -47,6 +46,7 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import WorkspaceMenuContent from '@/components/WorkspaceMenuContent.vue';
|
||||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||||
import { accounts, analytics, calendar } from '@/routes/app';
|
import { accounts, analytics, calendar } from '@/routes/app';
|
||||||
import { index as assets } from '@/routes/app/assets';
|
import { index as assets } from '@/routes/app/assets';
|
||||||
|
|
@ -54,8 +54,7 @@ import { index as automations } from '@/routes/app/automations';
|
||||||
import { portal } from '@/routes/app/billing';
|
import { portal } from '@/routes/app/billing';
|
||||||
import { index as labels } from '@/routes/app/labels';
|
import { index as labels } from '@/routes/app/labels';
|
||||||
import { index as signatures } from '@/routes/app/signatures';
|
import { index as signatures } from '@/routes/app/signatures';
|
||||||
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
|
import type { NavItem, User } from '@/types';
|
||||||
import type { NavItem } from '@/types';
|
|
||||||
|
|
||||||
interface Workspace {
|
interface Workspace {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -64,14 +63,23 @@ interface Workspace {
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const currentWorkspace = computed<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
|
const user = computed(() => page.props.auth.user as User);
|
||||||
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
|
const currentWorkspace = computed<Workspace | null>(
|
||||||
const subscriptionPastDue = computed<boolean>(() => Boolean(page.props.auth.subscriptionPastDue));
|
() => page.props.auth.currentWorkspace as Workspace | null,
|
||||||
|
);
|
||||||
|
const workspaces = computed<Workspace[]>(
|
||||||
|
() => page.props.auth.workspaces as Workspace[],
|
||||||
|
);
|
||||||
|
const subscriptionPastDue = computed<boolean>(() =>
|
||||||
|
Boolean(page.props.auth.subscriptionPastDue),
|
||||||
|
);
|
||||||
|
|
||||||
const { canCreatePost, canManageAccounts, canManageAutomations, canCreateWorkspace } = useWorkspaceRole();
|
const {
|
||||||
|
canCreatePost,
|
||||||
// Open the workspace menu to the side on desktop (like the user menu) but keep
|
canManageAccounts,
|
||||||
// it below the trigger on mobile, where the sidebar is an overlay sheet.
|
canManageAutomations,
|
||||||
|
canCreateWorkspace,
|
||||||
|
} = useWorkspaceRole();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
const mainNavItems = computed<NavItem[]>(() => [
|
const mainNavItems = computed<NavItem[]>(() => [
|
||||||
|
|
@ -102,7 +110,11 @@ const postsNavItems = computed<NavItem[]>(() => [
|
||||||
title: trans('sidebar.posts.all'),
|
title: trans('sidebar.posts.all'),
|
||||||
href: postsIndex.url(),
|
href: postsIndex.url(),
|
||||||
icon: IconFileText,
|
icon: IconFileText,
|
||||||
excludeActive: [postsIndex.url('scheduled'), postsIndex.url('published'), postsIndex.url('draft')],
|
excludeActive: [
|
||||||
|
postsIndex.url('scheduled'),
|
||||||
|
postsIndex.url('published'),
|
||||||
|
postsIndex.url('draft'),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: trans('sidebar.posts.scheduled'),
|
title: trans('sidebar.posts.scheduled'),
|
||||||
|
|
@ -152,7 +164,7 @@ const workspaceNavItems = computed<NavItem[]>(() => [
|
||||||
: []),
|
: []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const supportNavItems = computed(() => [
|
const bottomNavItems = computed(() => [
|
||||||
{
|
{
|
||||||
title: trans('sidebar.support.referral'),
|
title: trans('sidebar.support.referral'),
|
||||||
href: 'https://affiliates.trypost.it/',
|
href: 'https://affiliates.trypost.it/',
|
||||||
|
|
@ -169,16 +181,6 @@ const supportNavItems = computed(() => [
|
||||||
icon: IconLifebuoy,
|
icon: IconLifebuoy,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const switchWorkspace = (workspaceId: string) => {
|
|
||||||
router.post(switchMethod.url(workspaceId), {}, {
|
|
||||||
preserveScroll: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateWorkspace = () => {
|
|
||||||
router.visit(createWorkspaceRoute.url());
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -186,50 +188,64 @@ const handleCreateWorkspace = () => {
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<div class="flex items-center gap-1">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenu>
|
||||||
<SidebarMenuButton size="lg"
|
<DropdownMenuTrigger as-child>
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
<SidebarMenuButton
|
||||||
<Avatar :src="currentWorkspace?.logo_url" :name="currentWorkspace?.name ?? '?'"
|
size="lg"
|
||||||
class="h-8 w-8 shrink-0 rounded-md border-2 border-foreground"
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
fallback-class="bg-violet-100 text-violet-700 font-bold" />
|
data-test="sidebar-menu-button"
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
data-testid="sidebar-workspace-menu"
|
||||||
<span class="truncate font-semibold">
|
dusk="sidebar-workspace-menu"
|
||||||
{{ currentWorkspace?.name ?? $t('sidebar.select_workspace') }}
|
>
|
||||||
</span>
|
<Avatar
|
||||||
</div>
|
:src="currentWorkspace?.logo_url"
|
||||||
<component :is="isMobile ? IconSelector : IconChevronRight" class="ml-auto size-4" />
|
:name="currentWorkspace?.name ?? '?'"
|
||||||
</SidebarMenuButton>
|
class="h-8 w-8 shrink-0 rounded-md border-2 border-foreground"
|
||||||
</DropdownMenuTrigger>
|
fallback-class="bg-violet-100 text-violet-700 font-bold"
|
||||||
<DropdownMenuContent class="w-(--reka-dropdown-menu-trigger-width) min-w-56"
|
/>
|
||||||
align="start" :side="isMobile ? 'bottom' : 'right'" :side-offset="4">
|
<div
|
||||||
<DropdownMenuLabel>
|
class="grid min-w-0 flex-1 text-left text-sm leading-tight"
|
||||||
{{ $t('sidebar.workspaces') }}
|
>
|
||||||
</DropdownMenuLabel>
|
<span class="truncate font-semibold">
|
||||||
<div class="space-y-0.5">
|
{{
|
||||||
<DropdownMenuItem v-for="workspace in workspaces" :key="workspace.id"
|
currentWorkspace?.name ??
|
||||||
:class="workspace.id === currentWorkspace?.id ? 'bg-accent text-accent-foreground' : ''"
|
$t('sidebar.select_workspace')
|
||||||
@click="switchWorkspace(workspace.id)">
|
}}
|
||||||
<Avatar :src="workspace.logo_url" :name="workspace.name"
|
</span>
|
||||||
class="h-6 w-6 shrink-0 rounded-md border-2 border-foreground" fallback-class="text-[10px] bg-violet-100 text-violet-700 font-bold" />
|
</div>
|
||||||
{{ workspace.name }}
|
<component
|
||||||
</DropdownMenuItem>
|
:is="
|
||||||
</div>
|
isMobile
|
||||||
<template v-if="canCreateWorkspace">
|
? IconSelector
|
||||||
<DropdownMenuSeparator />
|
: IconChevronRight
|
||||||
<DropdownMenuItem @click="handleCreateWorkspace">
|
"
|
||||||
<IconPlus class="size-4" />
|
class="ml-auto size-4"
|
||||||
{{ $t('sidebar.create_workspace') }}
|
/>
|
||||||
</DropdownMenuItem>
|
</SidebarMenuButton>
|
||||||
</template>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuContent
|
||||||
</DropdownMenu>
|
class="w-(--reka-dropdown-menu-trigger-width) min-w-64"
|
||||||
|
align="start"
|
||||||
|
:side="isMobile ? 'bottom' : 'right'"
|
||||||
|
:side-offset="4"
|
||||||
|
>
|
||||||
|
<WorkspaceMenuContent
|
||||||
|
:user="user"
|
||||||
|
:current-workspace="currentWorkspace"
|
||||||
|
:workspaces="workspaces"
|
||||||
|
:can-create-workspace="canCreateWorkspace"
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<NotificationBell v-if="currentWorkspace" />
|
||||||
|
</div>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent class="gap-px">
|
<SidebarContent class="gap-px">
|
||||||
<!-- Create Post Button -->
|
|
||||||
<div v-if="currentWorkspace && canCreatePost" class="px-2 py-2">
|
<div v-if="currentWorkspace && canCreatePost" class="px-2 py-2">
|
||||||
<Link :href="createPost.url()" class="block">
|
<Link :href="createPost.url()" class="block">
|
||||||
<Button class="w-full">
|
<Button class="w-full">
|
||||||
|
|
@ -239,11 +255,25 @@ const handleCreateWorkspace = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavMain v-if="currentWorkspace" :items="mainNavItems" />
|
<NavMain v-if="currentWorkspace" :items="mainNavItems" />
|
||||||
<NavMain v-if="currentWorkspace" :items="postsNavItems" :label="$t('sidebar.groups.posts')" />
|
<NavMain
|
||||||
<NavMain v-if="currentWorkspace && workspaceNavItems.length" :items="workspaceNavItems" :label="$t('sidebar.groups.workspace')" />
|
v-if="currentWorkspace"
|
||||||
<NavSupport v-if="currentWorkspace" :items="supportNavItems" :label="$t('sidebar.groups.others')" />
|
:items="postsNavItems"
|
||||||
</SidebarContent>
|
:label="$t('sidebar.groups.posts')"
|
||||||
|
/>
|
||||||
|
<NavMain
|
||||||
|
v-if="currentWorkspace && workspaceNavItems.length"
|
||||||
|
:items="workspaceNavItems"
|
||||||
|
:label="$t('sidebar.groups.workspace')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
<NavSupport
|
||||||
|
v-if="currentWorkspace"
|
||||||
|
:items="bottomNavItems"
|
||||||
|
:label="$t('sidebar.groups.others')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<div
|
<div
|
||||||
v-if="subscriptionPastDue"
|
v-if="subscriptionPastDue"
|
||||||
|
|
@ -252,7 +282,9 @@ const handleCreateWorkspace = () => {
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-destructive">
|
<div class="flex items-center gap-2 text-destructive">
|
||||||
<IconAlertTriangle class="size-4 shrink-0" />
|
<IconAlertTriangle class="size-4 shrink-0" />
|
||||||
<span class="text-sm font-semibold">{{ $t('billing.past_due_notice.title') }}</span>
|
<span class="text-sm font-semibold">{{
|
||||||
|
$t('billing.past_due_notice.title')
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
{{ $t('billing.past_due_notice.description') }}
|
{{ $t('billing.past_due_notice.description') }}
|
||||||
|
|
@ -268,7 +300,6 @@ const handleCreateWorkspace = () => {
|
||||||
{{ $t('billing.past_due_notice.cta') }}
|
{{ $t('billing.past_due_notice.cta') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<NavUser />
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { usePage } from '@inertiajs/vue3';
|
|
||||||
import { IconChevronRight, IconSelector } from '@tabler/icons-vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
import NotificationBell from '@/components/NotificationBell.vue';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import {
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from '@/components/ui/sidebar';
|
|
||||||
import UserInfo from '@/components/UserInfo.vue';
|
|
||||||
|
|
||||||
import UserMenuContent from './UserMenuContent.vue';
|
|
||||||
|
|
||||||
const page = usePage();
|
|
||||||
const user = computed(() => page.props.auth.user);
|
|
||||||
const currentWorkspace = computed(() => page.props.auth.currentWorkspace);
|
|
||||||
const { isMobile } = useSidebar();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<NotificationBell v-if="currentWorkspace" />
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
data-test="sidebar-menu-button"
|
|
||||||
>
|
|
||||||
<UserInfo :user="user" />
|
|
||||||
<component :is="isMobile ? IconSelector : IconChevronRight" class="ml-auto size-4" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
class="w-(--reka-dropdown-menu-trigger-width) min-w-56"
|
|
||||||
:side="isMobile ? 'bottom' : 'right'"
|
|
||||||
align="end"
|
|
||||||
:side-offset="4"
|
|
||||||
>
|
|
||||||
<UserMenuContent :user="user" />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</template>
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { IconArchive, IconBell, IconCheck, IconChecks, IconInbox, IconX } from '
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSidebar } from '@/components/ui/sidebar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
|
|
@ -12,7 +13,7 @@ import {
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import dayjs from '@/dayjs';
|
import dayjs from '@/dayjs';
|
||||||
import { accounts } from '@/routes/app';
|
import { accounts } from '@/routes/app';
|
||||||
import { index, read, readAll, archiveAll } from '@/routes/app/notifications';
|
import { archiveAll, index, read, readAll } from '@/routes/app/notifications';
|
||||||
import { edit as editPost } from '@/routes/app/posts';
|
import { edit as editPost } from '@/routes/app/posts';
|
||||||
import type { SharedData } from '@/types';
|
import type { SharedData } from '@/types';
|
||||||
|
|
||||||
|
|
@ -165,6 +166,12 @@ const onEscape = (event: KeyboardEvent) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The panel is teleported to the body with an offset anchored to the open
|
||||||
|
// sidebar — closing the sidebar would leave it floating mid-screen.
|
||||||
|
const { state: sidebarState } = useSidebar();
|
||||||
|
|
||||||
|
watch(sidebarState, () => close());
|
||||||
|
|
||||||
const formatTime = (date: string) => {
|
const formatTime = (date: string) => {
|
||||||
return dayjs.utc(date).fromNow();
|
return dayjs.utc(date).fromNow();
|
||||||
};
|
};
|
||||||
|
|
@ -205,16 +212,16 @@ onBeforeUnmount(() => {
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition duration-200 ease-out"
|
enter-active-class="transition duration-200 ease-out"
|
||||||
enter-from-class="-translate-x-2 opacity-0"
|
enter-from-class="-translate-y-2 opacity-0 sm:-translate-x-2 sm:translate-y-0"
|
||||||
enter-to-class="translate-x-0 opacity-100"
|
enter-to-class="translate-x-0 translate-y-0 opacity-100"
|
||||||
leave-active-class="transition duration-150 ease-in"
|
leave-active-class="transition duration-150 ease-in"
|
||||||
leave-from-class="translate-x-0 opacity-100"
|
leave-from-class="translate-x-0 translate-y-0 opacity-100"
|
||||||
leave-to-class="-translate-x-2 opacity-0"
|
leave-to-class="-translate-y-2 opacity-0 sm:-translate-x-2 sm:translate-y-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="show"
|
v-if="show"
|
||||||
ref="panel"
|
ref="panel"
|
||||||
class="fixed inset-x-2 bottom-2 z-50 flex h-[32rem] max-h-[calc(100svh-1rem)] flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card shadow-md sm:inset-x-auto sm:left-[17rem] sm:w-[22rem]"
|
class="fixed inset-x-2 top-2 z-50 flex h-[32rem] max-h-[calc(100svh-1rem)] flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card shadow-md sm:inset-x-auto sm:top-4 sm:left-[17rem] sm:w-[22rem]"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between gap-2 border-b-2 border-foreground/10 px-4 py-3">
|
<div class="flex items-center justify-between gap-2 border-b-2 border-foreground/10 px-4 py-3">
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
|
||||||
import {
|
|
||||||
IconLanguage,
|
|
||||||
IconLogout,
|
|
||||||
IconSettings,
|
|
||||||
IconUser,
|
|
||||||
} from '@tabler/icons-vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
import { updateLanguage } from '@/actions/App/Http/Controllers/App/Settings/ProfileController';
|
|
||||||
import {
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import UserInfo from '@/components/UserInfo.vue';
|
|
||||||
import posthog from '@/posthog';
|
|
||||||
import { logout } from '@/routes';
|
|
||||||
import { settings as settingsHub } from '@/routes/app';
|
|
||||||
import { edit } from '@/routes/app/profile';
|
|
||||||
import type { User } from '@/types';
|
|
||||||
|
|
||||||
interface Language {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
defineProps<Props>();
|
|
||||||
|
|
||||||
const page = usePage();
|
|
||||||
const languages = computed<Language[]>(() => page.props.languages as Language[]);
|
|
||||||
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.locale));
|
|
||||||
|
|
||||||
const switchLanguage = (code: string) => {
|
|
||||||
router.put(updateLanguage.url(), { locale: code }, {
|
|
||||||
onSuccess: () => window.location.reload(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
posthog.reset();
|
|
||||||
router.flushAll();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DropdownMenuLabel class="p-0 text-sm font-normal normal-case tracking-normal text-foreground">
|
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
||||||
<UserInfo
|
|
||||||
:user="user"
|
|
||||||
:show-email="true"
|
|
||||||
fallback-class="bg-violet-100 text-violet-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem :as-child="true">
|
|
||||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
|
||||||
<IconUser class="size-4" />
|
|
||||||
{{ $t('sidebar.profile') }}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem :as-child="true">
|
|
||||||
<Link class="block w-full cursor-pointer" :href="settingsHub.url()" prefetch>
|
|
||||||
<IconSettings class="size-4" />
|
|
||||||
{{ $t('sidebar.settings') }}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuSub v-if="languages && languages.length > 1">
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<IconLanguage />
|
|
||||||
{{ $t('sidebar.language', { name: currentLanguage?.name ?? 'English' }) }}
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="language in languages"
|
|
||||||
:key="language.code"
|
|
||||||
:class="language.code === currentLanguage?.code ? 'bg-accent' : ''"
|
|
||||||
@click="switchLanguage(language.code)"
|
|
||||||
>
|
|
||||||
{{ language.name }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem :as-child="true">
|
|
||||||
<Link
|
|
||||||
class="block w-full cursor-pointer"
|
|
||||||
:href="logout()"
|
|
||||||
@click="handleLogout"
|
|
||||||
as="button"
|
|
||||||
data-test="logout-button"
|
|
||||||
>
|
|
||||||
<IconLogout class="size-4" />
|
|
||||||
{{ $t('sidebar.log_out') }}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</template>
|
|
||||||
235
resources/js/components/WorkspaceMenuContent.vue
Normal file
235
resources/js/components/WorkspaceMenuContent.vue
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconCreditCard,
|
||||||
|
IconLanguage,
|
||||||
|
IconLogout,
|
||||||
|
IconPlus,
|
||||||
|
IconSettings,
|
||||||
|
IconUser,
|
||||||
|
} from '@tabler/icons-vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { updateLanguage } from '@/actions/App/Http/Controllers/App/Settings/ProfileController';
|
||||||
|
import { Avatar } from '@/components/ui/avatar';
|
||||||
|
import {
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||||
|
import posthog from '@/posthog';
|
||||||
|
import { logout } from '@/routes';
|
||||||
|
import { edit as accountEdit } from '@/routes/app/account';
|
||||||
|
import { edit as profileEdit } from '@/routes/app/profile';
|
||||||
|
import { settings as workspaceSettings } from '@/routes/app/workspace';
|
||||||
|
import {
|
||||||
|
create as createWorkspaceRoute,
|
||||||
|
switchMethod,
|
||||||
|
} from '@/routes/app/workspaces';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: User;
|
||||||
|
currentWorkspace: Workspace | null;
|
||||||
|
workspaces: Workspace[];
|
||||||
|
canCreateWorkspace: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const { canManageBilling, canManageWorkspace } = useWorkspaceRole();
|
||||||
|
const selfHosted = computed(() => Boolean(page.props.selfHosted));
|
||||||
|
const showAccountSettings = computed(
|
||||||
|
() => canManageBilling.value && !selfHosted.value,
|
||||||
|
);
|
||||||
|
const showWorkspaceSettings = computed(() => canManageWorkspace.value);
|
||||||
|
const languages = computed<Language[]>(
|
||||||
|
() => page.props.languages as Language[],
|
||||||
|
);
|
||||||
|
const currentLanguage = computed(() =>
|
||||||
|
languages.value?.find((language) => language.code === page.props.locale),
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchLanguage = (code: string): void => {
|
||||||
|
router.put(
|
||||||
|
updateLanguage.url(),
|
||||||
|
{ locale: code },
|
||||||
|
{
|
||||||
|
onSuccess: () => window.location.reload(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchWorkspace = (workspaceId: string): void => {
|
||||||
|
if (workspaceId === props.currentWorkspace?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
switchMethod.url(workspaceId),
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWorkspace = (): void => {
|
||||||
|
router.visit(createWorkspaceRoute.url());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = (): void => {
|
||||||
|
posthog.reset();
|
||||||
|
router.flushAll();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuLabel
|
||||||
|
class="px-2 py-1.5 text-xs font-medium tracking-normal text-muted-foreground normal-case"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem :as-child="true">
|
||||||
|
<Link
|
||||||
|
class="block w-full cursor-pointer"
|
||||||
|
:href="profileEdit.url()"
|
||||||
|
prefetch
|
||||||
|
data-testid="sidebar-menu-my-account"
|
||||||
|
>
|
||||||
|
<IconUser class="size-4" />
|
||||||
|
{{ $t('sidebar.my_account') }}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem v-if="showAccountSettings" :as-child="true">
|
||||||
|
<Link
|
||||||
|
class="block w-full cursor-pointer"
|
||||||
|
:href="accountEdit.url()"
|
||||||
|
prefetch
|
||||||
|
data-testid="sidebar-menu-account-settings"
|
||||||
|
>
|
||||||
|
<IconCreditCard class="size-4" />
|
||||||
|
{{ $t('sidebar.account_settings') }}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem v-if="showWorkspaceSettings" :as-child="true">
|
||||||
|
<Link
|
||||||
|
class="block w-full cursor-pointer"
|
||||||
|
:href="workspaceSettings.url()"
|
||||||
|
prefetch
|
||||||
|
data-testid="sidebar-menu-workspace-settings"
|
||||||
|
>
|
||||||
|
<IconSettings class="size-4" />
|
||||||
|
{{ $t('sidebar.workspace_settings') }}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuSub v-if="languages && languages.length > 1">
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<IconLanguage />
|
||||||
|
{{
|
||||||
|
$t('sidebar.language', {
|
||||||
|
name: currentLanguage?.name ?? 'English',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="language in languages"
|
||||||
|
:key="language.code"
|
||||||
|
:class="
|
||||||
|
language.code === currentLanguage?.code
|
||||||
|
? 'bg-accent'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
@click="switchLanguage(language.code)"
|
||||||
|
>
|
||||||
|
{{ language.name }}
|
||||||
|
<IconCheck
|
||||||
|
v-if="language.code === currentLanguage?.code"
|
||||||
|
class="ms-auto size-4 shrink-0 text-foreground"
|
||||||
|
stroke-width="2.5"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel
|
||||||
|
class="px-2 py-1.5 text-xs font-medium tracking-normal text-muted-foreground normal-case"
|
||||||
|
>
|
||||||
|
{{ $t('sidebar.workspaces') }}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="workspace in workspaces"
|
||||||
|
:key="workspace.id"
|
||||||
|
class="gap-2"
|
||||||
|
@click="switchWorkspace(workspace.id)"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="workspace.logo_url"
|
||||||
|
:name="workspace.name"
|
||||||
|
class="h-6 w-6 shrink-0 rounded-md border-2 border-foreground"
|
||||||
|
fallback-class="text-[10px] bg-violet-100 text-violet-700 font-bold"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ workspace.name }}</span>
|
||||||
|
<IconCheck
|
||||||
|
v-if="workspace.id === currentWorkspace?.id"
|
||||||
|
class="size-4 shrink-0 text-foreground"
|
||||||
|
stroke-width="2.5"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-if="canCreateWorkspace"
|
||||||
|
@click="handleCreateWorkspace"
|
||||||
|
>
|
||||||
|
<IconPlus class="size-4" />
|
||||||
|
{{ $t('sidebar.create_workspace') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem :as-child="true">
|
||||||
|
<Link
|
||||||
|
class="block w-full cursor-pointer"
|
||||||
|
:href="logout()"
|
||||||
|
as="button"
|
||||||
|
data-test="logout-button"
|
||||||
|
data-testid="logout-button"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<IconLogout class="size-4" />
|
||||||
|
{{ $t('sidebar.log_out') }}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
190
tests/Browser/SidebarMenuTest.php
Normal file
190
tests/Browser/SidebarMenuTest.php
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Enums\UserWorkspace\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a data-testid element to mount and lay out. Pest browser assertions
|
||||||
|
* do not auto-wait on SPA paint.
|
||||||
|
*/
|
||||||
|
function waitForSidebarTestId(mixed $page, string $testId): void
|
||||||
|
{
|
||||||
|
$page->script(<<<JS
|
||||||
|
(async () => {
|
||||||
|
const sel = '[data-testid="{$testId}"]';
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.getBoundingClientRect().height > 0) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
JS);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('account owners see account and workspace settings in the sidebar menu', function () {
|
||||||
|
config(['trypost.self_hosted' => false]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'account_id' => $user->account_id,
|
||||||
|
]);
|
||||||
|
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
|
||||||
|
$user->update(['current_workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
|
subscribeAccount($user->account);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$page = visit(route('app.calendar'));
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'sidebar-workspace-menu');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-workspace-menu')
|
||||||
|
->click('@sidebar-workspace-menu');
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'logout-button');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-menu-my-account')
|
||||||
|
->assertVisible('@sidebar-menu-account-settings')
|
||||||
|
->assertVisible('@sidebar-menu-workspace-settings')
|
||||||
|
->assertVisible('@logout-button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('account billing is hidden in the sidebar menu when self-hosted', function () {
|
||||||
|
config(['trypost.self_hosted' => true]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'account_id' => $user->account_id,
|
||||||
|
]);
|
||||||
|
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
|
||||||
|
$user->update(['current_workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$page = visit(route('app.calendar'));
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'sidebar-workspace-menu');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-workspace-menu')
|
||||||
|
->click('@sidebar-workspace-menu');
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'logout-button');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-menu-my-account')
|
||||||
|
->assertMissing('@sidebar-menu-account-settings')
|
||||||
|
->assertVisible('@sidebar-menu-workspace-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace admins see workspace settings but not account billing', function () {
|
||||||
|
config(['trypost.self_hosted' => false]);
|
||||||
|
|
||||||
|
[
|
||||||
|
'owner' => $owner,
|
||||||
|
'member' => $admin,
|
||||||
|
'shared_workspaces' => [$workspace],
|
||||||
|
] = strandedMemberOnSharedAccount(
|
||||||
|
sharedWorkspaces: 1,
|
||||||
|
attachMember: true,
|
||||||
|
setMemberCurrent: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspace->members()->updateExistingPivot($admin->id, [
|
||||||
|
'role' => Role::Admin->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
subscribeAccount($owner->account);
|
||||||
|
|
||||||
|
$this->actingAs($admin->fresh());
|
||||||
|
|
||||||
|
$page = visit(route('app.calendar'));
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'sidebar-workspace-menu');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-workspace-menu')
|
||||||
|
->click('@sidebar-workspace-menu');
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'logout-button');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-menu-my-account')
|
||||||
|
->assertVisible('@sidebar-menu-workspace-settings')
|
||||||
|
->assertMissing('@sidebar-menu-account-settings')
|
||||||
|
->assertVisible('@logout-button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace members do not see account or workspace settings in the sidebar menu', function () {
|
||||||
|
config(['trypost.self_hosted' => false]);
|
||||||
|
|
||||||
|
[
|
||||||
|
'owner' => $owner,
|
||||||
|
'member' => $member,
|
||||||
|
'shared_workspaces' => [$workspace],
|
||||||
|
] = strandedMemberOnSharedAccount(
|
||||||
|
sharedWorkspaces: 1,
|
||||||
|
attachMember: true,
|
||||||
|
setMemberCurrent: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspace->members()->updateExistingPivot($member->id, [
|
||||||
|
'role' => Role::Member->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
subscribeAccount($owner->account);
|
||||||
|
|
||||||
|
$this->actingAs($member->fresh());
|
||||||
|
|
||||||
|
$page = visit(route('app.calendar'));
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'sidebar-workspace-menu');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-workspace-menu')
|
||||||
|
->click('@sidebar-workspace-menu');
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'logout-button');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-menu-my-account')
|
||||||
|
->assertMissing('@sidebar-menu-account-settings')
|
||||||
|
->assertMissing('@sidebar-menu-workspace-settings')
|
||||||
|
->assertVisible('@logout-button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace viewers do not see account or workspace settings in the sidebar menu', function () {
|
||||||
|
config(['trypost.self_hosted' => false]);
|
||||||
|
|
||||||
|
[
|
||||||
|
'owner' => $owner,
|
||||||
|
'member' => $viewer,
|
||||||
|
'shared_workspaces' => [$workspace],
|
||||||
|
] = strandedMemberOnSharedAccount(
|
||||||
|
sharedWorkspaces: 1,
|
||||||
|
attachMember: true,
|
||||||
|
setMemberCurrent: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspace->members()->updateExistingPivot($viewer->id, [
|
||||||
|
'role' => Role::Viewer->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
subscribeAccount($owner->account);
|
||||||
|
|
||||||
|
$this->actingAs($viewer->fresh());
|
||||||
|
|
||||||
|
$page = visit(route('app.calendar'));
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'sidebar-workspace-menu');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-workspace-menu')
|
||||||
|
->click('@sidebar-workspace-menu');
|
||||||
|
|
||||||
|
waitForSidebarTestId($page, 'logout-button');
|
||||||
|
|
||||||
|
$page->assertVisible('@sidebar-menu-my-account')
|
||||||
|
->assertMissing('@sidebar-menu-account-settings')
|
||||||
|
->assertMissing('@sidebar-menu-workspace-settings')
|
||||||
|
->assertVisible('@logout-button');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue