feat(KAN-23): rebuild project overview and entry pages
This commit is contained in:
@@ -1,4 +1,41 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'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',
|
||||||
'auth.home': 'KanbanGit home',
|
'auth.home': 'KanbanGit home',
|
||||||
'auth.accountAccess': 'Account access',
|
'auth.accountAccess': 'Account access',
|
||||||
'auth.backToLogin': 'Return to sign in',
|
'auth.backToLogin': 'Return to sign in',
|
||||||
|
|||||||
@@ -1,103 +1,184 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { Folder, Plus } from 'lucide-vue-next';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
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';
|
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
||||||
|
|
||||||
defineProps<ProjectsIndexPageProps>();
|
defineProps<ProjectsIndexPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
]);
|
||||||
href: index(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Projects" />
|
<Head :title="t('navigation.projects')" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<Heading
|
<div class="gap-stack grid min-w-0">
|
||||||
title="Projects"
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
description="Create and browse project spaces that will later host boards, backlog, and settings."
|
{{ 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
|
<Button
|
||||||
v-if="$page.props.auth.permissions.projectCreate"
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
as-child
|
as-child
|
||||||
|
class="self-start"
|
||||||
>
|
>
|
||||||
<Link :href="create()">
|
<Link :href="create()"
|
||||||
<Plus class="size-4" />
|
><Plus aria-hidden="true" />{{
|
||||||
New project
|
t('projects.create')
|
||||||
</Link>
|
}}</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"
|
|
||||||
>
|
>
|
||||||
<CardHeader class="gap-3">
|
</Button>
|
||||||
<div class="flex items-start justify-between gap-3">
|
</header>
|
||||||
<div class="space-y-1">
|
|
||||||
<CardTitle>{{ project.name }}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Project workspace entry point
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Badge variant="outline" class="px-2.5 py-1">
|
<FeedbackState
|
||||||
{{ project.key }}
|
v-if="status"
|
||||||
</Badge>
|
state="success"
|
||||||
</div>
|
:title="t('projects.updated')"
|
||||||
</CardHeader>
|
:description="status"
|
||||||
|
/>
|
||||||
|
|
||||||
<CardContent
|
<FeedbackState
|
||||||
class="text-muted-foreground flex items-center gap-2 text-sm"
|
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" />
|
<Link
|
||||||
Open board, backlog, and settings hub
|
:href="show(project.id)"
|
||||||
</CardContent>
|
:aria-label="
|
||||||
</Card>
|
t('projects.open', { name: project.name })
|
||||||
</Link>
|
"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,206 +1,227 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Columns3,
|
||||||
|
Layers3,
|
||||||
|
ListTodo,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { access, index, show } from '@/routes/projects/index';
|
import { projectSettingsNavigation } from '@/lib/navigation';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index, show } from '@/routes/projects';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import {
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
board as boardSettings,
|
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectShowPageProps>();
|
const props = defineProps<ProjectShowPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.projects'), href: index() },
|
||||||
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
|
]);
|
||||||
|
const projectAreas = computed(() => [
|
||||||
{
|
{
|
||||||
title: 'Projects',
|
title: t('navigation.board'),
|
||||||
href: index(),
|
description: t('projects.boardDescription'),
|
||||||
|
action: t('projects.boardAction'),
|
||||||
|
href: boardIndex(props.project.id),
|
||||||
|
icon: Columns3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: props.project.name,
|
title: t('navigation.backlog'),
|
||||||
href: show(props.project.id),
|
description: t('projects.backlogDescription'),
|
||||||
},
|
action: t('projects.backlogAction'),
|
||||||
];
|
href: backlogIndex(props.project.id),
|
||||||
|
|
||||||
const projectAreas = [
|
|
||||||
{
|
|
||||||
title: 'Backlog',
|
|
||||||
description:
|
|
||||||
'Create tickets, review incoming work, and move ready items into the board workflow.',
|
|
||||||
icon: ListTodo,
|
icon: ListTodo,
|
||||||
},
|
},
|
||||||
{
|
]);
|
||||||
title: 'Board',
|
const settingsItems = computed(() =>
|
||||||
description:
|
projectSettingsNavigation(
|
||||||
'Track active work across the project-defined Kanban columns and update ticket status.',
|
{
|
||||||
icon: LayoutGrid,
|
id: props.project.id,
|
||||||
},
|
name: props.project.name,
|
||||||
{
|
key: props.project.key,
|
||||||
title: 'Project settings',
|
canManage: props.canManageAccess,
|
||||||
description:
|
canCreateTickets: false,
|
||||||
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
|
},
|
||||||
icon: Settings2,
|
t,
|
||||||
},
|
),
|
||||||
{
|
);
|
||||||
title: 'Repositories',
|
|
||||||
description:
|
|
||||||
'Link project repositories so tickets can later discover related branches and pull requests.',
|
|
||||||
icon: FolderGit2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="project.name" />
|
<Head :title="project.name" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<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"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="gap-stack grid min-w-0">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<Badge
|
||||||
{{ 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
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
class="max-w-full justify-self-start whitespace-normal break-all"
|
||||||
|
>{{ project.key }}</Badge
|
||||||
>
|
>
|
||||||
<Link :href="projectTemplateCreate(project.id)">
|
<h1
|
||||||
Save as new template
|
class="text-headline-mobile md:text-headline break-words"
|
||||||
</Link>
|
>
|
||||||
</Button>
|
{{ project.name }}
|
||||||
|
</h1>
|
||||||
<Button as-child variant="outline">
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
<Link :href="index()">Back to projects</Link>
|
{{ t('projects.hubDescription') }}
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
</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">
|
<section
|
||||||
<CardHeader>
|
:aria-label="t('projects.workAreas')"
|
||||||
<CardTitle>
|
class="gap-column grid md:grid-cols-2"
|
||||||
Built from template {{ templateState.templateName }}
|
>
|
||||||
</CardTitle>
|
<Link
|
||||||
<CardDescription>
|
v-for="area in projectAreas"
|
||||||
This project keeps its template link. Inherited areas
|
:key="area.href.url"
|
||||||
follow the template baseline; overridden areas were
|
:href="area.href"
|
||||||
customized locally.
|
:aria-label="area.action"
|
||||||
</CardDescription>
|
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]"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent class="flex flex-wrap gap-2">
|
<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
|
<Badge
|
||||||
v-for="area in templateState.inheritedAreas"
|
v-for="area in templateState.inheritedAreas"
|
||||||
:key="`inherited-${area.value}`"
|
:key="`inherited-${area.value}`"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
class="max-w-full whitespace-normal break-words"
|
||||||
>
|
>
|
||||||
{{ area.label }} · inherited
|
{{ t('projects.inheritedArea', { name: area.label }) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-for="area in templateState.overriddenAreas"
|
v-for="area in templateState.overriddenAreas"
|
||||||
:key="`overridden-${area.value}`"
|
:key="`overridden-${area.value}`"
|
||||||
|
variant="secondary"
|
||||||
|
class="max-w-full whitespace-normal break-words"
|
||||||
>
|
>
|
||||||
{{ area.label }} · overridden
|
{{ t('projects.overriddenArea', { name: area.label }) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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:4178';
|
||||||
|
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 setRole = (value) =>
|
||||||
|
context.addCookies([{ name: 'project_test_role', value, url: base }]);
|
||||||
|
const content = () => page.locator('#main-content');
|
||||||
|
const focusWithTab = async (link) => {
|
||||||
|
await content().focus();
|
||||||
|
for (let step = 0; step < 30; 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('Project 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',
|
||||||
|
});
|
||||||
|
await setRole('admin');
|
||||||
|
for (const [path, title, label] of [
|
||||||
|
['/projects', 'Projects', 'index'],
|
||||||
|
[
|
||||||
|
'/projects/7?template',
|
||||||
|
'Engineering · Platform and developer experience',
|
||||||
|
'hub',
|
||||||
|
],
|
||||||
|
['/projects?empty', 'Projects', 'empty'],
|
||||||
|
]) {
|
||||||
|
await page.goto(base + path);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', {
|
||||||
|
name: title,
|
||||||
|
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,
|
||||||
|
`${label} overflows at ${width} in ${theme}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await content().locator('a a, a button').count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan23-${label}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/projects/7`);
|
||||||
|
const board = content().getByRole('link', {
|
||||||
|
name: 'Open board',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(board);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/board');
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Destination reached' })
|
||||||
|
.waitFor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await setRole('reader');
|
||||||
|
await page.goto(`${base}/projects`);
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.getByRole('link', { name: 'New project', exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const overview = content().getByRole('link', {
|
||||||
|
name: 'Open Engineering · Platform and developer experience overview',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(overview);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7');
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.locator(
|
||||||
|
'a[href*="/settings/"], a[href$="/access"], a[href*="/templates/"]',
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await content()
|
||||||
|
.getByRole('link', { name: 'Open backlog', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/projects/7/backlog');
|
||||||
|
|
||||||
|
await setRole('admin');
|
||||||
|
await page.goto(`${base}/projects/7`);
|
||||||
|
const settings = content().getByRole('region', {
|
||||||
|
name: 'Project settings',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assert.equal(await settings.getByRole('link').count(), 6);
|
||||||
|
const general = settings.getByRole('link', {
|
||||||
|
name: 'General',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(general);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/settings/general');
|
||||||
|
|
||||||
|
await setRole('creator');
|
||||||
|
await page.goto(`${base}/projects?empty`);
|
||||||
|
const create = content().getByRole('link', {
|
||||||
|
name: 'Create your first project',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(create);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/create');
|
||||||
|
await page.goto(`${base}/projects/7`);
|
||||||
|
await content()
|
||||||
|
.getByRole('link', { name: 'Save as new template', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/projects/7/templates/create');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project overview browser checks passed: keyboard, navigation, permissions, empty states, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Index from '../../resources/js/pages/projects/Index.vue';
|
||||||
|
import Show from '../../resources/js/pages/projects/Show.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
({ Index, Show })[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,263 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-overview-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4178, 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 secondProject = { id: 8, key: 'OPS', name: 'Operations' };
|
||||||
|
const templateState = {
|
||||||
|
inheritsFromTemplate: true,
|
||||||
|
templateName: 'Team baseline',
|
||||||
|
inheritedAreas: [{ value: 'board_columns', label: 'Board columns' }],
|
||||||
|
overriddenAreas: [{ value: 'ticket_types', label: 'Ticket types' }],
|
||||||
|
};
|
||||||
|
function makePage(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
manage = false,
|
||||||
|
create = false,
|
||||||
|
empty = false,
|
||||||
|
template = false,
|
||||||
|
status = null,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const path = new URL(url, 'http://localhost').pathname;
|
||||||
|
const isIndex = path === '/projects';
|
||||||
|
const isShow = path === '/projects/7';
|
||||||
|
return {
|
||||||
|
component: isIndex ? 'Index' : isShow ? 'Show' : 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: create,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: isIndex
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
...project,
|
||||||
|
canManage: manage,
|
||||||
|
canCreateTickets: manage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(isIndex
|
||||||
|
? { projects: empty ? [] : [project, secondProject], status }
|
||||||
|
: {
|
||||||
|
project,
|
||||||
|
canManageAccess: manage,
|
||||||
|
templateState: template ? templateState : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
// Isolated transport for real Vue pages. PHPUnit covers actual permissions and resources.
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (!url.pathname.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const role = request.headers.cookie?.match(
|
||||||
|
/project_test_role=(\w+)/,
|
||||||
|
)?.[1];
|
||||||
|
const page = makePage(request.url, {
|
||||||
|
manage: role === 'admin',
|
||||||
|
create: role === 'creator',
|
||||||
|
empty: url.searchParams.has('empty'),
|
||||||
|
template: url.searchParams.has('template'),
|
||||||
|
});
|
||||||
|
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/project-overview-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Project overview fixture ready at http://127.0.0.1:4178/projects',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const pages = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
['Index', 'Show'].map(async (name) => [
|
||||||
|
name,
|
||||||
|
(
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
`/resources/js/pages/projects/${name}.vue`,
|
||||||
|
)
|
||||||
|
).default,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const render = async (
|
||||||
|
url,
|
||||||
|
options = {},
|
||||||
|
locale = createLocalization(),
|
||||||
|
) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: makePage(url, options),
|
||||||
|
resolve: (name) => pages[name],
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return response.body.split('<main')[1].split('</main>')[0];
|
||||||
|
};
|
||||||
|
const index = await render('/projects');
|
||||||
|
assert.match(index, /2 projects available/);
|
||||||
|
for (const id of [7, 8]) {
|
||||||
|
for (const suffix of ['', '/board', '/backlog']) {
|
||||||
|
assert.ok(index.includes(`href="/projects/${id}${suffix}"`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(
|
||||||
|
index,
|
||||||
|
/href="\/projects\/create"|upcoming|will later/,
|
||||||
|
);
|
||||||
|
const empty = await render('/projects', { empty: true });
|
||||||
|
assert.match(empty, /No projects to show/);
|
||||||
|
assert.match(empty, /Ask a workspace or project administrator/);
|
||||||
|
assert.doesNotMatch(empty, /href="\/projects\/create"|<article/);
|
||||||
|
const creator = await render('/projects', {
|
||||||
|
empty: true,
|
||||||
|
create: true,
|
||||||
|
status: 'Project deleted.',
|
||||||
|
});
|
||||||
|
assert.match(creator, /href="\/projects\/create"/);
|
||||||
|
assert.match(creator, /Create your first project/);
|
||||||
|
assert.match(creator, /role="status"[^>]*data-feedback="success"/);
|
||||||
|
assert.match(creator, /Project deleted\./);
|
||||||
|
const reader = await render('/projects/7');
|
||||||
|
assert.match(reader, /aria-label="Open board"/);
|
||||||
|
assert.match(reader, /aria-label="Open backlog"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
reader,
|
||||||
|
/href="\/projects\/7\/(settings|access|templates)/,
|
||||||
|
);
|
||||||
|
const admin = await render('/projects/7', {
|
||||||
|
manage: true,
|
||||||
|
template: true,
|
||||||
|
});
|
||||||
|
for (const suffix of [
|
||||||
|
'settings/general',
|
||||||
|
'settings/board',
|
||||||
|
'settings/metadata',
|
||||||
|
'settings/repositories',
|
||||||
|
'settings/notifications',
|
||||||
|
'access',
|
||||||
|
]) {
|
||||||
|
assert.ok(admin.includes(`href="/projects/7/${suffix}"`));
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(admin, /href="\/projects\/7\/templates/);
|
||||||
|
assert.match(admin, /Based on Team baseline/);
|
||||||
|
assert.match(admin, /Board columns · inherited/);
|
||||||
|
assert.match(admin, /Ticket types · overridden/);
|
||||||
|
assert.match(
|
||||||
|
await render('/projects/7', { create: true }),
|
||||||
|
/href="\/projects\/7\/templates\/create"/,
|
||||||
|
);
|
||||||
|
const translated = await render(
|
||||||
|
'/projects',
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'navigation.projects': 'Projekte',
|
||||||
|
'projects.overview': 'Projektübersicht',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Projekte/);
|
||||||
|
assert.match(translated, /Projektübersicht/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
index + reader + admin,
|
||||||
|
/upcoming|will later|Use the project shortcuts/,
|
||||||
|
);
|
||||||
|
let insideLink = false;
|
||||||
|
for (const [tag] of (index + reader + admin).matchAll(
|
||||||
|
/<\/?a\b[^>]*>/g,
|
||||||
|
)) {
|
||||||
|
if (tag.startsWith('</')) {
|
||||||
|
insideLink = false;
|
||||||
|
} else {
|
||||||
|
assert.equal(insideLink, false, 'Links must not be nested');
|
||||||
|
insideLink = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Project overview behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user