KAN-19 Responsive app navigation #113

Merged
Twyco merged 1 commits from feature/KAN-19-responsive-app-navigation into main 2026-09-11 12:16:55 +00:00
28 changed files with 1547 additions and 428 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -60,6 +61,36 @@ class HandleInertiaRequests extends Middleware
'user' => $user, 'user' => $user,
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation' => fn (): array => [
'project' => $this->projectNavigation($request),
],
];
}
/**
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
*/
private function projectNavigation(Request $request): ?array
{
$project = $request->route('project');
$user = $request->user();
if (! $project instanceof Project || $user === null) {
return null;
}
$permissions = $this->permissionService->effectivePermissions($user, $project);
if (! in_array(Permission::ProjectRead, $permissions, true)) {
return null;
}
return [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
]; ];
} }
} }
+11 -10
View File
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import { Columns3 } from 'lucide-vue-next';
</script> </script>
<template> <template>
<div <span class="flex min-w-0 items-center gap-3">
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" <span
> class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
> >
</div> <Columns3 class="size-5" aria-hidden="true" />
</span>
<span
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
>KanbanGit</span
>
</span>
</template> </template>
+86 -90
View File
@@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { import { Settings2, X } from 'lucide-vue-next';
BookOpen, import { computed, onMounted, onUnmounted } from 'vue';
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue'; import { Button } from '@/components/ui/button';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -22,93 +13,98 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users'; import { edit as profile } from '@/routes/profile';
import { index as projectTemplatesIndex } from '@/routes/project-templates'; import { show } from '@/routes/projects';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const page = usePage(); const { t } = useLocalization();
const { currentUrl } = useCurrentUrl();
const mainNavItems = computed<NavItem[]>(() => { const { project, workspaceItems, projectItems, projectSettingsItems } =
const items: NavItem[] = [ useAppNavigation();
{ const { isMobile, setOpenMobile } = useSidebar();
title: 'Dashboard', const accountItems = computed(() => [
href: dashboard(), {
icon: LayoutGrid, title: t('navigation.settings'),
}, href: profile(),
{ icon: Settings2,
title: 'Projects', isActive: currentUrl.value.startsWith('/settings/'),
href: projectsIndex(), },
icon: Folder, ]);
}, let removeNavigationListener: (() => void) | undefined;
]; onMounted(() => {
removeNavigationListener = router.on('navigate', () =>
if (page.props.auth.permissions.projectCreate) { setOpenMobile(false),
items.push({ );
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
}); });
onUnmounted(() => removeNavigationListener?.());
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script> </script>
<template> <template>
<Sidebar collapsible="icon" variant="inset"> <Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
<SidebarHeader> <SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
<SidebarMenu> <div class="flex items-center gap-2">
<SidebarMenuItem> <SidebarMenu class="min-w-0">
<SidebarMenuButton size="lg" as-child> <SidebarMenuItem>
<Link :href="dashboard()"> <SidebarMenuButton
<AppLogo /> size="lg"
</Link> as-child
</SidebarMenuButton> tooltip="KanbanGit"
</SidebarMenuItem> >
</SidebarMenu> <Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Button
v-if="isMobile"
variant="ghost"
size="icon"
:aria-label="t('navigation.close')"
@click="setOpenMobile(false)"
>
<X aria-hidden="true" />
</Button>
</div>
<Link
v-if="project"
:href="show(project.id)"
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
>
<span
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
>{{ project.key }}</span
>
<span class="truncate font-semibold" :title="project.name">{{
project.name
}}</span>
</Link>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain :items="mainNavItems" /> <nav :aria-label="t('navigation.main')">
<NavMain
:items="workspaceItems"
:label="t('navigation.workspace')"
/>
<NavMain
v-if="projectItems.length"
:items="projectItems"
:label="t('navigation.project')"
/>
<NavMain
v-if="projectSettingsItems.length"
:items="projectSettingsItems"
:label="t('navigation.projectSettings')"
/>
</nav>
</SidebarContent> </SidebarContent>
<SidebarFooter class="border-sidebar-border border-t p-0">
<SidebarFooter> <NavMain :items="accountItems" :label="t('navigation.account')" />
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<slot />
</template> </template>
+131 -13
View File
@@ -1,27 +1,145 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import {
normalizeAppearance,
useAppearance,
} from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
withDefaults( withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
defineProps<{ breadcrumbs: () => [],
breadcrumbs?: BreadcrumbItem[]; });
}>(), const page = usePage();
{ const { project } = useAppNavigation();
breadcrumbs: () => [], const { isCurrentUrl } = useCurrentUrl();
}, const { t } = useLocalization();
const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
); );
const createAction = computed(() => {
if (project.value?.canCreateTickets) {
return {
title: t('navigation.createTicket'),
href: createTicket(project.value.id, {
query: {
source: isCurrentUrl(board(project.value.id))
? 'board'
: 'backlog',
},
}),
};
}
if (!project.value && page.props.auth.permissions.projectCreate) {
return { title: t('navigation.createProject'), href: createProject() };
}
return null;
});
</script> </script>
<template> <template>
<header <header
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4" class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
> >
<div class="flex items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger class="-ml-1" /> <SidebarTrigger class="shrink-0" />
<template v-if="breadcrumbs && breadcrumbs.length > 0"> <Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
<Breadcrumbs :breadcrumbs="breadcrumbs" /> <span v-else class="truncate font-semibold">KanbanGit</span>
</template> </div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="createAction"
as-child
:aria-label="createAction.title"
class="px-3"
>
<Link :href="createAction.href"
><Plus aria-hidden="true" /><span
class="hidden sm:inline"
>{{ createAction.title }}</span
></Link
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="t('appearance.label')"
><component :is="themeIcon" aria-hidden="true"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{
t('appearance.label')
}}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
:model-value="appearance"
@update:model-value="
(value) =>
updateAppearance(normalizeAppearance(value))
"
>
<DropdownMenuRadioItem value="light"
><Sun aria-hidden="true" />{{
t('appearance.light')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="dark"
><Moon aria-hidden="true" />{{
t('appearance.dark')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="system"
><Monitor aria-hidden="true" />{{
t('appearance.system')
}}</DropdownMenuRadioItem
>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="
t('navigation.userMenu', {
name: page.props.auth.user.name,
})
"
data-test="sidebar-menu-button"
>
<UserAvatar :user="page.props.auth.user" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-60">
<UserMenuContent :user="page.props.auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</header> </header>
</template> </template>
+25 -6
View File
@@ -9,29 +9,48 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
breadcrumbs: BreadcrumbItemType[]; breadcrumbs: BreadcrumbItemType[];
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Breadcrumb> <Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
<BreadcrumbList> <BreadcrumbList class="flex-nowrap">
<template v-for="(item, index) in breadcrumbs" :key="index"> <template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem> <BreadcrumbItem
:class="[
'min-w-0',
index !== breadcrumbs.length - 1
? 'hidden md:inline-flex'
: '',
]"
>
<template v-if="index === breadcrumbs.length - 1"> <template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage> <BreadcrumbPage class="truncate" :title="item.title">{{
item.title
}}</BreadcrumbPage>
</template> </template>
<template v-else> <template v-else>
<BreadcrumbLink as-child> <BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link> <Link
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
</BreadcrumbLink> </BreadcrumbLink>
</template> </template>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" /> <BreadcrumbSeparator
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
</template> </template>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
+29 -6
View File
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
defineProps<{ defineProps<{
items: NavItem[]; items: NavItem[];
label: string;
}>(); }>();
const { isCurrentUrl } = useCurrentUrl(); const { isCurrentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<SidebarGroup class="px-2 py-0"> <SidebarGroup class="px-3 py-2">
<SidebarGroupLabel>Platform</SidebarGroupLabel> <SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
<SidebarMenu> label
}}</SidebarGroupLabel>
<SidebarMenu role="list" :aria-label="label">
<SidebarMenuItem v-for="item in items" :key="item.title"> <SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton <SidebarMenuButton
as-child as-child
:is-active="isCurrentUrl(item.href)" :is-active="
item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
)
"
:tooltip="item.title" :tooltip="item.title"
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
> >
<Link :href="item.href"> <Link
<component :is="item.icon" /> :href="item.href"
:aria-current="
(item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
))
? 'page'
: undefined
"
>
<component :is="item.icon" aria-hidden="true" />
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, Settings2 } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
const { projectItems, projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const settingsActive = computed(() =>
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
);
</script>
<template>
<div
v-if="projectItems.length"
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
>
<nav
:aria-label="t('navigation.project')"
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
>
<Link
v-for="item in projectItems"
:key="item.title"
:href="item.href"
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
:class="[
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
isCurrentUrl(item.href)
? 'border-brand text-primary'
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4"
aria-hidden="true"
/>{{ item.title }}
</Link>
</nav>
<DropdownMenu v-if="projectSettingsItems.length">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
:aria-label="t('navigation.projectSettings')"
:class="{
'bg-accent text-accent-foreground': settingsActive,
}"
>
<Settings2 aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.projectSettings') }}</span
><ChevronDown class="hidden sm:block" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="item in projectSettingsItems"
:key="item.title"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
>
<component :is="item.icon" aria-hidden="true" />{{
item.title
}}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+12 -23
View File
@@ -1,33 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { User } from '@/types';
type Props = { withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
showEmail: false, showEmail: false,
}); });
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script> </script>
<template> <template>
<UserAvatar /> <div class="flex min-w-0 items-center gap-2">
<UserAvatar :user="user" />
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span> <span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{ <span
user.email v-if="showEmail"
}}</span> class="text-muted-foreground truncate text-xs"
>{{ user.email }}</span
>
</div>
</div> </div>
</template> </template>
+4 -2
View File
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes/index'; import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import type { User } from '@/types'; import type { User } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
user: User; user: User;
@@ -21,6 +22,7 @@ const handleLogout = () => {
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -34,7 +36,7 @@ defineProps<Props>();
<DropdownMenuItem :as-child="true"> <DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch> <Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" /> <Settings class="mr-2 h-4 w-4" />
Settings {{ t('navigation.settings') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@@ -48,7 +50,7 @@ defineProps<Props>();
data-test="logout-button" data-test="logout-button"
> >
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-2 h-4 w-4" />
Log out {{ t('navigation.logout') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
+104 -82
View File
@@ -1,96 +1,118 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SidebarProps } from "." import type { SidebarProps } from ".";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from "@/components/ui/sheet";
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue' import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue' import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue' import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils" import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarProps>(), { const props = withDefaults(defineProps<SidebarProps>(), {
side: "left", side: "left",
variant: "sidebar", variant: "sidebar",
collapsible: "offcanvas", collapsible: "offcanvas",
}) });
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useLocalization();
function restoreTriggerFocus(event: Event) {
event.preventDefault();
document
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
?.focus();
}
</script> </script>
<template> <template>
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div <div
:class="cn( v-if="collapsible === 'none'"
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', data-slot="sidebar"
'group-data-[collapsible=offcanvas]:w-0', :class="
'group-data-[side=right]:rotate-180', cn(
variant === 'floating' || variant === 'inset' 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' props.class,
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', )
)" "
/> v-bind="$attrs"
<div
:class="cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
> >
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot /> <slot />
</div>
</div> </div>
</div>
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
<SheetContent
v-bind="$attrs"
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
@close-auto-focus="restoreTriggerFocus"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
<SheetDescription>{{
t("navigation.mobileDescription")
}}</SheetDescription>
</SheetHeader>
<div class="flex min-h-0 h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)
"
/>
<div
:class="
cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)
"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
</div>
</div>
</template> </template>
@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)" :class="
> cn(
<slot /> 'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
</div> props.class,
)
"
>
<slot />
</div>
</template> </template>
@@ -1,21 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
:class="cn( :class="
'bg-background relative flex w-full flex-1 flex-col', cn(
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', 'bg-background relative flex min-w-0 w-full flex-1 flex-col',
props.class, 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
)" props.class,
> )
<slot /> "
</main> >
<slot />
</main>
</template> </template>
@@ -1,48 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from "vue" import type { Component } from "vue";
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue" import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import {
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue" Tooltip,
import { useSidebar } from "./utils" TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
import { useSidebar } from "./utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarMenuButtonProps & { const props = withDefaults(
tooltip?: string | Component defineProps<
}>(), { SidebarMenuButtonProps & {
as: "button", tooltip?: string | Component;
variant: "default", }
size: "default", >(),
}) {
as: "button",
variant: "default",
size: "default",
},
);
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const delegatedProps = reactiveOmit(props, "tooltip") const delegatedProps = reactiveOmit(props, "tooltip");
</script> </script>
<template> <template>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }"> <SidebarMenuButtonChild
<slot /> v-if="!tooltip || isMobile || state !== 'collapsed'"
</SidebarMenuButtonChild> v-bind="{ ...delegatedProps, ...$attrs }"
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
> >
<template v-if="typeof tooltip === 'string'"> <slot />
{{ tooltip }} </SidebarMenuButtonChild>
</template>
<component :is="tooltip" v-else /> <Tooltip v-else>
</TooltipContent> <TooltipTrigger as-child>
</Tooltip> <SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template> </template>
@@ -1,82 +1,112 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue" import type { HTMLAttributes, Ref } from "vue";
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core" import {
import { TooltipProvider } from "reka-ui" defaultDocument,
import { computed, ref } from "vue" useEventListener,
import { cn } from "@/lib/utils" useMediaQuery,
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils" useVModel,
} from "@vueuse/core";
import { TooltipProvider } from "reka-ui";
import { computed, ref, watch } from "vue";
import { cn } from "@/lib/utils";
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./utils";
const props = withDefaults(defineProps<{ const props = withDefaults(
defaultOpen?: boolean defineProps<{
open?: boolean defaultOpen?: boolean;
class?: HTMLAttributes["class"] open?: boolean;
}>(), { class?: HTMLAttributes["class"];
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`), }>(),
open: undefined, {
}) defaultOpen: !defaultDocument?.cookie.includes(
`${SIDEBAR_COOKIE_NAME}=false`,
),
open: undefined,
},
);
const emits = defineEmits<{ const emits = defineEmits<{
"update:open": [open: boolean] "update:open": [open: boolean];
}>() }>();
const isMobile = useMediaQuery("(max-width: 768px)") const isMobile = useMediaQuery("(width < 768px)");
const openMobile = ref(false) const openMobile = ref(false);
watch(isMobile, () => {
openMobile.value = false;
});
const open = useVModel(props, "open", emits, { const open = useVModel(props, "open", emits, {
defaultValue: props.defaultOpen ?? false, defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false, passive: (props.open === undefined) as false,
}) as Ref<boolean> }) as Ref<boolean>;
function setOpen(value: boolean) { function setOpen(value: boolean) {
open.value = value // emits('update:open', value) open.value = value; // emits('update:open', value)
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
} }
function setOpenMobile(value: boolean) { function setOpenMobile(value: boolean) {
openMobile.value = value openMobile.value = value;
} }
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
function toggleSidebar() { function toggleSidebar() {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value) return isMobile.value
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener("keydown", (event: KeyboardEvent) => { useEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { if (
event.preventDefault() event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
toggleSidebar() (event.metaKey || event.ctrlKey)
} ) {
}) event.preventDefault();
toggleSidebar();
}
});
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => open.value ? "expanded" : "collapsed") const state = computed(() => (open.value ? "expanded" : "collapsed"));
provideSidebarContext({ provideSidebarContext({
state, state,
open, open,
setOpen, setOpen,
isMobile, isMobile,
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}) });
</script> </script>
<template> <template>
<TooltipProvider :delay-duration="0"> <TooltipProvider :delay-duration="0">
<div <div
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
:style="{ :style="{
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)" :class="
v-bind="$attrs" cn(
> 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
<slot /> props.class,
</div> )
</TooltipProvider> "
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
</template> </template>
@@ -1,28 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next" import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { useSidebar } from "./utils" import { useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
const { isMobile, state, toggleSidebar } = useSidebar() const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Button <Button
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="cn('h-7 w-7', props.class)" type="button"
@click="toggleSidebar" aria-controls="app-navigation"
> :aria-expanded="isMobile ? openMobile : state === 'expanded'"
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" /> :aria-label="
<PanelLeftClose v-else /> isMobile
<span class="sr-only">Toggle sidebar</span> ? t(openMobile ? 'navigation.close' : 'navigation.open')
</Button> : t(
state === 'collapsed'
? 'navigation.expand'
: 'navigation.collapse',
)
"
:class="cn('size-control', props.class)"
@click="toggleSidebar"
>
<PanelLeftOpen
v-if="isMobile || state === 'collapsed'"
aria-hidden="true"
/>
<PanelLeftClose v-else aria-hidden="true" />
</Button>
</template> </template>
+11 -10
View File
@@ -1,8 +1,8 @@
import { createContext } from "reka-ui" import { createContext } from "reka-ui";
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from "vue";
export type SidebarContext = { export type SidebarContext = {
state: ComputedRef<'expanded' | 'collapsed'>; state: ComputedRef<"expanded" | "collapsed">;
open: Ref<boolean>; open: Ref<boolean>;
setOpen: (value: boolean) => void; setOpen: (value: boolean) => void;
isMobile: Ref<boolean>; isMobile: Ref<boolean>;
@@ -11,11 +11,12 @@ export type SidebarContext = {
toggleSidebar: () => void; toggleSidebar: () => void;
}; };
export const SIDEBAR_COOKIE_NAME = "sidebar_state" export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem" export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem" export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem" export const SIDEBAR_WIDTH_ICON = "4rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b" export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar") export const [useSidebar, provideSidebarContext] =
createContext<SidebarContext>("Sidebar");
@@ -0,0 +1,25 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import {
projectNavigation,
projectSettingsNavigation,
workspaceNavigation,
} from '@/lib/navigation';
export function useAppNavigation() {
const page = usePage();
const { t } = useLocalization();
const project = computed(() => page.props.navigation?.project ?? null);
return {
project,
workspaceItems: computed(() =>
workspaceNavigation(page.props.auth.permissions, t),
),
projectItems: computed(() => projectNavigation(project.value, t)),
projectSettingsItems: computed(() =>
projectSettingsNavigation(project.value, t),
),
};
}
+14 -32
View File
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue'; import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue'; import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { matchesNavigationUrl } from '@/lib/navigation';
export type UseCurrentUrlReturn = { export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>; currentUrl: DeepReadonly<ComputedRef<string>>;
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
) => T | F; ) => T | F;
}; };
const page = usePage();
const currentUrlReactive = computed(
() =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn { export function useCurrentUrl(): UseCurrentUrlReturn {
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, 'http://localhost').pathname,
);
function isCurrentUrl( function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string, currentUrl?: string,
startsWith: boolean = false, startsWith: boolean = false,
) { ) {
const urlToCompare = currentUrl ?? currentUrlReactive.value; return matchesNavigationUrl(
const urlString = toUrl(urlToCheck); toUrl(urlToCheck),
currentUrl ?? currentUrlReactive.value,
const comparePath = (path: string): boolean => startsWith,
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare; );
if (!urlString.startsWith('http')) {
return comparePath(urlString);
}
try {
const absoluteUrl = new URL(urlString);
return comparePath(absoluteUrl.pathname);
} catch {
return false;
}
} }
function isCurrentOrParentUrl( function isCurrentOrParentUrl(
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
return isCurrentUrl(urlToCheck, currentUrl, true); return isCurrentUrl(urlToCheck, currentUrl, true);
} }
function whenCurrentUrl( function whenCurrentUrl<T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any, ifTrue: T,
ifFalse: any = null, ifFalse: F = null as F,
) { ): T | F {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse; return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
} }
+17 -2
View File
@@ -3,6 +3,8 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue'; import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import ProjectNavigation from '@/components/ProjectNavigation.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
type Props = { type Props = {
@@ -12,14 +14,27 @@ type Props = {
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
breadcrumbs: () => [], breadcrumbs: () => [],
}); });
const { t } = useLocalization();
</script> </script>
<template> <template>
<AppShell variant="sidebar"> <AppShell variant="sidebar">
<a
href="#main-content"
class="bg-primary text-primary-foreground sr-only fixed left-4 top-4 z-50 rounded-md p-3 focus:not-sr-only"
>{{ t('navigation.skipToContent') }}</a
>
<AppSidebar /> <AppSidebar />
<AppContent variant="sidebar" class="overflow-x-hidden"> <AppContent variant="sidebar" class="min-w-0">
<AppSidebarHeader :breadcrumbs="breadcrumbs" /> <AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot /> <ProjectNavigation />
<div
id="main-content"
tabindex="-1"
class="flex min-w-0 flex-1 flex-col"
>
<slot />
</div>
</AppContent> </AppContent>
</AppShell> </AppShell>
</template> </template>
+27 -17
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance/index'; import { edit as editAppearance } from '@/routes/appearance/index';
import { edit as editApplications } from '@/routes/applications/index'; import { edit as editApplications } from '@/routes/applications/index';
@@ -12,28 +14,29 @@ import { edit as editProfile } from '@/routes/profile/index';
import { edit as editSecurity } from '@/routes/security/index'; import { edit as editSecurity } from '@/routes/security/index';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [ const { t } = useLocalization();
const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: 'Profile', title: t('navigation.profile'),
href: editProfile(), href: editProfile(),
}, },
{ {
title: 'Security', title: t('navigation.security'),
href: editSecurity(), href: editSecurity(),
}, },
{ {
title: 'Git connections', title: t('navigation.gitConnections'),
href: editGitConnections(), href: editGitConnections(),
}, },
{ {
title: 'Applications', title: t('navigation.applications'),
href: editApplications(), href: editApplications(),
}, },
{ {
title: 'Appearance', title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
}, },
]; ]);
const { isCurrentOrParentUrl } = useCurrentUrl(); const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
<template> <template>
<div class="px-4 py-6"> <div class="px-4 py-6">
<Heading <Heading
title="Settings" :title="t('navigation.settings')"
description="Manage your profile, security, and Git integration settings" :description="t('navigation.settingsDescription')"
/> />
<div class="flex flex-col lg:flex-row lg:space-x-12"> <div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
<aside class="w-full max-w-xl lg:w-48"> <aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<nav <nav
class="flex flex-col space-x-0 space-y-1" class="flex gap-1 overflow-x-auto lg:flex-col"
aria-label="Settings" :aria-label="t('navigation.settings')"
> >
<Button <Button
v-for="item in sidebarNavItems" v-for="item in sidebarNavItems"
:key="toUrl(item.href)" :key="toUrl(item.href)"
variant="ghost" variant="ghost"
:class="[ :class="[
'w-full justify-start', 'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) }, { 'bg-muted': isCurrentOrParentUrl(item.href) },
]" ]"
as-child as-child
> >
<Link :href="item.href"> <Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" /> <component :is="item.icon" class="h-4 w-4" />
{{ item.title }} {{ item.title }}
</Link> </Link>
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</nav> </nav>
</aside> </aside>
<Separator class="my-6 lg:hidden" /> <Separator class="lg:hidden" />
<div class="flex-1 md:max-w-2xl"> <div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12"> <section class="max-w-xl space-y-12">
<slot /> <slot />
</section> </section>
+166
View File
@@ -0,0 +1,166 @@
import {
Bell,
Columns3,
Folder,
FolderGit2,
House,
KeyRound,
Layers3,
LayoutGrid,
ListTodo,
Settings2,
Shield,
Tags,
Users,
} from 'lucide-vue-next';
import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users';
import { index as templates } from '@/routes/project-templates';
import { access, index as projects, show } from '@/routes/projects';
import { index as backlog } from '@/routes/projects/backlog';
import { index as board } from '@/routes/projects/board';
import {
board as boardSettings,
general,
metadata,
notifications,
repositories,
} from '@/routes/projects/settings';
import { index as teams } from '@/routes/teams';
import type { Localization } from '@/lib/localization';
import type { AuthPermissions, NavItem, NavigationProject } from '@/types';
export function workspaceNavigation(
permissions: AuthPermissions,
t: Localization['t'],
): NavItem[] {
const items: NavItem[] = [
{
title: t('navigation.dashboard'),
href: dashboard(),
icon: LayoutGrid,
},
{
title: t('navigation.projects'),
href: projects(),
icon: Folder,
match: 'section',
},
];
if (permissions.projectCreate) {
items.push({
title: t('navigation.templates'),
href: templates(),
icon: Layers3,
match: 'section',
});
}
if (permissions.canManageTeams) {
items.push({
title: t('navigation.teams'),
href: teams(),
icon: Users,
match: 'section',
});
}
if (permissions.globalAdmin) {
items.push({
title: t('navigation.users'),
href: adminUsers(),
icon: Shield,
match: 'section',
});
}
return items;
}
export function projectNavigation(
project: NavigationProject | null,
t: Localization['t'],
): NavItem[] {
if (!project) {
return [];
}
return [
{
title: t('navigation.overview'),
href: show(project.id),
icon: House,
},
{
title: t('navigation.board'),
href: board(project.id),
icon: Columns3,
match: 'section',
},
{
title: t('navigation.backlog'),
href: backlog(project.id),
icon: ListTodo,
match: 'section',
},
];
}
export function projectSettingsNavigation(
project: NavigationProject | null,
t: Localization['t'],
): NavItem[] {
if (!project?.canManage) {
return [];
}
return [
{
title: t('navigation.general'),
href: general(project.id),
icon: Settings2,
},
{
title: t('navigation.boardSettings'),
href: boardSettings(project.id),
icon: Columns3,
},
{
title: t('navigation.ticketSettings'),
href: metadata(project.id),
icon: Tags,
},
{
title: t('navigation.repositories'),
href: repositories(project.id),
icon: FolderGit2,
},
{
title: t('navigation.notifications'),
href: notifications(project.id),
icon: Bell,
},
{
title: t('navigation.access'),
href: access(project.id),
icon: KeyRound,
},
];
}
export function matchesNavigationUrl(
target: string,
current: string,
section = false,
): boolean {
try {
const base = 'http://localhost';
const targetPath =
new URL(target, base).pathname.replace(/\/+$/, '') || '/';
const currentPath =
new URL(current, base).pathname.replace(/\/+$/, '') || '/';
return (
currentPath === targetPath ||
(section &&
targetPath !== '/' &&
currentPath.startsWith(`${targetPath}/`))
);
} catch {
return false;
}
}
+39
View File
@@ -1,4 +1,43 @@
export const en = { export const en = {
'navigation.main': 'Main navigation',
'navigation.workspace': 'Workspace',
'navigation.project': 'Project navigation',
'navigation.projectSettings': 'Project settings',
'navigation.account': 'Account',
'navigation.dashboard': 'Dashboard',
'navigation.projects': 'Projects',
'navigation.templates': 'Project templates',
'navigation.users': 'Users',
'navigation.teams': 'Teams',
'navigation.overview': 'Overview',
'navigation.board': 'Board',
'navigation.backlog': 'Backlog',
'navigation.general': 'General',
'navigation.boardSettings': 'Board settings',
'navigation.ticketSettings': 'Ticket settings',
'navigation.repositories': 'Repositories',
'navigation.notifications': 'Notifications',
'navigation.access': 'Access',
'navigation.settings': 'Settings',
'navigation.profile': 'Profile',
'navigation.security': 'Security',
'navigation.gitConnections': 'Git connections',
'navigation.applications': 'Applications',
'navigation.appearance': 'Appearance',
'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.open': 'Open navigation',
'navigation.close': 'Close navigation',
'navigation.expand': 'Expand sidebar',
'navigation.collapse': 'Collapse sidebar',
'navigation.mobileDescription':
'Navigate your workspace and current project.',
'navigation.skipToContent': 'Skip to content',
'navigation.userMenu': 'Account menu for {name}',
'navigation.logout': 'Log out',
'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme', 'appearance.label': 'Color theme',
'appearance.light': 'Light', 'appearance.light': 'Light',
'appearance.dark': 'Dark', 'appearance.dark': 'Dark',
+2
View File
@@ -1,4 +1,5 @@
import type { Auth } from '@/types/auth'; import type { Auth } from '@/types/auth';
import type { AppNavigation } from '@/types/navigation';
import type { LocalizationSettings } from '@/types/localization'; import type { LocalizationSettings } from '@/types/localization';
import type { Appearance } from '@/types/ui'; import type { Appearance } from '@/types/ui';
import type { Page, Router } from '@inertiajs/core'; import type { Page, Router } from '@inertiajs/core';
@@ -25,6 +26,7 @@ declare module '@inertiajs/core' {
appearance: Appearance; appearance: Appearance;
auth: Auth; auth: Auth;
sidebarOpen: boolean; sidebarOpen: boolean;
navigation: AppNavigation;
}; };
} }
} }
+13
View File
@@ -11,4 +11,17 @@ export type NavItem = {
href: NonNullable<InertiaLinkProps['href']>; href: NonNullable<InertiaLinkProps['href']>;
icon?: LucideIcon; icon?: LucideIcon;
isActive?: boolean; isActive?: boolean;
match?: 'exact' | 'section';
};
export type NavigationProject = {
id: number;
name: string;
key: string;
canManage: boolean;
canCreateTickets: boolean;
};
export type AppNavigation = {
project: NavigationProject | null;
}; };
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class NavigationTest extends TestCase
{
use RefreshDatabase;
#[DataProvider('projectRoles')]
public function test_navigation_uses_effective_project_permissions(string $role, bool $teamAccess, bool $canManage, bool $canCreateTickets): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$assignee = $user;
if ($teamAccess) {
$assignee = Team::factory()->create();
$assignee->users()->attach($user);
}
$this->syncProjectPermissions($project, $assignee, [$role]);
$this->actingAs($user)->get(route('projects.board.index', $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->where('navigation.project', [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => $canManage,
'canCreateTickets' => $canCreateTickets,
]));
}
/** @return array<string, array{string, bool, bool, bool}> */
public static function projectRoles(): array
{
return [
'reader' => ['read', false, false, false],
'writer' => ['write', false, false, true],
'project admin' => ['admin', false, true, true],
'team reader' => ['read', true, false, false],
'team writer' => ['write', true, false, true],
'team admin' => ['admin', true, true, true],
];
}
public function test_global_admin_has_project_actions_without_direct_membership(): void
{
$user = User::factory()->admin()->create();
$project = Project::factory()->create();
$this->actingAs($user)->get(route('projects.show', $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->where('navigation.project.canManage', true)
->where('navigation.project.canCreateTickets', true)
->where('auth.permissions.globalAdmin', true));
}
public function test_every_project_admin_navigation_destination_is_authorized(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
foreach (['projects.show', 'projects.board.index', 'projects.backlog.index', 'projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) {
$this->actingAs($user)->get(route($route, $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->where('navigation.project.id', $project->id)
->where('navigation.project.canManage', true)
->where('auth.permissions.globalAdmin', false));
}
}
public function test_project_context_is_cleared_when_leaving_a_project(): void
{
$user = User::factory()->admin()->create();
$project = Project::factory()->create();
$this->actingAs($user)->get(route('projects.show', $project))
->assertInertia(fn (Assert $page) => $page->where('navigation.project.id', $project->id));
$this->get(route('dashboard'))->assertOk()
->assertInertia(fn (Assert $page) => $page->where('navigation.project', null));
$this->get(route('projects.create'))->assertOk()
->assertInertia(fn (Assert $page) => $page->where('navigation.project', null));
}
public function test_unrelated_projects_remain_inaccessible(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['name' => 'Private project']);
$this->actingAs($user)->get(route('projects.show', $project))
->assertForbidden()->assertDontSee('Private project');
}
public function test_navigation_rendering_and_url_matching(): void
{
$process = new Process(['node', 'tests/Fixtures/navigation.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Navigation behavior checks passed.', $process->getOutput());
}
}
+86
View File
@@ -0,0 +1,86 @@
import { createApp, h } from 'vue';
import { createInertiaApp, Head, usePage } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Preview = {
setup() {
const page = usePage();
return () => {
const title = page.url
.split('?')[0]
.split('/')
.filter(Boolean)
.at(-1);
return h(
AppLayout,
{
breadcrumbs: [
{ title: 'Projects', href: '/projects' },
...(page.props.navigation.project
? [
{
title: page.props.navigation.project.name,
href: '/projects/7',
},
]
: []),
{ title, href: page.url },
],
},
() => [
h(Head, { title }),
h('div', { class: 'grid gap-column p-page' }, [
h(
'h1',
{
class: 'text-headline',
'data-test': 'page-title',
},
title,
),
h(
'p',
{ class: 'text-muted-foreground' },
'Navigation preview with isolated test data.',
),
h(
'div',
{
class: 'grid gap-column sm:grid-cols-2 xl:grid-cols-3',
},
['To do', 'In progress', 'Done'].map((label) =>
h(
'section',
{
class: 'grid gap-stack rounded-md border bg-card p-card',
},
[
h('h2', { class: 'text-title' }, label),
h(
'p',
{},
'Plan and track the next iteration.',
),
],
),
),
),
]),
],
);
};
},
};
createInertiaApp({
resolve: () => Preview,
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+88
View File
@@ -0,0 +1,88 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
const server = await createServer({
configFile: false,
plugins: [vue(), tailwind()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { host: '127.0.0.1', port: 4175, strictPort: true, hmr: false },
appType: 'custom',
});
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (
!/^\/(dashboard|projects|project-templates|teams|admin|settings)(\/|$)/.test(
url.pathname,
)
) {
return next();
}
const reader =
request.headers.cookie?.includes('navigation_test_role=reader') ??
false;
const project = url.pathname.startsWith('/projects/7')
? {
id: 7,
name: 'Engineering · Platform and developer experience',
key: 'ENG',
canManage: !reader,
canCreateTickets: !reader,
}
: null;
const page = {
component: 'NavigationPreview',
url: request.url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
sidebarOpen: !request.headers.cookie?.includes(
'sidebar_state=false',
),
auth: {
permissions: {
globalAdmin: !reader,
projectCreate: !reader,
canManageTeams: !reader,
},
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
},
navigation: { project },
},
};
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escapedPage = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
const html =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escapedPage +
'"></div><script type="module" src="/tests/Fixtures/navigation-preview.mjs"></script></body></html>';
response.setHeader('Content-Type', 'text/html');
response.end(await server.transformIndexHtml(request.url, html));
});
await server.listen();
console.log(
'Navigation fixture ready at http://127.0.0.1:4175/projects/7/board',
);
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const {
matchesNavigationUrl,
projectNavigation,
projectSettingsNavigation,
workspaceNavigation,
} = await server.ssrLoadModule('/resources/js/lib/navigation.ts');
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: AppLayout } = await server.ssrLoadModule(
'/resources/js/layouts/AppLayout.vue',
);
const locale = createLocalization();
const permissions = {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
};
const project = {
id: 7,
name: 'Engineering',
key: 'ENG',
canManage: false,
canCreateTickets: false,
};
const targets = (items) => items.map((item) => item.href.url);
assert.deepEqual(targets(workspaceNavigation(permissions, locale.t)), [
'/dashboard',
'/projects',
]);
assert.deepEqual(
targets(
workspaceNavigation(
{ ...permissions, canManageTeams: true },
locale.t,
),
),
['/dashboard', '/projects', '/teams'],
);
assert.deepEqual(
targets(
workspaceNavigation(
{ ...permissions, projectCreate: true },
locale.t,
),
),
['/dashboard', '/projects', '/project-templates'],
);
assert.ok(
targets(
workspaceNavigation(
{ ...permissions, globalAdmin: true },
locale.t,
),
).includes('/admin/users'),
);
assert.equal(projectNavigation(null, locale.t).length, 0);
assert.deepEqual(targets(projectNavigation(project, locale.t)), [
'/projects/7',
'/projects/7/board',
'/projects/7/backlog',
]);
assert.equal(projectSettingsNavigation(project, locale.t).length, 0);
assert.deepEqual(
targets(
projectSettingsNavigation(
{ ...project, canManage: true },
locale.t,
),
),
[
'/projects/7/settings/general',
'/projects/7/settings/board',
'/projects/7/settings/metadata',
'/projects/7/settings/repositories',
'/projects/7/settings/notifications',
'/projects/7/access',
],
);
for (const [target, current, section, expected] of [
['/projects', '/projects/7/board', true, true],
['/projects', '/project-templates', true, false],
['/projects/7', '/projects/70', true, false],
['/projects/7', '/projects/7/board', false, false],
[
'/projects/7/board',
'/projects/7/board?filter=mine#lane',
false,
true,
],
[
'/projects/7/board/',
'https://kanbangit.test/projects/7/board',
false,
true,
],
['/', '/projects', true, false],
]) {
assert.equal(matchesNavigationUrl(target, current, section), expected);
}
const render = async (
url,
projectContext,
authPermissions = permissions,
localization = locale,
) => {
const response = await createInertiaApp({
page: {
component: 'Preview',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
sidebarOpen: true,
appearance: 'light',
auth: {
permissions: authPermissions,
user: {
id: 1,
name: 'Test User',
email: 'test@example.test',
avatar: null,
},
},
navigation: { project: projectContext },
},
},
resolve: () => ({
render: () =>
h(
AppLayout,
{
breadcrumbs: [
{ title: 'Board', href: '/projects/7/board' },
],
},
() => h('h1', {}, 'Content'),
),
}),
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(localization)
.use(createAppearance('light')),
});
return response.body;
};
const reader = await render('/projects/7/board?filter=mine', project);
assert.match(
reader,
/<a(?=[^>]*href="\/projects\/7\/board")(?=[^>]*aria-current="page")[^>]*>/,
);
assert.doesNotMatch(reader, /href="\/projects\/7\/settings/);
assert.doesNotMatch(reader, /href="\/admin\/users"/);
assert.doesNotMatch(reader, /href="\/projects\/7\/tickets\/create/);
assert.doesNotMatch(reader, /Laravel Starter Kit|vue-starter-kit/);
assert.match(reader, /aria-controls="app-navigation"/);
const writer = await render('/projects/7/backlog', {
...project,
canCreateTickets: true,
});
assert.match(
writer,
/href="\/projects\/7\/tickets\/create\?source=backlog"/,
);
assert.doesNotMatch(writer, /href="\/projects\/7\/settings/);
const admin = await render('/projects/7/settings/general', {
...project,
canManage: true,
canCreateTickets: true,
});
assert.match(
admin,
/<a(?=[^>]*href="\/projects\/7\/settings\/general")(?=[^>]*aria-current="page")[^>]*>/,
);
// Render another page in the same process to catch stale module-level Inertia state.
const dashboard = await render('/dashboard', null);
assert.match(
dashboard,
/<a(?=[^>]*href="\/dashboard")(?=[^>]*aria-current="page")[^>]*>/,
);
assert.doesNotMatch(dashboard, /href="\/projects\/7/);
const translated = await render(
'/dashboard',
null,
permissions,
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'navigation.dashboard': 'Übersicht',
'navigation.expand': 'Navigation öffnen',
},
},
),
);
assert.match(translated, /Übersicht/);
console.log('Navigation behavior checks passed.');
} finally {
await server.close();
}