Compare commits
19
Commits
2e2ddd5b74
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 | ||
|
|
802294d6a2 | ||
|
|
5e52e4fe78 | ||
|
|
f4a8bdd134 | ||
|
|
850ae337ca | ||
|
|
4adda6e53b | ||
|
|
de6f4e5b2f |
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
||||
'tickets' => TicketResource::collection(
|
||||
Ticket::query()
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -60,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
||||
'user' => $user,
|
||||
],
|
||||
'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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { Columns3 } from 'lucide-vue-next';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 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
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
</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>
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
BookOpen,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
LayoutGrid,
|
||||
Layers3,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { Settings2, X } from 'lucide-vue-next';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -22,93 +13,98 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||
import { index as projectsIndex } from '@/routes/projects';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { NavItem } from '@/types';
|
||||
import { edit as profile } from '@/routes/profile';
|
||||
import { show } from '@/routes/projects';
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectsIndex(),
|
||||
icon: Folder,
|
||||
},
|
||||
];
|
||||
|
||||
if (page.props.auth.permissions.projectCreate) {
|
||||
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;
|
||||
const { t } = useLocalization();
|
||||
const { currentUrl } = useCurrentUrl();
|
||||
const { project, workspaceItems, projectItems, projectSettingsItems } =
|
||||
useAppNavigation();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const accountItems = computed(() => [
|
||||
{
|
||||
title: t('navigation.settings'),
|
||||
href: profile(),
|
||||
icon: Settings2,
|
||||
isActive: currentUrl.value.startsWith('/settings/'),
|
||||
},
|
||||
]);
|
||||
let removeNavigationListener: (() => void) | undefined;
|
||||
onMounted(() => {
|
||||
removeNavigationListener = router.on('navigate', () =>
|
||||
setOpenMobile(false),
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
onUnmounted(() => removeNavigationListener?.());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<Link :href="dashboard()">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarMenu class="min-w-0">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
as-child
|
||||
tooltip="KanbanGit"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter :items="footerNavItems" />
|
||||
<NavUser />
|
||||
<SidebarFooter class="border-sidebar-border border-t p-0">
|
||||
<NavMain :items="accountItems" :label="t('navigation.account')" />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,145 @@
|
||||
<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 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 { 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';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}>(),
|
||||
{
|
||||
breadcrumbs: () => [],
|
||||
},
|
||||
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
const page = usePage();
|
||||
const { project } = useAppNavigation();
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SidebarTrigger class="shrink-0" />
|
||||
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
|
||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||
</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>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
import { login } from '@/routes/index';
|
||||
|
||||
const props = defineProps<{
|
||||
state: FormFeedbackState;
|
||||
responseStatus?: number | null;
|
||||
loadingTitle?: string;
|
||||
successTitle?: string;
|
||||
}>();
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const expired = computed(
|
||||
() => props.state === 'server' && props.responseStatus === 419,
|
||||
);
|
||||
const limited = computed(
|
||||
() => props.state === 'server' && props.responseStatus === 429,
|
||||
);
|
||||
const title = computed(() => {
|
||||
if (expired.value) {
|
||||
return t('auth.feedback.expiredTitle');
|
||||
}
|
||||
if (limited.value) {
|
||||
return t('auth.feedback.limitedTitle');
|
||||
}
|
||||
if (props.state === 'loading') {
|
||||
return props.loadingTitle ?? t('auth.feedback.pending');
|
||||
}
|
||||
if (props.state === 'success') {
|
||||
return props.successTitle ?? t('auth.feedback.completed');
|
||||
}
|
||||
if (props.state === 'validation') {
|
||||
return t('auth.feedback.validationTitle');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const description = computed(() => {
|
||||
if (expired.value) {
|
||||
return t('auth.feedback.expiredDescription');
|
||||
}
|
||||
if (limited.value) {
|
||||
return t('auth.feedback.limitedDescription');
|
||||
}
|
||||
if (props.state === 'validation') {
|
||||
return t('auth.feedback.validationDescription');
|
||||
}
|
||||
if (props.state === 'network') {
|
||||
return t('auth.feedback.networkDescription');
|
||||
}
|
||||
if (props.state === 'server') {
|
||||
return t('auth.feedback.serverDescription');
|
||||
}
|
||||
if (props.state === 'cancelled') {
|
||||
return t('auth.feedback.cancelledDescription');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-card grid" v-show="state !== 'idle'">
|
||||
<FormFeedback
|
||||
:state="state"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:retryable="!expired"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
<Button v-if="expired" as-child variant="outline">
|
||||
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
|
||||
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,29 +9,48 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
|
||||
<BreadcrumbList class="flex-nowrap">
|
||||
<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">
|
||||
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
|
||||
<BreadcrumbPage class="truncate" :title="item.title">{{
|
||||
item.title
|
||||
}}</BreadcrumbPage>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
|
||||
<BreadcrumbSeparator
|
||||
v-if="index !== breadcrumbs.length - 1"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
</template>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: 'empty' | 'loading' | 'error' | 'success';
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: false },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const icons = {
|
||||
empty: Inbox,
|
||||
loading: LoaderCircle,
|
||||
error: CircleAlert,
|
||||
success: CircleCheck,
|
||||
};
|
||||
const defaultTitles = {
|
||||
empty: 'feedback.empty',
|
||||
loading: 'common.loading',
|
||||
error: 'common.error',
|
||||
success: 'feedback.saved',
|
||||
} as const;
|
||||
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:role="state === 'error' ? 'alert' : 'status'"
|
||||
:aria-busy="state === 'loading' || undefined"
|
||||
:data-feedback="state"
|
||||
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
|
||||
:class="{
|
||||
'border-destructive/40': state === 'error',
|
||||
'border-success/40': state === 'success',
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="icons[state]"
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 size-5 shrink-0"
|
||||
:class="{
|
||||
'text-primary animate-spin motion-reduce:animate-none':
|
||||
state === 'loading',
|
||||
'text-destructive': state === 'error',
|
||||
'text-success-foreground': state === 'success',
|
||||
'text-muted-foreground': state === 'empty',
|
||||
}"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0 flex-1 break-words">
|
||||
<p class="font-semibold">{{ heading }}</p>
|
||||
<p v-if="description" class="text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div v-if="$slots.default"><slot /></div>
|
||||
<Button
|
||||
v-if="retryable && state === 'error'"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="$emit('retry')"
|
||||
>{{ t('common.retry') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: FormFeedbackState;
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: true },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const feedback = useTemplateRef<HTMLDivElement>('feedback');
|
||||
watch(
|
||||
() => props.state,
|
||||
async (state) => {
|
||||
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
|
||||
await nextTick();
|
||||
if (props.state === state) {
|
||||
feedback.value?.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
const titles = {
|
||||
idle: 'feedback.saved',
|
||||
loading: 'forms.saving',
|
||||
success: 'feedback.saved',
|
||||
validation: 'forms.validationTitle',
|
||||
network: 'forms.networkTitle',
|
||||
server: 'forms.serverTitle',
|
||||
cancelled: 'forms.cancelledTitle',
|
||||
} as const;
|
||||
const descriptions = {
|
||||
validation: 'forms.validationDescription',
|
||||
network: 'forms.networkDescription',
|
||||
server: 'forms.serverDescription',
|
||||
cancelled: 'forms.cancelledDescription',
|
||||
} as const;
|
||||
const description = computed(() =>
|
||||
props.state in descriptions
|
||||
? t(descriptions[props.state as keyof typeof descriptions])
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="feedback"
|
||||
v-show="state !== 'idle'"
|
||||
:data-form-state="state"
|
||||
tabindex="-1"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
>
|
||||
<FeedbackState
|
||||
v-if="state !== 'idle'"
|
||||
:state="
|
||||
state === 'loading' || state === 'success' ? state : 'error'
|
||||
"
|
||||
:title="props.title ?? t(titles[state])"
|
||||
:description="props.description ?? description"
|
||||
:retryable="
|
||||
props.retryable !== false &&
|
||||
(state === 'network' ||
|
||||
state === 'server' ||
|
||||
state === 'cancelled')
|
||||
"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,8 +5,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-600 dark:text-red-500">
|
||||
<div v-show="message" role="alert">
|
||||
<p class="text-body text-destructive">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
items: NavItem[];
|
||||
label: string;
|
||||
}>();
|
||||
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarGroup class="px-3 py-2">
|
||||
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
|
||||
label
|
||||
}}</SidebarGroupLabel>
|
||||
<SidebarMenu role="list" :aria-label="label">
|
||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="isCurrentUrl(item.href)"
|
||||
:is-active="
|
||||
item.isActive ??
|
||||
isCurrentUrl(
|
||||
item.href,
|
||||
undefined,
|
||||
item.match === 'section',
|
||||
)
|
||||
"
|
||||
: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">
|
||||
<component :is="item.icon" />
|
||||
<Link
|
||||
: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>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Eye, EyeOff } from 'lucide-vue-next';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | number | null>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
const inputRef = useTemplateRef('inputRef');
|
||||
const { t } = useLocalization();
|
||||
|
||||
defineExpose({
|
||||
$el: inputRef,
|
||||
@@ -28,7 +31,8 @@ defineExpose({
|
||||
ref="inputRef"
|
||||
v-model="model"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:class="cn('pr-10', props.class)"
|
||||
:class="cn('pr-12', props.class)"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<button
|
||||
@@ -36,11 +40,14 @@ defineExpose({
|
||||
@click="showPassword = !showPassword"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
)
|
||||
"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
:tabindex="-1"
|
||||
:aria-label="
|
||||
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
||||
"
|
||||
:aria-pressed="showPassword"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
|
||||
@@ -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>
|
||||
@@ -1,33 +1,22 @@
|
||||
<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 type { User } from '@/types';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
showEmail?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
|
||||
showEmail: false,
|
||||
});
|
||||
|
||||
const { getInitials } = useInitials();
|
||||
|
||||
// Compute whether we should show the avatar image
|
||||
const showAvatar = computed(
|
||||
() => props.user.avatar && props.user.avatar !== '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserAvatar />
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
|
||||
user.email
|
||||
}}</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar :user="user" />
|
||||
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span
|
||||
v-if="showEmail"
|
||||
class="text-muted-foreground truncate text-xs"
|
||||
>{{ user.email }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
|
||||
import { logout } from '@/routes/index';
|
||||
import { edit } from '@/routes/profile/index';
|
||||
import type { User } from '@/types';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -21,6 +22,7 @@ const handleLogout = () => {
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +36,7 @@ defineProps<Props>();
|
||||
<DropdownMenuItem :as-child="true">
|
||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
{{ t('navigation.settings') }}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
@@ -48,7 +50,7 @@ defineProps<Props>();
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
{{ t('navigation.logout') }}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ArrowUpRight } from 'lucide-vue-next';
|
||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type {
|
||||
TicketPresentationEmits,
|
||||
TicketPresentationProps,
|
||||
} from '@/types/ticket-presentation';
|
||||
|
||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||
showDescription: true,
|
||||
showStatus: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<TicketPresentationEmits>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="[
|
||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
"
|
||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
||||
@click="emit('open', ticket.id)"
|
||||
>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h3>
|
||||
|
||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||
|
||||
<p
|
||||
v-if="showDescription && ticket.description"
|
||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||
>
|
||||
{{
|
||||
t('tickets.blockerComment', {
|
||||
comment: ticket.blocked_comment,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div v-if="$slots.actions" class="border-t p-4">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ArrowUpRight } from 'lucide-vue-next';
|
||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type {
|
||||
TicketPresentationEmits,
|
||||
TicketPresentationProps,
|
||||
} from '@/types/ticket-presentation';
|
||||
|
||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||
showDescription: true,
|
||||
showStatus: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<TicketPresentationEmits>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
"
|
||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
|
||||
@click="emit('open', ticket.id)"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<h3
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h3>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||
|
||||
<p
|
||||
v-if="showDescription && ticket.description"
|
||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||
>
|
||||
{{
|
||||
t('tickets.blockerComment', {
|
||||
comment: ticket.blocked_comment,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { UserAvatar } from '@/components/ui/avatar';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showStatus?: boolean;
|
||||
ticket: TicketModel;
|
||||
}>(),
|
||||
{
|
||||
showStatus: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = useLocalization();
|
||||
const statusLabel = computed(() =>
|
||||
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-stack flex min-w-0 flex-wrap items-center">
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="warning"
|
||||
:aria-label="t('tickets.blocked')"
|
||||
>
|
||||
<OctagonAlert aria-hidden="true" />
|
||||
{{ t('tickets.blocked') }}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
:aria-label="
|
||||
t('tickets.typeLabel', { name: ticket.ticket_type.name })
|
||||
"
|
||||
:data-ticket-type-id="ticket.ticket_type.id"
|
||||
>
|
||||
{{ ticket.ticket_type.name }}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('tickets.priorityLabel', {
|
||||
name: ticket.ticket_priority.name,
|
||||
})
|
||||
"
|
||||
:data-ticket-priority-id="ticket.ticket_priority.id"
|
||||
>
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
v-if="showStatus"
|
||||
variant="outline"
|
||||
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
|
||||
:data-ticket-status-id="ticket.ticket_status?.id"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</Badge>
|
||||
|
||||
<span
|
||||
v-if="ticket.assignee"
|
||||
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
|
||||
:aria-label="
|
||||
t('tickets.assignedTo', { name: ticket.assignee.name })
|
||||
"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="ticket.assignee"
|
||||
size="sm"
|
||||
:label="ticket.assignee.name"
|
||||
/>
|
||||
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-muted-foreground flex items-center gap-1.5"
|
||||
>
|
||||
<CircleUserRound class="size-4" aria-hidden="true" />
|
||||
{{ t('tickets.unassigned') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TicketCard } from './TicketCard.vue';
|
||||
export { default as TicketListItem } from './TicketListItem.vue';
|
||||
@@ -1,53 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DialogContentProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
showCloseButton: true,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,59 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = defineProps<
|
||||
DialogContentProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import type {
|
||||
DropdownMenuCheckboxItemEmits,
|
||||
DropdownMenuCheckboxItemProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Check } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
const props = defineProps<
|
||||
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import type {
|
||||
DropdownMenuContentEmits,
|
||||
DropdownMenuContentProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
defineProps<
|
||||
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
||||
>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DropdownMenuItemProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(),
|
||||
{
|
||||
variant: "default",
|
||||
},
|
||||
);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import type {
|
||||
DropdownMenuRadioItemEmits,
|
||||
DropdownMenuRadioItemProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Circle } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<
|
||||
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type {
|
||||
DropdownMenuSubContentEmits,
|
||||
DropdownMenuSubContentProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
const props = defineProps<
|
||||
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
const props = defineProps<
|
||||
DropdownMenuSubTriggerProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
inset?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,62 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import SheetOverlay from "./SheetOverlay.vue"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SheetOverlay from "./SheetOverlay.vue";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
class?: HTMLAttributes["class"];
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: "right",
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
side: "right",
|
||||
});
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||
const delegatedProps = reactiveOmit(props, "class", "side");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right'
|
||||
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left'
|
||||
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top'
|
||||
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom'
|
||||
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<DialogClose
|
||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,96 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
||||
import type { SidebarProps } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
|
||||
import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
|
||||
import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas",
|
||||
})
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
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>
|
||||
|
||||
<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
|
||||
: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"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="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)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
:class="cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'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>
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background relative flex min-w-0 w-full flex-1 flex-col',
|
||||
'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>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
|
||||
import { useSidebar } from "./utils"
|
||||
import type { Component } from "vue";
|
||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
|
||||
import { useSidebar } from "./utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
||||
tooltip?: string | Component
|
||||
}>(), {
|
||||
as: "button",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
SidebarMenuButtonProps & {
|
||||
tooltip?: string | Component;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
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>
|
||||
|
||||
<template>
|
||||
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
|
||||
<Tooltip v-else>
|
||||
<TooltipTrigger as-child>
|
||||
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
:hidden="state !== 'collapsed' || isMobile"
|
||||
<SidebarMenuButtonChild
|
||||
v-if="!tooltip || isMobile || state !== 'collapsed'"
|
||||
v-bind="{ ...delegatedProps, ...$attrs }"
|
||||
>
|
||||
<template v-if="typeof tooltip === 'string'">
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
<component :is="tooltip" v-else />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
|
||||
<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'">
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
<component :is="tooltip" v-else />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
@@ -1,82 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes, Ref } from "vue"
|
||||
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
|
||||
import { TooltipProvider } from "reka-ui"
|
||||
import { computed, ref } 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"
|
||||
import type { HTMLAttributes, Ref } from "vue";
|
||||
import {
|
||||
defaultDocument,
|
||||
useEventListener,
|
||||
useMediaQuery,
|
||||
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<{
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
||||
open: undefined,
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>(),
|
||||
{
|
||||
defaultOpen: !defaultDocument?.cookie.includes(
|
||||
`${SIDEBAR_COOKIE_NAME}=false`,
|
||||
),
|
||||
open: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
"update:open": [open: boolean]
|
||||
}>()
|
||||
"update:open": [open: boolean];
|
||||
}>();
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
const openMobile = ref(false)
|
||||
const isMobile = useMediaQuery("(width < 768px)");
|
||||
const openMobile = ref(false);
|
||||
watch(isMobile, () => {
|
||||
openMobile.value = false;
|
||||
});
|
||||
|
||||
const open = useVModel(props, "open", emits, {
|
||||
defaultValue: props.defaultOpen ?? false,
|
||||
passive: (props.open === undefined) as false,
|
||||
}) as Ref<boolean>
|
||||
defaultValue: props.defaultOpen ?? false,
|
||||
passive: (props.open === undefined) as false,
|
||||
}) as Ref<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.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
}
|
||||
|
||||
function setOpenMobile(value: boolean) {
|
||||
openMobile.value = value
|
||||
openMobile.value = value;
|
||||
}
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
function toggleSidebar() {
|
||||
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
||||
return isMobile.value
|
||||
? setOpenMobile(!openMobile.value)
|
||||
: setOpen(!open.value);
|
||||
}
|
||||
|
||||
useEventListener("keydown", (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
})
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
const state = computed(() => open.value ? "expanded" : "collapsed")
|
||||
const state = computed(() => (open.value ? "expanded" : "collapsed"));
|
||||
|
||||
provideSidebarContext({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
})
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--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)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--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,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSidebar } from "./utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "./utils";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
|
||||
const { isMobile, state, toggleSidebar } = useSidebar()
|
||||
const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="cn('h-7 w-7', props.class)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
|
||||
<PanelLeftClose v-else />
|
||||
<span class="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
aria-controls="app-navigation"
|
||||
:aria-expanded="isMobile ? openMobile : state === 'expanded'"
|
||||
:aria-label="
|
||||
isMobile
|
||||
? t(openMobile ? 'navigation.close' : 'navigation.open')
|
||||
: 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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext } from "reka-ui"
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { createContext } from "reka-ui";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export type SidebarContext = {
|
||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
||||
state: ComputedRef<"expanded" | "collapsed">;
|
||||
open: Ref<boolean>;
|
||||
setOpen: (value: boolean) => void;
|
||||
isMobile: Ref<boolean>;
|
||||
@@ -11,11 +11,12 @@ export type SidebarContext = {
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
export const SIDEBAR_WIDTH = "16rem"
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
export const SIDEBAR_WIDTH = "16rem";
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
export const SIDEBAR_WIDTH_ICON = "4rem";
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
|
||||
import type { ComputedRef, DeepReadonly } from 'vue';
|
||||
import { computed, readonly } from 'vue';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { matchesNavigationUrl } from '@/lib/navigation';
|
||||
|
||||
export type UseCurrentUrlReturn = {
|
||||
currentUrl: DeepReadonly<ComputedRef<string>>;
|
||||
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
|
||||
) => 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 {
|
||||
const page = usePage();
|
||||
const currentUrlReactive = computed(
|
||||
() => new URL(page.url, 'http://localhost').pathname,
|
||||
);
|
||||
function isCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
startsWith: boolean = false,
|
||||
) {
|
||||
const urlToCompare = currentUrl ?? currentUrlReactive.value;
|
||||
const urlString = toUrl(urlToCheck);
|
||||
|
||||
const comparePath = (path: string): boolean =>
|
||||
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;
|
||||
}
|
||||
return matchesNavigationUrl(
|
||||
toUrl(urlToCheck),
|
||||
currentUrl ?? currentUrlReactive.value,
|
||||
startsWith,
|
||||
);
|
||||
}
|
||||
|
||||
function isCurrentOrParentUrl(
|
||||
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
return isCurrentUrl(urlToCheck, currentUrl, true);
|
||||
}
|
||||
|
||||
function whenCurrentUrl(
|
||||
function whenCurrentUrl<T, F = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: any,
|
||||
ifFalse: any = null,
|
||||
) {
|
||||
ifTrue: T,
|
||||
ifFalse: F = null as F,
|
||||
): T | F {
|
||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { injectDialogRootContext } from 'reka-ui';
|
||||
import { watch } from 'vue';
|
||||
|
||||
/** Reka restores registered triggers; also support dialogs opened programmatically. */
|
||||
export function useDialogFocus() {
|
||||
const context = injectDialogRootContext();
|
||||
let previousFocus: HTMLElement | null = null;
|
||||
|
||||
watch(
|
||||
context.open,
|
||||
(open) => {
|
||||
if (open && typeof document !== 'undefined') {
|
||||
previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
}
|
||||
},
|
||||
{ flush: 'sync', immediate: true },
|
||||
);
|
||||
|
||||
function restoreFocus(event: Event): void {
|
||||
if (
|
||||
!event.defaultPrevented &&
|
||||
!context.triggerElement.value &&
|
||||
previousFocus?.isConnected
|
||||
) {
|
||||
event.preventDefault();
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { restoreFocus };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
PendingVisit,
|
||||
UrlMethodPair,
|
||||
UseFormSubmitOptions,
|
||||
} from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import type { InertiaForm } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
|
||||
import {
|
||||
formRequestHeader,
|
||||
isFormNetworkFailure,
|
||||
matchesFormRequest,
|
||||
} from '@/lib/form-feedback';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
|
||||
export function useFormProtection<T extends object>(
|
||||
form: InertiaForm<T>,
|
||||
options: { isDirty?: () => boolean } = {},
|
||||
) {
|
||||
const failure = ref<FormFeedbackState | null>(null);
|
||||
const responseStatus = ref<number | null>(null);
|
||||
let ownVisit: PendingVisit | null = null;
|
||||
let requestId: string | null = null;
|
||||
const removeListeners: Array<() => void> = [];
|
||||
|
||||
const { confirmLeave } = useUnsavedChanges(
|
||||
() => form.processing || (options.isDirty?.() ?? form.isDirty),
|
||||
(visit) => visit === ownVisit,
|
||||
);
|
||||
|
||||
const state = computed<FormFeedbackState>(() => {
|
||||
if (form.processing) {
|
||||
return 'loading';
|
||||
}
|
||||
if (failure.value) {
|
||||
return failure.value;
|
||||
}
|
||||
if (Object.keys(form.errors).length > 0) {
|
||||
return 'validation';
|
||||
}
|
||||
return form.recentlySuccessful ? 'success' : 'idle';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
removeListeners.push(
|
||||
router.on('exception', (event) => {
|
||||
if (isFormNetworkFailure(event.detail.exception, requestId)) {
|
||||
event.preventDefault();
|
||||
failure.value = 'network';
|
||||
}
|
||||
}),
|
||||
router.on('invalid', (event) => {
|
||||
if (
|
||||
matchesFormRequest(event.detail.response.config, requestId)
|
||||
) {
|
||||
event.preventDefault();
|
||||
failure.value = 'server';
|
||||
responseStatus.value = event.detail.response.status;
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => removeListeners.forEach((remove) => remove()));
|
||||
|
||||
function submit(
|
||||
target: UrlMethodPair,
|
||||
visitOptions: UseFormSubmitOptions = {},
|
||||
): void {
|
||||
if (form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequestId = crypto.randomUUID();
|
||||
form.submit(target, {
|
||||
...visitOptions,
|
||||
preserveState: true,
|
||||
headers: {
|
||||
...visitOptions.headers,
|
||||
[formRequestHeader]: nextRequestId,
|
||||
},
|
||||
onBefore: (visit) => {
|
||||
if (visitOptions.onBefore?.(visit) === false) {
|
||||
return false;
|
||||
}
|
||||
ownVisit = visit;
|
||||
},
|
||||
onStart: (visit) => {
|
||||
requestId = nextRequestId;
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
visitOptions.onStart?.(visit);
|
||||
},
|
||||
onError: (errors) => {
|
||||
failure.value = 'validation';
|
||||
return visitOptions.onError?.(errors);
|
||||
},
|
||||
onSuccess: (page) => {
|
||||
failure.value = null;
|
||||
return visitOptions.onSuccess?.(page);
|
||||
},
|
||||
onCancel: () => {
|
||||
failure.value = 'cancelled';
|
||||
visitOptions.onCancel?.();
|
||||
},
|
||||
onFinish: (visit) => {
|
||||
requestId = null;
|
||||
ownVisit = null;
|
||||
visitOptions.onFinish?.(visit);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
}
|
||||
|
||||
return { state, responseStatus, submit, confirmLeave, clearFeedback };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PendingVisit } from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { onMounted, onUnmounted, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
export function useUnsavedChanges(
|
||||
dirty: MaybeRefOrGetter<boolean>,
|
||||
allowVisit: (visit: PendingVisit) => boolean = () => false,
|
||||
) {
|
||||
const { t } = useLocalization();
|
||||
let removeBeforeListener: (() => void) | undefined;
|
||||
|
||||
const confirmLeave = (): boolean =>
|
||||
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
|
||||
|
||||
const beforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
if (toValue(dirty)) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
removeBeforeListener = router.on('before', (event) => {
|
||||
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the original visit and its callbacks; never replay a mutation.
|
||||
return confirmLeave();
|
||||
});
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeBeforeListener?.();
|
||||
window.removeEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
return { confirmLeave };
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import AppContent from '@/components/AppContent.vue';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||
import ProjectNavigation from '@/components/ProjectNavigation.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
@@ -12,14 +14,27 @@ type Props = {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 />
|
||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
||||
<AppContent variant="sidebar" class="min-w-0">
|
||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||
<slot />
|
||||
<ProjectNavigation />
|
||||
<div
|
||||
id="main-content"
|
||||
tabindex="-1"
|
||||
class="flex min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import type { Component } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header
|
||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
||||
>
|
||||
<Link
|
||||
:href="home()"
|
||||
:aria-label="t('auth.home')"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
</header>
|
||||
<main
|
||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="auth-title"
|
||||
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
|
||||
>
|
||||
<div class="gap-column grid">
|
||||
<span
|
||||
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="icon ?? ShieldCheck"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="gap-stack grid">
|
||||
<p
|
||||
class="text-label text-muted-foreground uppercase tracking-wide"
|
||||
>
|
||||
{{ t('auth.accountAccess') }}
|
||||
</p>
|
||||
<h1
|
||||
id="auth-title"
|
||||
class="text-headline-mobile sm:text-headline"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="px-card text-body text-muted-foreground text-center"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pb-page flex justify-center px-4">
|
||||
<AppearanceTabs />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance/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 type { NavItem } from '@/types';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
const { t } = useLocalization();
|
||||
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: 'Profile',
|
||||
title: t('navigation.profile'),
|
||||
href: editProfile(),
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
title: t('navigation.security'),
|
||||
href: editSecurity(),
|
||||
},
|
||||
{
|
||||
title: 'Git connections',
|
||||
title: t('navigation.gitConnections'),
|
||||
href: editGitConnections(),
|
||||
},
|
||||
{
|
||||
title: 'Applications',
|
||||
title: t('navigation.applications'),
|
||||
href: editApplications(),
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
title: t('navigation.appearance'),
|
||||
href: editAppearance(),
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
<template>
|
||||
<div class="px-4 py-6">
|
||||
<Heading
|
||||
title="Settings"
|
||||
description="Manage your profile, security, and Git integration settings"
|
||||
:title="t('navigation.settings')"
|
||||
:description="t('navigation.settingsDescription')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-12">
|
||||
<aside class="w-full max-w-xl lg:w-48">
|
||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
||||
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
||||
<nav
|
||||
class="flex flex-col space-x-0 space-y-1"
|
||||
aria-label="Settings"
|
||||
class="flex gap-1 overflow-x-auto lg:flex-col"
|
||||
:aria-label="t('navigation.settings')"
|
||||
>
|
||||
<Button
|
||||
v-for="item in sidebarNavItems"
|
||||
:key="toUrl(item.href)"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
'w-full justify-start',
|
||||
'shrink-0 justify-start lg:w-full',
|
||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
||||
]"
|
||||
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" />
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</nav>
|
||||
</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">
|
||||
<slot />
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export type FormFeedbackState =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'success'
|
||||
| 'validation'
|
||||
| 'network'
|
||||
| 'server'
|
||||
| 'cancelled';
|
||||
|
||||
export const formRequestHeader = 'X-KanbanGit-Form-Request';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** Match failures to one submission, without intercepting other forms or polling. */
|
||||
export function matchesFormRequest(
|
||||
config: unknown,
|
||||
requestId: string | null,
|
||||
): boolean {
|
||||
if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return config.headers[formRequestHeader] === requestId;
|
||||
}
|
||||
|
||||
/** Runtime exceptions and cancellations are not network failures. */
|
||||
export function isFormNetworkFailure(
|
||||
error: unknown,
|
||||
requestId: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
isRecord(error) &&
|
||||
error.isAxiosError === true &&
|
||||
!error.response &&
|
||||
['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
|
||||
String(error.code),
|
||||
) &&
|
||||
matchesFormRequest(error.config, requestId)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
export function ticketStatusLabel(
|
||||
ticketStatus: TicketStatusModel | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (ticketStatus === null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined ||
|
||||
ticketStatus.board_column.name === ticketStatus.name
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
}
|
||||
@@ -1,9 +1,350 @@
|
||||
export const en = {
|
||||
'board.title': '{name} board',
|
||||
'board.description':
|
||||
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
||||
'board.columns': 'Workflow columns',
|
||||
'board.selectColumn': 'Choose a column',
|
||||
'board.columnOption': '{name} ({count})',
|
||||
'board.scrollHint': 'Scroll horizontally to explore all columns.',
|
||||
'board.openBacklog': 'Open backlog',
|
||||
'board.backToProject': 'Project overview',
|
||||
'board.empty.title': 'No workflow columns yet',
|
||||
'board.empty.description':
|
||||
'Set up columns to start tracking work on this board.',
|
||||
'board.emptyColumn': 'No tickets in this column.',
|
||||
'board.emptyStatus': 'No tickets in this status.',
|
||||
'board.noStatuses': 'No statuses configured for this column.',
|
||||
'board.moveTicket': 'Move {key}',
|
||||
'board.updateStatus': 'Update status',
|
||||
'board.updatingStatus': 'Updating…',
|
||||
'board.noMoves': 'No allowed moves',
|
||||
'board.noMovesDescription':
|
||||
'This status does not allow moving anywhere else.',
|
||||
'projectCreation.title': 'Create a project',
|
||||
'projectCreation.description':
|
||||
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
||||
'projectCreation.start': 'How would you like to start?',
|
||||
'projectCreation.scratch': 'Start from scratch',
|
||||
'projectCreation.scratchDescription':
|
||||
'Create an empty project with the default setup.',
|
||||
'projectCreation.duplicate': 'Duplicate a project',
|
||||
'projectCreation.duplicateDescription':
|
||||
'Choose an existing project and copy selected settings.',
|
||||
'projectCreation.template': 'Use a template',
|
||||
'projectCreation.templateDescription':
|
||||
'Start with a saved, reusable project configuration.',
|
||||
'projectCreation.noProjects':
|
||||
'No accessible projects are available to copy yet.',
|
||||
'projectCreation.noTemplates': 'No published templates are available yet.',
|
||||
'projectCreation.details': 'Project details',
|
||||
'projectCreation.name': 'Project name',
|
||||
'projectCreation.namePlaceholder': 'Platform engineering',
|
||||
'projectCreation.key': 'Project key',
|
||||
'projectCreation.keyPlaceholder': 'CORE',
|
||||
'projectCreation.keyHelp':
|
||||
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
|
||||
'projectCreation.copyTitle': 'Choose what to copy',
|
||||
'projectCreation.copyHelp':
|
||||
'Copy configuration into an independent project. Select only what you need.',
|
||||
'projectCreation.sourceProject': 'Source project',
|
||||
'projectCreation.selectProject': 'Select a source project',
|
||||
'projectCreation.copyOptions': 'Configuration areas',
|
||||
'projectCreation.copyDefaults':
|
||||
'Unselected areas keep the default setup. You can also continue without copying any settings.',
|
||||
'projectCreation.chooseTemplate': 'Choose a template',
|
||||
'projectCreation.templateLabel': 'Template',
|
||||
'projectCreation.selectTemplate': 'Select a template',
|
||||
'projectCreation.templateHelp':
|
||||
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
|
||||
'projectCreation.includedAreas': 'Included configuration',
|
||||
'projectCreation.templateDefaults':
|
||||
'This template uses the default project setup.',
|
||||
'projectCreation.summary': 'Your new project',
|
||||
'projectCreation.untitled': 'Add a project name',
|
||||
'projectCreation.ticketPreview': 'First ticket key',
|
||||
'projectCreation.setup': 'Starting point',
|
||||
'projectCreation.cleanStart':
|
||||
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
|
||||
'projectCreation.submit': 'Create project',
|
||||
'projectCreation.creating': 'Creating project…',
|
||||
'projectCreation.areas.projectSettings.label': 'Project settings',
|
||||
'projectCreation.areas.projectSettings.description':
|
||||
'Story points and author visibility.',
|
||||
'projectCreation.areas.boardColumns.label': 'Board columns',
|
||||
'projectCreation.areas.boardColumns.description':
|
||||
'Columns and their order on the board.',
|
||||
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
|
||||
'projectCreation.areas.ticketStatuses.description':
|
||||
'Statuses and their board columns.',
|
||||
'projectCreation.areas.statusTransitions.label': 'Status transitions',
|
||||
'projectCreation.areas.statusTransitions.description':
|
||||
'Allowed moves between statuses.',
|
||||
'projectCreation.areas.ticketTypes.label': 'Ticket types',
|
||||
'projectCreation.areas.ticketTypes.description':
|
||||
'Available types, including parent types.',
|
||||
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
|
||||
'projectCreation.areas.ticketPriorities.description':
|
||||
'Available priority levels.',
|
||||
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
|
||||
'projectCreation.areas.ticketFields.description':
|
||||
'Field definitions without ticket values.',
|
||||
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
|
||||
'projectCreation.areas.ticketLinkTypes.description':
|
||||
'Relationship labels between tickets.',
|
||||
'projectCreation.areas.repositories.label': 'Repository connections',
|
||||
'projectCreation.areas.repositories.description':
|
||||
'Repositories linked to the project.',
|
||||
'projectCreation.areas.notifications.label': 'Notification actions',
|
||||
'projectCreation.areas.notifications.description':
|
||||
'Notification rules and delivery settings.',
|
||||
'projectCreation.selectedCount': {
|
||||
one: '{count} area selected',
|
||||
other: '{count} areas selected',
|
||||
},
|
||||
|
||||
'projects.workspace': 'Workspace',
|
||||
'projects.description':
|
||||
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
|
||||
'projects.create': 'New project',
|
||||
'projects.updated': 'Project update',
|
||||
'projects.available': {
|
||||
one: '{count} project available',
|
||||
other: '{count} projects available',
|
||||
},
|
||||
'projects.open': 'Open {name} overview',
|
||||
'projects.openBoard': 'Open {name} board',
|
||||
'projects.openBacklog': 'Open {name} backlog',
|
||||
'projects.overview': 'Project overview',
|
||||
'projects.empty.title': 'No projects to show',
|
||||
'projects.empty.createDescription':
|
||||
'Create a project to give your team a place to plan and track work.',
|
||||
'projects.empty.accessDescription':
|
||||
'Projects appear here when you have access. Ask a workspace or project administrator to add you.',
|
||||
'projects.empty.create': 'Create your first project',
|
||||
'projects.back': 'All projects',
|
||||
'projects.hubDescription':
|
||||
'Follow work on the board or explore tickets in the backlog.',
|
||||
'projects.workAreas': 'Project work areas',
|
||||
'projects.boardDescription':
|
||||
'See work across the project’s workflow and open tickets in their current status.',
|
||||
'projects.backlogDescription':
|
||||
'Explore incoming work, review ticket details, and plan what comes next.',
|
||||
'projects.boardAction': 'Open board',
|
||||
'projects.backlogAction': 'Open backlog',
|
||||
'projects.settingsDescription':
|
||||
'Manage this project’s configuration, integrations, and access.',
|
||||
'projects.saveTemplate': 'Save as new template',
|
||||
'projects.templateTitle': 'Based on {name}',
|
||||
'projects.templateDescription':
|
||||
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||
'projects.inheritedArea': '{name} · inherited',
|
||||
'projects.overriddenArea': '{name} · overridden',
|
||||
'dashboard.eyebrow': 'Personal workspace',
|
||||
'dashboard.greeting': 'Welcome back, {name}',
|
||||
'dashboard.description':
|
||||
'A current view of tickets assigned to you in projects you can access.',
|
||||
'dashboard.summary': 'Work summary',
|
||||
'dashboard.summary.assignedTickets': 'Assigned tickets',
|
||||
'dashboard.summary.projects': 'Projects with assigned work',
|
||||
'dashboard.summary.blockedTickets': 'Blocked tickets',
|
||||
'dashboard.assignedWork': 'My assigned tickets',
|
||||
'dashboard.assignedWorkDescription':
|
||||
'Your work is grouped by project and ordered with the newest tickets first.',
|
||||
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
|
||||
'dashboard.openProject': 'Open {name}',
|
||||
'dashboard.openProjectAction': 'Open project',
|
||||
'dashboard.empty.title': 'No tickets assigned to you',
|
||||
'dashboard.empty.description':
|
||||
'Tickets assigned to you in projects you can access will appear here.',
|
||||
'dashboard.empty.action': 'Browse projects',
|
||||
'auth.home': 'KanbanGit home',
|
||||
'auth.accountAccess': 'Account access',
|
||||
'auth.backToLogin': 'Return to sign in',
|
||||
'auth.signOut': 'Sign out',
|
||||
'auth.signingOut': 'Signing out…',
|
||||
'auth.password.show': 'Show password',
|
||||
'auth.password.hide': 'Hide password',
|
||||
'auth.login.title': 'Welcome back',
|
||||
'auth.login.description': 'Sign in to continue to your workspace.',
|
||||
'auth.login.email': 'Email address',
|
||||
'auth.login.emailPlaceholder': 'you@example.com',
|
||||
'auth.login.password': 'Password',
|
||||
'auth.login.remember': 'Remember me',
|
||||
'auth.login.forgot': 'Forgot password?',
|
||||
'auth.login.submit': 'Sign in',
|
||||
'auth.login.pending': 'Signing in…',
|
||||
'auth.login.help': 'Need an account? Contact your workspace administrator.',
|
||||
'auth.login.statusTitle': 'Account update',
|
||||
'auth.forgot.title': 'Reset your password',
|
||||
'auth.forgot.description':
|
||||
'Enter your account email and we will send you a secure reset link.',
|
||||
'auth.forgot.email': 'Email address',
|
||||
'auth.forgot.emailPlaceholder': 'you@example.com',
|
||||
'auth.forgot.emailHint':
|
||||
'Use the email address associated with your KanbanGit account.',
|
||||
'auth.forgot.submit': 'Send reset link',
|
||||
'auth.forgot.pending': 'Sending reset link…',
|
||||
'auth.forgot.sentTitle': 'Reset link sent',
|
||||
'auth.reset.title': 'Choose a new password',
|
||||
'auth.reset.description':
|
||||
'Create a new password for the account linked below.',
|
||||
'auth.reset.email': 'Account email',
|
||||
'auth.reset.password': 'New password',
|
||||
'auth.reset.passwordHint':
|
||||
'Use a password you do not use for another account.',
|
||||
'auth.reset.passwordConfirmation': 'Confirm new password',
|
||||
'auth.reset.submit': 'Save new password',
|
||||
'auth.reset.pending': 'Updating your password…',
|
||||
'auth.reset.invalidTitle': 'This reset link is no longer valid',
|
||||
'auth.reset.requestNew': 'Request a new reset link',
|
||||
'auth.reset.footer':
|
||||
'For your security, reset links expire and can only be used once.',
|
||||
'auth.confirm.title': 'Confirm it is you',
|
||||
'auth.confirm.description':
|
||||
'This action affects sensitive account information. Enter your password to continue.',
|
||||
'auth.confirm.password': 'Current password',
|
||||
'auth.confirm.passwordHint':
|
||||
'Your password is checked securely and is never saved in a draft.',
|
||||
'auth.confirm.submit': 'Confirm and continue',
|
||||
'auth.confirm.pending': 'Confirming your password…',
|
||||
'auth.confirm.footer':
|
||||
'Password confirmation expires automatically after a short time.',
|
||||
'auth.forceChange.title': 'Secure your account',
|
||||
'auth.forceChange.description':
|
||||
'You need to replace your temporary password before continuing to KanbanGit.',
|
||||
'auth.forceChange.currentPassword': 'Temporary password',
|
||||
'auth.forceChange.password': 'New password',
|
||||
'auth.forceChange.passwordHint':
|
||||
'Choose a password that differs from your temporary password.',
|
||||
'auth.forceChange.passwordConfirmation': 'Confirm new password',
|
||||
'auth.forceChange.submit': 'Save password and continue',
|
||||
'auth.forceChange.pending': 'Securing your account…',
|
||||
'auth.forceChange.statusTitle': 'Password update',
|
||||
'auth.forceChange.wrongAccount': 'Not the account you expected?',
|
||||
'auth.forceChange.footer':
|
||||
'Your password entries are kept in memory only and are cleared when you leave.',
|
||||
'auth.verify.title': 'Check your inbox',
|
||||
'auth.verify.description':
|
||||
'Open the verification link in your email to finish confirming your account.',
|
||||
'auth.verify.emailLabel': 'Verification email address',
|
||||
'auth.verify.resend': 'Resend verification email',
|
||||
'auth.verify.pending': 'Sending verification email…',
|
||||
'auth.verify.sentTitle': 'Verification link sent',
|
||||
'auth.verify.sentDescription':
|
||||
'Check your inbox for a new link. If it does not arrive, check your spam folder.',
|
||||
'auth.verify.help': 'Already verified in another tab?',
|
||||
'auth.verify.continue': 'Continue to workspace',
|
||||
'auth.verify.wrongAccount':
|
||||
'Wrong account? Sign out and use a different email address.',
|
||||
'auth.twoFactor.title': 'Two-step verification',
|
||||
'auth.twoFactor.description':
|
||||
'Enter the six-digit code from your authenticator app to finish signing in.',
|
||||
'auth.twoFactor.code': 'Authentication code',
|
||||
'auth.twoFactor.codeHint':
|
||||
'Enter all six digits, including any leading zero.',
|
||||
'auth.twoFactor.recoveryTitle': 'Use a recovery code',
|
||||
'auth.twoFactor.recoveryDescription':
|
||||
'Enter one of the recovery codes you saved when you set up two-factor authentication.',
|
||||
'auth.twoFactor.recoveryCode': 'Recovery code',
|
||||
'auth.twoFactor.recoveryHint': 'Each recovery code can only be used once.',
|
||||
'auth.twoFactor.useRecovery': 'Use a recovery code instead',
|
||||
'auth.twoFactor.useCode': 'Use an authenticator code instead',
|
||||
'auth.twoFactor.submit': 'Verify and sign in',
|
||||
'auth.twoFactor.pending': 'Verifying your code…',
|
||||
'auth.feedback.pending': 'Please wait…',
|
||||
'auth.feedback.completed': 'Request completed',
|
||||
'auth.feedback.validationTitle': 'Check your details',
|
||||
'auth.feedback.validationDescription':
|
||||
'Review the message below and try again. Your entries have been kept.',
|
||||
'auth.feedback.expiredTitle': 'Your session has expired',
|
||||
'auth.feedback.expiredDescription':
|
||||
'Return to sign in to start a new session. You will need to enter your details again.',
|
||||
'auth.feedback.limitedTitle': 'Too many attempts',
|
||||
'auth.feedback.limitedDescription':
|
||||
'Please wait a moment before trying again.',
|
||||
'auth.feedback.networkDescription':
|
||||
'We could not confirm the result. Your entries are still here. Check your connection before trying again.',
|
||||
'auth.feedback.serverDescription':
|
||||
'The server returned an unexpected response. Your entries are still here. Please try again in a moment.',
|
||||
'auth.feedback.cancelledDescription':
|
||||
'The request was interrupted. Your entries are still here if you want to try again.',
|
||||
'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.light': 'Light',
|
||||
'appearance.dark': 'Dark',
|
||||
'appearance.system': 'System',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.retry': 'Try again',
|
||||
'feedback.empty': 'Nothing here yet',
|
||||
'feedback.saved': 'Changes saved',
|
||||
'forms.saving': 'Saving changes…',
|
||||
'forms.leaveConfirm': 'You have unsaved changes. Leave without saving?',
|
||||
'forms.validationTitle': 'Check your entries',
|
||||
'forms.validationDescription':
|
||||
'The server rejected some fields. Your entries are still here. Correct the highlighted errors and save again.',
|
||||
'forms.networkTitle': 'Connection interrupted',
|
||||
'forms.networkDescription':
|
||||
'Your entries are still here, but we could not confirm whether the server saved them. Check your connection and verify the result before trying again.',
|
||||
'forms.serverTitle': 'The request could not be completed',
|
||||
'forms.serverDescription':
|
||||
'Your entries are still here. The server returned an unexpected response; your session may have expired. Verify the result before trying again.',
|
||||
'forms.cancelledTitle': 'Saving was interrupted',
|
||||
'forms.cancelledDescription':
|
||||
'Your entries are still here. Check whether the changes were saved before trying again.',
|
||||
'tickets.noBranches': 'No matching branches',
|
||||
'tickets.noBranchesDescription':
|
||||
'No matching branches were found across the linked repositories.',
|
||||
'tickets.noPullRequests': 'No matching pull requests',
|
||||
'tickets.noPullRequestsDescription':
|
||||
'No matching pull requests were found across the linked repositories.',
|
||||
'tickets.open': 'Open {key}: {title}',
|
||||
'tickets.blocked': 'Blocked',
|
||||
'tickets.blockerComment': 'Blocker: {comment}',
|
||||
'tickets.typeLabel': 'Type: {name}',
|
||||
'tickets.priorityLabel': 'Priority: {name}',
|
||||
'tickets.statusLabel': 'Status: {name}',
|
||||
'tickets.noStatus': 'No status',
|
||||
'tickets.assignedTo': 'Assigned to {name}',
|
||||
'tickets.unassigned': 'Unassigned',
|
||||
'common.save': 'Save',
|
||||
'common.loading': 'Loading…',
|
||||
'common.error': 'Something went wrong.',
|
||||
|
||||
@@ -1,47 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
FolderGit2,
|
||||
ListTodo,
|
||||
OctagonAlert,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { TicketCard } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { index as projectsIndex, show as showProject } from '@/routes/projects';
|
||||
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
const props = defineProps<DashboardPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.dashboard'), href: dashboard() },
|
||||
]);
|
||||
const assignedTickets = computed(() =>
|
||||
props.assignedTicketsByProject.flatMap((group) => group.tickets),
|
||||
);
|
||||
const summaryItems = computed(() => [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
label: t('dashboard.summary.assignedTickets'),
|
||||
value: assignedTickets.value.length,
|
||||
icon: ListTodo,
|
||||
},
|
||||
];
|
||||
{
|
||||
label: t('dashboard.summary.projects'),
|
||||
value: props.assignedTicketsByProject.length,
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.summary.blockedTickets'),
|
||||
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
|
||||
.length,
|
||||
icon: OctagonAlert,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||
>
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
||||
<Head :title="t('navigation.dashboard')" />
|
||||
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('dashboard.eyebrow') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{
|
||||
t('dashboard.greeting', {
|
||||
name: $page.props.auth.user.name,
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section :aria-label="t('dashboard.summary')">
|
||||
<ul class="grid gap-3 sm:grid-cols-3">
|
||||
<li
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<Card class="h-full gap-0 rounded-md py-0">
|
||||
<CardContent
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="text-caption text-muted-foreground break-words"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-title mt-1 tabular-nums">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="assigned-work-title"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<h2 id="assigned-work-title" class="text-title">
|
||||
{{ t('dashboard.assignedWork') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.assignedWorkDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="assignedTicketsByProject.length === 0"
|
||||
state="empty"
|
||||
:title="t('dashboard.empty.title')"
|
||||
:description="t('dashboard.empty.description')"
|
||||
>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectsIndex()">
|
||||
<FolderGit2 aria-hidden="true" />
|
||||
{{ t('dashboard.empty.action') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<div v-else class="gap-page grid min-w-0">
|
||||
<section
|
||||
v-for="group in assignedTicketsByProject"
|
||||
:key="group.project.id"
|
||||
:aria-labelledby="`dashboard-project-${group.project.id}`"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="max-w-full shrink-0 whitespace-normal break-all"
|
||||
>
|
||||
{{ group.project.key }}
|
||||
</Badge>
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
:id="`dashboard-project-${group.project.id}`"
|
||||
class="text-body break-words font-semibold"
|
||||
>
|
||||
{{ group.project.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('tickets.count', {
|
||||
count: group.tickets.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
as-child
|
||||
variant="link"
|
||||
class="self-start sm:self-auto"
|
||||
>
|
||||
<Link
|
||||
:href="showProject(group.project.id)"
|
||||
:aria-label="
|
||||
t('dashboard.openProject', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t('dashboard.openProjectAction') }}
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
:aria-label="
|
||||
t('dashboard.projectTickets', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
|
||||
>
|
||||
<li
|
||||
v-for="ticket in group.tickets"
|
||||
:key="ticket.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<TicketCard
|
||||
:project-id="group.project.id"
|
||||
:ticket="ticket"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,52 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import { onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({ password: '' }).dontRemember('password');
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const submit = (): void =>
|
||||
protection.submit(store(), { onSuccess: () => form.reset() });
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Confirm your password"
|
||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.confirm.title')"
|
||||
:description="t('auth.confirm.description')"
|
||||
:icon="ShieldCheck"
|
||||
>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
reset-on-success
|
||||
v-slot="{ errors, processing }"
|
||||
<Head :title="t('auth.confirm.title')" />
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
data-test="confirm-password-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Confirm password
|
||||
</Button>
|
||||
</div>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.confirm.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password">{{ t('auth.confirm.password') }}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="password-hint password-error"
|
||||
/>
|
||||
<p
|
||||
id="password-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('auth.confirm.passwordHint') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.confirm.pending')"
|
||||
data-test="confirm-password-button"
|
||||
>{{ t('auth.confirm.submit') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.confirm.footer') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,115 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { LogOut } from 'lucide-vue-next';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { KeyRound } from 'lucide-vue-next';
|
||||
import { computed, onUnmounted } from 'vue';
|
||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { logout } from '@/routes';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { logout } from '@/routes/index';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
status?: string | null;
|
||||
}>(),
|
||||
{
|
||||
status: null,
|
||||
},
|
||||
);
|
||||
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
}).dontRemember('current_password', 'password', 'password_confirmation');
|
||||
const logoutForm = useForm({});
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const logoutProtection = useFormProtection(logoutForm);
|
||||
const busy = computed(() => form.processing || logoutForm.processing);
|
||||
const submit = (): void =>
|
||||
protection.submit(SecurityController.update(), {
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
const signOut = (): void => {
|
||||
if (busy.value) {
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
logoutProtection.submit(logout());
|
||||
};
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Change your password"
|
||||
description="You must update your password before you can continue using the application."
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.forceChange.title')"
|
||||
:description="t('auth.forceChange.description')"
|
||||
:icon="KeyRound"
|
||||
>
|
||||
<Head title="Change your password" />
|
||||
|
||||
<div class="mb-6 flex justify-end">
|
||||
<Button as-child variant="ghost" size="sm">
|
||||
<Link :href="logout()" as="button">
|
||||
<LogOut class="size-4" />
|
||||
Log out
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.forceChange.title')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
|
||||
state="success"
|
||||
:title="t('auth.forceChange.statusTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="SecurityController.update.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="SecurityController.update.form()"
|
||||
reset-on-success
|
||||
:reset-on-error="[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="current_password">Current password</Label>
|
||||
<PasswordInput
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="Current password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.current_password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
data-test="force-password-change-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Save new password
|
||||
</Button>
|
||||
</div>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.forceChange.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="current_password">{{
|
||||
t('auth.forceChange.currentPassword')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="current_password"
|
||||
v-model="form.current_password"
|
||||
name="current_password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
:disabled="busy"
|
||||
:invalid="Boolean(form.errors.current_password)"
|
||||
aria-describedby="current-password-error"
|
||||
/>
|
||||
<InputError
|
||||
id="current-password-error"
|
||||
:message="form.errors.current_password"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password">{{
|
||||
t('auth.forceChange.password')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="busy"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="new-password-hint new-password-error"
|
||||
/>
|
||||
<p
|
||||
id="new-password-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('auth.forceChange.passwordHint') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="new-password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password_confirmation">{{
|
||||
t('auth.forceChange.passwordConfirmation')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
name="password_confirmation"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="busy"
|
||||
:invalid="Boolean(form.errors.password_confirmation)"
|
||||
aria-describedby="new-password-confirmation-error"
|
||||
/>
|
||||
<InputError
|
||||
id="new-password-confirmation-error"
|
||||
:message="form.errors.password_confirmation"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="logoutForm.processing"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.forceChange.pending')"
|
||||
data-test="force-password-change-button"
|
||||
>{{ t('auth.forceChange.submit') }}</Button
|
||||
>
|
||||
</form>
|
||||
<div class="gap-stack pt-column grid border-t">
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('auth.forceChange.wrongAccount') }}
|
||||
</p>
|
||||
<form
|
||||
:action="logout.url()"
|
||||
method="post"
|
||||
class="gap-card grid"
|
||||
@submit.prevent="signOut"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="logoutProtection.state.value"
|
||||
:response-status="logoutProtection.responseStatus.value"
|
||||
:loading-title="t('auth.signingOut')"
|
||||
@retry="signOut"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:disabled="form.processing"
|
||||
:loading="logoutForm.processing"
|
||||
:loading-label="t('auth.signingOut')"
|
||||
>{{ t('auth.signOut') }}</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<template #footer>{{ t('auth.forceChange.footer') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,65 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { Mail } from 'lucide-vue-next';
|
||||
import { onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { login } from '@/routes/index';
|
||||
import { email } from '@/routes/password';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
}>();
|
||||
defineProps<{ status?: string }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({ email: '' }).dontRemember('email');
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const submit = (): void => protection.submit(email());
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Forgot password"
|
||||
description="Enter your email to receive a password reset link"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.forgot.title')"
|
||||
:description="t('auth.forgot.description')"
|
||||
:icon="Mail"
|
||||
>
|
||||
<Head title="Forgot password" />
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.forgot.title')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
state="success"
|
||||
:title="t('auth.forgot.sentTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="email.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Form v-bind="email.form()" v-slot="{ errors, processing }">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="my-6 flex items-center justify-start">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
data-test="email-password-reset-link-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Email password reset link
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="text-muted-foreground space-x-1 text-center text-sm">
|
||||
<span>Or, return to</span>
|
||||
<TextLink :href="login()">log in</TextLink>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.forgot.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="email">{{ t('auth.forgot.email') }}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('auth.forgot.emailPlaceholder')"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-hint email-error"
|
||||
/>
|
||||
<p id="email-hint" class="text-caption text-muted-foreground">
|
||||
{{ t('auth.forgot.emailHint') }}
|
||||
</p>
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.forgot.pending')"
|
||||
data-test="email-password-reset-link-button"
|
||||
>{{ t('auth.forgot.submit') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer
|
||||
><TextLink :href="login()">{{
|
||||
t('auth.backToLogin')
|
||||
}}</TextLink></template
|
||||
>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { LogIn } from 'lucide-vue-next';
|
||||
import { onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
@@ -7,94 +11,114 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}>();
|
||||
defineProps<{ status?: string; canResetPassword: boolean }>();
|
||||
const { t } = useLocalization();
|
||||
// Credentials stay in memory only; no remembered form key or browser storage.
|
||||
const form = useForm({ email: '', password: '', remember: false }).dontRemember(
|
||||
'password',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const submit = (): void =>
|
||||
protection.submit(store(), {
|
||||
onSuccess: () => form.reset('password'),
|
||||
});
|
||||
onUnmounted(() => form.reset('password'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthBase
|
||||
title="Log in to your account"
|
||||
description="Enter your email and password below to log in"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.login.title')"
|
||||
:description="t('auth.login.description')"
|
||||
:icon="LogIn"
|
||||
>
|
||||
<Head title="Log in" />
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.login.submit')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
state="success"
|
||||
:title="t('auth.login.statusTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
:reset-on-success="['password']"
|
||||
v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autofocus
|
||||
:tabindex="1"
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="password">Password</Label>
|
||||
<TextLink
|
||||
v-if="canResetPassword"
|
||||
:href="request()"
|
||||
class="text-sm"
|
||||
:tabindex="5"
|
||||
>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
:tabindex="2"
|
||||
autocomplete="current-password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="remember" class="flex items-center space-x-3">
|
||||
<Checkbox id="remember" name="remember" :tabindex="3" />
|
||||
<span>Remember me</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="mt-4 w-full"
|
||||
:tabindex="4"
|
||||
:disabled="processing"
|
||||
data-test="login-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Log in
|
||||
</Button>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.login.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="email">{{ t('auth.login.email') }}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('auth.login.emailPlaceholder')"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
</Form>
|
||||
</AuthBase>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password">{{ t('auth.login.password') }}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="password-error"
|
||||
/>
|
||||
<InputError
|
||||
id="password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
<div class="gap-card flex flex-wrap items-center justify-between">
|
||||
<Label for="remember" class="min-h-control gap-stack">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
v-model="form.remember"
|
||||
name="remember"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
{{ t('auth.login.remember') }}
|
||||
</Label>
|
||||
<TextLink
|
||||
v-if="canResetPassword"
|
||||
:href="request()"
|
||||
class="min-h-control text-body inline-flex items-center"
|
||||
>{{ t('auth.login.forgot') }}</TextLink
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.login.pending')"
|
||||
data-test="login-button"
|
||||
>{{ t('auth.login.submit') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.login.help') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,88 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { KeyRound } from 'lucide-vue-next';
|
||||
import { computed, onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { update } from '@/routes/password';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { request, update } from '@/routes/password';
|
||||
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
const inputEmail = ref(props.email);
|
||||
const props = defineProps<{ token: string; email: string }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
token: props.token,
|
||||
email: props.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
}).dontRemember('token', 'email', 'password', 'password_confirmation');
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const invalidLink = computed(() => Boolean(form.errors.email));
|
||||
const feedbackState = computed(() =>
|
||||
invalidLink.value ? 'idle' : protection.state.value,
|
||||
);
|
||||
const submit = (): void =>
|
||||
protection.submit(update(), {
|
||||
onSuccess: () => form.reset('password', 'password_confirmation'),
|
||||
});
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Reset password"
|
||||
description="Please enter your new password below"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.reset.title')"
|
||||
:description="t('auth.reset.description')"
|
||||
:icon="KeyRound"
|
||||
>
|
||||
<Head title="Reset password" />
|
||||
|
||||
<Form
|
||||
v-bind="update.form()"
|
||||
:transform="(data) => ({ ...data, token, email })"
|
||||
:reset-on-success="['password', 'password_confirmation']"
|
||||
v-slot="{ errors, processing }"
|
||||
<Head :title="t('auth.reset.title')" />
|
||||
<FeedbackState
|
||||
v-if="invalidLink"
|
||||
state="error"
|
||||
:title="t('auth.reset.invalidTitle')"
|
||||
:description="form.errors.email"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
v-model="inputEmail"
|
||||
class="mt-1 block w-full"
|
||||
readonly
|
||||
/>
|
||||
<InputError :message="errors.email" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 block w-full"
|
||||
autofocus
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 block w-full"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="mt-4 w-full"
|
||||
:disabled="processing"
|
||||
data-test="reset-password-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Reset password
|
||||
</Button>
|
||||
<Button as-child variant="outline" class="w-full">
|
||||
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
<form
|
||||
:action="update.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="feedbackState"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.reset.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<input type="hidden" name="token" :value="form.token" />
|
||||
<div class="gap-stack grid">
|
||||
<Label for="email">{{ t('auth.reset.email') }}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
readonly
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password">{{ t('auth.reset.password') }}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="password-hint password-error"
|
||||
/>
|
||||
<p
|
||||
id="password-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('auth.reset.passwordHint') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password_confirmation">{{
|
||||
t('auth.reset.passwordConfirmation')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
name="password_confirmation"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password_confirmation)"
|
||||
aria-describedby="password-confirmation-error"
|
||||
/>
|
||||
<InputError
|
||||
id="password-confirmation-error"
|
||||
:message="form.errors.password_confirmation"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.reset.pending')"
|
||||
data-test="reset-password-button"
|
||||
>{{ t('auth.reset.submit') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.reset.footer') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,133 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { KeyRound, ShieldCheck } from 'lucide-vue-next';
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { login } from '@/routes/index';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
const authConfigContent = computed<TwoFactorConfigContent>(() => {
|
||||
if (showRecoveryInput.value) {
|
||||
return {
|
||||
title: 'Recovery code',
|
||||
description:
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
buttonText: 'login using an authentication code',
|
||||
};
|
||||
const { t } = useLocalization();
|
||||
const recovery = ref(false);
|
||||
const form = useForm({ code: '', recovery_code: '' }).dontRemember(
|
||||
'code',
|
||||
'recovery_code',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const codeInput = useTemplateRef('codeInput');
|
||||
const recoveryInput = useTemplateRef('recoveryInput');
|
||||
const fieldError = computed(() =>
|
||||
recovery.value
|
||||
? (form.errors.recovery_code ?? form.errors.code)
|
||||
: form.errors.code,
|
||||
);
|
||||
const toggleMode = async (): Promise<void> => {
|
||||
if (form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication code',
|
||||
description:
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
buttonText: 'login using a recovery code',
|
||||
};
|
||||
});
|
||||
|
||||
const showRecoveryInput = ref<boolean>(false);
|
||||
|
||||
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
||||
showRecoveryInput.value = !showRecoveryInput.value;
|
||||
clearErrors();
|
||||
code.value = '';
|
||||
recovery.value = !recovery.value;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
await nextTick();
|
||||
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
|
||||
};
|
||||
|
||||
const code = ref<string>('');
|
||||
const pasteCode = (event: ClipboardEvent): void => {
|
||||
const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
|
||||
if (/^[0-9]{6}$/.test(code)) {
|
||||
event.preventDefault();
|
||||
form.code = code;
|
||||
}
|
||||
};
|
||||
const submit = (): void => {
|
||||
form.transform((data) =>
|
||||
recovery.value
|
||||
? { recovery_code: data.recovery_code.trim() }
|
||||
: { code: data.code },
|
||||
);
|
||||
protection.submit(store(), { onSuccess: () => form.reset() });
|
||||
};
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
:title="authConfigContent.title"
|
||||
:description="authConfigContent.description"
|
||||
<AuthFlowLayout
|
||||
:title="
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.recoveryTitle'
|
||||
: 'auth.twoFactor.title',
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.recoveryDescription'
|
||||
: 'auth.twoFactor.description',
|
||||
)
|
||||
"
|
||||
:icon="recovery ? KeyRound : ShieldCheck"
|
||||
>
|
||||
<Head title="Two-factor authentication" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<template v-if="!showRecoveryInput">
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
class="space-y-4"
|
||||
reset-on-error
|
||||
@error="code = ''"
|
||||
#default="{ errors, processing, clearErrors }"
|
||||
<Head :title="t('auth.twoFactor.title')" />
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.twoFactor.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div v-if="!recovery" class="gap-stack grid">
|
||||
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
key="code"
|
||||
ref="codeInput"
|
||||
v-model="form.code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
minlength="6"
|
||||
maxlength="6"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(fieldError)"
|
||||
aria-describedby="code-hint code-error"
|
||||
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
|
||||
@paste="pasteCode"
|
||||
/>
|
||||
<p id="code-hint" class="text-caption text-muted-foreground">
|
||||
{{ t('auth.twoFactor.codeHint') }}
|
||||
</p>
|
||||
<InputError id="code-error" :message="fieldError" />
|
||||
</div>
|
||||
<div v-else class="gap-stack grid">
|
||||
<Label for="recovery_code">{{
|
||||
t('auth.twoFactor.recoveryCode')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="recovery_code"
|
||||
key="recovery"
|
||||
ref="recoveryInput"
|
||||
v-model="form.recovery_code"
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(fieldError)"
|
||||
aria-describedby="recovery-hint recovery-error"
|
||||
class="font-mono"
|
||||
/>
|
||||
<p
|
||||
id="recovery-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
<input type="hidden" name="code" :value="code" />
|
||||
<div
|
||||
class="flex flex-col items-center justify-center space-y-3 text-center"
|
||||
>
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
autofocus
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<InputError :message="errors.code" />
|
||||
</div>
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
>
|
||||
<div class="text-muted-foreground text-center text-sm">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||
@click="() => toggleRecoveryMode(clearErrors)"
|
||||
>
|
||||
{{ authConfigContent.buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
class="space-y-4"
|
||||
reset-on-error
|
||||
#default="{ errors, processing, clearErrors }"
|
||||
>
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
:autofocus="showRecoveryInput"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.recovery_code" />
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
>
|
||||
|
||||
<div class="text-muted-foreground text-center text-sm">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||
@click="() => toggleRecoveryMode(clearErrors)"
|
||||
>
|
||||
{{ authConfigContent.buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
{{ t('auth.twoFactor.recoveryHint') }}
|
||||
</p>
|
||||
<InputError id="recovery-error" :message="fieldError" />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.twoFactor.pending')"
|
||||
>{{ t('auth.twoFactor.submit') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full whitespace-normal"
|
||||
:disabled="form.processing"
|
||||
@click="toggleMode"
|
||||
>{{
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.useCode'
|
||||
: 'auth.twoFactor.useRecovery',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer
|
||||
><TextLink :href="login()">{{
|
||||
t('auth.backToLogin')
|
||||
}}</TextLink></template
|
||||
>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,49 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Head, router, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { Mail } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { logout } from '@/routes/index';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { dashboard, logout } from '@/routes/index';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
}>();
|
||||
const props = defineProps<{ status?: string }>();
|
||||
const { t } = useLocalization();
|
||||
const page = usePage();
|
||||
const email = computed(() => page.props.auth.user?.email);
|
||||
const resendForm = useForm({});
|
||||
const logoutForm = useForm({});
|
||||
const resendProtection = useFormProtection(resendForm);
|
||||
const logoutProtection = useFormProtection(logoutForm);
|
||||
const busy = computed(() => resendForm.processing || logoutForm.processing);
|
||||
const sent = computed(
|
||||
() =>
|
||||
resendProtection.state.value === 'success' ||
|
||||
(resendProtection.state.value === 'idle' &&
|
||||
props.status === 'verification-link-sent'),
|
||||
);
|
||||
const resend = (): void => {
|
||||
if (!busy.value) {
|
||||
resendProtection.submit(send(), { preserveScroll: true });
|
||||
}
|
||||
};
|
||||
const signOut = (): void => {
|
||||
if (!busy.value) {
|
||||
logoutProtection.submit(logout());
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Verify email"
|
||||
description="Please verify your email address by clicking on the link we just emailed to you."
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.verify.title')"
|
||||
:description="t('auth.verify.description')"
|
||||
:icon="Mail"
|
||||
>
|
||||
<Head title="Email verification" />
|
||||
|
||||
<Head :title="t('auth.verify.title')" />
|
||||
<div
|
||||
v-if="status === 'verification-link-sent'"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
v-if="email"
|
||||
class="gap-stack bg-muted p-card grid rounded-md border"
|
||||
>
|
||||
A new verification link has been sent to the email address you
|
||||
provided during registration.
|
||||
<p class="text-label text-muted-foreground uppercase tracking-wide">
|
||||
{{ t('auth.verify.emailLabel') }}
|
||||
</p>
|
||||
<p class="text-body break-all font-medium">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="send.form()"
|
||||
class="space-y-6 text-center"
|
||||
v-slot="{ processing }"
|
||||
<FeedbackState
|
||||
v-if="sent"
|
||||
state="success"
|
||||
:title="t('auth.verify.sentTitle')"
|
||||
:description="t('auth.verify.sentDescription')"
|
||||
/>
|
||||
<form
|
||||
:action="send.url()"
|
||||
method="post"
|
||||
class="gap-column grid"
|
||||
@submit.prevent="resend"
|
||||
>
|
||||
<Button :disabled="processing" variant="secondary">
|
||||
<Spinner v-if="processing" />
|
||||
Resend verification email
|
||||
</Button>
|
||||
|
||||
<TextLink
|
||||
:href="logout()"
|
||||
as="button"
|
||||
class="mx-auto block text-sm"
|
||||
<AuthFormFeedback
|
||||
:state="
|
||||
resendProtection.state.value === 'success'
|
||||
? 'idle'
|
||||
: resendProtection.state.value
|
||||
"
|
||||
:response-status="resendProtection.responseStatus.value"
|
||||
:loading-title="t('auth.verify.pending')"
|
||||
@retry="resend"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="busy"
|
||||
:loading="resendForm.processing"
|
||||
:loading-label="t('auth.verify.pending')"
|
||||
>{{ t('auth.verify.resend') }}</Button
|
||||
>
|
||||
Log out
|
||||
</TextLink>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
</form>
|
||||
<div class="gap-stack pt-column grid border-t">
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('auth.verify.help') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="router.visit(dashboard())"
|
||||
>{{ t('auth.verify.continue') }}</Button
|
||||
>
|
||||
</div>
|
||||
<form
|
||||
:action="logout.url()"
|
||||
method="post"
|
||||
class="gap-card grid"
|
||||
@submit.prevent="signOut"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="logoutProtection.state.value"
|
||||
:response-status="logoutProtection.responseStatus.value"
|
||||
:loading-title="t('auth.signingOut')"
|
||||
@retry="signOut"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
:disabled="busy"
|
||||
:loading="logoutForm.processing"
|
||||
:loading-label="t('auth.signingOut')"
|
||||
>{{ t('auth.signOut') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.verify.wrongAccount') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { TicketListItem } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -12,13 +13,11 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import {
|
||||
create as createTicket,
|
||||
show as showTicket,
|
||||
} from '@/routes/projects/tickets';
|
||||
import { create as createTicket } from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
@@ -40,25 +39,6 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||
if (ticketStatus === null) {
|
||||
return 'Unknown status';
|
||||
}
|
||||
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
};
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
@@ -132,163 +112,98 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
No backlog tickets exist yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
<ul v-else class="gap-column grid p-4">
|
||||
<TicketListItem
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
:project-id="project.id"
|
||||
:ticket="ticket"
|
||||
>
|
||||
<thead
|
||||
class="bg-muted/40 text-muted-foreground text-left"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Key</th>
|
||||
<th class="px-4 py-3 font-medium">Title</th>
|
||||
<th class="px-4 py-3 font-medium">Type</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Priority
|
||||
</th>
|
||||
<th
|
||||
v-if="canEditTickets"
|
||||
class="px-4 py-3 font-medium"
|
||||
<template v-if="canEditTickets" #actions>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="flex w-full flex-col gap-2"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="backlog"
|
||||
/>
|
||||
<label
|
||||
:for="`backlog-ticket-status-${ticket.id}`"
|
||||
class="text-label text-muted-foreground uppercase"
|
||||
>
|
||||
Move to status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-border divide-y">
|
||||
<tr
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
class="bg-background"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<select
|
||||
:id="`backlog-ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0 || processing
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
</Link>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</Link>
|
||||
<div
|
||||
v-if="ticket.description"
|
||||
class="text-muted-foreground mt-1 line-clamp-2"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-muted-foreground px-4 py-4">
|
||||
{{ ticket.ticket_type.name }}
|
||||
</td>
|
||||
<td class="text-muted-foreground px-4 py-4">
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticket.ticket_status,
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td class="text-muted-foreground px-4 py-4">
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</td>
|
||||
<td v-if="canEditTickets" class="px-4 py-4">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form(
|
||||
{
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
},
|
||||
)
|
||||
"
|
||||
class="flex flex-col gap-2 sm:flex-row"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="backlog"
|
||||
/>
|
||||
<select
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0 || processing
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
value=""
|
||||
>
|
||||
No allowed moves
|
||||
</option>
|
||||
<option
|
||||
v-for="ticketStatus in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticketStatus,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<p
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground text-xs sm:basis-full"
|
||||
value=""
|
||||
>
|
||||
This status does not allow
|
||||
moving anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
No allowed moves
|
||||
</option>
|
||||
<option
|
||||
v-for="ticketStatus in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticketStatus,
|
||||
'Unknown status',
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0
|
||||
"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0
|
||||
"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
This status does not allow moving
|
||||
anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
</template>
|
||||
</TicketListItem>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,91 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { TicketCard } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||
import { board as boardSettings } from '@/routes/projects/settings';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
create as createTicket,
|
||||
show as showTicket,
|
||||
} from '@/routes/projects/tickets';
|
||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||
import { create as createTicket } from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
const props = defineProps<ProjectBoardPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectIndex(),
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: projectIndex() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('navigation.board'), href: boardIndex(props.project.id) },
|
||||
]);
|
||||
const selectedColumnId = ref<number>();
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
props.project.id,
|
||||
props.boardColumns.map((column) => column.id),
|
||||
] as const,
|
||||
([projectId, columnIds], previous) => {
|
||||
if (
|
||||
projectId !== previous?.[0] ||
|
||||
!columnIds.includes(selectedColumnId.value ?? -1)
|
||||
) {
|
||||
selectedColumnId.value = columnIds[0];
|
||||
}
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||
if (ticketStatus === null) {
|
||||
return 'Unknown status';
|
||||
}
|
||||
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
};
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
{ immediate: true },
|
||||
);
|
||||
const columns = computed(() =>
|
||||
props.boardColumns.map((column) => {
|
||||
const ticketsByStatus = new Map<number | null, TicketModel[]>();
|
||||
for (const ticket of column.tickets ?? []) {
|
||||
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
|
||||
tickets.push(ticket);
|
||||
ticketsByStatus.set(ticket.ticket_status_id, tickets);
|
||||
}
|
||||
return {
|
||||
...column,
|
||||
count: column.tickets?.length ?? 0,
|
||||
groups: (column.ticket_statuses ?? []).map((status) => ({
|
||||
status,
|
||||
tickets: ticketsByStatus.get(status.id) ?? [],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const ticketCount = computed(() =>
|
||||
columns.value.reduce((count, column) => count + column.count, 0),
|
||||
);
|
||||
const allowedTargetStatuses = (ticket: TicketModel) =>
|
||||
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(status) => status.id !== ticket.ticket_status_id,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} board`" />
|
||||
<Head :title="t('board.title', { name: project.name })" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} board`"
|
||||
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>{{ project.key }}</Badge
|
||||
>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('tickets.count', { count: ticketCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
<h1
|
||||
class="text-headline-mobile md:text-headline wrap-anywhere"
|
||||
>
|
||||
{{ t('board.title', { name: project.name }) }}
|
||||
</h1>
|
||||
<p class="text-caption text-muted-foreground max-w-2xl">
|
||||
{{ t('board.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
@@ -95,245 +107,280 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
)
|
||||
"
|
||||
>
|
||||
Create ticket
|
||||
<Plus aria-hidden="true" />{{
|
||||
t('navigation.createTicket')
|
||||
}}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="backlogIndex(project.id)"
|
||||
>Open backlog</Link
|
||||
><ListTodo aria-hidden="true" />{{
|
||||
t('board.openBacklog')
|
||||
}}</Link
|
||||
>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Button as-child variant="ghost">
|
||||
<Link :href="show(project.id)"
|
||||
><ArrowLeft aria-hidden="true" />{{
|
||||
t('board.backToProject')
|
||||
}}</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
<FeedbackState v-if="status" state="success" :title="status" />
|
||||
<FeedbackState
|
||||
v-if="columns.length === 0"
|
||||
state="empty"
|
||||
:title="t('board.empty.title')"
|
||||
:description="t('board.empty.description')"
|
||||
/>
|
||||
|
||||
<Card v-if="boardColumns.length === 0">
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
The board has no workflow columns yet, so no tickets can
|
||||
appear here.
|
||||
</p>
|
||||
<div v-if="canEditTickets">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Open board settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="boardColumn in boardColumns"
|
||||
:key="boardColumn.id"
|
||||
class="flex h-full flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
<CardHeader class="bg-muted/20 border-b">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{{ boardColumn.name }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ boardColumn.tickets?.length ?? 0 }}
|
||||
active ticket(s)
|
||||
</CardDescription>
|
||||
<div
|
||||
v-if="
|
||||
(boardColumn.ticket_statuses?.length ??
|
||||
0) > 0
|
||||
"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="ticketStatus in boardColumn.ticket_statuses"
|
||||
:key="ticketStatus.id"
|
||||
variant="outline"
|
||||
>
|
||||
{{ ticketStatus.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary">
|
||||
#{{ boardColumn.position }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex flex-1 flex-col gap-4 p-4">
|
||||
<div
|
||||
v-if="(boardColumn.tickets?.length ?? 0) === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
<template v-else>
|
||||
<div class="grid min-w-0 gap-2 md:hidden">
|
||||
<label for="board-column" class="text-body font-semibold">{{
|
||||
t('board.selectColumn')
|
||||
}}</label>
|
||||
<select
|
||||
id="board-column"
|
||||
v-model="selectedColumnId"
|
||||
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||
:aria-controls="`board-column-${selectedColumnId}`"
|
||||
>
|
||||
<option
|
||||
v-for="column in columns"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
>
|
||||
No tickets are currently in this column.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<Card
|
||||
v-for="ticket in boardColumn.tickets"
|
||||
:key="ticket.id"
|
||||
:class="[
|
||||
'gap-0',
|
||||
ticket.is_blocked
|
||||
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
||||
: '',
|
||||
]"
|
||||
{{
|
||||
t('board.columnOption', {
|
||||
name: column.name,
|
||||
count: column.count,
|
||||
})
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
id="board-scroll-hint"
|
||||
class="text-caption text-muted-foreground hidden md:block"
|
||||
>
|
||||
{{ t('board.scrollHint') }}
|
||||
</p>
|
||||
<section
|
||||
:aria-label="t('board.columns')"
|
||||
aria-describedby="board-scroll-hint"
|
||||
tabindex="0"
|
||||
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
||||
>
|
||||
<section
|
||||
v-for="column in columns"
|
||||
:id="`board-column-${column.id}`"
|
||||
:key="`${project.id}-${column.id}`"
|
||||
:aria-labelledby="`board-column-title-${column.id}`"
|
||||
:class="[
|
||||
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
||||
selectedColumnId === column.id ? 'flex' : 'hidden',
|
||||
]"
|
||||
>
|
||||
<header
|
||||
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
|
||||
>
|
||||
<h2
|
||||
:id="`board-column-title-${column.id}`"
|
||||
class="text-title wrap-anywhere"
|
||||
>
|
||||
<CardHeader class="pb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="destructive"
|
||||
>
|
||||
Blocked
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ ticket.ticket_type.name }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticket.ticket_status,
|
||||
)
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle class="text-base">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary transition"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
v-if="ticket.description"
|
||||
class="line-clamp-3"
|
||||
{{ column.name }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="shrink-0 tabular-nums"
|
||||
:aria-label="
|
||||
t('tickets.count', { count: column.count })
|
||||
"
|
||||
>{{ column.count }}</Badge
|
||||
>
|
||||
</header>
|
||||
<p
|
||||
v-if="column.count === 0"
|
||||
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
|
||||
>
|
||||
{{ t('board.emptyColumn') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="column.groups.length === 0"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('board.noStatuses') }}
|
||||
</p>
|
||||
<section
|
||||
v-for="group in column.groups"
|
||||
:key="group.status.id"
|
||||
:aria-labelledby="`board-status-${group.status.id}`"
|
||||
class="grid min-w-0 gap-3"
|
||||
>
|
||||
<div
|
||||
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
|
||||
>
|
||||
<h3
|
||||
:id="`board-status-${group.status.id}`"
|
||||
class="wrap-anywhere font-semibold"
|
||||
>
|
||||
{{ group.status.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="shrink-0 tabular-nums"
|
||||
:aria-label="
|
||||
t('tickets.count', {
|
||||
count: group.tickets.length,
|
||||
})
|
||||
"
|
||||
>{{ group.tickets.length }}</span
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="group.tickets.length === 0"
|
||||
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
|
||||
>
|
||||
{{ t('board.emptyStatus') }}
|
||||
</p>
|
||||
<ul v-else class="grid min-w-0 gap-3">
|
||||
<li
|
||||
v-for="ticket in group.tickets"
|
||||
:key="ticket.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<TicketCard
|
||||
:project-id="project.id"
|
||||
:ticket="ticket"
|
||||
:show-status="false"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</CardDescription>
|
||||
<CardDescription
|
||||
v-if="
|
||||
ticket.is_blocked &&
|
||||
ticket.blocked_comment
|
||||
"
|
||||
class="mt-2 text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
{{ ticket.blocked_comment }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent v-if="canEditTickets" class="pt-0">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="board"
|
||||
/>
|
||||
<label
|
||||
:for="`ticket-status-${ticket.id}`"
|
||||
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
|
||||
<template
|
||||
v-if="canEditTickets"
|
||||
#actions
|
||||
>
|
||||
Move ticket
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<select
|
||||
:id="`ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0 || processing
|
||||
<Form
|
||||
v-bind="
|
||||
move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
v-slot="{ processing, errors }"
|
||||
:error-bag="`board-ticket-${ticket.id}`"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
class="grid min-w-0 gap-2"
|
||||
>
|
||||
<option
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="board"
|
||||
/>
|
||||
<label
|
||||
:for="`ticket-status-${ticket.id}`"
|
||||
class="text-caption text-muted-foreground font-semibold"
|
||||
>{{
|
||||
t('board.moveTicket', {
|
||||
key: ticket.ticket_key,
|
||||
})
|
||||
}}</label
|
||||
>
|
||||
<select
|
||||
:id="`ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
:aria-invalid="
|
||||
!!errors.ticket_status_id ||
|
||||
undefined
|
||||
"
|
||||
:aria-describedby="
|
||||
errors.ticket_status_id
|
||||
? `ticket-status-error-${ticket.id}`
|
||||
: undefined
|
||||
"
|
||||
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
value=""
|
||||
>
|
||||
{{ t('board.noMoves') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="target in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="target.id"
|
||||
:value="target.id"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
target,
|
||||
t(
|
||||
'tickets.noStatus',
|
||||
),
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:id="`ticket-status-error-${ticket.id}`"
|
||||
:message="
|
||||
errors.ticket_status_id
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
>{{
|
||||
t(
|
||||
processing
|
||||
? 'board.updatingStatus'
|
||||
: 'board.updateStatus',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
value=""
|
||||
>
|
||||
No allowed moves
|
||||
</option>
|
||||
<option
|
||||
v-for="ticketStatus in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticketStatus,
|
||||
t(
|
||||
'board.noMovesDescription',
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
>
|
||||
Update status
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
This status does not allow moving
|
||||
anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</p>
|
||||
</Form>
|
||||
</template>
|
||||
</TicketCard>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,349 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
FolderPlus,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, watch } from 'vue';
|
||||
import { store } from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { create, index } from '@/routes/projects/index';
|
||||
import { create, index } from '@/routes/projects';
|
||||
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectsCreatePageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: 'New project',
|
||||
href: create(),
|
||||
},
|
||||
];
|
||||
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: t('projects.create'), href: create() },
|
||||
]);
|
||||
const defaultTemplateId =
|
||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||
|
||||
const form = useForm({
|
||||
copy_areas: [] as string[],
|
||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||
key: '',
|
||||
name: '',
|
||||
source_project_id: props.sourceProjects[0]?.id
|
||||
? String(props.sourceProjects[0].id)
|
||||
: '',
|
||||
source_template_id:
|
||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||
});
|
||||
|
||||
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
||||
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
||||
|
||||
const copyAreasError = computed(() => {
|
||||
return (
|
||||
const form = useForm(
|
||||
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
|
||||
{
|
||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||
name: '',
|
||||
key: '',
|
||||
source_project_id: props.sourceProjects[0]?.id
|
||||
? String(props.sourceProjects[0].id)
|
||||
: '',
|
||||
source_template_id:
|
||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||
copy_areas: [] as string[],
|
||||
},
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
const modes = computed(() => [
|
||||
{
|
||||
value: 'scratch',
|
||||
icon: FolderPlus,
|
||||
title: t('projectCreation.scratch'),
|
||||
description: t('projectCreation.scratchDescription'),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: 'duplicate',
|
||||
icon: Copy,
|
||||
title: t('projectCreation.duplicate'),
|
||||
description: t(
|
||||
props.sourceProjects.length
|
||||
? 'projectCreation.duplicateDescription'
|
||||
: 'projectCreation.noProjects',
|
||||
),
|
||||
disabled: props.sourceProjects.length === 0,
|
||||
},
|
||||
{
|
||||
value: 'template',
|
||||
icon: LayoutTemplate,
|
||||
title: t('projectCreation.template'),
|
||||
description: t(
|
||||
props.sourceTemplates.length
|
||||
? 'projectCreation.templateDescription'
|
||||
: 'projectCreation.noTemplates',
|
||||
),
|
||||
disabled: props.sourceTemplates.length === 0,
|
||||
},
|
||||
]);
|
||||
const areaKeys = {
|
||||
project_settings: 'projectCreation.areas.projectSettings',
|
||||
board_columns: 'projectCreation.areas.boardColumns',
|
||||
ticket_statuses: 'projectCreation.areas.ticketStatuses',
|
||||
status_transitions: 'projectCreation.areas.statusTransitions',
|
||||
ticket_types: 'projectCreation.areas.ticketTypes',
|
||||
ticket_priorities: 'projectCreation.areas.ticketPriorities',
|
||||
ticket_fields: 'projectCreation.areas.ticketFields',
|
||||
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
|
||||
repository_connections: 'projectCreation.areas.repositories',
|
||||
notification_actions: 'projectCreation.areas.notifications',
|
||||
} as const;
|
||||
function areaText(value: string, part: 'label' | 'description'): string {
|
||||
const key = areaKeys[value as keyof typeof areaKeys];
|
||||
return key
|
||||
? t(`${key}.${part}`)
|
||||
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
|
||||
value);
|
||||
}
|
||||
const selectedProject = computed(() =>
|
||||
props.sourceProjects.find(
|
||||
(project) => String(project.id) === form.source_project_id,
|
||||
),
|
||||
);
|
||||
const selectedTemplate = computed(() =>
|
||||
props.sourceTemplates.find(
|
||||
(template) => String(template.id) === form.source_template_id,
|
||||
),
|
||||
);
|
||||
const selectedMode = computed(() =>
|
||||
modes.value.find((mode) => mode.value === form.creation_mode),
|
||||
);
|
||||
const ticketKey = computed(
|
||||
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
|
||||
);
|
||||
const copyAreasError = computed(
|
||||
() =>
|
||||
form.errors.copy_areas ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('copy_areas.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
)?.[1],
|
||||
);
|
||||
const selectClass =
|
||||
'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||
|
||||
const submit = (): void => {
|
||||
if (form.creation_mode !== 'duplicate') {
|
||||
form.source_project_id = '';
|
||||
form.copy_areas = [];
|
||||
}
|
||||
watch(
|
||||
() => form.creation_mode,
|
||||
() => {
|
||||
form.clearErrors(
|
||||
'creation_mode',
|
||||
'source_project_id',
|
||||
'source_template_id',
|
||||
'copy_areas',
|
||||
);
|
||||
for (const key of Object.keys(form.errors)) {
|
||||
if (key.startsWith('copy_areas.')) {
|
||||
form.clearErrors(key as 'copy_areas');
|
||||
}
|
||||
}
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
|
||||
if (form.creation_mode !== 'template') {
|
||||
form.source_template_id = '';
|
||||
}
|
||||
|
||||
form.post(ProjectController.store.url(), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
function submit(): void {
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
source_project_id:
|
||||
data.creation_mode === 'duplicate' ? data.source_project_id : '',
|
||||
source_template_id:
|
||||
data.creation_mode === 'template' ? data.source_template_id : '',
|
||||
copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
|
||||
}));
|
||||
protection.submit(store(), { preserveScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="New project" />
|
||||
<Head :title="t('projects.create')" />
|
||||
<div
|
||||
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||
>
|
||||
<header class="gap-stack grid">
|
||||
<Link
|
||||
:href="index()"
|
||||
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
|
||||
>
|
||||
<ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||
t('projects.back')
|
||||
}}
|
||||
</Link>
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('projects.workspace') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline">
|
||||
{{ t('projectCreation.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('projectCreation.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<Heading
|
||||
title="Create project"
|
||||
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project details</CardTitle>
|
||||
<CardDescription>
|
||||
The key is uppercased automatically, must be unique, and
|
||||
can contain up to 5 characters.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid gap-3">
|
||||
<Label>How do you want to start?</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<Label
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
value="scratch"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
Create from scratch
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Start with the default project
|
||||
setup.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
class="flex items-start gap-3 rounded-xl border p-4"
|
||||
:class="
|
||||
hasSourceProjects
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-60'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
value="duplicate"
|
||||
class="mt-1"
|
||||
:disabled="!hasSourceProjects"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
Create from existing project
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Copy only the settings you
|
||||
explicitly select. Tickets are never
|
||||
duplicated.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
class="flex items-start gap-3 rounded-xl border p-4"
|
||||
:class="
|
||||
hasSourceTemplates
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-60'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
value="template"
|
||||
class="mt-1"
|
||||
:disabled="!hasSourceTemplates"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
Create from template
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Reuse a saved configuration
|
||||
template.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Project name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Kanban foundation"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="key">Project key</Label>
|
||||
<Input
|
||||
id="key"
|
||||
v-model="form.key"
|
||||
required
|
||||
maxlength="5"
|
||||
placeholder="CORE"
|
||||
class="uppercase"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Example ticket keys will look like
|
||||
<span class="text-foreground font-medium"
|
||||
>CORE-1</span
|
||||
>.
|
||||
</p>
|
||||
<InputError :message="form.errors.key" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.creation_mode === 'duplicate'"
|
||||
class="space-y-6 rounded-xl border border-dashed p-4"
|
||||
<form
|
||||
class="gap-page grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
form.processing
|
||||
? t('projectCreation.creating')
|
||||
: undefined
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
<fieldset
|
||||
class="gap-column grid min-w-0"
|
||||
:disabled="form.processing"
|
||||
aria-describedby="creation-mode-error"
|
||||
>
|
||||
<legend class="text-title mb-3">
|
||||
{{ t('projectCreation.start') }}
|
||||
</legend>
|
||||
<div class="gap-column grid md:grid-cols-3">
|
||||
<label
|
||||
v-for="mode in modes"
|
||||
:key="mode.value"
|
||||
class="relative min-w-0"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="source_project_id"
|
||||
>Source project</Label
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
name="creation_mode"
|
||||
:value="mode.value"
|
||||
:disabled="mode.disabled"
|
||||
:aria-invalid="!!form.errors.creation_mode"
|
||||
:aria-labelledby="`mode-${mode.value}-title`"
|
||||
:aria-describedby="`mode-${mode.value}-description`"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
|
||||
:class="
|
||||
!mode.disabled
|
||||
? 'hover:border-primary/50'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<component
|
||||
:is="mode.icon"
|
||||
class="text-primary size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Check
|
||||
v-if="form.creation_mode === mode.value"
|
||||
class="text-primary size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
:id="`mode-${mode.value}-title`"
|
||||
class="font-semibold"
|
||||
>{{ mode.title }}</span
|
||||
>
|
||||
<span
|
||||
:id="`mode-${mode.value}-description`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>{{ mode.description }}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
id="creation-mode-error"
|
||||
:message="form.errors.creation_mode"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
|
||||
>
|
||||
<div class="gap-page grid min-w-0">
|
||||
<section
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="project-details-title"
|
||||
>
|
||||
<h2 id="project-details-title" class="text-title">
|
||||
{{ t('projectCreation.details') }}
|
||||
</h2>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="name">{{
|
||||
t('projectCreation.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="form.processing"
|
||||
:placeholder="
|
||||
t('projectCreation.namePlaceholder')
|
||||
"
|
||||
:invalid="!!form.errors.name"
|
||||
aria-describedby="name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="key">{{
|
||||
t('projectCreation.key')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="key"
|
||||
v-model="form.key"
|
||||
name="key"
|
||||
required
|
||||
maxlength="5"
|
||||
:disabled="form.processing"
|
||||
:placeholder="
|
||||
t('projectCreation.keyPlaceholder')
|
||||
"
|
||||
class="uppercase sm:max-w-48"
|
||||
:invalid="!!form.errors.key"
|
||||
aria-describedby="key-help key-error"
|
||||
autocapitalize="characters"
|
||||
:spellcheck="false"
|
||||
/>
|
||||
<p
|
||||
id="key-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projectCreation.keyHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="key-error"
|
||||
:message="form.errors.key"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="form.creation_mode === 'duplicate'"
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="copy-title"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<h2 id="copy-title" class="text-title">
|
||||
{{ t('projectCreation.copyTitle') }}
|
||||
</h2>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.copyHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<Label for="source_project_id">{{
|
||||
t('projectCreation.sourceProject')
|
||||
}}</Label>
|
||||
<select
|
||||
id="source_project_id"
|
||||
v-model="form.source_project_id"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasSourceProjects"
|
||||
required
|
||||
:class="selectClass"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="
|
||||
!!form.errors.source_project_id
|
||||
"
|
||||
aria-describedby="source-project-error"
|
||||
>
|
||||
<option value="">
|
||||
Select a source project
|
||||
{{ t('projectCreation.selectProject') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="sourceProject in sourceProjects"
|
||||
:key="sourceProject.id"
|
||||
:value="String(sourceProject.id)"
|
||||
v-for="project in sourceProjects"
|
||||
:key="project.id"
|
||||
:value="String(project.id)"
|
||||
>
|
||||
{{ sourceProject.name }} ({{
|
||||
sourceProject.key
|
||||
}})
|
||||
{{ project.name }} ({{ project.key }})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
The new project keeps its own identity, key,
|
||||
and future ticket numbering.
|
||||
</p>
|
||||
<InputError
|
||||
id="source-project-error"
|
||||
:message="form.errors.source_project_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<Label>Copy options</Label>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose which settings should be copied
|
||||
into the new project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<Label
|
||||
v-for="copyAreaOption in copyAreaOptions"
|
||||
:key="copyAreaOption.value"
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||
<fieldset
|
||||
class="min-w-0"
|
||||
:disabled="form.processing"
|
||||
aria-describedby="copy-help copy-areas-error"
|
||||
>
|
||||
<legend class="mb-2 font-semibold">
|
||||
{{ t('projectCreation.copyOptions') }}
|
||||
</legend>
|
||||
<p
|
||||
id="copy-help"
|
||||
class="text-caption text-muted-foreground mb-3"
|
||||
>
|
||||
{{ t('projectCreation.copyDefaults') }}
|
||||
</p>
|
||||
<div class="gap-stack grid sm:grid-cols-2">
|
||||
<label
|
||||
v-for="area in copyAreaOptions"
|
||||
:key="area.value"
|
||||
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
|
||||
>
|
||||
<input
|
||||
v-model="form.copy_areas"
|
||||
type="checkbox"
|
||||
:value="copyAreaOption.value"
|
||||
class="mt-1"
|
||||
:value="area.value"
|
||||
:aria-labelledby="`copy-${area.value}-label`"
|
||||
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
|
||||
:aria-invalid="!!copyAreasError"
|
||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ copyAreaOption.label }}
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
<span class="gap-stack grid min-w-0">
|
||||
<span
|
||||
:id="`copy-${area.value}-label`"
|
||||
class="text-body font-semibold"
|
||||
>{{
|
||||
areaText(
|
||||
area.value,
|
||||
'label',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
{{ copyAreaOption.description }}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
<span
|
||||
:id="`copy-${area.value}-description`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>{{
|
||||
areaText(
|
||||
area.value,
|
||||
'description',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
id="copy-areas-error"
|
||||
class="mt-3"
|
||||
:message="copyAreasError"
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Operational ticket data is always left
|
||||
behind so the new project starts clean.
|
||||
</p>
|
||||
<InputError :message="copyAreasError" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<section
|
||||
v-else-if="form.creation_mode === 'template'"
|
||||
class="space-y-4 rounded-xl border border-dashed p-4"
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="template-title"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="source_template_id">Template</Label>
|
||||
<h2 id="template-title" class="text-title">
|
||||
{{ t('projectCreation.chooseTemplate') }}
|
||||
</h2>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<Label for="source_template_id">{{
|
||||
t('projectCreation.templateLabel')
|
||||
}}</Label>
|
||||
<select
|
||||
id="source_template_id"
|
||||
v-model="form.source_template_id"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasSourceTemplates"
|
||||
required
|
||||
:class="selectClass"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="
|
||||
!!form.errors.source_template_id
|
||||
"
|
||||
aria-describedby="template-help source-template-error"
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
<option value="">
|
||||
{{
|
||||
t('projectCreation.selectTemplate')
|
||||
}}
|
||||
</option>
|
||||
<option
|
||||
v-for="sourceTemplate in sourceTemplates"
|
||||
:key="sourceTemplate.id"
|
||||
:value="String(sourceTemplate.id)"
|
||||
v-for="template in sourceTemplates"
|
||||
:key="template.id"
|
||||
:value="String(template.id)"
|
||||
>
|
||||
{{ sourceTemplate.name }} ({{
|
||||
sourceTemplate.slug
|
||||
{{ template.name }} ({{
|
||||
template.slug
|
||||
}})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Templates apply their saved configuration
|
||||
groups to the new project.
|
||||
<p
|
||||
id="template-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projectCreation.templateHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="source-template-error"
|
||||
:message="form.errors.source_template_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTemplate"
|
||||
class="gap-stack grid"
|
||||
aria-live="polite"
|
||||
>
|
||||
<h3 class="text-body font-semibold">
|
||||
{{ t('projectCreation.includedAreas') }}
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTemplate.copy_areas.length"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<li
|
||||
v-for="area in selectedTemplate.copy_areas"
|
||||
:key="area"
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="whitespace-normal"
|
||||
>{{
|
||||
areaText(area, 'label')
|
||||
}}</Badge
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projectCreation.templateDefaults') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
!hasSourceProjects && !hasSourceTemplates
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
<aside
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="summary-title"
|
||||
>
|
||||
<h2 id="summary-title" class="text-title">
|
||||
{{ t('projectCreation.summary') }}
|
||||
</h2>
|
||||
<dl class="gap-column grid min-w-0">
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.name') }}
|
||||
</dt>
|
||||
<dd class="break-words font-semibold">
|
||||
{{
|
||||
form.name.trim() ||
|
||||
t('projectCreation.untitled')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.ticketPreview') }}
|
||||
</dt>
|
||||
<dd>
|
||||
<Badge variant="outline">{{
|
||||
ticketKey
|
||||
}}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.setup') }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{ selectedMode?.title }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.creation_mode === 'duplicate'"
|
||||
class="gap-stack grid"
|
||||
>
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.sourceProject') }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{
|
||||
selectedProject?.name ||
|
||||
t('projectCreation.selectProject')
|
||||
}}
|
||||
</dd>
|
||||
<dd class="text-caption text-muted-foreground">
|
||||
{{
|
||||
t('projectCreation.selectedCount', {
|
||||
count: form.copy_areas.length,
|
||||
})
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="form.creation_mode === 'template'"
|
||||
class="gap-stack grid"
|
||||
>
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.templateLabel') }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{
|
||||
selectedTemplate?.name ||
|
||||
t('projectCreation.selectTemplate')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
class="text-caption text-muted-foreground border-t pt-4"
|
||||
>
|
||||
No accessible source projects or saved templates are
|
||||
available yet.
|
||||
</div>
|
||||
{{ t('projectCreation.cleanStart') }}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create project
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="index()">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<footer
|
||||
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
><Link :href="index()">{{
|
||||
t('common.cancel')
|
||||
}}</Link></Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="form.processing"
|
||||
:aria-label="
|
||||
t(
|
||||
form.processing
|
||||
? 'projectCreation.creating'
|
||||
: 'projectCreation.submit',
|
||||
)
|
||||
"
|
||||
:loading-label="t('projectCreation.creating')"
|
||||
><FolderPlus
|
||||
v-if="!form.processing"
|
||||
aria-hidden="true"
|
||||
/>{{
|
||||
t(
|
||||
form.processing
|
||||
? 'projectCreation.creating'
|
||||
: 'projectCreation.submit',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,103 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { Folder, Plus } from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Columns3,
|
||||
FolderGit2,
|
||||
ListTodo,
|
||||
Plus,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { create, index, show } from '@/routes/projects/index';
|
||||
import { create, index, show } from '@/routes/projects';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
||||
|
||||
defineProps<ProjectsIndexPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
];
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Projects" />
|
||||
<Head :title="t('navigation.projects')" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<Heading
|
||||
title="Projects"
|
||||
description="Create and browse project spaces that will later host boards, backlog, and settings."
|
||||
/>
|
||||
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('projects.workspace') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline">
|
||||
{{ t('navigation.projects') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('projects.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
as-child
|
||||
class="self-start"
|
||||
>
|
||||
<Link :href="create()">
|
||||
<Plus class="size-4" />
|
||||
New project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projects.length === 0"
|
||||
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
|
||||
>
|
||||
No projects exist yet. Create the first one to start building
|
||||
out board, backlog, and settings areas.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Link
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:href="show(project.id)"
|
||||
class="group block"
|
||||
>
|
||||
<Card
|
||||
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
|
||||
<Link :href="create()"
|
||||
><Plus aria-hidden="true" />{{
|
||||
t('projects.create')
|
||||
}}</Link
|
||||
>
|
||||
<CardHeader class="gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<CardTitle>{{ project.name }}</CardTitle>
|
||||
<CardDescription>
|
||||
Project workspace entry point
|
||||
</CardDescription>
|
||||
</div>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Badge variant="outline" class="px-2.5 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
state="success"
|
||||
:title="t('projects.updated')"
|
||||
:description="status"
|
||||
/>
|
||||
|
||||
<CardContent
|
||||
class="text-muted-foreground flex items-center gap-2 text-sm"
|
||||
<FeedbackState
|
||||
v-if="projects.length === 0"
|
||||
state="empty"
|
||||
:title="t('projects.empty.title')"
|
||||
:description="
|
||||
$page.props.auth.permissions.projectCreate
|
||||
? t('projects.empty.createDescription')
|
||||
: t('projects.empty.accessDescription')
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="create()"
|
||||
><Plus aria-hidden="true" />{{
|
||||
t('projects.empty.create')
|
||||
}}</Link
|
||||
>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<section
|
||||
v-else
|
||||
aria-labelledby="project-list-title"
|
||||
class="gap-column grid"
|
||||
>
|
||||
<h2
|
||||
id="project-list-title"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projects.available', { count: projects.length }) }}
|
||||
</h2>
|
||||
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
|
||||
<li
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<article
|
||||
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
|
||||
>
|
||||
<Folder class="size-4" />
|
||||
Open board, backlog, and settings hub
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
:href="show(project.id)"
|
||||
:aria-label="
|
||||
t('projects.open', { name: project.name })
|
||||
"
|
||||
class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<FolderGit2
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="gap-stack grid min-w-0 flex-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="max-w-full justify-self-start whitespace-normal break-all"
|
||||
>{{ project.key }}</Badge
|
||||
>
|
||||
<h3
|
||||
class="text-title group-hover:text-primary break-words"
|
||||
>
|
||||
{{ project.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="text-caption text-muted-foreground"
|
||||
>{{ t('projects.overview') }}</span
|
||||
>
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground mt-1 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
<div
|
||||
class="gap-stack p-card grid grid-cols-2 border-t"
|
||||
>
|
||||
<Button as-child variant="secondary">
|
||||
<Link
|
||||
:href="boardIndex(project.id)"
|
||||
:aria-label="
|
||||
t('projects.openBoard', {
|
||||
name: project.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
<Columns3 aria-hidden="true" />{{
|
||||
t('navigation.board')
|
||||
}}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="ghost">
|
||||
<Link
|
||||
:href="backlogIndex(project.id)"
|
||||
:aria-label="
|
||||
t('projects.openBacklog', {
|
||||
name: project.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
<ListTodo aria-hidden="true" />{{
|
||||
t('navigation.backlog')
|
||||
}}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,206 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Columns3,
|
||||
Layers3,
|
||||
ListTodo,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { access, index, show } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { projectSettingsNavigation } from '@/lib/navigation';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
metadata as metadataSettings,
|
||||
notifications as notificationSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectShowPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
]);
|
||||
const projectAreas = computed(() => [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
title: t('navigation.board'),
|
||||
description: t('projects.boardDescription'),
|
||||
action: t('projects.boardAction'),
|
||||
href: boardIndex(props.project.id),
|
||||
icon: Columns3,
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const projectAreas = [
|
||||
{
|
||||
title: 'Backlog',
|
||||
description:
|
||||
'Create tickets, review incoming work, and move ready items into the board workflow.',
|
||||
title: t('navigation.backlog'),
|
||||
description: t('projects.backlogDescription'),
|
||||
action: t('projects.backlogAction'),
|
||||
href: backlogIndex(props.project.id),
|
||||
icon: ListTodo,
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
description:
|
||||
'Track active work across the project-defined Kanban columns and update ticket status.',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Project settings',
|
||||
description:
|
||||
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
title: 'Repositories',
|
||||
description:
|
||||
'Link project repositories so tickets can later discover related branches and pull requests.',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
];
|
||||
]);
|
||||
const settingsItems = computed(() =>
|
||||
projectSettingsNavigation(
|
||||
{
|
||||
id: props.project.id,
|
||||
name: props.project.name,
|
||||
key: props.project.key,
|
||||
canManage: props.canManageAccess,
|
||||
canCreateTickets: false,
|
||||
},
|
||||
t,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="project.name" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<Link
|
||||
:href="index()"
|
||||
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
|
||||
>
|
||||
<ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||
t('projects.back')
|
||||
}}
|
||||
</Link>
|
||||
|
||||
<header
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="project.name"
|
||||
description="Project hub for the upcoming backlog, board, and settings work."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="backlogIndex(project.id)">
|
||||
Open backlog
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
as-child
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="max-w-full justify-self-start whitespace-normal break-all"
|
||||
>{{ project.key }}</Badge
|
||||
>
|
||||
<Link :href="projectTemplateCreate(project.id)">
|
||||
Save as new template
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="index()">Back to projects</Link>
|
||||
</Button>
|
||||
<h1
|
||||
class="text-headline-mobile md:text-headline break-words"
|
||||
>
|
||||
{{ project.name }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('projects.hubDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
as-child
|
||||
variant="outline"
|
||||
class="self-start"
|
||||
>
|
||||
<Link :href="projectTemplateCreate(project.id)"
|
||||
><Layers3 aria-hidden="true" />{{
|
||||
t('projects.saveTemplate')
|
||||
}}</Link
|
||||
>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Card v-if="templateState?.inheritsFromTemplate">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Built from template {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This project keeps its template link. Inherited areas
|
||||
follow the template baseline; overridden areas were
|
||||
customized locally.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<section
|
||||
:aria-label="t('projects.workAreas')"
|
||||
class="gap-column grid md:grid-cols-2"
|
||||
>
|
||||
<Link
|
||||
v-for="area in projectAreas"
|
||||
:key="area.href.url"
|
||||
:href="area.href"
|
||||
:aria-label="area.action"
|
||||
class="gap-column bg-card hover:border-primary/50 hover:shadow-card-hover group flex min-w-0 flex-col rounded-md border p-5 transition-[border-color,box-shadow]"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-primary flex size-11 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="area.icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="gap-stack grid">
|
||||
<h2 class="text-title">{{ area.title }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ area.description }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
|
||||
>
|
||||
{{ area.action
|
||||
}}<ArrowRight class="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="settingsItems.length"
|
||||
aria-labelledby="project-settings-title"
|
||||
class="gap-column grid"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<h2 id="project-settings-title" class="text-title">
|
||||
{{ t('navigation.projectSettings') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('projects.settingsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
|
||||
<li
|
||||
v-for="item in settingsItems"
|
||||
:key="item.title"
|
||||
class="min-w-0"
|
||||
>
|
||||
<Link
|
||||
:href="item.href"
|
||||
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 break-words font-medium"
|
||||
>{{ item.title }}</span
|
||||
>
|
||||
<ArrowRight
|
||||
class="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="templateState?.inheritsFromTemplate"
|
||||
aria-labelledby="project-template-title"
|
||||
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
|
||||
>
|
||||
<div class="gap-card flex items-start">
|
||||
<Layers3
|
||||
class="text-muted-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<h2
|
||||
id="project-template-title"
|
||||
class="text-title break-words"
|
||||
>
|
||||
{{
|
||||
t('projects.templateTitle', {
|
||||
name: templateState.templateName,
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('projects.templateDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-stack flex flex-wrap">
|
||||
<Badge
|
||||
v-for="area in templateState.inheritedAreas"
|
||||
:key="`inherited-${area.value}`"
|
||||
variant="outline"
|
||||
class="max-w-full whitespace-normal break-words"
|
||||
>
|
||||
{{ area.label }} · inherited
|
||||
{{ t('projects.inheritedArea', { name: area.label }) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="area in templateState.overriddenAreas"
|
||||
:key="`overridden-${area.value}`"
|
||||
variant="secondary"
|
||||
class="max-w-full whitespace-normal break-words"
|
||||
>
|
||||
{{ area.label }} · overridden
|
||||
{{ t('projects.overriddenArea', { name: area.label }) }}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
|
||||
<Button as-child>
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Board settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="generalSettings(project.id)">
|
||||
General settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
Ticket settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="notificationSettings(project.id)">
|
||||
Notification settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="access(project.id)">Manage access</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card
|
||||
v-for="area in projectAreas"
|
||||
:key="area.title"
|
||||
class="h-full"
|
||||
>
|
||||
<CardHeader>
|
||||
<div
|
||||
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<component :is="area.icon" class="size-5" />
|
||||
</div>
|
||||
<CardTitle>{{ area.title }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
area.description
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-muted-foreground text-sm">
|
||||
Use the project shortcuts above to open this area.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
Form,
|
||||
useForm,
|
||||
useRemember,
|
||||
Head,
|
||||
Link,
|
||||
usePage,
|
||||
} from '@inertiajs/vue3';
|
||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref, toRef, unref, watch } from 'vue';
|
||||
import {
|
||||
acceptanceCriteriaToEditorState,
|
||||
buildAcceptanceCriteria,
|
||||
@@ -14,6 +21,10 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,6 +56,7 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
|
||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectTicketPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const page = usePage();
|
||||
const appName = String(page.props.name);
|
||||
|
||||
@@ -122,8 +134,14 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
const acceptanceCriteriaChecklist = ref(
|
||||
acceptanceCriteriaEditorState.checklist,
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
{ checklist: acceptanceCriteriaEditorState.checklist },
|
||||
`${draftKey}:checklist`,
|
||||
);
|
||||
const acceptanceCriteriaChecklist = toRef(
|
||||
unref(acceptanceCriteriaDraft),
|
||||
'checklist',
|
||||
);
|
||||
const savedAcceptanceCriteria = ref(
|
||||
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||
@@ -131,7 +149,7 @@ const savedAcceptanceCriteria = ref(
|
||||
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||
const acceptanceCriteriaToggleError = ref('');
|
||||
|
||||
const form = useForm({
|
||||
const form = useForm(draftKey, {
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
@@ -170,6 +188,12 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||
formatChecklistLines(savedAcceptanceCriteria.value),
|
||||
);
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () =>
|
||||
props.canEditTicket &&
|
||||
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
||||
});
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
@@ -187,10 +211,20 @@ const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||
const branchCreationForm = useForm<{
|
||||
branch_name: string;
|
||||
repository_connection_id: string;
|
||||
}>({
|
||||
}>(`${draftKey}:branch`, {
|
||||
branch_name: props.suggestedBranchName,
|
||||
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
||||
});
|
||||
const branchProtection = useFormProtection(branchCreationForm, {
|
||||
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
|
||||
});
|
||||
|
||||
const updateBranchDialogOpen = (open: boolean): void => {
|
||||
if (!open && !branchProtection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
isCreateBranchDialogOpen.value = open;
|
||||
};
|
||||
|
||||
const selectedBranchRepositoryConnection = computed(
|
||||
(): RepositoryConnectionModel | null =>
|
||||
@@ -207,6 +241,9 @@ const selectedBranchRepositoryHasPersonalAccess = computed(
|
||||
);
|
||||
|
||||
const initializeBranchCreationForm = (): void => {
|
||||
if (branchCreationForm.isDirty) {
|
||||
return;
|
||||
}
|
||||
branchCreationForm.clearErrors();
|
||||
branchCreationForm.branch_name = props.suggestedBranchName;
|
||||
branchCreationForm.repository_connection_id =
|
||||
@@ -348,7 +385,7 @@ const submitBranchCreation = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
branchCreationForm.submit(
|
||||
branchProtection.submit(
|
||||
ProjectTicketBranchController.store({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
@@ -482,13 +519,21 @@ const submit = (): void => {
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
||||
|
||||
form.submit(
|
||||
protection.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -574,16 +619,25 @@ const formatDateTime = (value: string): string => {
|
||||
|
||||
<CardContent>
|
||||
<form class="grid gap-6" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.title" />
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
||||
@@ -995,7 +1049,10 @@ const formatDateTime = (value: string): string => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="isCreateBranchDialogOpen">
|
||||
<Dialog
|
||||
:open="isCreateBranchDialogOpen"
|
||||
@update:open="updateBranchDialogOpen"
|
||||
>
|
||||
<Button
|
||||
v-if="canCreateBranches"
|
||||
type="button"
|
||||
@@ -1023,6 +1080,12 @@ const formatDateTime = (value: string): string => {
|
||||
class="grid gap-4"
|
||||
@submit.prevent="submitBranchCreation"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="
|
||||
branchProtection.state.value
|
||||
"
|
||||
@retry="submitBranchCreation"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="repository_connection_id"
|
||||
@@ -1153,7 +1216,9 @@ const formatDateTime = (value: string): string => {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="
|
||||
isCreateBranchDialogOpen = false
|
||||
updateBranchDialogOpen(
|
||||
false,
|
||||
)
|
||||
"
|
||||
>
|
||||
Cancel
|
||||
@@ -1177,13 +1242,14 @@ const formatDateTime = (value: string): string => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryBranches.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
No matching branches were found across the
|
||||
linked repositories you can access.
|
||||
</div>
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="
|
||||
t('tickets.noBranchesDescription')
|
||||
"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
@@ -1434,15 +1500,16 @@ const formatDateTime = (value: string): string => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
<FeedbackState
|
||||
v-if="
|
||||
visibleRepositoryPullRequests.length === 0
|
||||
"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
No matching pull requests were found across the
|
||||
linked repositories you can access.
|
||||
</div>
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="
|
||||
t('tickets.noPullRequestsDescription')
|
||||
"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
||||
import { computed, toRef, unref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -62,9 +64,17 @@ const hasTicketMetadata =
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
|
||||
const acceptanceCriteriaChecklist = ref('');
|
||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
{ checklist: '' },
|
||||
`${draftKey}:checklist`,
|
||||
);
|
||||
const acceptanceCriteriaChecklist = toRef(
|
||||
unref(acceptanceCriteriaDraft),
|
||||
'checklist',
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
const form = useForm(draftKey, {
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
@@ -80,6 +90,10 @@ const form = useForm({
|
||||
title: '',
|
||||
});
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
);
|
||||
@@ -99,9 +113,12 @@ const submit = (): void => {
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
protection.submit(
|
||||
ProjectTicketController.store({ project: props.project.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
@@ -165,17 +182,26 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
|
||||
<CardContent>
|
||||
<form class="grid gap-6" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
placeholder="Implement repository sync"
|
||||
/>
|
||||
<InputError :message="form.errors.title" />
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
Vendored
+2
@@ -1,4 +1,5 @@
|
||||
import type { Auth } from '@/types/auth';
|
||||
import type { AppNavigation } from '@/types/navigation';
|
||||
import type { LocalizationSettings } from '@/types/localization';
|
||||
import type { Appearance } from '@/types/ui';
|
||||
import type { Page, Router } from '@inertiajs/core';
|
||||
@@ -25,6 +26,7 @@ declare module '@inertiajs/core' {
|
||||
appearance: Appearance;
|
||||
auth: Auth;
|
||||
sidebarOpen: boolean;
|
||||
navigation: AppNavigation;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,17 @@ export type NavItem = {
|
||||
href: NonNullable<InertiaLinkProps['href']>;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
match?: 'exact' | 'section';
|
||||
};
|
||||
|
||||
export type NavigationProject = {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
canManage: boolean;
|
||||
canCreateTickets: boolean;
|
||||
};
|
||||
|
||||
export type AppNavigation = {
|
||||
project: NavigationProject | null;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AssignedTicketsProjectGroup = {
|
||||
project: ProjectModel;
|
||||
tickets: TicketModel[];
|
||||
};
|
||||
|
||||
export type DashboardPageProps = {
|
||||
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
||||
};
|
||||
|
||||
export type ProjectsIndexPageProps = {
|
||||
projects: ProjectModel[];
|
||||
status?: string | null;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
|
||||
export type TicketPresentationProps = {
|
||||
projectId: number;
|
||||
showDescription?: boolean;
|
||||
showStatus?: boolean;
|
||||
ticket: TicketModel;
|
||||
};
|
||||
|
||||
export type TicketPresentationEmits = {
|
||||
open: [ticketId: number];
|
||||
};
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -17,7 +18,7 @@ Route::get('api/docs/openapi.json', function (): Response {
|
||||
})->name('api.docs.openapi');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||
|
||||
require __DIR__.'/web/admin.php';
|
||||
require __DIR__.'/web/project-templates.php';
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
@@ -85,7 +86,7 @@ class AuthenticationTest extends TestCase
|
||||
$this->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
])->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
@@ -100,6 +101,22 @@ class AuthenticationTest extends TestCase
|
||||
$response->assertRedirect(route('home'));
|
||||
}
|
||||
|
||||
public function test_login_preserves_the_intended_destination_and_remember_choice(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withSession(['url.intended' => route('dashboard')])
|
||||
->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'remember' => true,
|
||||
])
|
||||
->assertRedirect(route('dashboard'))
|
||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_users_are_rate_limited()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -61,7 +61,7 @@ class EmailVerificationTest extends TestCase
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
||||
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
@@ -79,7 +79,7 @@ class EmailVerificationTest extends TestCase
|
||||
['id' => 123, 'hash' => sha1($user->email)],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
||||
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
@@ -97,6 +97,34 @@ class EmailVerificationTest extends TestCase
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_the_ui_rework_preserves_the_existing_optional_verification_policy(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk();
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
|
||||
public function test_guests_cannot_view_the_verification_prompt(): void
|
||||
{
|
||||
$this->get(route('verification.notice'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_expired_verification_links_cannot_verify_an_email(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->subMinute(),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
|
||||
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -11,7 +11,7 @@ class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered()
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -24,10 +24,32 @@ class PasswordConfirmationTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_password_confirmation_requires_authentication()
|
||||
public function test_password_confirmation_requires_authentication(): void
|
||||
{
|
||||
$response = $this->get(route('password.confirm'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('password.confirm'), ['password' => 'password'])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertNotNull(session('auth.password_confirmed_at'));
|
||||
}
|
||||
|
||||
public function test_password_confirmation_rejects_an_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('password.confirm'), ['password' => 'incorrect'])
|
||||
->assertSessionHasErrors('password');
|
||||
|
||||
$this->assertNull(session('auth.password_confirmed_at'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -20,11 +21,16 @@ class PasswordResetTest extends TestCase
|
||||
$this->skipUnlessFortifyFeature(Features::resetPasswords());
|
||||
}
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered()
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get(route('password.request'));
|
||||
|
||||
$response->assertOk();
|
||||
$response
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('auth/ForgotPassword')
|
||||
->where('status', null),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested()
|
||||
@@ -38,7 +44,7 @@ class PasswordResetTest extends TestCase
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered()
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
@@ -46,10 +52,19 @@ class PasswordResetTest extends TestCase
|
||||
|
||||
$this->post(route('password.email'), ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get(route('password.reset', $notification->token));
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->get(route('password.reset', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('auth/ResetPassword')
|
||||
->where('email', $user->email)
|
||||
->where('token', $notification->token),
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -52,4 +54,77 @@ class TwoFactorChallengeTest extends TestCase
|
||||
->component('auth/TwoFactorChallenge'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_valid_authenticator_code_completes_the_pending_login(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
$this->mock(TwoFactorAuthenticationProvider::class)
|
||||
->shouldReceive('verify')->once()->with('secret', '012345')->andReturnTrue();
|
||||
|
||||
$this->withSession(['url.intended' => route('dashboard')])
|
||||
->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'remember' => true,
|
||||
])->assertRedirect(route('two-factor.login'));
|
||||
$this->assertGuest();
|
||||
|
||||
$this->post(route('two-factor.login.store'), ['code' => '012345'])
|
||||
->assertRedirect(route('dashboard'))
|
||||
->assertSessionMissing('login.id')
|
||||
->assertSessionMissing('login.remember')
|
||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_invalid_authenticator_code_keeps_the_pending_login_for_retry(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
$this->mock(TwoFactorAuthenticationProvider::class)
|
||||
->shouldReceive('verify')->once()->with('secret', '000000')->andReturnFalse();
|
||||
|
||||
$this->withSession(['login.id' => $user->id])
|
||||
->from(route('two-factor.login'))
|
||||
->post(route('two-factor.login.store'), ['code' => '000000'])
|
||||
->assertRedirect(route('two-factor.login'))
|
||||
->assertSessionHasErrors('code')
|
||||
->assertSessionHas('login.id', $user->id);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_recovery_code_signs_in_once_and_is_replaced(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
|
||||
$this->withSession(['login.id' => $user->id, 'login.remember' => true])
|
||||
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
|
||||
->assertRedirect(route('dashboard', absolute: false))
|
||||
->assertSessionMissing('login.id')
|
||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes());
|
||||
$this->assertCount(1, $user->fresh()->recoveryCodes());
|
||||
|
||||
$this->post(route('logout'));
|
||||
$this->withSession(['login.id' => $user->id])
|
||||
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
|
||||
->assertSessionHasErrors('recovery_code')
|
||||
->assertSessionHas('login.id', $user->id);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_challenge_submission_cannot_sign_in_without_a_pending_login(): void
|
||||
{
|
||||
$this->from(route('two-factor.login'))
|
||||
->post(route('two-factor.login.store'), ['code' => '012345'])
|
||||
->assertRedirect(route('two-factor.login'))
|
||||
->assertSessionHasErrors('code');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->get(route('two-factor.login'))->assertRedirect(route('login'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ class VerificationNotificationTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('verification.send'))
|
||||
->assertRedirect(route('home'));
|
||||
->assertRedirect(route('home'))
|
||||
->assertSessionHas('status', 'verification-link-sent');
|
||||
|
||||
Notification::assertSentTo($user, VerifyEmail::class);
|
||||
}
|
||||
@@ -45,4 +46,27 @@ class VerificationNotificationTest extends TestCase
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_guests_cannot_request_a_verification_email(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$this->post(route('verification.send'))->assertRedirect(route('login'));
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_resending_verification_emails_is_throttled(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$user = User::factory()->unverified()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
for ($attempt = 0; $attempt < 6; $attempt++) {
|
||||
$this->post(route('verification.send'))->assertRedirect();
|
||||
}
|
||||
|
||||
$this->post(route('verification.send'))->assertTooManyRequests();
|
||||
Notification::assertSentToTimes($user, VerifyEmail::class, 6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,117 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guests_are_redirected_to_the_login_page()
|
||||
public function test_guests_are_redirected_to_the_login_page(): void
|
||||
{
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_authenticated_users_can_visit_the_dashboard()
|
||||
public function test_authenticated_users_see_an_empty_dashboard_without_assigned_tickets(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertOk();
|
||||
$this->get(route('dashboard'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Dashboard')
|
||||
->has('assignedTicketsByProject', 0));
|
||||
}
|
||||
|
||||
public function test_dashboard_only_contains_the_users_tickets_from_visible_projects(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$directProject = Project::factory()->create([
|
||||
'key' => 'WEB',
|
||||
'name' => 'Web platform',
|
||||
]);
|
||||
$teamProject = Project::factory()->create([
|
||||
'key' => 'OPS',
|
||||
'name' => 'Operations',
|
||||
]);
|
||||
$hiddenProject = Project::factory()->create([
|
||||
'key' => 'SEC',
|
||||
'name' => 'Private security',
|
||||
]);
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$team->users()->attach($user);
|
||||
$this->syncProjectPermissions($directProject, $user, ['view']);
|
||||
$this->syncProjectPermissions($teamProject, $team, ['view']);
|
||||
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $directProject->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'WEB-1',
|
||||
'title' => 'Older assigned ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $directProject->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'WEB-2',
|
||||
'title' => 'Newest assigned ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $teamProject->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'OPS-1',
|
||||
'title' => 'Team-visible assigned ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $otherUser->id,
|
||||
'project_id' => $directProject->id,
|
||||
'sequence_number' => 3,
|
||||
'ticket_key' => 'WEB-3',
|
||||
'title' => 'Another users ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $hiddenProject->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'SEC-1',
|
||||
'title' => 'Hidden project ticket',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('dashboard'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Dashboard')
|
||||
->has('assignedTicketsByProject', 2)
|
||||
->where('assignedTicketsByProject.0.project.id', $teamProject->id)
|
||||
->has('assignedTicketsByProject.0.tickets', 1)
|
||||
->where('assignedTicketsByProject.0.tickets.0.ticket_key', 'OPS-1')
|
||||
->where('assignedTicketsByProject.1.project.id', $directProject->id)
|
||||
->has('assignedTicketsByProject.1.tickets', 2)
|
||||
->where('assignedTicketsByProject.1.tickets.0.ticket_key', 'WEB-2')
|
||||
->where('assignedTicketsByProject.1.tickets.1.ticket_key', 'WEB-1'));
|
||||
}
|
||||
|
||||
public function test_dashboard_presentation_uses_real_data_and_handles_empty_state(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/dashboard.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Dashboard behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FormFeedbackTest extends TestCase
|
||||
{
|
||||
public function test_feedback_states_and_request_failure_classification(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/form-feedback.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Form feedback behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ class ProjectBacklogTest extends TestCase
|
||||
public function test_users_with_view_permission_can_view_a_projects_backlog(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$assignee = User::factory()->create([
|
||||
'name' => 'Taylor Reed',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'AUTH',
|
||||
]);
|
||||
@@ -39,6 +42,7 @@ class ProjectBacklogTest extends TestCase
|
||||
->firstOrFail();
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'project_id' => $project->id,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_status_id' => $backlogStatus->id,
|
||||
@@ -59,6 +63,10 @@ class ProjectBacklogTest extends TestCase
|
||||
->where('canEditTickets', false)
|
||||
->where('project.id', $project->id)
|
||||
->where('tickets.0.ticket_key', $ticket->ticket_key)
|
||||
->where('tickets.0.assignee.id', $assignee->id)
|
||||
->where('tickets.0.assignee.name', $assignee->name)
|
||||
->where('tickets.0.ticket_priority.name', $priority->name)
|
||||
->where('tickets.0.ticket_status.name', $backlogStatus->name)
|
||||
->where('canCreateTickets', false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ class ProjectBoardControllerTest extends TestCase
|
||||
public function test_users_with_view_permission_can_view_a_projects_board(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$assignee = User::factory()->create([
|
||||
'name' => 'Taylor Reed',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
@@ -58,6 +61,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$boardTicket = Ticket::factory()->create([
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
@@ -81,6 +85,10 @@ class ProjectBoardControllerTest extends TestCase
|
||||
->has('boardColumns.1.tickets', 1)
|
||||
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
|
||||
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
|
||||
->where('boardColumns.1.tickets.0.assignee.id', $assignee->id)
|
||||
->where('boardColumns.1.tickets.0.assignee.name', $assignee->name)
|
||||
->where('boardColumns.1.tickets.0.ticket_priority.name', $priority->name)
|
||||
->where('boardColumns.1.tickets.0.ticket_status.name', $doingStatus->name)
|
||||
->where('canCreateTickets', false)
|
||||
->where('canEditTickets', false),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectBoardPresentationTest extends TestCase
|
||||
{
|
||||
public function test_board_renders_configured_status_groups_counts_and_permission_aware_actions(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/board.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Board behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Models\ProjectTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCreationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[DataProvider('missingSources')]
|
||||
public function test_creation_requires_the_selected_source(string $mode, string $field): void
|
||||
{
|
||||
$user = User::factory()->admin()->create();
|
||||
$this->actingAs($user)->from(route('projects.create'))
|
||||
->post(route('projects.store'), ['creation_mode' => $mode, 'name' => 'New project', 'key' => 'NEW'])
|
||||
->assertRedirect(route('projects.create'))->assertSessionHasErrors($field);
|
||||
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function missingSources(): array
|
||||
{
|
||||
return ['duplicate' => ['duplicate', 'source_project_id'], 'template' => ['template', 'source_template_id']];
|
||||
}
|
||||
|
||||
public function test_an_unpublished_template_cannot_be_used_to_create_a_project(): void
|
||||
{
|
||||
$user = User::factory()->admin()->create();
|
||||
$template = ProjectTemplate::factory()->create(['is_published' => false]);
|
||||
$this->actingAs($user)->post(route('projects.store'), [
|
||||
'creation_mode' => 'template', 'source_template_id' => $template->id, 'name' => 'New project', 'key' => 'NEW',
|
||||
])->assertSessionHasErrors('source_template_id');
|
||||
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
|
||||
}
|
||||
|
||||
public function test_creation_page_rendering_and_localization(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-creation.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Project creation rendering checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
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 ProjectOverviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_projects_without_access_do_not_appear_in_the_empty_overview(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Project::factory()->create(['name' => 'Private workspace']);
|
||||
|
||||
$this->actingAs($user)->get(route('projects.index'))
|
||||
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Index')
|
||||
->has('projects', 0)
|
||||
->where('auth.permissions.projectCreate', false));
|
||||
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
$this->get(route('projects.index'))
|
||||
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->has('projects', 0)
|
||||
->where('auth.permissions.projectCreate', true));
|
||||
}
|
||||
|
||||
#[DataProvider('projectRoles')]
|
||||
public function test_project_entry_actions_follow_effective_permissions(string $role, bool $teamAccess, bool $canManage): 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.show', $project))
|
||||
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Show')
|
||||
->where('project.id', $project->id)
|
||||
->where('canManageAccess', $canManage)
|
||||
->where('auth.permissions.projectCreate', false));
|
||||
|
||||
foreach (['projects.board.index', 'projects.backlog.index'] as $route) {
|
||||
$this->get(route($route, $project))->assertOk();
|
||||
}
|
||||
foreach (['projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) {
|
||||
$this->get(route($route, $project))->assertStatus($canManage ? 200 : 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, bool, bool}> */
|
||||
public static function projectRoles(): array
|
||||
{
|
||||
return [
|
||||
'reader' => ['read', false, false],
|
||||
'writer' => ['write', false, false],
|
||||
'project admin' => ['admin', false, true],
|
||||
'team reader' => ['read', true, false],
|
||||
'team admin' => ['admin', true, true],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_project_overview_rendering_and_actions(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-overview.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Project overview behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,8 @@ class ProjectTicketControllerTest extends TestCase
|
||||
*/
|
||||
public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void
|
||||
{
|
||||
config()->set('inertia.ssr.enabled', false);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketPresentationsTest extends TestCase
|
||||
{
|
||||
public function test_ticket_cards_and_list_items_share_accessible_data_driven_behavior(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/ticket-presentations.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Ticket presentation behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogScrollContent,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { index as projectsIndex } from '@/routes/projects/index';
|
||||
|
||||
const form = useForm('feedback-preview', { title: '' });
|
||||
const protection = useFormProtection(form);
|
||||
const dialogOpen = ref(false);
|
||||
const menuSelection = ref('');
|
||||
const submit = () =>
|
||||
protection.submit(ProjectTicketController.store({ project: 7 }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="gap-column p-page mx-auto grid max-w-3xl">
|
||||
<div class="gap-stack flex flex-wrap items-center justify-between">
|
||||
<h1 class="text-headline">Dialogs and feedback</h1>
|
||||
<AppearanceTabs />
|
||||
</div>
|
||||
<FeedbackState
|
||||
state="empty"
|
||||
description="New tickets will appear here."
|
||||
/>
|
||||
<FeedbackState state="loading" />
|
||||
<form
|
||||
class="gap-card bg-card p-card grid rounded-md border"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<label for="title">Title</label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
/>
|
||||
<InputError id="title-error" :message="form.errors.title" />
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<Button type="submit" :loading="form.processing">Save draft</Button>
|
||||
<Button type="button" variant="outline" @click="form.cancel()"
|
||||
>Cancel request</Button
|
||||
>
|
||||
</form>
|
||||
<Link :href="projectsIndex()">Leave page</Link>
|
||||
<div class="gap-stack flex flex-wrap">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<Button id="programmatic-trigger" @click="dialogOpen = true"
|
||||
>Open dialog</Button
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
><DialogTitle>Edit details</DialogTitle
|
||||
><DialogDescription
|
||||
>Keyboard and focus preview.</DialogDescription
|
||||
></DialogHeader
|
||||
>
|
||||
<Input aria-label="Dialog title" />
|
||||
<Dialog>
|
||||
<DialogTrigger as-child
|
||||
><Button>Open nested dialog</Button></DialogTrigger
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
><DialogTitle>Nested details</DialogTitle
|
||||
><DialogDescription
|
||||
>Returns focus to the parent
|
||||
dialog.</DialogDescription
|
||||
></DialogHeader
|
||||
>
|
||||
<DialogClose as-child
|
||||
><Button>Done</Button></DialogClose
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogClose as-child
|
||||
><Button variant="outline">Cancel</Button></DialogClose
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child
|
||||
><Button>Open long dialog</Button></DialogTrigger
|
||||
>
|
||||
<DialogScrollContent>
|
||||
<DialogHeader
|
||||
><DialogTitle>Long details</DialogTitle
|
||||
><DialogDescription
|
||||
>Scrollable content on small
|
||||
screens.</DialogDescription
|
||||
></DialogHeader
|
||||
>
|
||||
<p v-for="item in 30" :key="item">Detail {{ item }}</p>
|
||||
<DialogClose as-child
|
||||
><Button>Finish reading</Button></DialogClose
|
||||
>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
<Sheet>
|
||||
<SheetTrigger as-child
|
||||
><Button>Open sheet</Button></SheetTrigger
|
||||
>
|
||||
<SheetContent class="p-page">
|
||||
<SheetTitle>Sheet details</SheetTitle
|
||||
><SheetDescription
|
||||
>Focus returns to the trigger.</SheetDescription
|
||||
>
|
||||
<Input aria-label="Sheet title" />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child
|
||||
><Button>Open actions</Button></DropdownMenuTrigger
|
||||
>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled>Unavailable</DropdownMenuItem>
|
||||
<DropdownMenuItem @select="menuSelection = 'selected'"
|
||||
>Select action</DropdownMenuItem
|
||||
>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span role="status">{{ menuSelection }}</span>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { TicketCard, TicketListItem } from '@/components/tickets';
|
||||
|
||||
defineProps({
|
||||
fullTicket: Object,
|
||||
optionalTicket: Object,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto grid max-w-5xl gap-8 p-4 sm:p-8">
|
||||
<section class="grid gap-4" aria-labelledby="cards-title">
|
||||
<h1 id="cards-title" class="text-headline-mobile md:text-headline">
|
||||
Ticket cards
|
||||
</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<TicketCard :project-id="7" :ticket="fullTicket" />
|
||||
<TicketCard :project-id="7" :ticket="optionalTicket" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4" aria-labelledby="rows-title">
|
||||
<h2 id="rows-title" class="text-title">Ticket list items</h2>
|
||||
<ul class="grid gap-4">
|
||||
<TicketListItem :project-id="7" :ticket="fullTicket" />
|
||||
<TicketListItem :project-id="7" :ticket="optionalTicket" />
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,342 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4177';
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
const submissions = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST') {
|
||||
submissions.push({
|
||||
path: new URL(request.url()).pathname,
|
||||
data: request.postDataJSON(),
|
||||
});
|
||||
}
|
||||
});
|
||||
const outcome = (value) =>
|
||||
context.addCookies([{ name: 'auth_test_result', value, url: base }]);
|
||||
const open = async (path) => {
|
||||
await page.goto(base + path);
|
||||
await page.locator('main h1').waitFor();
|
||||
};
|
||||
const state = (value) =>
|
||||
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
||||
const credentials = async () => {
|
||||
await page
|
||||
.getByLabel('Email address', { exact: true })
|
||||
.fill('alex@example.test');
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('fixture-private-password');
|
||||
};
|
||||
const noStoredSecrets = async () => {
|
||||
const persisted = await page.evaluate(() =>
|
||||
JSON.stringify({
|
||||
local: { ...localStorage },
|
||||
session: { ...sessionStorage },
|
||||
history: history.state,
|
||||
}),
|
||||
);
|
||||
for (const secret of [
|
||||
'fixture-private-password',
|
||||
'012345',
|
||||
'fixture-recovery-code',
|
||||
]) {
|
||||
assert.ok(
|
||||
!persisted.includes(secret),
|
||||
'Credentials must not be remembered in browser storage',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
for (const path of [
|
||||
'/login',
|
||||
'/email/verify',
|
||||
'/two-factor-challenge',
|
||||
]) {
|
||||
await open(path);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: theme === 'light' ? 'Light' : 'Dark',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: theme === 'light' ? 'Light' : 'Dark',
|
||||
exact: true,
|
||||
pressed: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
`${path} must fit at ${width}`,
|
||||
);
|
||||
const controls = await page
|
||||
.locator(
|
||||
'main button:not([role="checkbox"]), main label[for="remember"], main input:not([type="checkbox"]):not([aria-hidden="true"])',
|
||||
)
|
||||
.evaluateAll((els) =>
|
||||
els
|
||||
.filter(
|
||||
(el) => el.getBoundingClientRect().width > 0,
|
||||
)
|
||||
.map((el) => el.getBoundingClientRect().height),
|
||||
);
|
||||
assert.ok(
|
||||
controls.every((height) => height >= 44),
|
||||
`${path} has touch-sized controls`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan21-${path.split('/').at(-1)}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await open('/login');
|
||||
await page.getByRole('button', { name: 'System', exact: true }).click();
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.waitForFunction(
|
||||
() => !document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.waitForFunction(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
await credentials();
|
||||
const password = page.locator('#password');
|
||||
assert.equal(
|
||||
await password.getAttribute('autocomplete'),
|
||||
'current-password',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#email').getAttribute('autocomplete'),
|
||||
'username',
|
||||
);
|
||||
await password.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Show password' })
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await password.getAttribute('type'), 'text');
|
||||
await page.getByRole('button', { name: 'Hide password' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Remember me' }).check();
|
||||
await outcome('validation');
|
||||
await password.press('Enter');
|
||||
await state('validation');
|
||||
assert.equal(await password.inputValue(), 'fixture-private-password');
|
||||
assert.equal(
|
||||
await page.locator('#email').inputValue(),
|
||||
'alex@example.test',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#email').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(submissions.at(-1).data.remember, true);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-form-state="validation"]')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await noStoredSecrets();
|
||||
|
||||
await page.route('**/login', (route) =>
|
||||
route.request().method() === 'POST'
|
||||
? route.abort('failed')
|
||||
: route.continue(),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await state('network');
|
||||
assert.equal(await password.inputValue(), 'fixture-private-password');
|
||||
await page.unroute('**/login');
|
||||
await outcome('limited');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await page.getByText('Too many attempts', { exact: true }).waitFor();
|
||||
await outcome('expired');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await page.getByText('Your session has expired', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: 'Try again' }).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole('link', { name: 'Return to sign in' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#password')?.value === '',
|
||||
);
|
||||
|
||||
await outcome('success');
|
||||
await credentials();
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/login', async (route) => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
const before = submissions.length;
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await state('loading');
|
||||
assert.equal(await password.isDisabled(), true);
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: 'Show password' }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await page
|
||||
.locator('form')
|
||||
.evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
assert.equal(submissions.length, before + 1);
|
||||
release();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Signed in destination' })
|
||||
.waitFor();
|
||||
await page.unroute('**/login');
|
||||
await noStoredSecrets();
|
||||
await page.goBack();
|
||||
await page.locator('#password').waitFor();
|
||||
assert.equal(await password.inputValue(), '');
|
||||
|
||||
await open('/login?no-reset=1');
|
||||
assert.equal(
|
||||
await page.getByRole('link', { name: 'Forgot password?' }).count(),
|
||||
0,
|
||||
);
|
||||
await credentials();
|
||||
await outcome('challenge');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.locator('#code').waitFor();
|
||||
assert.equal(
|
||||
await page.locator('#code').getAttribute('inputmode'),
|
||||
'numeric',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#code').getAttribute('autocomplete'),
|
||||
'one-time-code',
|
||||
);
|
||||
await page.locator('#code').evaluate((el) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData('text', '012 345');
|
||||
el.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.equal(await page.locator('#code').inputValue(), '012345');
|
||||
await outcome('validation');
|
||||
await page.locator('#code').press('Enter');
|
||||
await state('validation');
|
||||
assert.deepEqual(submissions.at(-1).data, { code: '012345' });
|
||||
assert.equal(await page.locator('#code').inputValue(), '012345');
|
||||
await page
|
||||
.getByRole('button', { name: 'Use a recovery code instead' })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#recovery_code')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
await page.locator('#recovery_code').fill('fixture-recovery-code');
|
||||
await page.locator('#recovery_code').press('Enter');
|
||||
await state('validation');
|
||||
assert.deepEqual(submissions.at(-1).data, {
|
||||
recovery_code: 'fixture-recovery-code',
|
||||
});
|
||||
assert.equal(
|
||||
await page.locator('#recovery_code').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#recovery_code').inputValue(),
|
||||
'fixture-recovery-code',
|
||||
);
|
||||
await noStoredSecrets();
|
||||
await page
|
||||
.getByRole('button', { name: 'Use an authenticator code instead' })
|
||||
.click();
|
||||
assert.equal(await page.locator('#code').inputValue(), '');
|
||||
await page
|
||||
.getByRole('button', { name: 'Use a recovery code instead' })
|
||||
.click();
|
||||
assert.equal(await page.locator('#recovery_code').inputValue(), '');
|
||||
await page.locator('#recovery_code').fill('fixture-recovery-code');
|
||||
await outcome('server');
|
||||
await page.getByRole('button', { name: 'Verify and sign in' }).click();
|
||||
await state('server');
|
||||
assert.equal(
|
||||
await page.locator('#recovery_code').inputValue(),
|
||||
'fixture-recovery-code',
|
||||
);
|
||||
await outcome('success');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Signed in destination' })
|
||||
.waitFor();
|
||||
await noStoredSecrets();
|
||||
|
||||
await open('/email/verify');
|
||||
await page.getByText('alex.morgan@example.test', { exact: true }).waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'Resend verification email', exact: true })
|
||||
.click();
|
||||
await page.getByText('Verification link sent', { exact: true }).waitFor();
|
||||
assert.equal(submissions.at(-1).path, '/email/verification-notification');
|
||||
await outcome('limited');
|
||||
await page
|
||||
.getByRole('button', { name: 'Resend verification email', exact: true })
|
||||
.click();
|
||||
await page.getByText('Too many attempts', { exact: true }).waitFor();
|
||||
await outcome('success');
|
||||
await page.getByRole('button', { name: 'Continue to workspace' }).click();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Signed in destination' })
|
||||
.waitFor();
|
||||
await open('/email/verify');
|
||||
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
|
||||
await page.locator('#email').waitFor();
|
||||
assert.equal(submissions.at(-1).path, '/logout');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Auth browser checks passed: themes, responsive layout, keyboard, autofill, retention, retry, expiry, throttling, 2FA, recovery, verification and logout.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Login from '../../resources/js/pages/auth/Login.vue';
|
||||
import VerifyEmail from '../../resources/js/pages/auth/VerifyEmail.vue';
|
||||
import TwoFactorChallenge from '../../resources/js/pages/auth/TwoFactorChallenge.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
const Destination = { render: () => h('h1', 'Signed in destination') };
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ Login, VerifyEmail, TwoFactorChallenge, Destination })[name],
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
// Isolated Inertia transport fixture; real Fortify sessions are covered by PHPUnit.
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/auth-fixture',
|
||||
plugins: [vue(), tailwind()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: { host: '127.0.0.1', port: 4177, strictPort: true, hmr: false },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const pages = {
|
||||
'/login': 'Login',
|
||||
'/two-factor-challenge': 'TwoFactorChallenge',
|
||||
'/email/verify': 'VerifyEmail',
|
||||
'/email/verification-notification': 'VerifyEmail',
|
||||
'/dashboard': 'Destination',
|
||||
'/logout': 'Login',
|
||||
'/forgot-password': 'Destination',
|
||||
'/': 'Destination',
|
||||
};
|
||||
if (!(url.pathname in pages)) {
|
||||
return next();
|
||||
}
|
||||
const mutation = request.method === 'POST';
|
||||
const result =
|
||||
request.headers.cookie?.match(/auth_test_result=(\w+)/)?.[1] ??
|
||||
'success';
|
||||
if (mutation && ['server', 'expired', 'limited'].includes(result)) {
|
||||
response.statusCode = { server: 503, expired: 419, limited: 429 }[
|
||||
result
|
||||
];
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end('<h1>Simulated failed response</h1>');
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
if (mutation) {
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
}
|
||||
const submitted = body ? JSON.parse(body) : {};
|
||||
let path = url.pathname;
|
||||
let errors = {};
|
||||
let status;
|
||||
if (mutation && result === 'validation') {
|
||||
path = new URL(request.headers.referer).pathname;
|
||||
const field =
|
||||
path === '/login'
|
||||
? 'email'
|
||||
: 'recovery_code' in submitted
|
||||
? 'recovery_code'
|
||||
: 'code';
|
||||
errors = { [field]: 'These details could not be verified.' };
|
||||
} else if (mutation) {
|
||||
if (path === '/email/verification-notification') {
|
||||
path = '/email/verify';
|
||||
status = 'verification-link-sent';
|
||||
} else if (path === '/logout') {
|
||||
path = '/login';
|
||||
} else {
|
||||
path =
|
||||
result === 'challenge' ? '/two-factor-challenge' : '/dashboard';
|
||||
}
|
||||
}
|
||||
const page = {
|
||||
component: pages[path],
|
||||
url: path,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors,
|
||||
status,
|
||||
canResetPassword: !url.searchParams.has('no-reset'),
|
||||
auth: {
|
||||
user:
|
||||
path === '/email/verify'
|
||||
? {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex.morgan@example.test',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
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 escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
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="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/auth-preview.mjs"></script></body></html>';
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(await server.transformIndexHtml(request.url, html));
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Auth fixture ready at http://127.0.0.1:4177/login');
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const { chromium } = playwright.default ?? playwright;
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const base = 'http://127.0.0.1:4181/projects/7/board';
|
||||
const content = () => page.locator('#main-content');
|
||||
const visibleColumns = () =>
|
||||
content().locator('[id^="board-column-title-"]:visible');
|
||||
const visit = async (query = '') => {
|
||||
await page.goto(base + query);
|
||||
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
};
|
||||
const tabTo = async (locator) => {
|
||||
await content().focus();
|
||||
for (let step = 0; step < 40; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (
|
||||
await locator.evaluate(
|
||||
(element) => element === document.activeElement,
|
||||
)
|
||||
) {
|
||||
assert.equal(
|
||||
await locator.evaluate((element) =>
|
||||
element.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail('Board control is unreachable by keyboard');
|
||||
};
|
||||
try {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
for (const [query, state] of [
|
||||
['', 'filled'],
|
||||
['?empty', 'empty'],
|
||||
['?one&noTickets', 'one-empty'],
|
||||
]) {
|
||||
await visit(query);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`${state} overflow at ${width}/${theme}`,
|
||||
);
|
||||
assert.equal(
|
||||
await visibleColumns().count(),
|
||||
state === 'empty'
|
||||
? 0
|
||||
: width < 768 || state === 'one-empty'
|
||||
? 1
|
||||
: 8,
|
||||
);
|
||||
if (width < 768 && state !== 'empty') {
|
||||
assert.ok(
|
||||
(await page.getByLabel('Choose a column').boundingBox())
|
||||
.height >= 44,
|
||||
);
|
||||
}
|
||||
if (state === 'filled' && width >= 768) {
|
||||
const region = content().getByRole('region', {
|
||||
name: 'Workflow columns',
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(
|
||||
await region.evaluate(
|
||||
(el) => el.scrollWidth > el.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await region.focus();
|
||||
await page.keyboard.press('End');
|
||||
await content()
|
||||
.locator('#board-column-8')
|
||||
.scrollIntoViewIfNeeded();
|
||||
assert.ok(await region.evaluate((el) => el.scrollLeft > 0));
|
||||
await region.evaluate((el) => {
|
||||
el.scrollLeft = 0;
|
||||
});
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan27-board-${state}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await visit();
|
||||
const chooseColumn = page.getByLabel('Choose a column');
|
||||
await tabTo(chooseColumn);
|
||||
// Native popup selection is handled by macOS outside the browser DOM.
|
||||
await chooseColumn.selectOption('2');
|
||||
assert.equal(await chooseColumn.inputValue(), '2');
|
||||
assert.equal(await visibleColumns().innerText(), 'In development');
|
||||
await chooseColumn.selectOption('8');
|
||||
assert.equal(await visibleColumns().innerText(), 'Column without statuses');
|
||||
await content()
|
||||
.getByText('No statuses configured for this column.')
|
||||
.waitFor();
|
||||
await chooseColumn.selectOption('1');
|
||||
await content()
|
||||
.getByRole('heading', { name: 'Ready · Planned', exact: true })
|
||||
.waitFor();
|
||||
await content()
|
||||
.getByRole('heading', { name: 'Ready · Active', exact: true })
|
||||
.waitFor();
|
||||
const terminalSelect = page.getByLabel('Move ENG-43', { exact: true });
|
||||
assert.equal(await terminalSelect.isDisabled(), true);
|
||||
const moveSelect = page.getByLabel('Move ENG-42', { exact: true });
|
||||
assert.deepEqual(
|
||||
await moveSelect
|
||||
.locator('option')
|
||||
.evaluateAll((options) => options.map((option) => option.value)),
|
||||
['20', '11', '99'],
|
||||
);
|
||||
await tabTo(moveSelect);
|
||||
await moveSelect.selectOption('20');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.locator(':focus').innerText(), 'Update status');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await chooseColumn.inputValue(), '1');
|
||||
assert.match(
|
||||
await chooseColumn.locator('option').first().innerText(),
|
||||
/\(1\)/,
|
||||
);
|
||||
await chooseColumn.selectOption('2');
|
||||
await content()
|
||||
.getByRole('link', {
|
||||
name: 'Open ENG-42: Ship accessible board navigation',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
|
||||
await visit();
|
||||
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('99');
|
||||
await content()
|
||||
.locator('article')
|
||||
.filter({ hasText: 'ENG-42' })
|
||||
.getByRole('button', { name: 'Update status' })
|
||||
.click();
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await content()
|
||||
.getByRole('link', {
|
||||
name: 'Open ENG-42: Ship accessible board navigation',
|
||||
exact: true,
|
||||
})
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.match(
|
||||
await chooseColumn.locator('option').first().innerText(),
|
||||
/\(1\)/,
|
||||
);
|
||||
|
||||
await visit('?reject');
|
||||
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('20');
|
||||
await content()
|
||||
.locator('article')
|
||||
.filter({ hasText: 'ENG-42' })
|
||||
.getByRole('button', { name: 'Update status' })
|
||||
.click();
|
||||
await page
|
||||
.getByText('This transition is no longer allowed.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Move ENG-42', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await content()
|
||||
.getByText('This transition is no longer allowed.', { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Move ENG-43', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
null,
|
||||
);
|
||||
|
||||
await visit('?readonly');
|
||||
assert.equal(await content().locator('form').count(), 0);
|
||||
const ticketLink = content().getByRole('link', {
|
||||
name: 'Open ENG-42: Ship accessible board navigation',
|
||||
exact: true,
|
||||
});
|
||||
await tabTo(ticketLink);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects/7/tickets/42');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||
.waitFor();
|
||||
await visit('?empty');
|
||||
await tabTo(
|
||||
content().getByRole('link', { name: 'Open backlog', exact: true }),
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects/7/backlog');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Board browser checks passed: responsive columns, keyboard, status changes and validation, permissions, empty states and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Board from '../../resources/js/pages/projects/Board.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Board'
|
||||
? Board
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/board-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4181, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const project = {
|
||||
id: 7,
|
||||
key: 'ENG',
|
||||
name: 'Engineering · Platform and developer experience',
|
||||
};
|
||||
const columnNames = [
|
||||
'Ready',
|
||||
'In development',
|
||||
'Review',
|
||||
'Testing',
|
||||
'Release',
|
||||
'Done',
|
||||
'Archived',
|
||||
'Column without statuses',
|
||||
];
|
||||
const columns = columnNames.map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
position: index + 1,
|
||||
project_id: 7,
|
||||
tickets: [],
|
||||
ticket_statuses: [],
|
||||
}));
|
||||
const statuses = columns.slice(0, -1).flatMap((column) =>
|
||||
['Planned', 'Active'].map((name, index) => ({
|
||||
id: column.id * 10 + index,
|
||||
name: `${column.name} · ${name}`,
|
||||
position: index + 1,
|
||||
project_id: 7,
|
||||
board_column_id: column.id,
|
||||
board_column: { ...column },
|
||||
})),
|
||||
);
|
||||
const backlog = {
|
||||
id: 99,
|
||||
name: 'Backlog',
|
||||
board_column_id: null,
|
||||
board_column: null,
|
||||
};
|
||||
for (const column of columns) {
|
||||
column.ticket_statuses = statuses.filter(
|
||||
(status) => status.board_column_id === column.id,
|
||||
);
|
||||
}
|
||||
function ticket(id, status, title) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
ticket_key: `ENG-${id}`,
|
||||
project_id: 7,
|
||||
sequence_number: id,
|
||||
description:
|
||||
'A real ticket card in the isolated board behavior fixture.',
|
||||
ticket_status_id: status.id,
|
||||
ticket_status: {
|
||||
...status,
|
||||
allowed_target_statuses:
|
||||
id === 42 ? [status, statuses[2], statuses[1], backlog] : [],
|
||||
},
|
||||
ticket_priority: { id: 1, name: 'Urgent custom priority', position: 1 },
|
||||
ticket_type: { id: 1, name: 'Development', is_parent: false },
|
||||
assignee: null,
|
||||
assignee_user_id: null,
|
||||
is_blocked: id === 43,
|
||||
blocked_comment: id === 43 ? 'Awaiting the external review.' : null,
|
||||
};
|
||||
}
|
||||
function makePage(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const params = parsed.searchParams;
|
||||
const isBoard = parsed.pathname === '/projects/7/board';
|
||||
const boardColumns = structuredClone(
|
||||
params.has('empty')
|
||||
? []
|
||||
: params.has('one')
|
||||
? columns.slice(0, 1)
|
||||
: columns,
|
||||
);
|
||||
if (!params.has('noTickets')) {
|
||||
const target =
|
||||
[...statuses, backlog].find(
|
||||
(status) => status.id === Number(params.get('target')),
|
||||
) ?? statuses[0];
|
||||
const tickets = [
|
||||
ticket(42, target, 'Ship accessible board navigation'),
|
||||
ticket(
|
||||
43,
|
||||
statuses[1],
|
||||
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
||||
),
|
||||
];
|
||||
for (const column of boardColumns) {
|
||||
column.tickets = tickets.filter(
|
||||
(item) => item.ticket_status.board_column_id === column.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
component: isBoard ? 'Board' : 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: params.has('rejected')
|
||||
? {
|
||||
'board-ticket-42': {
|
||||
ticket_status_id:
|
||||
'This transition is no longer allowed.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
sidebarOpen: true,
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
},
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
project: {
|
||||
...project,
|
||||
canManage: false,
|
||||
canCreateTickets: !params.has('readonly'),
|
||||
},
|
||||
},
|
||||
project,
|
||||
boardColumns,
|
||||
canCreateTickets: !params.has('readonly'),
|
||||
canEditTickets: !params.has('readonly'),
|
||||
status: params.has('target') ? 'ENG-42 moved successfully.' : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
if (request.method === 'POST') {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const data = JSON.parse(body);
|
||||
assert.equal(
|
||||
request.url,
|
||||
'/projects/7/tickets/42/status?_method=PATCH',
|
||||
);
|
||||
assert.equal(data.redirect_to, 'board');
|
||||
assert.ok([11, 20, 99].includes(Number(data.ticket_status_id)));
|
||||
const previous = new URL(request.headers.referer);
|
||||
const reject = previous.searchParams.has('reject');
|
||||
response.writeHead(303, {
|
||||
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const page = makePage(request.url);
|
||||
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 escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!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="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/board-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log(
|
||||
'Board fixture ready at http://127.0.0.1:4181/projects/7/board',
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { default: Board } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/Board.vue',
|
||||
);
|
||||
const render = async (query = '') => {
|
||||
const result = await createInertiaApp({
|
||||
page: makePage(`/projects/7/board${query}`),
|
||||
resolve: () => Board,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance('light')),
|
||||
});
|
||||
return result.body.split('id="main-content"')[1];
|
||||
};
|
||||
const board = await render();
|
||||
assert.equal((board.match(/id="board-column-title-/g) ?? []).length, 8);
|
||||
assert.equal((board.match(/id="board-status-/g) ?? []).length, 14);
|
||||
assert.match(board, /Ready · Planned/);
|
||||
assert.match(board, /Ready · Active/);
|
||||
assert.match(board, /No statuses configured for this column/);
|
||||
assert.match(board, /No tickets in this column/);
|
||||
assert.match(board, /No tickets in this status/);
|
||||
assert.match(board, /2 tickets/);
|
||||
assert.match(board, /Urgent custom priority/);
|
||||
assert.match(board, /href="\/projects\/7\/tickets\/42"/);
|
||||
assert.match(
|
||||
board,
|
||||
/action="\/projects\/7\/tickets\/42\/status\?_method=PATCH"/,
|
||||
);
|
||||
assert.match(
|
||||
board,
|
||||
/href="\/projects\/7\/tickets\/create\?source=board"/,
|
||||
);
|
||||
assert.match(board, /href="\/projects\/7\/backlog"/);
|
||||
assert.match(board, /No allowed moves/);
|
||||
assert.equal((board.match(/<article/g) ?? []).length, 2);
|
||||
const readonly = await render('?readonly');
|
||||
assert.doesNotMatch(readonly, /<form|Move ENG-|New ticket/);
|
||||
const empty = await render('?empty');
|
||||
assert.match(empty, /No workflow columns yet/);
|
||||
assert.doesNotMatch(empty, /id="board-column"|<article/);
|
||||
const one = await render('?one&noTickets');
|
||||
assert.equal((one.match(/id="board-column-title-/g) ?? []).length, 1);
|
||||
assert.match(one, /0 tickets/);
|
||||
assert.doesNotMatch(one, /<article/);
|
||||
console.log('Board behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const { chromium } = playwright.default ?? playwright;
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4180';
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const content = () => page.locator('#main-content');
|
||||
const focusWithTab = async (link) => {
|
||||
await content().focus();
|
||||
for (let step = 0; step < 40; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (
|
||||
await link.evaluate((element) => element === document.activeElement)
|
||||
) {
|
||||
assert.equal(
|
||||
await link.evaluate((element) =>
|
||||
element.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail('Dashboard action was not reachable by keyboard');
|
||||
};
|
||||
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
for (const [path, state] of [
|
||||
['/dashboard', 'filled'],
|
||||
['/dashboard?empty', 'empty'],
|
||||
]) {
|
||||
await page.goto(base + path);
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Welcome back, Alex Morgan',
|
||||
exact: true,
|
||||
level: 1,
|
||||
})
|
||||
.waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((element) =>
|
||||
element.classList.contains('dark'),
|
||||
),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`${state} dashboard overflows at ${width} in ${theme}`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan25-dashboard-${state}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await page.goto(`${base}/dashboard`);
|
||||
const ticket = content().getByRole('link', {
|
||||
name: 'Open ENG-42: Ship the dashboard with real assigned work',
|
||||
exact: true,
|
||||
});
|
||||
await focusWithTab(ticket);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects/7/tickets/42');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await page.goto(`${base}/dashboard?empty`);
|
||||
const projects = content().getByRole('link', {
|
||||
name: 'Browse projects',
|
||||
exact: true,
|
||||
});
|
||||
await focusWithTab(projects);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Dashboard browser checks passed: keyboard, empty and filled states, 320/390/1440px and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Dashboard from '../../resources/js/pages/Dashboard.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ Dashboard })[name] ?? {
|
||||
render: () => h('h1', 'Destination reached'),
|
||||
},
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/dashboard-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4180, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
const user = {
|
||||
id: 8,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
avatar_color: '#22c55e',
|
||||
avatar_is_generated: true,
|
||||
email_verified_at: '2026-09-11T00:00:00Z',
|
||||
must_change_password_on_next_login: false,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
};
|
||||
const project = {
|
||||
id: 7,
|
||||
key: 'ENG',
|
||||
name: 'Engineering · Platform and developer experience',
|
||||
show_author: true,
|
||||
show_story_points: true,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
};
|
||||
const secondProject = {
|
||||
...project,
|
||||
id: 9,
|
||||
key: 'OPS',
|
||||
name: 'Operations',
|
||||
};
|
||||
const ticketDefaults = {
|
||||
acceptance_criteria: [],
|
||||
assignee: user,
|
||||
assignee_user_id: user.id,
|
||||
author: null,
|
||||
author_user_id: null,
|
||||
blocked_comment: null,
|
||||
description: 'Real work loaded from the server.',
|
||||
is_blocked: false,
|
||||
parent_ticket_id: null,
|
||||
story_points: 3,
|
||||
ticket_priority: {
|
||||
id: 1,
|
||||
name: 'Medium',
|
||||
position: 3,
|
||||
project_id: 7,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
ticket_priority_id: 1,
|
||||
ticket_status: {
|
||||
id: 3,
|
||||
board_column_id: 2,
|
||||
name: 'In Progress',
|
||||
position: 1,
|
||||
project_id: 7,
|
||||
slug: 'in-progress',
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
board_column: {
|
||||
id: 2,
|
||||
name: 'In Progress',
|
||||
position: 2,
|
||||
project_id: 7,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
},
|
||||
ticket_status_id: 3,
|
||||
ticket_type: {
|
||||
id: 1,
|
||||
is_parent: false,
|
||||
name: 'Dev task',
|
||||
position: 1,
|
||||
project_id: 7,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
ticket_type_id: 1,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
};
|
||||
const tickets = [
|
||||
{
|
||||
...ticketDefaults,
|
||||
id: 42,
|
||||
project_id: project.id,
|
||||
sequence_number: 42,
|
||||
ticket_key: 'ENG-42',
|
||||
title: 'Ship the dashboard with real assigned work',
|
||||
},
|
||||
{
|
||||
...ticketDefaults,
|
||||
id: 41,
|
||||
project_id: project.id,
|
||||
sequence_number: 41,
|
||||
ticket_key: 'ENG-41',
|
||||
title: 'Verify keyboard navigation',
|
||||
},
|
||||
];
|
||||
const blockedTicket = {
|
||||
...ticketDefaults,
|
||||
id: 12,
|
||||
blocked_comment: 'Waiting for review.',
|
||||
is_blocked: true,
|
||||
project_id: secondProject.id,
|
||||
sequence_number: 12,
|
||||
ticket_key: 'OPS-12',
|
||||
title: 'Resolve the deployment blocker',
|
||||
};
|
||||
const assignedTicketsByProject = [
|
||||
{ project: secondProject, tickets: [blockedTicket] },
|
||||
{ project, tickets },
|
||||
];
|
||||
|
||||
function makePage(url) {
|
||||
const parsedUrl = new URL(url, 'http://localhost');
|
||||
const isDashboard = parsedUrl.pathname === '/dashboard';
|
||||
return {
|
||||
component: isDashboard ? 'Dashboard' : 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
auth: {
|
||||
user,
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
...(isDashboard
|
||||
? {
|
||||
assignedTicketsByProject: parsedUrl.searchParams.has(
|
||||
'empty',
|
||||
)
|
||||
? []
|
||||
: assignedTicketsByProject,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
const page = makePage(request.url);
|
||||
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 escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!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="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/dashboard-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Dashboard fixture ready at http://127.0.0.1:4180/dashboard');
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { default: Dashboard } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/Dashboard.vue',
|
||||
);
|
||||
const render = async (url) => {
|
||||
const response = await createInertiaApp({
|
||||
page: makePage(url),
|
||||
resolve: () => Dashboard,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance('light')),
|
||||
});
|
||||
return response.body.split('<main')[1].split('</main>')[0];
|
||||
};
|
||||
|
||||
const dashboard = await render('/dashboard');
|
||||
assert.match(dashboard, /Welcome back, Alex Morgan/);
|
||||
assert.match(dashboard, /Assigned tickets/);
|
||||
assert.match(dashboard, /Projects with assigned work/);
|
||||
assert.match(dashboard, /Blocked tickets/);
|
||||
assert.match(dashboard, /Ship the dashboard with real assigned work/);
|
||||
assert.match(dashboard, /Resolve the deployment blocker/);
|
||||
assert.match(dashboard, /href="\/projects\/7\/tickets\/42"/);
|
||||
assert.match(dashboard, /href="\/projects\/9"/);
|
||||
assert.doesNotMatch(
|
||||
dashboard,
|
||||
/API-integration|Finished this week|Recent activity|PlaceholderPattern/,
|
||||
);
|
||||
|
||||
const empty = await render('/dashboard?empty');
|
||||
assert.match(empty, /No tickets assigned to you/);
|
||||
assert.match(empty, /href="\/projects"/);
|
||||
assert.doesNotMatch(empty, /ENG-42|OPS-12|<article/);
|
||||
|
||||
console.log('Dashboard behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4176';
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const state = (value) =>
|
||||
page
|
||||
.locator(`[data-form-state="${value}"]`)
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
const setResult = (value) =>
|
||||
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
|
||||
const verify = async (condition) => {
|
||||
assert.equal(await condition(), true);
|
||||
};
|
||||
const confirmNavigation = (accepted) =>
|
||||
page.once('dialog', (dialog) =>
|
||||
accepted ? dialog.accept() : dialog.dismiss(),
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto(`${base}/feedback`);
|
||||
await page.getByLabel('Title', { exact: true }).fill('Unsaved draft');
|
||||
await verify(() =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}),
|
||||
);
|
||||
confirmNavigation(false);
|
||||
await page.getByRole('link', { name: 'Leave page' }).click();
|
||||
assert.equal(new URL(page.url()).pathname, '/feedback');
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved draft',
|
||||
);
|
||||
|
||||
await setResult('validation');
|
||||
await page.getByRole('button', { name: 'Save draft' }).click();
|
||||
await state('validation');
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved draft',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Title', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: 'Try again' }).count(),
|
||||
0,
|
||||
);
|
||||
|
||||
let failedRequests = 0;
|
||||
await page.route('**/projects/7/tickets', (route) => {
|
||||
failedRequests++;
|
||||
return route.abort('failed');
|
||||
});
|
||||
await page.getByRole('button', { name: 'Save draft' }).click();
|
||||
await state('network');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.activeElement?.getAttribute('data-form-state') ===
|
||||
'network',
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved draft',
|
||||
);
|
||||
assert.equal(failedRequests, 1);
|
||||
await page.unroute('**/projects/7/tickets');
|
||||
await setResult('server');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await state('server');
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved draft',
|
||||
);
|
||||
|
||||
await setResult('success');
|
||||
let pending;
|
||||
let submissions = 0;
|
||||
await page.route('**/projects/7/tickets', (route) => {
|
||||
submissions++;
|
||||
pending = route;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await state('loading');
|
||||
await verify(() =>
|
||||
page.evaluate(() => {
|
||||
const event = new CustomEvent('inertia:exception', {
|
||||
cancelable: true,
|
||||
detail: { exception: new Error('Unrelated runtime error') },
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return !event.defaultPrevented;
|
||||
}),
|
||||
);
|
||||
await verify(() =>
|
||||
page.getByRole('button', { name: 'Save draft' }).isDisabled(),
|
||||
);
|
||||
await page.locator('form').evaluate((form) => form.requestSubmit());
|
||||
assert.equal(submissions, 1);
|
||||
await page.getByRole('button', { name: 'Cancel request' }).click();
|
||||
await state('cancelled');
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved draft',
|
||||
);
|
||||
await pending.abort();
|
||||
await page.unroute('**/projects/7/tickets');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await state('success');
|
||||
await verify(() =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return !event.defaultPrevented;
|
||||
}),
|
||||
);
|
||||
|
||||
await page.getByLabel('Title', { exact: true }).fill('History draft');
|
||||
confirmNavigation(true);
|
||||
await page.getByRole('link', { name: 'Leave page' }).click();
|
||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||
await page.goBack();
|
||||
await page.getByLabel('Title', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'History draft',
|
||||
);
|
||||
await page.goForward();
|
||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||
await page.goBack();
|
||||
await page.getByLabel('Title', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'History draft',
|
||||
);
|
||||
// Native history cannot be cancelled; remembered state preserves the draft.
|
||||
await page.getByRole('button', { name: 'Save draft' }).click();
|
||||
await state('success');
|
||||
|
||||
for (const width of [1280, 390]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
console.log(`Checking dialogs: ${width}px, ${theme}`);
|
||||
await page
|
||||
.getByRole('button', { name: theme, exact: true })
|
||||
.click();
|
||||
await page.waitForFunction(
|
||||
(dark) =>
|
||||
document.documentElement.classList.contains('dark') ===
|
||||
dark,
|
||||
theme === 'Dark',
|
||||
);
|
||||
const opener = page.getByRole('button', {
|
||||
name: 'Open dialog',
|
||||
exact: true,
|
||||
});
|
||||
await opener.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('dialog', { name: 'Edit details' }).waitFor();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Edit details' })
|
||||
.evaluate((element) =>
|
||||
Promise.all(
|
||||
[
|
||||
element,
|
||||
...document.querySelectorAll(
|
||||
'[data-slot="dialog-overlay"]',
|
||||
),
|
||||
]
|
||||
.flatMap((surface) => surface.getAnimations())
|
||||
.map((animation) => animation.finished),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Edit details' })
|
||||
.evaluate((element) => getComputedStyle(element).opacity),
|
||||
'1',
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.closest('[role="dialog"]'),
|
||||
);
|
||||
for (let step = 0; step < 7; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
await verify(() =>
|
||||
page.evaluate(() =>
|
||||
Boolean(
|
||||
document.activeElement?.closest('[role="dialog"]'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await verify(() =>
|
||||
page.evaluate(() =>
|
||||
Boolean(document.activeElement?.closest('[role="dialog"]')),
|
||||
),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan20-dialog-${width}-${theme.toLowerCase()}.png`,
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: 'Open nested dialog' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Nested details' })
|
||||
.waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Nested details' })
|
||||
.waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.textContent.includes(
|
||||
'Open nested dialog',
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Edit details' })
|
||||
.waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(
|
||||
() => document.activeElement?.id === 'programmatic-trigger',
|
||||
);
|
||||
await opener.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Edit details' })
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
await page.waitForFunction(
|
||||
() => document.activeElement?.id === 'programmatic-trigger',
|
||||
);
|
||||
|
||||
const longOpener = page.getByRole('button', {
|
||||
name: 'Open long dialog',
|
||||
});
|
||||
await longOpener.click();
|
||||
const longDialog = page.getByRole('dialog', {
|
||||
name: 'Long details',
|
||||
});
|
||||
await longDialog.waitFor();
|
||||
const box = await longDialog.boundingBox();
|
||||
assert.ok(box.x >= 0 && box.x + box.width <= width);
|
||||
await longDialog
|
||||
.getByRole('button', { name: 'Finish reading' })
|
||||
.click();
|
||||
await longDialog.waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.textContent.includes(
|
||||
'Open long dialog',
|
||||
),
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Open sheet' }).click();
|
||||
await page.getByRole('dialog', { name: 'Sheet details' }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Sheet details' })
|
||||
.waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.textContent.includes('Open sheet'),
|
||||
);
|
||||
|
||||
const actions = page.getByRole('button', { name: 'Open actions' });
|
||||
await actions.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('menu').waitFor();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.evaluate((element) =>
|
||||
Promise.all(
|
||||
element
|
||||
.getAnimations()
|
||||
.map((animation) => animation.finished),
|
||||
),
|
||||
);
|
||||
const menuItem = await page
|
||||
.getByRole('menuitem', { name: 'Select action' })
|
||||
.boundingBox();
|
||||
assert.ok(menuItem.height >= 44);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('menu').waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.textContent.includes('Open actions'),
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByText('selected', { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
await verify(() =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan20-feedback-${width}-${theme.toLowerCase()}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real ticket pages as well as the isolated components.
|
||||
await page.goto(`${base}/projects/7/tickets/create`);
|
||||
await page.locator('#title').fill('New ticket draft');
|
||||
await page.locator('#description').fill('Keep this description');
|
||||
await page
|
||||
.locator('#acceptance_criteria_checklist')
|
||||
.fill('[ ] Keep this checklist');
|
||||
await setResult('validation');
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
await state('validation');
|
||||
assert.equal(
|
||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
||||
'[ ] Keep this checklist',
|
||||
);
|
||||
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
await state('network');
|
||||
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
||||
assert.equal(
|
||||
await page.locator('#description').inputValue(),
|
||||
'Keep this description',
|
||||
);
|
||||
await page.unroute('**/projects/7/tickets');
|
||||
confirmNavigation(true);
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to backlog', exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||
await page.goBack();
|
||||
await page.locator('#title').waitFor();
|
||||
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
||||
assert.equal(
|
||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
||||
'[ ] Keep this checklist',
|
||||
);
|
||||
await setResult('success');
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||
|
||||
await page.goto(`${base}/projects/7/tickets/31`);
|
||||
await page.locator('#title').fill('Updated ticket draft');
|
||||
await page
|
||||
.locator('#acceptance_criteria_checklist')
|
||||
.fill('[ ] Edited checklist');
|
||||
await setResult('server');
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
await state('server');
|
||||
assert.equal(
|
||||
await page.locator('#title').inputValue(),
|
||||
'Updated ticket draft',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
||||
'[ ] Edited checklist',
|
||||
);
|
||||
await setResult('success');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await state('success');
|
||||
await verify(() =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return !event.defaultPrevented;
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Feedback browser behavior checks passed (desktop/mobile, light/dark, real ticket forms).',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Active element:',
|
||||
await page.evaluate(() =>
|
||||
document.activeElement?.outerHTML.slice(0, 500),
|
||||
),
|
||||
);
|
||||
console.error('Browser errors:', errors);
|
||||
await page.screenshot({
|
||||
path: '/private/tmp/kan20-feedback-failure.png',
|
||||
fullPage: true,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp, Link } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import FeedbackPreview from './FeedbackPreview.vue';
|
||||
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
|
||||
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
const Destination = {
|
||||
render: () =>
|
||||
h('main', [
|
||||
h('h1', 'Destination'),
|
||||
h(Link, { href: '/feedback' }, () => 'Feedback preview'),
|
||||
]),
|
||||
};
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ FeedbackPreview, TicketCreate, Ticket, Destination })[name],
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user