Compare commits
42
Commits
2e2ddd5b74
...
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d | ||
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad | ||
|
|
002de1b293 | ||
|
|
707ebe6e48 | ||
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 | ||
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 | ||
|
|
802294d6a2 | ||
|
|
5e52e4fe78 | ||
|
|
f4a8bdd134 | ||
|
|
850ae337ca | ||
|
|
4adda6e53b | ||
|
|
de6f4e5b2f |
@@ -33,9 +33,6 @@
|
|||||||
vendor
|
vendor
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.env.example
|
|
||||||
.env.mariadb
|
|
||||||
.env.mariadb.example
|
|
||||||
.env.production
|
.env.production
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
MARIADB_DATABASE=kanbangit
|
|
||||||
MARIADB_USER=kanbangit
|
|
||||||
MARIADB_PASSWORD=
|
|
||||||
MARIADB_ROOT_PASSWORD=
|
|
||||||
@@ -2,7 +2,13 @@ name: CI/CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -11,9 +17,19 @@ env:
|
|||||||
REGISTRY: git.twyco.de
|
REGISTRY: git.twyco.de
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test_and_check_app:
|
preconditions:
|
||||||
name: test_and_check_app
|
if: >
|
||||||
|
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Conditions met
|
||||||
|
run: |
|
||||||
|
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||||
|
|
||||||
|
test_and_check_app:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- preconditions
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
@@ -27,6 +43,7 @@ jobs:
|
|||||||
github.event_name == 'push' &&
|
github.event_name == 'push' &&
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||||
needs:
|
needs:
|
||||||
|
- preconditions
|
||||||
- test_and_check_app
|
- test_and_check_app
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+5
-3
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
|||||||
COPY . /var/www
|
COPY . /var/www
|
||||||
|
|
||||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||||
&& npm ci \
|
&& corepack enable pnpm \
|
||||||
&& npm run build
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run build
|
||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
|
|
||||||
RUN touch .env \
|
RUN cp .env.example .env \
|
||||||
|
&& php artisan key:generate\
|
||||||
&& composer audit --locked --no-interaction \
|
&& composer audit --locked --no-interaction \
|
||||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
|||||||
'tickets' => TicketResource::collection(
|
'tickets' => TicketResource::collection(
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
|||||||
->where('assignee_user_id', $assignee->id)
|
->where('assignee_user_id', $assignee->id)
|
||||||
->whereIn('project_id', $visibleProjectIds)
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
->with([
|
->with([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'project',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'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\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -60,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'navigation' => fn (): array => [
|
||||||
|
'project' => $this->projectNavigation($request),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||||
|
*/
|
||||||
|
private function projectNavigation(Request $request): ?array
|
||||||
|
{
|
||||||
|
$project = $request->route('project');
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $project instanceof Project || $user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||||
|
|
||||||
|
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $project->id,
|
||||||
|
'name' => $project->name,
|
||||||
|
'key' => $project->key,
|
||||||
|
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||||
|
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -47,15 +47,15 @@
|
|||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
"@php artisan key:generate",
|
"@php artisan key:generate",
|
||||||
"@php artisan migrate --force",
|
"@php artisan migrate --force",
|
||||||
"npm install",
|
"pnpm install",
|
||||||
"npm run build"
|
"pnpm run build"
|
||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"@php artisan dev"
|
"@php artisan dev"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
"dev:ssr": [
|
||||||
"npm run build:ssr",
|
"pnpm run build:ssr",
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||||
],
|
],
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npm audit --audit-level=high",
|
"pnpm audit --audit-level=high",
|
||||||
"npm run lint:check",
|
"pnpm run lint:check",
|
||||||
"npm run types:check",
|
"pnpm run types:check",
|
||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const prettierOptions = {
|
|||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
|
'bootstrap/ssr/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'.github/**',
|
'.github/**',
|
||||||
|
|||||||
Generated
-7269
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,13 @@
|
|||||||
"$schema": "https://www.schemastore.org/package.json",
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"devEngines": {
|
||||||
|
"packageManager": {
|
||||||
|
"name": "pnpm",
|
||||||
|
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
|
||||||
|
"onFail": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:ssr": "vite build && vite build --ssr",
|
"build:ssr": "vite build && vite build --ssr",
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
"vue-tsc": "^2.2.4"
|
"vue-tsc": "^2.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inertiajs/core": "2.3.18",
|
||||||
"@inertiajs/vue3": "^2.3.7",
|
"@inertiajs/vue3": "^2.3.7",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
|
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
||||||
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
||||||
|
|||||||
Generated
+4832
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
minimumReleaseAge: 10080
|
||||||
|
allowBuilds:
|
||||||
|
unrs-resolver: true
|
||||||
|
vue-demi: true
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
import { Columns3 } from 'lucide-vue-next';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<span class="flex min-w-0 items-center gap-3">
|
||||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
<span
|
||||||
>
|
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
|
||||||
</div>
|
|
||||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
|
||||||
<span class="mb-0.5 truncate font-semibold leading-tight"
|
|
||||||
>Laravel Starter Kit</span
|
|
||||||
>
|
>
|
||||||
</div>
|
<Columns3 class="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
|
||||||
|
>KanbanGit</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, router } from '@inertiajs/vue3';
|
||||||
import {
|
import { Settings2, X } from 'lucide-vue-next';
|
||||||
BookOpen,
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
Folder,
|
|
||||||
FolderGit2,
|
|
||||||
LayoutGrid,
|
|
||||||
Layers3,
|
|
||||||
Shield,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import NavUser from '@/components/NavUser.vue';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -22,93 +13,98 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { index as adminUsers } from '@/routes/admin/users';
|
import { edit as profile } from '@/routes/profile';
|
||||||
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
import { show } from '@/routes/projects';
|
||||||
import { index as projectsIndex } from '@/routes/projects';
|
|
||||||
import { index as teamsIndex } from '@/routes/teams';
|
|
||||||
import type { NavItem } from '@/types';
|
|
||||||
|
|
||||||
const page = usePage();
|
const { t } = useLocalization();
|
||||||
|
const { currentUrl } = useCurrentUrl();
|
||||||
const mainNavItems = computed<NavItem[]>(() => {
|
const { project, workspaceItems, projectItems, projectSettingsItems } =
|
||||||
const items: NavItem[] = [
|
useAppNavigation();
|
||||||
{
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
title: 'Dashboard',
|
const accountItems = computed(() => [
|
||||||
href: dashboard(),
|
{
|
||||||
icon: LayoutGrid,
|
title: t('navigation.settings'),
|
||||||
},
|
href: profile(),
|
||||||
{
|
icon: Settings2,
|
||||||
title: 'Projects',
|
isActive: currentUrl.value.startsWith('/settings/'),
|
||||||
href: projectsIndex(),
|
},
|
||||||
icon: Folder,
|
]);
|
||||||
},
|
let removeNavigationListener: (() => void) | undefined;
|
||||||
];
|
onMounted(() => {
|
||||||
|
removeNavigationListener = router.on('navigate', () =>
|
||||||
if (page.props.auth.permissions.projectCreate) {
|
setOpenMobile(false),
|
||||||
items.push({
|
);
|
||||||
title: 'Project templates',
|
|
||||||
href: projectTemplatesIndex(),
|
|
||||||
icon: Layers3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.props.auth.permissions.globalAdmin) {
|
|
||||||
items.push({
|
|
||||||
title: 'Users',
|
|
||||||
href: adminUsers(),
|
|
||||||
icon: Shield,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.props.auth.permissions.canManageTeams) {
|
|
||||||
items.push({
|
|
||||||
title: 'Teams',
|
|
||||||
href: teamsIndex(),
|
|
||||||
icon: Users,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
});
|
});
|
||||||
|
onUnmounted(() => removeNavigationListener?.());
|
||||||
const footerNavItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Repository',
|
|
||||||
href: 'https://github.com/laravel/vue-starter-kit',
|
|
||||||
icon: FolderGit2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Documentation',
|
|
||||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
|
||||||
<SidebarHeader>
|
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
|
||||||
<SidebarMenu>
|
<div class="flex items-center gap-2">
|
||||||
<SidebarMenuItem>
|
<SidebarMenu class="min-w-0">
|
||||||
<SidebarMenuButton size="lg" as-child>
|
<SidebarMenuItem>
|
||||||
<Link :href="dashboard()">
|
<SidebarMenuButton
|
||||||
<AppLogo />
|
size="lg"
|
||||||
</Link>
|
as-child
|
||||||
</SidebarMenuButton>
|
tooltip="KanbanGit"
|
||||||
</SidebarMenuItem>
|
>
|
||||||
</SidebarMenu>
|
<Link :href="dashboard()" aria-label="KanbanGit"
|
||||||
|
><AppLogo
|
||||||
|
/></Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
<Button
|
||||||
|
v-if="isMobile"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="t('navigation.close')"
|
||||||
|
@click="setOpenMobile(false)"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
v-if="project"
|
||||||
|
:href="show(project.id)"
|
||||||
|
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
|
||||||
|
>{{ project.key }}</span
|
||||||
|
>
|
||||||
|
<span class="truncate font-semibold" :title="project.name">{{
|
||||||
|
project.name
|
||||||
|
}}</span>
|
||||||
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain :items="mainNavItems" />
|
<nav :aria-label="t('navigation.main')">
|
||||||
|
<NavMain
|
||||||
|
:items="workspaceItems"
|
||||||
|
:label="t('navigation.workspace')"
|
||||||
|
/>
|
||||||
|
<NavMain
|
||||||
|
v-if="projectItems.length"
|
||||||
|
:items="projectItems"
|
||||||
|
:label="t('navigation.project')"
|
||||||
|
/>
|
||||||
|
<NavMain
|
||||||
|
v-if="projectSettingsItems.length"
|
||||||
|
:items="projectSettingsItems"
|
||||||
|
:label="t('navigation.projectSettings')"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter class="border-sidebar-border border-t p-0">
|
||||||
<SidebarFooter>
|
<NavMain :items="accountItems" :label="t('navigation.account')" />
|
||||||
<NavFooter :items="footerNavItems" />
|
|
||||||
<NavUser />
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<slot />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,27 +1,145 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
|
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import {
|
||||||
|
normalizeAppearance,
|
||||||
|
useAppearance,
|
||||||
|
} from '@/composables/useAppearance';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { create as createProject } from '@/routes/projects';
|
||||||
|
import { index as board } from '@/routes/projects/board';
|
||||||
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
|
||||||
defineProps<{
|
breadcrumbs: () => [],
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
});
|
||||||
}>(),
|
const page = usePage();
|
||||||
{
|
const { project } = useAppNavigation();
|
||||||
breadcrumbs: () => [],
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
},
|
const { t } = useLocalization();
|
||||||
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const themeIcon = computed(
|
||||||
|
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||||
);
|
);
|
||||||
|
const createAction = computed(() => {
|
||||||
|
if (project.value?.canCreateTickets) {
|
||||||
|
return {
|
||||||
|
title: t('navigation.createTicket'),
|
||||||
|
href: createTicket(project.value.id, {
|
||||||
|
query: {
|
||||||
|
source: isCurrentUrl(board(project.value.id))
|
||||||
|
? 'board'
|
||||||
|
: 'backlog',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!project.value && page.props.auth.permissions.projectCreate) {
|
||||||
|
return { title: t('navigation.createProject'), href: createProject() };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
|
class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<SidebarTrigger class="-ml-1" />
|
<SidebarTrigger class="shrink-0" />
|
||||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
|
||||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||||
</template>
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="createAction"
|
||||||
|
as-child
|
||||||
|
:aria-label="createAction.title"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<Link :href="createAction.href"
|
||||||
|
><Plus aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ createAction.title }}</span
|
||||||
|
></Link
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="t('appearance.label')"
|
||||||
|
><component :is="themeIcon" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{{
|
||||||
|
t('appearance.label')
|
||||||
|
}}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
:model-value="appearance"
|
||||||
|
@update:model-value="
|
||||||
|
(value) =>
|
||||||
|
updateAppearance(normalizeAppearance(value))
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="light"
|
||||||
|
><Sun aria-hidden="true" />{{
|
||||||
|
t('appearance.light')
|
||||||
|
}}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="dark"
|
||||||
|
><Moon aria-hidden="true" />{{
|
||||||
|
t('appearance.dark')
|
||||||
|
}}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="system"
|
||||||
|
><Monitor aria-hidden="true" />{{
|
||||||
|
t('appearance.system')
|
||||||
|
}}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('navigation.userMenu', {
|
||||||
|
name: page.props.auth.user.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
data-test="sidebar-menu-button"
|
||||||
|
>
|
||||||
|
<UserAvatar :user="page.props.auth.user" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="min-w-60">
|
||||||
|
<UserMenuContent :user="page.props.auth.user" />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { TicketCard } from '@/components/tickets';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type {
|
||||||
|
BacklogTicketTreeEmits,
|
||||||
|
BacklogTicketTreeProps,
|
||||||
|
} from '@/types/backlog';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
const props = defineProps<BacklogTicketTreeProps>();
|
||||||
|
const emit = defineEmits<BacklogTicketTreeEmits>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const allowedTargetStatuses = (ticket: TicketModel) =>
|
||||||
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="grid min-w-0 gap-3">
|
||||||
|
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
|
||||||
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
|
<div class="flex justify-center pt-1">
|
||||||
|
<button
|
||||||
|
v-if="node.children.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
|
||||||
|
:aria-controls="`backlog-children-${node.ticket.id}`"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
collapsedTicketIds.has(node.ticket.id)
|
||||||
|
? 'backlog.tree.expand'
|
||||||
|
: 'backlog.tree.collapse',
|
||||||
|
{ key: node.ticket.ticket_key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="emit('toggle', node.ticket.id)"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
v-if="collapsedTicketIds.has(node.ticket.id)"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="bg-border mt-4 size-1.5 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="!node.matchesFilters"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-fit"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.parentContext') }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<TicketCard
|
||||||
|
:project-id="projectId"
|
||||||
|
:ticket="node.ticket"
|
||||||
|
layout="list"
|
||||||
|
:class="{ 'opacity-80': !node.matchesFilters }"
|
||||||
|
>
|
||||||
|
<template v-if="canEditTickets" #actions>
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
move.form({
|
||||||
|
project: projectId,
|
||||||
|
ticket: node.ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-slot="{ processing, errors }"
|
||||||
|
:error-bag="`backlog-ticket-${node.ticket.id}`"
|
||||||
|
:options="{ preserveScroll: true }"
|
||||||
|
:on-before="() => pendingTicketId === null"
|
||||||
|
class="grid min-w-0 gap-2"
|
||||||
|
@start="emit('moveStart', node.ticket.id)"
|
||||||
|
@finish="emit('moveFinish', node.ticket.id)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="redirect_to"
|
||||||
|
value="backlog"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="`backlog-ticket-status-${node.ticket.id}`"
|
||||||
|
class="text-caption text-muted-foreground font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('backlog.moveTicket', {
|
||||||
|
key: node.ticket.ticket_key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:id="`backlog-ticket-status-${node.ticket.id}`"
|
||||||
|
name="ticket_status_id"
|
||||||
|
:disabled="
|
||||||
|
pendingTicketId !== null ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errors.ticket_status_id || undefined
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
errors.ticket_status_id
|
||||||
|
? `backlog-ticket-status-error-${node.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(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
{{ t('board.noMoves') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="target in allowedTargetStatuses(
|
||||||
|
node.ticket,
|
||||||
|
)"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:id="`backlog-ticket-status-error-${node.ticket.id}`"
|
||||||
|
:message="errors.ticket_status_id"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
pendingTicketId !== null ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
processing
|
||||||
|
? 'board.updatingStatus'
|
||||||
|
: 'board.updateStatus',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.noMovesDescription') }}
|
||||||
|
</p>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
</TicketCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
node.children.length > 0 &&
|
||||||
|
!collapsedTicketIds.has(node.ticket.id)
|
||||||
|
"
|
||||||
|
:id="`backlog-children-${node.ticket.id}`"
|
||||||
|
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||||
|
>
|
||||||
|
<BacklogTicketTree
|
||||||
|
:nodes="node.children"
|
||||||
|
:project-id="projectId"
|
||||||
|
:can-edit-tickets="canEditTickets"
|
||||||
|
:collapsed-ticket-ids="collapsedTicketIds"
|
||||||
|
:pending-ticket-id="pendingTicketId"
|
||||||
|
@toggle="emit('toggle', $event)"
|
||||||
|
@move-start="emit('moveStart', $event)"
|
||||||
|
@move-finish="emit('moveFinish', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -9,29 +9,48 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
breadcrumbs: BreadcrumbItemType[];
|
breadcrumbs: BreadcrumbItemType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Breadcrumb>
|
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList class="flex-nowrap">
|
||||||
<template v-for="(item, index) in breadcrumbs" :key="index">
|
<template v-for="(item, index) in breadcrumbs" :key="index">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem
|
||||||
|
:class="[
|
||||||
|
'min-w-0',
|
||||||
|
index !== breadcrumbs.length - 1
|
||||||
|
? 'hidden md:inline-flex'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
<template v-if="index === breadcrumbs.length - 1">
|
<template v-if="index === breadcrumbs.length - 1">
|
||||||
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
|
<BreadcrumbPage class="truncate" :title="item.title">{{
|
||||||
|
item.title
|
||||||
|
}}</BreadcrumbPage>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<BreadcrumbLink as-child>
|
<BreadcrumbLink as-child>
|
||||||
<Link :href="item.href">{{ item.title }}</Link>
|
<Link
|
||||||
|
:href="item.href"
|
||||||
|
class="max-w-40 truncate"
|
||||||
|
:title="item.title"
|
||||||
|
>{{ item.title }}</Link
|
||||||
|
>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</template>
|
</template>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
|
<BreadcrumbSeparator
|
||||||
|
v-if="index !== breadcrumbs.length - 1"
|
||||||
|
class="hidden md:block"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|||||||
@@ -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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-show="message">
|
<div v-show="message" role="alert">
|
||||||
<p class="text-sm text-red-600 dark:text-red-500">
|
<p class="text-body text-destructive">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
|
label: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarGroup class="px-2 py-0">
|
<SidebarGroup class="px-3 py-2">
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
|
||||||
<SidebarMenu>
|
label
|
||||||
|
}}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu role="list" :aria-label="label">
|
||||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
as-child
|
as-child
|
||||||
:is-active="isCurrentUrl(item.href)"
|
:is-active="
|
||||||
|
item.isActive ??
|
||||||
|
isCurrentUrl(
|
||||||
|
item.href,
|
||||||
|
undefined,
|
||||||
|
item.match === 'section',
|
||||||
|
)
|
||||||
|
"
|
||||||
:tooltip="item.title"
|
:tooltip="item.title"
|
||||||
|
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
|
||||||
>
|
>
|
||||||
<Link :href="item.href">
|
<Link
|
||||||
<component :is="item.icon" />
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
(item.isActive ??
|
||||||
|
isCurrentUrl(
|
||||||
|
item.href,
|
||||||
|
undefined,
|
||||||
|
item.match === 'section',
|
||||||
|
))
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" aria-hidden="true" />
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Eye, EyeOff } from 'lucide-vue-next';
|
import { Eye, EyeOff } from 'lucide-vue-next';
|
||||||
import { ref, useTemplateRef } from 'vue';
|
import { ref, useTemplateRef } from 'vue';
|
||||||
import type { HTMLAttributes } from 'vue';
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes['class'];
|
class?: HTMLAttributes['class'];
|
||||||
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const model = defineModel<string | number | null>();
|
const model = defineModel<string | number | null>();
|
||||||
|
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const inputRef = useTemplateRef('inputRef');
|
const inputRef = useTemplateRef('inputRef');
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
$el: inputRef,
|
$el: inputRef,
|
||||||
@@ -28,7 +31,8 @@ defineExpose({
|
|||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="model"
|
v-model="model"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
:class="cn('pr-10', props.class)"
|
:class="cn('pr-12', props.class)"
|
||||||
|
:disabled="disabled"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -36,11 +40,14 @@ defineExpose({
|
|||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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'"
|
:aria-label="
|
||||||
:tabindex="-1"
|
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
||||||
|
"
|
||||||
|
:aria-pressed="showPassword"
|
||||||
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
<EyeOff v-if="showPassword" class="size-4" />
|
<EyeOff v-if="showPassword" class="size-4" />
|
||||||
<Eye v-else 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,45 +1,93 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { reactive } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeListProps } from '@/types/ticket-tree';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<TicketTreeListProps>();
|
||||||
nodes: TicketTreeNodeModel[];
|
|
||||||
projectId: number;
|
const { t } = useLocalization();
|
||||||
}>();
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
|
|
||||||
|
function toggleTicket(ticketId: number): void {
|
||||||
|
if (collapsedTicketIds.has(ticketId)) {
|
||||||
|
collapsedTicketIds.delete(ticketId);
|
||||||
|
} else {
|
||||||
|
collapsedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="space-y-3">
|
<ul class="grid min-w-0 gap-3">
|
||||||
<li v-for="node in nodes" :key="node.id">
|
<li v-for="node in nodes" :key="node.id" class="min-w-0">
|
||||||
<div class="border-border rounded-xl border px-4 py-4">
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex justify-center pt-1">
|
||||||
<Link
|
<button
|
||||||
:href="
|
v-if="node.children.length > 0"
|
||||||
showTicket({
|
type="button"
|
||||||
project: projectId,
|
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
ticket: node.id,
|
:aria-expanded="!collapsedTicketIds.has(node.id)"
|
||||||
})
|
:aria-controls="`ticket-tree-children-${node.id}`"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
collapsedTicketIds.has(node.id)
|
||||||
|
? 'tickets.tree.expand'
|
||||||
|
: 'tickets.tree.collapse',
|
||||||
|
{ key: node.ticket_key },
|
||||||
|
)
|
||||||
"
|
"
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
@click="toggleTicket(node.id)"
|
||||||
>
|
>
|
||||||
{{ node.ticket_key }}
|
<ChevronRight
|
||||||
</Link>
|
v-if="collapsedTicketIds.has(node.id)"
|
||||||
|
class="size-4"
|
||||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
aria-hidden="true"
|
||||||
{{ node.ticket_type_name }}
|
/>
|
||||||
</Badge>
|
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="bg-border mt-4 size-1.5 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-foreground mt-2 text-sm">
|
<Link
|
||||||
{{ node.title }}
|
:href="
|
||||||
</div>
|
showTicket({
|
||||||
|
project: projectId,
|
||||||
|
ticket: node.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ node.ticket_key }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||||
|
{{ node.ticket_type_name }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||||
|
>
|
||||||
|
{{ node.title }}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="node.children.length > 0"
|
v-if="
|
||||||
class="border-border mt-3 border-l pl-4"
|
node.children.length > 0 && !collapsedTicketIds.has(node.id)
|
||||||
|
"
|
||||||
|
:id="`ticket-tree-children-${node.id}`"
|
||||||
|
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||||
>
|
>
|
||||||
<TicketTreeList
|
<TicketTreeList
|
||||||
:nodes="node.children"
|
:nodes="node.children"
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useInitials } from '@/composables/useInitials';
|
|
||||||
import type { User } from '@/types';
|
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
|
||||||
user: User;
|
|
||||||
showEmail?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
showEmail: false,
|
showEmail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getInitials } = useInitials();
|
|
||||||
|
|
||||||
// Compute whether we should show the avatar image
|
|
||||||
const showAvatar = computed(
|
|
||||||
() => props.user.avatar && props.user.avatar !== '',
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UserAvatar />
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<UserAvatar :user="user" />
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-medium">{{ user.name }}</span>
|
<span class="truncate font-medium">{{ user.name }}</span>
|
||||||
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
|
<span
|
||||||
user.email
|
v-if="showEmail"
|
||||||
}}</span>
|
class="text-muted-foreground truncate text-xs"
|
||||||
|
>{{ user.email }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
|
|||||||
import { logout } from '@/routes/index';
|
import { logout } from '@/routes/index';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -21,6 +22,7 @@ const handleLogout = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -34,7 +36,7 @@ defineProps<Props>();
|
|||||||
<DropdownMenuItem :as-child="true">
|
<DropdownMenuItem :as-child="true">
|
||||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
||||||
<Settings class="mr-2 h-4 w-4" />
|
<Settings class="mr-2 h-4 w-4" />
|
||||||
Settings
|
{{ t('navigation.settings') }}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
@@ -48,7 +50,7 @@ defineProps<Props>();
|
|||||||
data-test="logout-button"
|
data-test="logout-button"
|
||||||
>
|
>
|
||||||
<LogOut class="mr-2 h-4 w-4" />
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
Log out
|
{{ t('navigation.logout') }}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
IndentDecrease,
|
||||||
|
IndentIncrease,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, ref, useId, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
canIndentAcceptanceCriterion,
|
||||||
|
indentAcceptanceCriterion,
|
||||||
|
removeAcceptanceCriterion,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: AcceptanceCriteriaModel;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
persistToggles?: boolean;
|
||||||
|
allowEditing?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
dirty?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
persistToggles: false,
|
||||||
|
allowEditing: true,
|
||||||
|
saving: undefined,
|
||||||
|
dirty: false,
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [items: AcceptanceCriteriaModel];
|
||||||
|
toggle: [index: number, checked: boolean];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const id = useId();
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editing = ref(!props.persistToggles);
|
||||||
|
const completed = computed(
|
||||||
|
() => props.modelValue.filter((item) => item.checked).length,
|
||||||
|
);
|
||||||
|
const locked = computed(() => props.readonly || props.disabled);
|
||||||
|
const update = (items: AcceptanceCriteriaModel): void => {
|
||||||
|
if (!locked.value) {
|
||||||
|
emit('update:modelValue', items);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusRow = async (index: number): Promise<void> => {
|
||||||
|
await nextTick();
|
||||||
|
const inputs =
|
||||||
|
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
|
||||||
|
if (inputs?.[index]) {
|
||||||
|
inputs[index].focus();
|
||||||
|
} else {
|
||||||
|
root.value
|
||||||
|
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const add = (): void => {
|
||||||
|
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
|
||||||
|
void focusRow(props.modelValue.length);
|
||||||
|
};
|
||||||
|
const remove = (index: number): void => {
|
||||||
|
update(removeAcceptanceCriterion(props.modelValue, index));
|
||||||
|
void focusRow(Math.max(0, index - 1));
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
root.value
|
||||||
|
?.querySelectorAll<HTMLTextAreaElement>('textarea')
|
||||||
|
.forEach((input) => {
|
||||||
|
input.setCustomValidity(
|
||||||
|
input.value.trim() === '' ? t('criteria.required') : '',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const changeText = (index: number, event: Event): void => {
|
||||||
|
const text = (event.target as HTMLTextAreaElement).value;
|
||||||
|
update(
|
||||||
|
props.modelValue.map((item, position) =>
|
||||||
|
position === index ? { ...item, text } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const toggle = (index: number, event: Event): void => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const checked = target.checked;
|
||||||
|
target.checked = props.modelValue[index].checked;
|
||||||
|
if (locked.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.persistToggles && !editing.value && !props.dirty) {
|
||||||
|
emit('toggle', index, checked);
|
||||||
|
} else {
|
||||||
|
update(
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
checked,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeydown = (index: number, event: KeyboardEvent): void => {
|
||||||
|
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
event.key === 'ArrowRight' ? 1 : -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="root"
|
||||||
|
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:aria-labelledby="`${id}-title`"
|
||||||
|
:aria-busy="disabled"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3
|
||||||
|
:id="`${id}-title`"
|
||||||
|
class="flex items-center gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<ClipboardCheck
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('criteria.title') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-muted-foreground text-sm" aria-live="polite">{{
|
||||||
|
t('criteria.progress', { completed, total: modelValue.length })
|
||||||
|
}}</span>
|
||||||
|
<Button
|
||||||
|
v-if="persistToggles && allowEditing && !readonly"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
(editing &&
|
||||||
|
modelValue.some((item) => item.text.trim() === ''))
|
||||||
|
"
|
||||||
|
:aria-pressed="editing"
|
||||||
|
@click="editing = !editing"
|
||||||
|
>
|
||||||
|
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('criteria.empty') }}
|
||||||
|
</p>
|
||||||
|
<ol v-else class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in modelValue"
|
||||||
|
:key="item.id ?? `new-${index}`"
|
||||||
|
:data-indent="item.indent"
|
||||||
|
class="border-border min-w-0"
|
||||||
|
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
|
||||||
|
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<label
|
||||||
|
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-60': locked }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="item.checked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="locked"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.complete', {
|
||||||
|
number: index + 1,
|
||||||
|
text: item.text,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-input accent-primary size-5 rounded"
|
||||||
|
@change="toggle(index, $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<textarea
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
:value="item.text"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.item', { number: index + 1 })
|
||||||
|
"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
required
|
||||||
|
rows="2"
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
|
||||||
|
@input="changeText(index, $event)"
|
||||||
|
@keydown="onKeydown(index, $event)"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground line-through':
|
||||||
|
item.checked,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
class="flex flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled || item.indent === 0"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.outdent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentDecrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!canIndentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.indent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentIncrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.remove', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="remove(index)"
|
||||||
|
><Trash2 aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<template v-if="editing && !readonly">
|
||||||
|
<Button
|
||||||
|
data-add-criterion
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="add"
|
||||||
|
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
|
||||||
|
>
|
||||||
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||||
|
{{ t('criteria.hint') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p
|
||||||
|
v-if="persistToggles && dirty"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.unsaved') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="saving ?? disabled"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.saving') }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" role="alert" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<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>(), {
|
||||||
|
layout: 'card',
|
||||||
|
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',
|
||||||
|
layout === 'card' ? 'shadow-sm' : '',
|
||||||
|
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
layout === 'list'
|
||||||
|
? '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 p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
|
||||||
|
layout === 'list'
|
||||||
|
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
|
||||||
|
: 'rounded-t-md',
|
||||||
|
]"
|
||||||
|
@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',
|
||||||
|
layout === 'list'
|
||||||
|
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind?: 'text' | 'number' | 'textarea' | 'select';
|
||||||
|
options?: { value: string | number; label: string }[];
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
min?: number;
|
||||||
|
step?: string;
|
||||||
|
}>(),
|
||||||
|
{ kind: 'text', rows: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = defineModel<string | number | null>({ required: true });
|
||||||
|
const controlClass =
|
||||||
|
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 content-start gap-2">
|
||||||
|
<Label :for="id">{{ label }}</Label>
|
||||||
|
<select
|
||||||
|
v-if="kind === 'select'"
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:class="controlClass"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
v-else-if="kind === 'textarea'"
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:rows="rows"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:class="controlClass"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:type="kind"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:min="min"
|
||||||
|
:step="step"
|
||||||
|
:maxlength="kind === 'text' ? 255 : undefined"
|
||||||
|
:invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
:id="`${id}-hint`"
|
||||||
|
class="text-muted-foreground text-xs empty:hidden"
|
||||||
|
>
|
||||||
|
{{ hint }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { ProjectTicketCreatePageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
Pick<
|
||||||
|
ProjectTicketCreatePageProps,
|
||||||
|
| 'project'
|
||||||
|
| 'ticketTypes'
|
||||||
|
| 'ticketPriorities'
|
||||||
|
| 'users'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
> & {
|
||||||
|
errors: Record<string, string>;
|
||||||
|
disabled?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const title = defineModel<string>('title', { required: true });
|
||||||
|
const description = defineModel<string>('description', { required: true });
|
||||||
|
const type = defineModel<string | number>('type', { required: true });
|
||||||
|
const priority = defineModel<string | number>('priority', { required: true });
|
||||||
|
const assignee = defineModel<string | number>('assignee', { required: true });
|
||||||
|
const parent = defineModel<string | number>('parent', { required: true });
|
||||||
|
const points = defineModel<string | number | null>('points', {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const status = defineModel<string | number>('status');
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
);
|
||||||
|
const priorityOptions = computed(() =>
|
||||||
|
props.ticketPriorities.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const userOptions = computed(() => [
|
||||||
|
{ value: '', label: t('tickets.unassigned') },
|
||||||
|
...props.users.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
]);
|
||||||
|
const parentOptions = computed(() => [
|
||||||
|
{ value: '', label: t('ticketDetail.noParent') },
|
||||||
|
...props.parentTicketOptions.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.ticket_key} - ${item.title}`,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
const statusOptions = computed(
|
||||||
|
() =>
|
||||||
|
props.ticketStatusOptions?.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-6">
|
||||||
|
<TicketFormField
|
||||||
|
id="title"
|
||||||
|
v-model="title"
|
||||||
|
:label="t('ticketDetail.title')"
|
||||||
|
:error="errors.title"
|
||||||
|
:disabled="disabled"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="description"
|
||||||
|
v-model="description"
|
||||||
|
kind="textarea"
|
||||||
|
:label="t('ticketDetail.description')"
|
||||||
|
:error="errors.description"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="t('ticketForm.descriptionPlaceholder')"
|
||||||
|
/>
|
||||||
|
<slot name="content" />
|
||||||
|
<fieldset
|
||||||
|
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<legend class="px-2 text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.metadata') }}
|
||||||
|
</legend>
|
||||||
|
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_type_id"
|
||||||
|
v-model="type"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.type')"
|
||||||
|
:options="typeOptions"
|
||||||
|
:error="errors.ticket_type_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_priority_id"
|
||||||
|
v-model="priority"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.priority')"
|
||||||
|
:options="priorityOptions"
|
||||||
|
:error="errors.ticket_priority_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="ticketStatusOptions && status !== undefined"
|
||||||
|
id="ticket_status_id"
|
||||||
|
v-model="status"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.status')"
|
||||||
|
:options="statusOptions"
|
||||||
|
:error="errors.ticket_status_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="assignee_user_id"
|
||||||
|
v-model="assignee"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.assignee')"
|
||||||
|
:options="userOptions"
|
||||||
|
:error="errors.assignee_user_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="parent_ticket_id"
|
||||||
|
v-model="parent"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.parent')"
|
||||||
|
:options="parentOptions"
|
||||||
|
:error="errors.parent_ticket_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="project.show_story_points"
|
||||||
|
id="story_points"
|
||||||
|
v-model="points"
|
||||||
|
kind="number"
|
||||||
|
:label="t('ticketDetail.storyPoints')"
|
||||||
|
:error="errors.story_points"
|
||||||
|
:disabled="disabled"
|
||||||
|
:min="0"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
<slot name="metadata" />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</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,518 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||||
|
import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue';
|
||||||
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import {
|
||||||
|
normalizeAcceptanceCriteria,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailContentProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const editor = useTemplateRef<HTMLDetailsElement>('editor');
|
||||||
|
async function editParent(): Promise<void> {
|
||||||
|
if (!props.canEditTicket || !editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.value.open = true;
|
||||||
|
await nextTick();
|
||||||
|
editor.value
|
||||||
|
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
defineExpose({ editParent });
|
||||||
|
const canEditAuthor = computed(
|
||||||
|
() => props.canEditTicket && props.canManageProject,
|
||||||
|
);
|
||||||
|
const canViewAuthor = computed(
|
||||||
|
() => props.project.show_author || props.canManageProject,
|
||||||
|
);
|
||||||
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||||
|
const acceptanceCriteriaDraft = useRemember(
|
||||||
|
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
|
||||||
|
`${draftKey}:criteria`,
|
||||||
|
);
|
||||||
|
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
|
||||||
|
const savedAcceptanceCriteria = ref(
|
||||||
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||||
|
);
|
||||||
|
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||||
|
const acceptanceCriteriaToggleError = ref('');
|
||||||
|
|
||||||
|
const savedFormData = () => ({
|
||||||
|
acceptance_criteria: normalizeAcceptanceCriteria(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
),
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||||
|
author_user_id: props.ticket.author_user_id ?? '',
|
||||||
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
|
description: props.ticket.description ?? '',
|
||||||
|
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
||||||
|
const currentFieldValue = props.ticket.field_values?.find(
|
||||||
|
(fieldValue) =>
|
||||||
|
fieldValue.ticket_field_definition_id ===
|
||||||
|
ticketFieldDefinition.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticket_field_definition_id: ticketFieldDefinition.id,
|
||||||
|
value_number: currentFieldValue?.value_number ?? '',
|
||||||
|
value_user_id: currentFieldValue?.value_user_id ?? '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm(draftKey, savedFormData());
|
||||||
|
|
||||||
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||||
|
JSON.stringify(savedAcceptanceCriteria.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
props.canEditTicket &&
|
||||||
|
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
||||||
|
const reset = (): void => {
|
||||||
|
if (
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value ||
|
||||||
|
!protection.confirmLeave()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
);
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptanceCriteriaError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.acceptance_criteria ??
|
||||||
|
Object.entries(form.errors).find(([key]) =>
|
||||||
|
key.startsWith('acceptance_criteria.'),
|
||||||
|
)?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSavedTicketPayload = (
|
||||||
|
acceptanceCriteria = savedAcceptanceCriteria.value,
|
||||||
|
) => ({
|
||||||
|
acceptance_criteria: acceptanceCriteria,
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id,
|
||||||
|
author_user_id: props.ticket.author_user_id,
|
||||||
|
blocked_comment: props.ticket.blocked_comment,
|
||||||
|
description: props.ticket.description,
|
||||||
|
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
||||||
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||||
|
value_number: fieldValue.value_number,
|
||||||
|
value_user_id: fieldValue.value_user_id,
|
||||||
|
})),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistAcceptanceCriteriaToggle = async (
|
||||||
|
index: number,
|
||||||
|
checked: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (
|
||||||
|
!props.canEditTicket ||
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value ||
|
||||||
|
hasUnsavedAcceptanceCriteriaDraft.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
index,
|
||||||
|
checked,
|
||||||
|
);
|
||||||
|
const csrfToken = document
|
||||||
|
.querySelector('meta[name="csrf-token"]')
|
||||||
|
?.getAttribute('content');
|
||||||
|
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value = true;
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
ProjectTicketController.update.url({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken ?? '',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(
|
||||||
|
buildSavedTicketPayload(nextAcceptanceCriteria),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Checklist toggle update failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
||||||
|
|
||||||
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
|
nextAcceptanceCriteria,
|
||||||
|
);
|
||||||
|
form.acceptance_criteria = nextAcceptanceCriteria;
|
||||||
|
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||||
|
} catch (_error) {
|
||||||
|
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
||||||
|
} finally {
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (
|
||||||
|
!props.canEditTicket ||
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.acceptance_criteria = acceptanceCriteriaItems.value;
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
ProjectTicketController.update({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||||
|
props.ticket.acceptance_criteria,
|
||||||
|
);
|
||||||
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
);
|
||||||
|
Object.assign(form, savedFormData());
|
||||||
|
form.defaults();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
|
<section
|
||||||
|
id="ticket-description"
|
||||||
|
aria-labelledby="ticket-description-title"
|
||||||
|
class="scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
><h2
|
||||||
|
id="ticket-description-title"
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.description') }}
|
||||||
|
</h2></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<p
|
||||||
|
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground': !ticket.description,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticket.description ||
|
||||||
|
t('ticketDetail.noDescription')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<div id="ticket-checklist" class="scroll-mt-6">
|
||||||
|
<AcceptanceCriteriaChecklist
|
||||||
|
:model-value="savedAcceptanceCriteria"
|
||||||
|
:readonly="!canEditTicket"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
|
hasUnsavedAcceptanceCriteriaDraft
|
||||||
|
"
|
||||||
|
:saving="
|
||||||
|
form.processing || isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
:allow-editing="false"
|
||||||
|
persist-toggles
|
||||||
|
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
||||||
|
:error="acceptanceCriteriaToggleError"
|
||||||
|
@toggle="persistAcceptanceCriteriaToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<details
|
||||||
|
v-if="canEditTicket"
|
||||||
|
id="ticket-edit"
|
||||||
|
ref="editor"
|
||||||
|
class="bg-card group rounded-xl border shadow-sm"
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.edit') }}
|
||||||
|
</summary>
|
||||||
|
<div class="border-t p-4 sm:p-6">
|
||||||
|
<p class="text-muted-foreground mb-6 text-sm">
|
||||||
|
{{ t('ticketDetail.editDescription') }}
|
||||||
|
</p>
|
||||||
|
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<TicketFormFields
|
||||||
|
v-model:title="form.title"
|
||||||
|
v-model:description="form.description"
|
||||||
|
v-model:type="form.ticket_type_id"
|
||||||
|
v-model:priority="form.ticket_priority_id"
|
||||||
|
v-model:assignee="form.assignee_user_id"
|
||||||
|
v-model:parent="form.parent_ticket_id"
|
||||||
|
v-model:points="form.story_points"
|
||||||
|
:project="project"
|
||||||
|
:ticket-types="ticketTypes"
|
||||||
|
:ticket-priorities="ticketPriorities"
|
||||||
|
:users="users"
|
||||||
|
:parent-ticket-options="parentTicketOptions"
|
||||||
|
:errors="form.errors"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<AcceptanceCriteriaChecklist
|
||||||
|
v-model="acceptanceCriteriaItems"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
:error="acceptanceCriteriaError"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #metadata>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="canViewAuthor"
|
||||||
|
id="author_user_id"
|
||||||
|
v-model="form.author_user_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.author')"
|
||||||
|
:hint="
|
||||||
|
!canEditAuthor
|
||||||
|
? t('ticketForm.authorRestricted')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t('ticketDetail.noAuthor'),
|
||||||
|
},
|
||||||
|
...users.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="form.errors.author_user_id"
|
||||||
|
:disabled="!canEditAuthor || form.processing"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TicketFormFields>
|
||||||
|
<fieldset
|
||||||
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<legend class="px-2 text-sm font-semibold">
|
||||||
|
{{ t('ticketForm.blocker') }}
|
||||||
|
</legend>
|
||||||
|
<div class="min-h-control flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="is_blocked"
|
||||||
|
v-model="form.is_blocked"
|
||||||
|
type="checkbox"
|
||||||
|
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||||
|
aria-describedby="is_blocked-error"
|
||||||
|
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
<Label for="is_blocked">{{
|
||||||
|
t('ticketDetail.blocked')
|
||||||
|
}}</Label>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="is_blocked-error"
|
||||||
|
:message="form.errors.is_blocked"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="blocked_comment"
|
||||||
|
v-model="form.blocked_comment"
|
||||||
|
kind="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:label="t('ticketDetail.blockerComment')"
|
||||||
|
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||||
|
:error="form.errors.blocked_comment"
|
||||||
|
:required="form.is_blocked"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset
|
||||||
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<legend class="px-2 text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.customFields') }}
|
||||||
|
</legend>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.customFieldsDescription') }}
|
||||||
|
</p>
|
||||||
|
<InputError :message="form.errors.field_values" />
|
||||||
|
<p
|
||||||
|
v-if="!ticketFieldDefinitions.length"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noCustomFields') }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||||
|
<template
|
||||||
|
v-for="(
|
||||||
|
definition, index
|
||||||
|
) in ticketFieldDefinitions"
|
||||||
|
:key="definition.id"
|
||||||
|
>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="definition.field_type === 'user'"
|
||||||
|
:id="`field-${definition.id}`"
|
||||||
|
v-model="
|
||||||
|
form.field_values[index].value_user_id
|
||||||
|
"
|
||||||
|
kind="select"
|
||||||
|
:label="definition.label"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t('tickets.unassigned'),
|
||||||
|
},
|
||||||
|
...users.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.value_user_id`
|
||||||
|
] ??
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.ticket_field_definition_id`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-else
|
||||||
|
:id="`field-${definition.id}`"
|
||||||
|
v-model="
|
||||||
|
form.field_values[index].value_number
|
||||||
|
"
|
||||||
|
kind="number"
|
||||||
|
step="0.1"
|
||||||
|
:label="definition.label"
|
||||||
|
:error="
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.value_number`
|
||||||
|
] ??
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.ticket_field_definition_id`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
form.processing
|
||||||
|
? t('ticketForm.saving')
|
||||||
|
: t('ticketDetail.saveChanges')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
|
!isDirty
|
||||||
|
"
|
||||||
|
@click="reset"
|
||||||
|
>{{ t('ticketForm.reset') }}</Button
|
||||||
|
>
|
||||||
|
<p role="status" class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
isDirty
|
||||||
|
? t('ticketForm.unsaved')
|
||||||
|
: t('ticketForm.saved')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
|
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
|
import type {
|
||||||
|
TicketDetailGitProps,
|
||||||
|
TicketBranchFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailGitProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const appName = String(usePage().props.name);
|
||||||
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||||
|
const visibleRepositoryBranches = computed(() =>
|
||||||
|
props.repositoryBranches.filter(
|
||||||
|
(repositoryBranch) =>
|
||||||
|
repositoryBranch.branches.length > 0 ||
|
||||||
|
repositoryBranch.error !== null ||
|
||||||
|
!repositoryBranch.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleRepositoryPullRequests = computed(() =>
|
||||||
|
props.repositoryPullRequests.filter(
|
||||||
|
(repositoryPullRequest) =>
|
||||||
|
repositoryPullRequest.pull_requests.length > 0 ||
|
||||||
|
repositoryPullRequest.error !== null ||
|
||||||
|
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||||
|
props.repositoryBranches.map(
|
||||||
|
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canCreateBranches = computed(
|
||||||
|
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
||||||
|
);
|
||||||
|
const defaultBranchRepositoryConnectionId = computed(() =>
|
||||||
|
String(
|
||||||
|
linkedRepositoryConnections.value.find(
|
||||||
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||||
|
)?.id ??
|
||||||
|
linkedRepositoryConnections.value[0]?.id ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCreateBranchDialogOpen = ref(false);
|
||||||
|
const isCheckingBranchName = ref(false);
|
||||||
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||||
|
|
||||||
|
const branchCreationForm = useForm<TicketBranchFormData>(`${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 =>
|
||||||
|
linkedRepositoryConnections.value.find(
|
||||||
|
(repositoryConnection) =>
|
||||||
|
String(repositoryConnection.id) ===
|
||||||
|
branchCreationForm.repository_connection_id,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedBranchRepositoryHasPersonalAccess = computed(
|
||||||
|
() =>
|
||||||
|
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeBranchCreationForm = (): void => {
|
||||||
|
if (branchCreationForm.isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
branchCreationForm.clearErrors();
|
||||||
|
branchCreationForm.branch_name = props.suggestedBranchName;
|
||||||
|
branchCreationForm.repository_connection_id =
|
||||||
|
defaultBranchRepositoryConnectionId.value;
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => isCreateBranchDialogOpen.value,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
branchCreationForm.clearErrors();
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeBranchCreationForm();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => isCreateBranchDialogOpen.value,
|
||||||
|
() => branchCreationForm.repository_connection_id,
|
||||||
|
() => branchCreationForm.branch_name,
|
||||||
|
],
|
||||||
|
(
|
||||||
|
[isOpen, repositoryConnectionId, branchName],
|
||||||
|
_previousValues,
|
||||||
|
onCleanup,
|
||||||
|
) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryConnection = selectedBranchRepositoryConnection.value;
|
||||||
|
|
||||||
|
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repositoryConnection.has_personal_access) {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.connectGitFirst'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchName.trim() === '') {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchRequired'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
isCheckingBranchName.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
ProjectTicketBranchController.validate.url(
|
||||||
|
{
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
branch_name: branchName,
|
||||||
|
repository_connection_id:
|
||||||
|
repositoryConnectionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Branch validation failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value =
|
||||||
|
(await response.json()) as BranchNameValidationModel;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === 'AbortError'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchValidationFailed', {
|
||||||
|
app: appName,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abortController.abort();
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitBranchCreation = (): void => {
|
||||||
|
if (
|
||||||
|
!canCreateBranches.value ||
|
||||||
|
!selectedBranchRepositoryHasPersonalAccess.value ||
|
||||||
|
branchNameValidation.value?.is_valid !== true
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchProtection.submit(
|
||||||
|
ProjectTicketBranchController.store({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
isCreateBranchDialogOpen.value = false;
|
||||||
|
},
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-git"
|
||||||
|
aria-labelledby="ticket-git-title"
|
||||||
|
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||||
|
>
|
||||||
|
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.git') }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid min-w-0 gap-6">
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.branchesDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
:open="isCreateBranchDialogOpen"
|
||||||
|
@update:open="updateBranchDialogOpen"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="canCreateBranches"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="gap-2"
|
||||||
|
@click="isCreateBranchDialogOpen = true"
|
||||||
|
>
|
||||||
|
<GitBranchPlus class="size-4" />{{
|
||||||
|
t('ticketDetail.createBranch')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader class="space-y-3">
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('ticketDetail.createBranchTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('ticketDetail.createBranchDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="grid gap-4"
|
||||||
|
@submit.prevent="submitBranchCreation"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="branchProtection.state.value"
|
||||||
|
@retry="submitBranchCreation"
|
||||||
|
/>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="repository_connection_id">{{
|
||||||
|
t('ticketDetail.repository')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="repository_connection_id"
|
||||||
|
v-model="
|
||||||
|
branchCreationForm.repository_connection_id
|
||||||
|
"
|
||||||
|
:disabled="
|
||||||
|
branchCreationForm.processing
|
||||||
|
"
|
||||||
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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-for="repositoryConnection in linkedRepositoryConnections"
|
||||||
|
:key="repositoryConnection.id"
|
||||||
|
:value="
|
||||||
|
String(repositoryConnection.id)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="
|
||||||
|
branchCreationForm.errors
|
||||||
|
.repository_connection_id
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="branch_name">{{
|
||||||
|
t('ticketDetail.branchName')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="branch_name"
|
||||||
|
v-model="branchCreationForm.branch_name"
|
||||||
|
:disabled="
|
||||||
|
branchCreationForm.processing ||
|
||||||
|
!selectedBranchRepositoryHasPersonalAccess
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="
|
||||||
|
branchCreationForm.errors
|
||||||
|
.branch_name
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border px-4 py-3"
|
||||||
|
:class="
|
||||||
|
branchNameValidation?.is_valid
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
||||||
|
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<CheckCircle2
|
||||||
|
v-if="
|
||||||
|
branchNameValidation?.is_valid
|
||||||
|
"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<XCircle
|
||||||
|
v-else-if="
|
||||||
|
branchNameValidation !== null
|
||||||
|
"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
isCheckingBranchName
|
||||||
|
? t(
|
||||||
|
'ticketDetail.checkingBranch',
|
||||||
|
)
|
||||||
|
: (branchNameValidation?.message ??
|
||||||
|
t(
|
||||||
|
'ticketDetail.selectRepository',
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!selectedBranchRepositoryHasPersonalAccess &&
|
||||||
|
selectedBranchRepositoryConnection !==
|
||||||
|
null
|
||||||
|
"
|
||||||
|
class="border-border rounded-xl border border-dashed px-4 py-4"
|
||||||
|
>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.branchAccess') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="updateBranchDialogOpen(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="
|
||||||
|
branchCreationForm.processing ||
|
||||||
|
isCheckingBranchName ||
|
||||||
|
branchNameValidation?.is_valid !==
|
||||||
|
true
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
t('ticketDetail.createNow')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="visibleRepositoryBranches.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noBranches')"
|
||||||
|
:description="t('tickets.noBranchesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="repositoryBranch in visibleRepositoryBranches"
|
||||||
|
:key="repositoryBranch.repository_connection.id"
|
||||||
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.base_url
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!repositoryBranch.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
class="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.inspectBranches') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button as-child variant="outline" size="sm">
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="repositoryBranch.error"
|
||||||
|
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
{{ repositoryBranch.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="branch in repositoryBranch.branches"
|
||||||
|
:key="branch.name"
|
||||||
|
variant="outline"
|
||||||
|
class="px-3 py-1"
|
||||||
|
>
|
||||||
|
{{ branch.name }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.pullRequestsDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="visibleRepositoryPullRequests.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noPullRequests')"
|
||||||
|
:description="t('tickets.noPullRequestsDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
||||||
|
:key="
|
||||||
|
repositoryPullRequest.repository_connection.id
|
||||||
|
"
|
||||||
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.base_url
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!repositoryPullRequest.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
class="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.inspectPullRequests') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button as-child variant="outline" size="sm">
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="repositoryPullRequest.error"
|
||||||
|
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
{{ repositoryPullRequest.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-3 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||||
|
:key="pullRequest.html_url"
|
||||||
|
class="border-border/70 rounded-xl border px-4 py-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge variant="outline">
|
||||||
|
#{{ pullRequest.number }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ pullRequest.status }}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="pullRequest.html_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="text-foreground hover:text-primary font-medium transition"
|
||||||
|
>
|
||||||
|
{{ pullRequest.title }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground mt-2 text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('ticketDetail.sourceBranch', {
|
||||||
|
name: pullRequest.source_branch,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
|
||||||
|
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
|
import { show } from '@/routes/projects';
|
||||||
|
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
|
||||||
|
defineProps<TicketDetailHeaderProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sections = [
|
||||||
|
'description',
|
||||||
|
'checklist',
|
||||||
|
'metadata',
|
||||||
|
'relationships',
|
||||||
|
'history',
|
||||||
|
'git',
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="flex min-w-0 flex-col gap-5">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Button as-child variant="ghost" class="max-w-full"
|
||||||
|
><Link :href="show(project.id)"
|
||||||
|
><ArrowLeft aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.backToProject')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="backlogIndex(project.id)"
|
||||||
|
><ListTree aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.openBacklog')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="boardIndex(project.id)"
|
||||||
|
><Columns3 aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.openBoard')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-primary font-mono text-sm font-semibold">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><Badge v-if="!canEditTicket" variant="outline">{{
|
||||||
|
t('ticketDetail.readOnly')
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
</h1>
|
||||||
|
<TicketMetadata :ticket="ticket" />
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
:aria-label="t('ticketDetail.sections')"
|
||||||
|
class="flex flex-wrap gap-1 border-b pb-3"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section"
|
||||||
|
:href="`#ticket-${section}`"
|
||||||
|
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>{{ t(`ticketDetail.${section}`) }}</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||||
|
defineProps<TicketDetailHistoryProps>();
|
||||||
|
const { t, date } = useLocalization();
|
||||||
|
const formatDateTime = (value: string): string =>
|
||||||
|
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-history"
|
||||||
|
aria-labelledby="ticket-history-title"
|
||||||
|
class="wrap-anywhere min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<h2 id="ticket-history-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.history') }}
|
||||||
|
</h2>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.historyDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
|
||||||
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noHistory') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-for="worklogEntry in ticket.worklog_entries"
|
||||||
|
:key="worklogEntry.id"
|
||||||
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ worklogEntry.entry_type }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-foreground text-sm font-medium">
|
||||||
|
{{
|
||||||
|
worklogEntry.actor?.name ??
|
||||||
|
t('ticketDetail.system')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-foreground mt-3 text-sm">
|
||||||
|
{{ worklogEntry.summary }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground mt-2 text-xs">
|
||||||
|
{{ formatDateTime(worklogEntry.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailMetadataProps>();
|
||||||
|
const { t, date, number } = useLocalization();
|
||||||
|
const fields = computed(() =>
|
||||||
|
props.ticketFieldDefinitions.map((definition) => {
|
||||||
|
const value = props.ticket.field_values?.find(
|
||||||
|
(field) => field.ticket_field_definition_id === definition.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: definition.id,
|
||||||
|
label: definition.label,
|
||||||
|
value:
|
||||||
|
definition.field_type === 'user'
|
||||||
|
? (value?.user_value?.name ?? t('tickets.unassigned'))
|
||||||
|
: value?.value_number != null
|
||||||
|
? number(value.value_number)
|
||||||
|
: t('ticketDetail.notSet'),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-metadata"
|
||||||
|
aria-labelledby="ticket-metadata-title"
|
||||||
|
class="min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card class="bg-muted/30">
|
||||||
|
<CardHeader
|
||||||
|
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.metadata') }}
|
||||||
|
</h2></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
|
||||||
|
<dl
|
||||||
|
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.status') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
ticket.ticket_status,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.type') }}
|
||||||
|
</dt>
|
||||||
|
<dd>{{ ticket.ticket_type.name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.priority') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
ticket.ticket_priority.name
|
||||||
|
}}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.assignee') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="ticket.assignee"
|
||||||
|
:user="ticket.assignee"
|
||||||
|
size="sm"
|
||||||
|
/>{{
|
||||||
|
ticket.assignee?.name ?? t('tickets.unassigned')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="project.show_author || canManageProject"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.author') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="ticket.author"
|
||||||
|
:user="ticket.author"
|
||||||
|
size="sm"
|
||||||
|
/>{{
|
||||||
|
ticket.author?.name ??
|
||||||
|
t('ticketDetail.noAuthor')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="project.show_story_points" class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.storyPoints') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
ticket.story_points != null
|
||||||
|
? number(ticket.story_points)
|
||||||
|
: t('ticketDetail.notSet')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="field in fields"
|
||||||
|
:key="field.id"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground">{{ field.label }}</dt>
|
||||||
|
<dd>{{ field.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div
|
||||||
|
v-if="ticket.is_blocked"
|
||||||
|
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
|
||||||
|
<p class="whitespace-pre-wrap">
|
||||||
|
{{ ticket.blocked_comment }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl
|
||||||
|
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt>{{ t('ticketDetail.created') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<time :datetime="ticket.created_at">{{
|
||||||
|
date(new Date(ticket.created_at), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt>{{ t('ticketDetail.updated') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<time :datetime="ticket.updated_at">{{
|
||||||
|
date(new Date(ticket.updated_at), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, useTemplateRef } from 'vue';
|
||||||
|
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
|
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
|
defineEmits<{ editParent: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
|
||||||
|
const currentParentTicket = computed(
|
||||||
|
() =>
|
||||||
|
props.ticket.parent_ticket ??
|
||||||
|
props.parentTicketOptions.find(
|
||||||
|
(parent) => parent.id === props.ticket.parent_ticket_id,
|
||||||
|
) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const canAddLink = computed(
|
||||||
|
() =>
|
||||||
|
props.canEditTicket &&
|
||||||
|
props.ticketLinkTypes.length > 0 &&
|
||||||
|
props.ticketLinkOptions.length > 0,
|
||||||
|
);
|
||||||
|
const form = useForm({
|
||||||
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
linked_ticket_id: '',
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
|
});
|
||||||
|
const selectedType = computed(() =>
|
||||||
|
props.ticketLinkTypes.find(
|
||||||
|
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedTicket = computed(() =>
|
||||||
|
props.ticketLinkOptions.find(
|
||||||
|
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function focusLinks(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
linksHeading.value?.focus();
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
if (!canAddLink.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
store({ project: props.project.id, ticket: props.ticket.id }),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.linked_ticket_id = '';
|
||||||
|
form.clearErrors();
|
||||||
|
void focusLinks();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-relationships"
|
||||||
|
aria-labelledby="ticket-relationships-title"
|
||||||
|
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||||
|
>
|
||||||
|
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.relationships') }}
|
||||||
|
</h2>
|
||||||
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
|
<GitFork class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('ticketRelationships.hierarchy')
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
v-if="canEditTicket"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('editParent')"
|
||||||
|
>{{ t('ticketRelationships.editParent') }}</Button
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<h4
|
||||||
|
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.parent') }}
|
||||||
|
</h4>
|
||||||
|
<Link
|
||||||
|
v-if="currentParentTicket"
|
||||||
|
:href="
|
||||||
|
showTicket({
|
||||||
|
project: project.id,
|
||||||
|
ticket: currentParentTicket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-primary font-mono text-xs font-semibold"
|
||||||
|
>{{ currentParentTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="group-hover:text-primary text-sm font-medium"
|
||||||
|
>{{ currentParentTicket.title }}</span
|
||||||
|
>
|
||||||
|
</Link>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noParent') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{{ t('ticketRelationships.parentHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="ticket.ticket_type?.is_parent"
|
||||||
|
class="grid min-w-0 gap-3 border-t pt-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.children') }}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
childTicketTree.length
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('ticketRelationships.childrenHelp') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="childTicketTree.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noChildren') }}
|
||||||
|
</p>
|
||||||
|
<TicketTreeList
|
||||||
|
v-else
|
||||||
|
:nodes="childTicketTree"
|
||||||
|
:project-id="project.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<h3
|
||||||
|
ref="linksHeading"
|
||||||
|
tabindex="-1"
|
||||||
|
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<Link2 class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.linkedTickets')
|
||||||
|
}}<Badge variant="secondary">{{
|
||||||
|
linkedTickets.length
|
||||||
|
}}</Badge>
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.linkedTicketsDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<p
|
||||||
|
v-if="linkedTickets.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noLinks') }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
|
<TicketRelationshipLink
|
||||||
|
v-for="link in linkedTickets"
|
||||||
|
:key="link.id"
|
||||||
|
:link="link"
|
||||||
|
:project-id="project.id"
|
||||||
|
:ticket-id="ticket.id"
|
||||||
|
:ticket-key="ticket.ticket_key"
|
||||||
|
:can-edit="canEditTicket"
|
||||||
|
@removed="focusLinks"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<form
|
||||||
|
v-if="canAddLink"
|
||||||
|
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<h4 class="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketRelationships.addLink')
|
||||||
|
}}
|
||||||
|
</h4>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||||
|
>
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_link_type_id"
|
||||||
|
v-model="form.ticket_link_type_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkType')"
|
||||||
|
:options="
|
||||||
|
ticketLinkTypes.map((type) => ({
|
||||||
|
value: type.id,
|
||||||
|
label: type.label,
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
:error="form.errors.ticket_link_type_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="linked_ticket_id"
|
||||||
|
v-model="form.linked_ticket_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkedTicket')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t(
|
||||||
|
'ticketRelationships.selectTicket',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...ticketLinkOptions.map((ticket) => ({
|
||||||
|
value: ticket.id,
|
||||||
|
label: `${ticket.ticket_key} — ${ticket.title}`,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="form.errors.linked_ticket_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<p
|
||||||
|
v-if="selectedType && selectedTicket"
|
||||||
|
aria-live="polite"
|
||||||
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><span>{{ selectedType.label }}</span
|
||||||
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
|
class="font-mono text-xs"
|
||||||
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('ticketRelationships.directionHelp') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing || !selectedTicket"
|
||||||
|
class="sm:justify-self-start"
|
||||||
|
>{{
|
||||||
|
form.processing
|
||||||
|
? t('forms.saving')
|
||||||
|
: t('ticketDetail.saveLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<p
|
||||||
|
v-else-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
ticketLinkTypes.length === 0
|
||||||
|
? 'ticketRelationships.noTypes'
|
||||||
|
: 'ticketRelationships.noTargets',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show } from '@/routes/projects/tickets';
|
||||||
|
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
link: TicketLinkModel;
|
||||||
|
projectId: number;
|
||||||
|
ticketId: number;
|
||||||
|
ticketKey: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
function remove(): void {
|
||||||
|
if (!props.canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
destroy({
|
||||||
|
project: props.projectId,
|
||||||
|
ticket: props.ticketId,
|
||||||
|
ticketLink: props.link.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => emit('removed'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="grid min-w-0 gap-3 rounded-xl border p-4"
|
||||||
|
:data-ticket-link="link.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-muted-foreground max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
v-if="outgoing"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/><ArrowDownLeft
|
||||||
|
v-else
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
outgoing
|
||||||
|
? 'ticketRelationships.outgoing'
|
||||||
|
: 'ticketRelationships.incoming',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
|
ticketKey
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
link.ticket_link_type.label
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
show({ project: projectId, ticket: link.linked_ticket.id })
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="text-primary font-mono text-xs font-semibold">{{
|
||||||
|
link.linked_ticket.ticket_key
|
||||||
|
}}</span>
|
||||||
|
<span class="wrap-anywhere text-sm font-medium">{{
|
||||||
|
link.linked_ticket.title
|
||||||
|
}}</span>
|
||||||
|
</Link>
|
||||||
|
<form v-if="canEdit" @submit.prevent="remove">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-muted-foreground hover:text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketRelationships.removeLink', {
|
||||||
|
key: link.linked_ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:disabled="form.processing"
|
||||||
|
><Unlink class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.removeLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
v-if="canEdit"
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="remove"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</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">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import DialogOverlay from "./DialogOverlay.vue"
|
import DialogOverlay from "./DialogOverlay.vue";
|
||||||
|
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
const props = withDefaults(
|
||||||
showCloseButton: true,
|
defineProps<
|
||||||
})
|
DialogContentProps & {
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
@close-auto-focus="restoreFocus"
|
||||||
cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'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 />
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
data-slot="dialog-close"
|
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"
|
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 />
|
<X />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">{{ t("common.close") }}</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,59 +1,71 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay
|
<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"
|
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"
|
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<DialogContent
|
||||||
<span class="sr-only">Close</span>
|
:class="
|
||||||
</DialogClose>
|
cn(
|
||||||
</DialogContent>
|
'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',
|
||||||
</DialogOverlay>
|
props.class,
|
||||||
</DialogPortal>
|
)
|
||||||
|
"
|
||||||
|
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>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
import type {
|
||||||
import type { HTMLAttributes } from "vue"
|
DropdownMenuCheckboxItemEmits,
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
DropdownMenuCheckboxItemProps,
|
||||||
import { Check } from "lucide-vue-next"
|
} from "reka-ui";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { Check } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuItemIndicator,
|
DropdownMenuItemIndicator,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class=" cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'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">
|
<span
|
||||||
<Check class="size-4" />
|
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||||
</slot>
|
>
|
||||||
</DropdownMenuItemIndicator>
|
<DropdownMenuItemIndicator>
|
||||||
</span>
|
<slot name="indicator-icon">
|
||||||
<slot />
|
<Check class="size-4" />
|
||||||
</DropdownMenuCheckboxItem>
|
</slot>
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
import type {
|
||||||
import type { HTMLAttributes } from "vue"
|
DropdownMenuContentEmits,
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
DropdownMenuContentProps,
|
||||||
|
} from "reka-ui";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
defineProps<
|
||||||
{
|
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
||||||
sideOffset: 4,
|
>(),
|
||||||
},
|
{
|
||||||
)
|
sideOffset: 4,
|
||||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
},
|
||||||
|
);
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
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)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuContent>
|
props.class,
|
||||||
</DropdownMenuPortal>
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,31 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuItemProps } from "reka-ui"
|
import type { DropdownMenuItemProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
const props = withDefaults(
|
||||||
class?: HTMLAttributes["class"]
|
defineProps<
|
||||||
inset?: boolean
|
DropdownMenuItemProps & {
|
||||||
variant?: "default" | "destructive"
|
class?: HTMLAttributes["class"];
|
||||||
}>(), {
|
inset?: boolean;
|
||||||
variant: "default",
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-slot="dropdown-menu-item"
|
data-slot="dropdown-menu-item"
|
||||||
:data-inset="inset ? '' : undefined"
|
:data-inset="inset ? '' : undefined"
|
||||||
:data-variant="variant"
|
:data-variant="variant"
|
||||||
v-bind="forwardedProps"
|
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)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuItem>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,40 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
import type {
|
||||||
import type { HTMLAttributes } from "vue"
|
DropdownMenuRadioItemEmits,
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
DropdownMenuRadioItemProps,
|
||||||
import { Circle } from "lucide-vue-next"
|
} from "reka-ui";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { Circle } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DropdownMenuItemIndicator,
|
DropdownMenuItemIndicator,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'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">
|
<span
|
||||||
<Circle class="size-2 fill-current" />
|
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||||
</slot>
|
>
|
||||||
</DropdownMenuItemIndicator>
|
<DropdownMenuItemIndicator>
|
||||||
</span>
|
<slot name="indicator-icon">
|
||||||
<slot />
|
<Circle class="size-2 fill-current" />
|
||||||
</DropdownMenuRadioItem>
|
</slot>
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
import type {
|
||||||
import type { HTMLAttributes } from "vue"
|
DropdownMenuSubContentEmits,
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
DropdownMenuSubContentProps,
|
||||||
import {
|
} from "reka-ui";
|
||||||
DropdownMenuSubContent,
|
import type { HTMLAttributes } from "vue";
|
||||||
useForwardPropsEmits,
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
} from "reka-ui"
|
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuSubContent
|
<DropdownMenuSubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
v-bind="forwarded"
|
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)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuSubContent>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuSubContent>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
import type { DropdownMenuSubTriggerProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { ChevronRight } from "lucide-vue-next"
|
import { ChevronRight } from "lucide-vue-next";
|
||||||
import {
|
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
|
||||||
DropdownMenuSubTrigger,
|
import { cn } from "@/lib/utils";
|
||||||
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 delegatedProps = reactiveOmit(props, "class", "inset");
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuSubTrigger
|
<DropdownMenuSubTrigger
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'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>
|
<slot />
|
||||||
|
<ChevronRight class="ml-auto size-4" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,62 +1,70 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import SheetOverlay from "./SheetOverlay.vue"
|
import SheetOverlay from "./SheetOverlay.vue";
|
||||||
|
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
interface SheetContentProps extends DialogContentProps {
|
interface SheetContentProps extends DialogContentProps {
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
side: "right",
|
side: "right",
|
||||||
})
|
});
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
side === 'right'
|
'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',
|
||||||
&& '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 === 'right' &&
|
||||||
side === 'left'
|
'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',
|
||||||
&& '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 === 'left' &&
|
||||||
side === 'top'
|
'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',
|
||||||
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
side === 'top' &&
|
||||||
side === 'bottom'
|
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
side === 'bottom' &&
|
||||||
props.class)"
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
props.class,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@close-auto-focus="restoreFocus"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<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"
|
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" />
|
<X class="size-4" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">{{ t("common.close") }}</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,96 +1,118 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SidebarProps } from "."
|
import type { SidebarProps } from ".";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
|
||||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
|
||||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
|
||||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||||
side: "left",
|
side: "left",
|
||||||
variant: "sidebar",
|
variant: "sidebar",
|
||||||
collapsible: "offcanvas",
|
collapsible: "offcanvas",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
function restoreTriggerFocus(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
document
|
||||||
|
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
|
||||||
v-if="collapsible === 'none'"
|
|
||||||
data-slot="sidebar"
|
|
||||||
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
|
|
||||||
v-bind="$attrs"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
|
||||||
<SheetContent
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
data-slot="sidebar"
|
|
||||||
data-mobile="true"
|
|
||||||
:side="side"
|
|
||||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
|
||||||
:style="{
|
|
||||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<SheetHeader class="sr-only">
|
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div class="flex h-full w-full flex-col">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="group peer text-sidebar-foreground hidden md:block"
|
|
||||||
data-slot="sidebar"
|
|
||||||
:data-state="state"
|
|
||||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
|
||||||
:data-variant="variant"
|
|
||||||
:data-side="side"
|
|
||||||
>
|
|
||||||
<!-- This is what handles the sidebar gap on desktop -->
|
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
v-if="collapsible === 'none'"
|
||||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
data-slot="sidebar"
|
||||||
'group-data-[collapsible=offcanvas]:w-0',
|
:class="
|
||||||
'group-data-[side=right]:rotate-180',
|
cn(
|
||||||
variant === 'floating' || variant === 'inset'
|
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
props.class,
|
||||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
)
|
||||||
)"
|
"
|
||||||
/>
|
v-bind="$attrs"
|
||||||
<div
|
|
||||||
:class="cn(
|
|
||||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
|
||||||
side === 'left'
|
|
||||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
|
||||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
|
||||||
// Adjust the padding for floating and inset variants.
|
|
||||||
variant === 'floating' || variant === 'inset'
|
|
||||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
|
||||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
|
||||||
props.class,
|
|
||||||
)"
|
|
||||||
v-bind="$attrs"
|
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
|
||||||
>
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
|
||||||
|
<SheetContent
|
||||||
|
v-bind="$attrs"
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
:side="side"
|
||||||
|
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
|
||||||
|
@close-auto-focus="restoreTriggerFocus"
|
||||||
|
:style="{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<SheetHeader class="sr-only">
|
||||||
|
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
|
||||||
|
<SheetDescription>{{
|
||||||
|
t("navigation.mobileDescription")
|
||||||
|
}}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="flex min-h-0 h-full w-full flex-col">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-slot="sidebar"
|
||||||
|
:data-state="state"
|
||||||
|
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-side="side"
|
||||||
|
>
|
||||||
|
<!-- This is what handles the sidebar gap on desktop -->
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||||
|
side === 'left'
|
||||||
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||||
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-content"
|
data-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
:class="cn(
|
:class="
|
||||||
'bg-background relative flex w-full flex-1 flex-col',
|
cn(
|
||||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
'bg-background relative flex min-w-0 w-full flex-1 flex-col',
|
||||||
props.class,
|
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||||
)"
|
props.class,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
</main>
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from "vue"
|
import type { Component } from "vue";
|
||||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
|
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import {
|
||||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
|
Tooltip,
|
||||||
import { useSidebar } from "./utils"
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
|
||||||
|
import { useSidebar } from "./utils";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
const props = withDefaults(
|
||||||
tooltip?: string | Component
|
defineProps<
|
||||||
}>(), {
|
SidebarMenuButtonProps & {
|
||||||
as: "button",
|
tooltip?: string | Component;
|
||||||
variant: "default",
|
}
|
||||||
size: "default",
|
>(),
|
||||||
})
|
{
|
||||||
|
as: "button",
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "tooltip")
|
const delegatedProps = reactiveOmit(props, "tooltip");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
<SidebarMenuButtonChild
|
||||||
<slot />
|
v-if="!tooltip || isMobile || state !== 'collapsed'"
|
||||||
</SidebarMenuButtonChild>
|
v-bind="{ ...delegatedProps, ...$attrs }"
|
||||||
|
|
||||||
<Tooltip v-else>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
|
||||||
<slot />
|
|
||||||
</SidebarMenuButtonChild>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
align="center"
|
|
||||||
:hidden="state !== 'collapsed' || isMobile"
|
|
||||||
>
|
>
|
||||||
<template v-if="typeof tooltip === 'string'">
|
<slot />
|
||||||
{{ tooltip }}
|
</SidebarMenuButtonChild>
|
||||||
</template>
|
|
||||||
<component :is="tooltip" v-else />
|
<Tooltip v-else>
|
||||||
</TooltipContent>
|
<TooltipTrigger as-child>
|
||||||
</Tooltip>
|
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||||
|
<slot />
|
||||||
|
</SidebarMenuButtonChild>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
:hidden="state !== 'collapsed' || isMobile"
|
||||||
|
>
|
||||||
|
<template v-if="typeof tooltip === 'string'">
|
||||||
|
{{ tooltip }}
|
||||||
|
</template>
|
||||||
|
<component :is="tooltip" v-else />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,82 +1,112 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes, Ref } from "vue"
|
import type { HTMLAttributes, Ref } from "vue";
|
||||||
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
|
import {
|
||||||
import { TooltipProvider } from "reka-ui"
|
defaultDocument,
|
||||||
import { computed, ref } from "vue"
|
useEventListener,
|
||||||
import { cn } from "@/lib/utils"
|
useMediaQuery,
|
||||||
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
|
useVModel,
|
||||||
|
} from "@vueuse/core";
|
||||||
|
import { TooltipProvider } from "reka-ui";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
provideSidebarContext,
|
||||||
|
SIDEBAR_COOKIE_MAX_AGE,
|
||||||
|
SIDEBAR_COOKIE_NAME,
|
||||||
|
SIDEBAR_KEYBOARD_SHORTCUT,
|
||||||
|
SIDEBAR_WIDTH,
|
||||||
|
SIDEBAR_WIDTH_ICON,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
defaultOpen?: boolean
|
defineProps<{
|
||||||
open?: boolean
|
defaultOpen?: boolean;
|
||||||
class?: HTMLAttributes["class"]
|
open?: boolean;
|
||||||
}>(), {
|
class?: HTMLAttributes["class"];
|
||||||
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
}>(),
|
||||||
open: undefined,
|
{
|
||||||
})
|
defaultOpen: !defaultDocument?.cookie.includes(
|
||||||
|
`${SIDEBAR_COOKIE_NAME}=false`,
|
||||||
|
),
|
||||||
|
open: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
"update:open": [open: boolean]
|
"update:open": [open: boolean];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
const isMobile = useMediaQuery("(width < 768px)");
|
||||||
const openMobile = ref(false)
|
const openMobile = ref(false);
|
||||||
|
watch(isMobile, () => {
|
||||||
|
openMobile.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
const open = useVModel(props, "open", emits, {
|
const open = useVModel(props, "open", emits, {
|
||||||
defaultValue: props.defaultOpen ?? false,
|
defaultValue: props.defaultOpen ?? false,
|
||||||
passive: (props.open === undefined) as false,
|
passive: (props.open === undefined) as false,
|
||||||
}) as Ref<boolean>
|
}) as Ref<boolean>;
|
||||||
|
|
||||||
function setOpen(value: boolean) {
|
function setOpen(value: boolean) {
|
||||||
open.value = value // emits('update:open', value)
|
open.value = value; // emits('update:open', value)
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// This sets the cookie to keep the sidebar state.
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOpenMobile(value: boolean) {
|
function setOpenMobile(value: boolean) {
|
||||||
openMobile.value = value
|
openMobile.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
return isMobile.value
|
||||||
|
? setOpenMobile(!openMobile.value)
|
||||||
|
: setOpen(!open.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEventListener("keydown", (event: KeyboardEvent) => {
|
useEventListener("keydown", (event: KeyboardEvent) => {
|
||||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
if (
|
||||||
event.preventDefault()
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
toggleSidebar()
|
(event.metaKey || event.ctrlKey)
|
||||||
}
|
) {
|
||||||
})
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
// This makes it easier to style the sidebar with Tailwind classes.
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
const state = computed(() => open.value ? "expanded" : "collapsed")
|
const state = computed(() => (open.value ? "expanded" : "collapsed"));
|
||||||
|
|
||||||
provideSidebarContext({
|
provideSidebarContext({
|
||||||
state,
|
state,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
isMobile,
|
isMobile,
|
||||||
openMobile,
|
openMobile,
|
||||||
setOpenMobile,
|
setOpenMobile,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider :delay-duration="0">
|
<TooltipProvider :delay-duration="0">
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-wrapper"
|
data-slot="sidebar-wrapper"
|
||||||
:style="{
|
:style="{
|
||||||
'--sidebar-width': SIDEBAR_WIDTH,
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
}"
|
}"
|
||||||
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
:class="
|
||||||
v-bind="$attrs"
|
cn(
|
||||||
>
|
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||||
<slot />
|
props.class,
|
||||||
</div>
|
)
|
||||||
</TooltipProvider>
|
"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next"
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSidebar } from "./utils"
|
import { useSidebar } from "./utils";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const { isMobile, state, toggleSidebar } = useSidebar()
|
const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
data-sidebar="trigger"
|
data-sidebar="trigger"
|
||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
:class="cn('h-7 w-7', props.class)"
|
type="button"
|
||||||
@click="toggleSidebar"
|
aria-controls="app-navigation"
|
||||||
>
|
:aria-expanded="isMobile ? openMobile : state === 'expanded'"
|
||||||
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
|
:aria-label="
|
||||||
<PanelLeftClose v-else />
|
isMobile
|
||||||
<span class="sr-only">Toggle sidebar</span>
|
? t(openMobile ? 'navigation.close' : 'navigation.open')
|
||||||
</Button>
|
: t(
|
||||||
|
state === 'collapsed'
|
||||||
|
? 'navigation.expand'
|
||||||
|
: 'navigation.collapse',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:class="cn('size-control', props.class)"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen
|
||||||
|
v-if="isMobile || state === 'collapsed'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PanelLeftClose v-else aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createContext } from "reka-ui"
|
import { createContext } from "reka-ui";
|
||||||
import type { ComputedRef, Ref } from 'vue';
|
import type { ComputedRef, Ref } from "vue";
|
||||||
|
|
||||||
export type SidebarContext = {
|
export type SidebarContext = {
|
||||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
state: ComputedRef<"expanded" | "collapsed">;
|
||||||
open: Ref<boolean>;
|
open: Ref<boolean>;
|
||||||
setOpen: (value: boolean) => void;
|
setOpen: (value: boolean) => void;
|
||||||
isMobile: Ref<boolean>;
|
isMobile: Ref<boolean>;
|
||||||
@@ -11,11 +11,12 @@ export type SidebarContext = {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
export const SIDEBAR_WIDTH = "16rem"
|
export const SIDEBAR_WIDTH = "16rem";
|
||||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
export const SIDEBAR_WIDTH_ICON = "4rem";
|
||||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
export const [useSidebar, provideSidebarContext] =
|
||||||
|
createContext<SidebarContext>("Sidebar");
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
projectNavigation,
|
||||||
|
projectSettingsNavigation,
|
||||||
|
workspaceNavigation,
|
||||||
|
} from '@/lib/navigation';
|
||||||
|
|
||||||
|
export function useAppNavigation() {
|
||||||
|
const page = usePage();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const project = computed(() => page.props.navigation?.project ?? null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
workspaceItems: computed(() =>
|
||||||
|
workspaceNavigation(page.props.auth.permissions, t),
|
||||||
|
),
|
||||||
|
projectItems: computed(() => projectNavigation(project.value, t)),
|
||||||
|
projectSettingsItems: computed(() =>
|
||||||
|
projectSettingsNavigation(project.value, t),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
/** Pointer capture keeps mouse, pen and touch drags on the same code path. */
|
||||||
|
export function useBoardDrag(options: {
|
||||||
|
canStart: (ticket: TicketModel) => boolean;
|
||||||
|
drop: (ticket: TicketModel, columnId: number) => void;
|
||||||
|
}) {
|
||||||
|
const ticket = shallowRef<TicketModel | null>(null);
|
||||||
|
const active = ref(false);
|
||||||
|
const x = ref(0);
|
||||||
|
const y = ref(0);
|
||||||
|
const columnId = ref<number | null>(null);
|
||||||
|
let pointerId: number | null = null;
|
||||||
|
let handle: HTMLElement | null = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const draggedTicket = computed(() => (active.value ? ticket.value : null));
|
||||||
|
|
||||||
|
function locateTarget(): void {
|
||||||
|
const target = document
|
||||||
|
.elementFromPoint(x.value, y.value)
|
||||||
|
?.closest<HTMLElement>('[data-board-drop-column]');
|
||||||
|
columnId.value = target ? Number(target.dataset.boardDropColumn) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scroll(): void {
|
||||||
|
if (!active.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = document.elementFromPoint(x.value, y.value);
|
||||||
|
const region = element?.closest<HTMLElement>(
|
||||||
|
'[data-board-drag-scroll]',
|
||||||
|
);
|
||||||
|
if (region) {
|
||||||
|
const bounds = region.getBoundingClientRect();
|
||||||
|
const left = Math.max(0, bounds.left);
|
||||||
|
const right = Math.min(window.innerWidth, bounds.right);
|
||||||
|
const delta =
|
||||||
|
x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0;
|
||||||
|
region.scrollLeft += delta;
|
||||||
|
}
|
||||||
|
if (!element?.closest('[data-board-mobile-targets]')) {
|
||||||
|
const delta =
|
||||||
|
y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0;
|
||||||
|
window.scrollBy(0, delta);
|
||||||
|
}
|
||||||
|
locateTarget();
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
const previousPointer = pointerId;
|
||||||
|
pointerId = null;
|
||||||
|
active.value = false;
|
||||||
|
ticket.value = null;
|
||||||
|
columnId.value = null;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
if (
|
||||||
|
previousPointer !== null &&
|
||||||
|
handle?.hasPointerCapture(previousPointer)
|
||||||
|
) {
|
||||||
|
handle.releasePointerCapture(previousPointer);
|
||||||
|
}
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(event: PointerEvent, source: TicketModel): void {
|
||||||
|
if (
|
||||||
|
pointerId !== null ||
|
||||||
|
!event.isPrimary ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
!options.canStart(source)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle = event.currentTarget as HTMLElement;
|
||||||
|
handle.focus({ preventScroll: true });
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
ticket.value = source;
|
||||||
|
startX = x.value = event.clientX;
|
||||||
|
startY = y.value = event.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event: PointerEvent): void {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.value = event.clientX;
|
||||||
|
y.value = event.clientY;
|
||||||
|
if (
|
||||||
|
!active.value &&
|
||||||
|
Math.hypot(x.value - startX, y.value - startY) >= 6
|
||||||
|
) {
|
||||||
|
active.value = true;
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
if (active.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
locateTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(event: PointerEvent): void {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.value = event.clientX;
|
||||||
|
y.value = event.clientY;
|
||||||
|
locateTarget();
|
||||||
|
const source = draggedTicket.value;
|
||||||
|
const target = columnId.value;
|
||||||
|
cancel();
|
||||||
|
if (source && target !== null) {
|
||||||
|
options.drop(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPointer(event: PointerEvent): void {
|
||||||
|
if (event.pointerId === pointerId) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape' && pointerId !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointermove', move, { passive: false });
|
||||||
|
document.addEventListener('pointerup', finish);
|
||||||
|
document.addEventListener('pointercancel', cancelPointer);
|
||||||
|
document.addEventListener('lostpointercapture', cancelPointer);
|
||||||
|
document.addEventListener('keydown', keydown);
|
||||||
|
window.addEventListener('blur', cancel);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancel();
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', finish);
|
||||||
|
document.removeEventListener('pointercancel', cancelPointer);
|
||||||
|
document.removeEventListener('lostpointercapture', cancelPointer);
|
||||||
|
document.removeEventListener('keydown', keydown);
|
||||||
|
window.removeEventListener('blur', cancel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { draggedTicket, columnId, x, y, start, cancel };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
|
|||||||
import type { ComputedRef, DeepReadonly } from 'vue';
|
import type { ComputedRef, DeepReadonly } from 'vue';
|
||||||
import { computed, readonly } from 'vue';
|
import { computed, readonly } from 'vue';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
|
import { matchesNavigationUrl } from '@/lib/navigation';
|
||||||
|
|
||||||
export type UseCurrentUrlReturn = {
|
export type UseCurrentUrlReturn = {
|
||||||
currentUrl: DeepReadonly<ComputedRef<string>>;
|
currentUrl: DeepReadonly<ComputedRef<string>>;
|
||||||
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
|
|||||||
) => T | F;
|
) => T | F;
|
||||||
};
|
};
|
||||||
|
|
||||||
const page = usePage();
|
|
||||||
const currentUrlReactive = computed(
|
|
||||||
() =>
|
|
||||||
new URL(
|
|
||||||
page.url,
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? window.location.origin
|
|
||||||
: 'http://localhost',
|
|
||||||
).pathname,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||||
|
const page = usePage();
|
||||||
|
const currentUrlReactive = computed(
|
||||||
|
() => new URL(page.url, 'http://localhost').pathname,
|
||||||
|
);
|
||||||
function isCurrentUrl(
|
function isCurrentUrl(
|
||||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||||
currentUrl?: string,
|
currentUrl?: string,
|
||||||
startsWith: boolean = false,
|
startsWith: boolean = false,
|
||||||
) {
|
) {
|
||||||
const urlToCompare = currentUrl ?? currentUrlReactive.value;
|
return matchesNavigationUrl(
|
||||||
const urlString = toUrl(urlToCheck);
|
toUrl(urlToCheck),
|
||||||
|
currentUrl ?? currentUrlReactive.value,
|
||||||
const comparePath = (path: string): boolean =>
|
startsWith,
|
||||||
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare;
|
);
|
||||||
|
|
||||||
if (!urlString.startsWith('http')) {
|
|
||||||
return comparePath(urlString);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const absoluteUrl = new URL(urlString);
|
|
||||||
|
|
||||||
return comparePath(absoluteUrl.pathname);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCurrentOrParentUrl(
|
function isCurrentOrParentUrl(
|
||||||
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
|||||||
return isCurrentUrl(urlToCheck, currentUrl, true);
|
return isCurrentUrl(urlToCheck, currentUrl, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function whenCurrentUrl(
|
function whenCurrentUrl<T, F = null>(
|
||||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||||
ifTrue: any,
|
ifTrue: T,
|
||||||
ifFalse: any = null,
|
ifFalse: F = null as F,
|
||||||
) {
|
): T | F {
|
||||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 AppShell from '@/components/AppShell.vue';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||||
|
import ProjectNavigation from '@/components/ProjectNavigation.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,14 +14,27 @@ type Props = {
|
|||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
breadcrumbs: () => [],
|
breadcrumbs: () => [],
|
||||||
});
|
});
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell variant="sidebar">
|
<AppShell variant="sidebar">
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
class="bg-primary text-primary-foreground sr-only fixed left-4 top-4 z-50 rounded-md p-3 focus:not-sr-only"
|
||||||
|
>{{ t('navigation.skipToContent') }}</a
|
||||||
|
>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
<AppContent variant="sidebar" class="min-w-0">
|
||||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||||
<slot />
|
<ProjectNavigation />
|
||||||
|
<div
|
||||||
|
id="main-content"
|
||||||
|
tabindex="-1"
|
||||||
|
class="flex min-w-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||||
import { edit as editApplications } from '@/routes/applications/index';
|
import { edit as editApplications } from '@/routes/applications/index';
|
||||||
@@ -12,28 +14,29 @@ import { edit as editProfile } from '@/routes/profile/index';
|
|||||||
import { edit as editSecurity } from '@/routes/security/index';
|
import { edit as editSecurity } from '@/routes/security/index';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const sidebarNavItems: NavItem[] = [
|
const { t } = useLocalization();
|
||||||
|
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||||
{
|
{
|
||||||
title: 'Profile',
|
title: t('navigation.profile'),
|
||||||
href: editProfile(),
|
href: editProfile(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Security',
|
title: t('navigation.security'),
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Git connections',
|
title: t('navigation.gitConnections'),
|
||||||
href: editGitConnections(),
|
href: editGitConnections(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Applications',
|
title: t('navigation.applications'),
|
||||||
href: editApplications(),
|
href: editApplications(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Appearance',
|
title: t('navigation.appearance'),
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||||
</script>
|
</script>
|
||||||
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
<Heading
|
<Heading
|
||||||
title="Settings"
|
:title="t('navigation.settings')"
|
||||||
description="Manage your profile, security, and Git integration settings"
|
:description="t('navigation.settingsDescription')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row lg:space-x-12">
|
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
||||||
<aside class="w-full max-w-xl lg:w-48">
|
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
||||||
<nav
|
<nav
|
||||||
class="flex flex-col space-x-0 space-y-1"
|
class="flex gap-1 overflow-x-auto lg:flex-col"
|
||||||
aria-label="Settings"
|
:aria-label="t('navigation.settings')"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-for="item in sidebarNavItems"
|
v-for="item in sidebarNavItems"
|
||||||
:key="toUrl(item.href)"
|
:key="toUrl(item.href)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:class="[
|
:class="[
|
||||||
'w-full justify-start',
|
'shrink-0 justify-start lg:w-full',
|
||||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
||||||
]"
|
]"
|
||||||
as-child
|
as-child
|
||||||
>
|
>
|
||||||
<Link :href="item.href">
|
<Link
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentOrParentUrl(item.href)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
<component :is="item.icon" class="h-4 w-4" />
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Separator class="my-6 lg:hidden" />
|
<Separator class="lg:hidden" />
|
||||||
|
|
||||||
<div class="flex-1 md:max-w-2xl">
|
<div class="min-w-0 flex-1 md:max-w-2xl">
|
||||||
<section class="max-w-xl space-y-12">
|
<section class="max-w-xl space-y-12">
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,6 +5,76 @@ import type {
|
|||||||
|
|
||||||
const MAX_INDENT_LEVEL = 8;
|
const MAX_INDENT_LEVEL = 8;
|
||||||
|
|
||||||
|
export const canIndentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): boolean => {
|
||||||
|
const item = items[index];
|
||||||
|
const previous = items[index - 1];
|
||||||
|
if (!item || !previous || item.indent > previous.indent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
|
||||||
|
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptanceCriteriaSubtreeEnd = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number => {
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < items.length && items[end].indent > items[index].indent) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const indentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (
|
||||||
|
!items[index] ||
|
||||||
|
(direction === 1
|
||||||
|
? !canIndentAcceptanceCriterion(items, index)
|
||||||
|
: items[index].indent === 0)
|
||||||
|
) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.map((item, position) =>
|
||||||
|
position >= index && position < end
|
||||||
|
? { ...item, indent: item.indent + direction }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove only the selected criterion, promoting its children without losing them. */
|
||||||
|
export const removeAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (!items[index]) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.flatMap((item, position) =>
|
||||||
|
position === index
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
indent:
|
||||||
|
position > index && position < end
|
||||||
|
? item.indent - 1
|
||||||
|
: item.indent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AcceptanceCriteriaEditorState = {
|
export type AcceptanceCriteriaEditorState = {
|
||||||
checklist: string;
|
checklist: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type {
|
||||||
|
BacklogFilterState,
|
||||||
|
BacklogTicketTreeNode,
|
||||||
|
} from '@/types/backlog';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export function buildBacklogTicketTree(
|
||||||
|
tickets: TicketModel[],
|
||||||
|
): BacklogTicketTreeNode[] {
|
||||||
|
const nodesByTicketId = new Map<number, BacklogTicketTreeNode>();
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
nodesByTicketId.set(ticket.id, {
|
||||||
|
children: [],
|
||||||
|
matchesFilters: true,
|
||||||
|
ticket,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: BacklogTicketTreeNode[] = [];
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
const node = nodesByTicketId.get(ticket.id);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentNode =
|
||||||
|
ticket.parent_ticket_id === null
|
||||||
|
? undefined
|
||||||
|
: nodesByTicketId.get(ticket.parent_ticket_id);
|
||||||
|
|
||||||
|
if (parentNode) {
|
||||||
|
parentNode.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterBacklogTicketTree(
|
||||||
|
nodes: BacklogTicketTreeNode[],
|
||||||
|
filters: BacklogFilterState,
|
||||||
|
): BacklogTicketTreeNode[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
const children = filterBacklogTicketTree(node.children, filters);
|
||||||
|
const matchesFilters = ticketMatchesFilters(node.ticket, filters);
|
||||||
|
|
||||||
|
if (!matchesFilters && children.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...node,
|
||||||
|
children,
|
||||||
|
matchesFilters,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countBacklogTreeTickets(
|
||||||
|
nodes: BacklogTicketTreeNode[],
|
||||||
|
): number {
|
||||||
|
return nodes.reduce(
|
||||||
|
(count, node) => count + 1 + countBacklogTreeTickets(node.children),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandableBacklogTicketIds(
|
||||||
|
nodes: BacklogTicketTreeNode[],
|
||||||
|
): number[] {
|
||||||
|
return nodes.flatMap((node) => [
|
||||||
|
...(node.children.length > 0 ? [node.ticket.id] : []),
|
||||||
|
...expandableBacklogTicketIds(node.children),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketMatchesFilters(
|
||||||
|
ticket: TicketModel,
|
||||||
|
filters: BacklogFilterState,
|
||||||
|
): boolean {
|
||||||
|
const query = filters.query.trim().toLocaleLowerCase();
|
||||||
|
const searchableText = [
|
||||||
|
ticket.ticket_key,
|
||||||
|
ticket.title,
|
||||||
|
ticket.description,
|
||||||
|
ticket.ticket_type.name,
|
||||||
|
ticket.ticket_priority.name,
|
||||||
|
ticket.ticket_status?.name,
|
||||||
|
ticket.assignee?.name,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(' ')
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
(query === '' || searchableText.includes(query)) &&
|
||||||
|
(filters.typeId === '' || ticket.ticket_type_id === filters.typeId) &&
|
||||||
|
(filters.priorityId === '' ||
|
||||||
|
ticket.ticket_priority_id === filters.priorityId) &&
|
||||||
|
(filters.statusId === '' ||
|
||||||
|
ticket.ticket_status_id === filters.statusId) &&
|
||||||
|
(filters.assigneeId === '' ||
|
||||||
|
(filters.assigneeId === 'unassigned'
|
||||||
|
? ticket.assignee_user_id === null
|
||||||
|
: ticket.assignee_user_id === filters.assigneeId))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,416 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'criteria.title': 'Acceptance criteria',
|
||||||
|
'criteria.progress': '{completed} of {total} complete',
|
||||||
|
'criteria.empty': 'No acceptance criteria yet.',
|
||||||
|
'criteria.required': 'Enter a criterion or remove this row.',
|
||||||
|
'criteria.edit': 'Edit checklist',
|
||||||
|
'criteria.preview': 'Preview checklist',
|
||||||
|
'criteria.item': 'Criterion {number}',
|
||||||
|
'criteria.complete': 'Complete criterion {number}: {text}',
|
||||||
|
'criteria.indent': 'Indent criterion {number}',
|
||||||
|
'criteria.outdent': 'Outdent criterion {number}',
|
||||||
|
'criteria.remove': 'Remove criterion {number}',
|
||||||
|
'criteria.add': 'Add criterion',
|
||||||
|
'criteria.hint':
|
||||||
|
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
|
||||||
|
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
|
||||||
|
'criteria.saving': 'Saving checklist…',
|
||||||
|
'criteria.saveError':
|
||||||
|
'Checklist status could not be saved. Please try again.',
|
||||||
|
|
||||||
|
'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.drag': 'Drag to move',
|
||||||
|
'board.dragTicket': 'Drag {key} to change status',
|
||||||
|
'board.dragHint':
|
||||||
|
'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.',
|
||||||
|
'board.dropHint': 'Drop on a column. Hold near an edge to scroll.',
|
||||||
|
'board.dragging':
|
||||||
|
'Dragging {key}. Drop on an allowed column or press Escape to cancel.',
|
||||||
|
'board.chooseStatus': 'Choose a destination status',
|
||||||
|
'board.chooseStatusDescription':
|
||||||
|
'More than one status is available for {key} in this column.',
|
||||||
|
'board.targetStatus': 'Destination status',
|
||||||
|
'board.noMovesDescription':
|
||||||
|
'This status does not allow moving anywhere else.',
|
||||||
|
'backlog.title': '{name} backlog',
|
||||||
|
'backlog.description':
|
||||||
|
'Shape incoming work without losing sight of parent and child tickets.',
|
||||||
|
'backlog.openBoard': 'Open board',
|
||||||
|
'backlog.backToProject': 'Project overview',
|
||||||
|
'backlog.moveTicket': 'Move {key}',
|
||||||
|
'backlog.filters.title': 'Find backlog tickets',
|
||||||
|
'backlog.filters.description':
|
||||||
|
'Filter this view locally. Matching child tickets keep their parent context visible.',
|
||||||
|
'backlog.filters.search': 'Search',
|
||||||
|
'backlog.filters.searchPlaceholder': 'Key, title, description, or metadata',
|
||||||
|
'backlog.filters.type': 'Type',
|
||||||
|
'backlog.filters.allTypes': 'All types',
|
||||||
|
'backlog.filters.priority': 'Priority',
|
||||||
|
'backlog.filters.allPriorities': 'All priorities',
|
||||||
|
'backlog.filters.status': 'Status',
|
||||||
|
'backlog.filters.allStatuses': 'All backlog statuses',
|
||||||
|
'backlog.filters.assignee': 'Assignee',
|
||||||
|
'backlog.filters.allAssignees': 'All assignees',
|
||||||
|
'backlog.filters.clear': 'Clear filters',
|
||||||
|
'backlog.filters.parentContext': 'Parent context',
|
||||||
|
'backlog.filters.empty.title': 'No tickets match these filters',
|
||||||
|
'backlog.filters.empty.description':
|
||||||
|
'Try a broader search or clear the filters to see the complete backlog.',
|
||||||
|
'backlog.results.title': 'Backlog tickets',
|
||||||
|
'backlog.results.count': 'Showing {count} of {total} tickets',
|
||||||
|
'backlog.empty.title': 'Your backlog is clear',
|
||||||
|
'backlog.empty.description':
|
||||||
|
'New tickets without a workflow column will appear here.',
|
||||||
|
'backlog.empty.action': 'Create the first ticket',
|
||||||
|
'backlog.tree.expand': 'Expand children of {key}',
|
||||||
|
'backlog.tree.collapse': 'Collapse children of {key}',
|
||||||
|
'backlog.tree.expandAll': 'Expand all',
|
||||||
|
'backlog.tree.collapseAll': 'Collapse all',
|
||||||
|
'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.label': 'Color theme',
|
||||||
'appearance.light': 'Light',
|
'appearance.light': 'Light',
|
||||||
'appearance.dark': 'Dark',
|
'appearance.dark': 'Dark',
|
||||||
'appearance.system': 'System',
|
'appearance.system': 'System',
|
||||||
'common.cancel': 'Cancel',
|
'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',
|
||||||
|
'tickets.tree.expand': 'Expand children of {key}',
|
||||||
|
'tickets.tree.collapse': 'Collapse children of {key}',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.error': 'Something went wrong.',
|
'common.error': 'Something went wrong.',
|
||||||
@@ -12,4 +419,143 @@ export const en = {
|
|||||||
one: '{count} ticket',
|
one: '{count} ticket',
|
||||||
other: '{count} tickets',
|
other: '{count} tickets',
|
||||||
},
|
},
|
||||||
|
'ticketDetail.description': 'Description',
|
||||||
|
'ticketDetail.noDescription': 'No description has been added yet.',
|
||||||
|
'ticketDetail.checklist': 'Acceptance criteria',
|
||||||
|
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
|
||||||
|
'ticketDetail.saveChecklistFirst':
|
||||||
|
'Save checklist text changes before toggling completion directly.',
|
||||||
|
'ticketDetail.edit': 'Edit ticket',
|
||||||
|
'ticketDetail.editDescription':
|
||||||
|
'Tracked ticket changes create worklog entries automatically.',
|
||||||
|
'ticketDetail.git': 'Git',
|
||||||
|
'ticketDetail.relationships': 'Relationships',
|
||||||
|
'ticketDetail.history': 'Worklog',
|
||||||
|
'ticketDetail.metadata': 'Ticket information',
|
||||||
|
'ticketDetail.sections': 'Ticket sections',
|
||||||
|
'ticketDetail.status': 'Status',
|
||||||
|
'ticketDetail.type': 'Type',
|
||||||
|
'ticketDetail.priority': 'Priority',
|
||||||
|
'ticketDetail.assignee': 'Assignee',
|
||||||
|
'ticketDetail.author': 'Author',
|
||||||
|
'ticketDetail.noAuthor': 'No author',
|
||||||
|
'ticketDetail.storyPoints': 'Estimated story points',
|
||||||
|
'ticketDetail.notSet': 'Not set',
|
||||||
|
'ticketDetail.created': 'Created',
|
||||||
|
'ticketDetail.updated': 'Last updated',
|
||||||
|
'ticketDetail.backToProject': 'Back to project',
|
||||||
|
'ticketDetail.openBacklog': 'Open backlog',
|
||||||
|
'ticketDetail.openBoard': 'Open board',
|
||||||
|
'ticketDetail.readOnly': 'Read only',
|
||||||
|
'ticketDetail.title': 'Title',
|
||||||
|
'ticketDetail.parent': 'Parent ticket',
|
||||||
|
'ticketDetail.noParent': 'No parent ticket',
|
||||||
|
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
|
||||||
|
'ticketDetail.checklistHelp':
|
||||||
|
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
|
||||||
|
'ticketDetail.checklistPlaceholder':
|
||||||
|
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||||
|
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||||
|
'ticketDetail.blockerComment': 'Blocker comment',
|
||||||
|
'ticketDetail.blockerPlaceholder':
|
||||||
|
'Describe what currently blocks progress on this ticket.',
|
||||||
|
'ticketDetail.customFields': 'Custom fields',
|
||||||
|
'ticketDetail.customFieldsDescription':
|
||||||
|
'Project-configured fields can be tracked directly on the ticket.',
|
||||||
|
'ticketDetail.noCustomFields':
|
||||||
|
'No custom fields are configured for this project yet.',
|
||||||
|
'ticketDetail.saveChanges': 'Save changes',
|
||||||
|
'ticketDetail.branches': 'Branches',
|
||||||
|
'ticketDetail.branchesDescription':
|
||||||
|
'Matching branches are discovered from linked repositories when the branch name starts with this ticket key.',
|
||||||
|
'ticketDetail.createBranch': 'Create branch',
|
||||||
|
'ticketDetail.createBranchTitle': 'Create branch from ticket',
|
||||||
|
'ticketDetail.createBranchDescription':
|
||||||
|
'Pick a linked repository, confirm the suggested branch name, and create it directly in Gitea.',
|
||||||
|
'ticketDetail.repository': 'Repository',
|
||||||
|
'ticketDetail.branchName': 'Branch name',
|
||||||
|
'ticketDetail.branchAccess':
|
||||||
|
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||||
|
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||||
|
'ticketDetail.createNow': 'Create now',
|
||||||
|
'ticketDetail.inspectBranches':
|
||||||
|
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||||
|
'ticketDetail.pullRequests': 'Pull requests',
|
||||||
|
'ticketDetail.pullRequestsDescription':
|
||||||
|
'Matching pull requests are detected from linked repositories by checking whether the source branch starts with this ticket key.',
|
||||||
|
'ticketDetail.inspectPullRequests':
|
||||||
|
'Connect a matching Git account in your user settings to inspect pull requests for this repository.',
|
||||||
|
'ticketDetail.checkingBranch':
|
||||||
|
'Checking whether this branch name is valid and still available...',
|
||||||
|
'ticketDetail.selectRepository':
|
||||||
|
'Select a linked repository to validate the branch name.',
|
||||||
|
'ticketDetail.connectGitFirst':
|
||||||
|
'Connect a matching Git connection in your user settings first.',
|
||||||
|
'ticketDetail.branchRequired': 'Branch name is required.',
|
||||||
|
'ticketDetail.branchValidationFailed':
|
||||||
|
'{app} could not validate this branch name right now.',
|
||||||
|
'ticketDetail.checklistSaveFailed':
|
||||||
|
'Checklist status could not be saved right now.',
|
||||||
|
'ticketDetail.children': 'Child tickets',
|
||||||
|
'ticketDetail.childrenDescription':
|
||||||
|
'Parent tickets expose their full child tree here, including nested parent chains.',
|
||||||
|
'ticketDetail.noChildren':
|
||||||
|
'No child tickets are assigned to this ticket yet.',
|
||||||
|
'ticketDetail.parentDescription':
|
||||||
|
'This ticket belongs to the parent shown here.',
|
||||||
|
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||||
|
'ticketDetail.linkedTicketsDescription':
|
||||||
|
'Track cross-ticket relationships with project-defined link labels.',
|
||||||
|
'ticketDetail.linkType': 'Link type',
|
||||||
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
|
'ticketDetail.saveLink': 'Save link',
|
||||||
|
'ticketDetail.noLinks':
|
||||||
|
'No ticket links are configured for this ticket yet.',
|
||||||
|
'ticketDetail.removeLink': 'Remove',
|
||||||
|
'ticketRelationships.hierarchy': 'Hierarchy',
|
||||||
|
'ticketRelationships.editParent': 'Change parent',
|
||||||
|
'ticketRelationships.parentHelp':
|
||||||
|
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
|
||||||
|
'ticketRelationships.childrenHelp':
|
||||||
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
|
'ticketRelationships.directionHelp':
|
||||||
|
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
||||||
|
'ticketRelationships.outgoing': 'Outgoing',
|
||||||
|
'ticketRelationships.incoming': 'Incoming',
|
||||||
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
|
'ticketRelationships.noTypes':
|
||||||
|
'No link types are configured for this project.',
|
||||||
|
'ticketRelationships.noTargets':
|
||||||
|
'No other tickets are available to link in this project.',
|
||||||
|
'ticketDetail.historyDescription':
|
||||||
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
|
'ticketDetail.system': 'System',
|
||||||
|
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||||
|
'ticketForm.create': 'Create ticket',
|
||||||
|
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||||
|
'ticketForm.createDescription': 'Add a ticket to {project}.',
|
||||||
|
'ticketForm.backToBoard': 'Back to board',
|
||||||
|
'ticketForm.backToBacklog': 'Back to backlog',
|
||||||
|
'ticketForm.details': 'Ticket details',
|
||||||
|
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
|
||||||
|
'ticketForm.titlePlaceholder': 'Implement repository sync',
|
||||||
|
'ticketForm.descriptionPlaceholder':
|
||||||
|
'Describe the expected behavior and relevant context.',
|
||||||
|
'ticketForm.checklistHelp':
|
||||||
|
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
|
||||||
|
'ticketForm.checklistPreview': 'Checklist preview',
|
||||||
|
'ticketForm.metadataRequired':
|
||||||
|
'Ticket creation needs at least one type, one priority, and one status.',
|
||||||
|
'ticketForm.openSettings': 'Open ticket settings',
|
||||||
|
'ticketForm.creating': 'Creating ticket…',
|
||||||
|
'ticketForm.saving': 'Saving changes…',
|
||||||
|
'ticketForm.reset': 'Discard changes',
|
||||||
|
'ticketForm.unsaved': 'Unsaved changes',
|
||||||
|
'ticketForm.ready': 'Ready for a new ticket',
|
||||||
|
'ticketForm.saved': 'All changes saved',
|
||||||
|
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||||
|
'ticketForm.blocker': 'Blocker',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,47 +1,211 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { dashboard } from '@/routes/index';
|
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',
|
label: t('dashboard.summary.assignedTickets'),
|
||||||
href: dashboard(),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
|
||||||
|
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<div
|
<Head :title="t('navigation.dashboard')" />
|
||||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
|
||||||
>
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
<header class="gap-stack grid min-w-0">
|
||||||
<div
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
{{ t('dashboard.eyebrow') }}
|
||||||
>
|
</p>
|
||||||
<PlaceholderPattern />
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
</div>
|
{{
|
||||||
<div
|
t('dashboard.greeting', {
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
name: $page.props.auth.user.name,
|
||||||
>
|
})
|
||||||
<PlaceholderPattern />
|
}}
|
||||||
</div>
|
</h1>
|
||||||
<div
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
{{ t('dashboard.description') }}
|
||||||
>
|
</p>
|
||||||
<PlaceholderPattern />
|
</header>
|
||||||
</div>
|
|
||||||
</div>
|
<section :aria-label="t('dashboard.summary')">
|
||||||
<div
|
<ul class="grid gap-3 sm:grid-cols-3">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
<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 />
|
<header class="gap-stack grid min-w-0">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,52 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<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 InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
import { store } from '@/routes/password/confirm';
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Confirm your password"
|
:title="t('auth.confirm.title')"
|
||||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
:description="t('auth.confirm.description')"
|
||||||
|
:icon="ShieldCheck"
|
||||||
>
|
>
|
||||||
<Head title="Confirm password" />
|
<Head :title="t('auth.confirm.title')" />
|
||||||
|
<form
|
||||||
<Form
|
:action="store.url()"
|
||||||
v-bind="store.form()"
|
method="post"
|
||||||
reset-on-success
|
class="grid gap-5"
|
||||||
v-slot="{ errors, processing }"
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
<div class="space-y-6">
|
<AuthFormFeedback
|
||||||
<div class="grid gap-2">
|
:state="protection.state.value"
|
||||||
<Label htmlFor="password">Password</Label>
|
:response-status="protection.responseStatus.value"
|
||||||
<PasswordInput
|
:loading-title="t('auth.confirm.pending')"
|
||||||
id="password"
|
@retry="submit"
|
||||||
name="password"
|
/>
|
||||||
class="mt-1 block w-full"
|
<div class="gap-stack grid">
|
||||||
required
|
<Label for="password">{{ t('auth.confirm.password') }}</Label>
|
||||||
autocomplete="current-password"
|
<PasswordInput
|
||||||
autofocus
|
id="password"
|
||||||
/>
|
v-model="form.password"
|
||||||
|
name="password"
|
||||||
<InputError :message="errors.password" />
|
required
|
||||||
</div>
|
autocomplete="current-password"
|
||||||
|
autofocus
|
||||||
<div class="flex items-center">
|
:disabled="form.processing"
|
||||||
<Button
|
:invalid="Boolean(form.errors.password)"
|
||||||
class="w-full"
|
aria-describedby="password-hint password-error"
|
||||||
:disabled="processing"
|
/>
|
||||||
data-test="confirm-password-button"
|
<p
|
||||||
>
|
id="password-hint"
|
||||||
<Spinner v-if="processing" />
|
class="text-caption text-muted-foreground"
|
||||||
Confirm password
|
>
|
||||||
</Button>
|
{{ t('auth.confirm.passwordHint') }}
|
||||||
</div>
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="password-error"
|
||||||
|
:message="form.errors.password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<Button
|
||||||
</AuthLayout>
|
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>
|
</template>
|
||||||
|
|||||||
@@ -1,115 +1,170 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { LogOut } from 'lucide-vue-next';
|
import { KeyRound } from 'lucide-vue-next';
|
||||||
|
import { computed, onUnmounted } from 'vue';
|
||||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
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 InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { logout } from '@/routes';
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
|
import { logout } from '@/routes/index';
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
|
||||||
defineProps<{
|
const { t } = useLocalization();
|
||||||
status?: string | null;
|
const form = useForm({
|
||||||
}>(),
|
current_password: '',
|
||||||
{
|
password: '',
|
||||||
status: null,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Change your password"
|
:title="t('auth.forceChange.title')"
|
||||||
description="You must update your password before you can continue using the application."
|
:description="t('auth.forceChange.description')"
|
||||||
|
:icon="KeyRound"
|
||||||
>
|
>
|
||||||
<Head title="Change your password" />
|
<Head :title="t('auth.forceChange.title')" />
|
||||||
|
<FeedbackState
|
||||||
<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
|
|
||||||
v-if="status"
|
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 }}
|
<AuthFormFeedback
|
||||||
</div>
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
<Form
|
:loading-title="t('auth.forceChange.pending')"
|
||||||
v-bind="SecurityController.update.form()"
|
@retry="submit"
|
||||||
reset-on-success
|
/>
|
||||||
:reset-on-error="[
|
<div class="gap-stack grid">
|
||||||
'password',
|
<Label for="current_password">{{
|
||||||
'password_confirmation',
|
t('auth.forceChange.currentPassword')
|
||||||
'current_password',
|
}}</Label>
|
||||||
]"
|
<PasswordInput
|
||||||
v-slot="{ errors, processing }"
|
id="current_password"
|
||||||
>
|
v-model="form.current_password"
|
||||||
<div class="space-y-6">
|
name="current_password"
|
||||||
<div class="grid gap-2">
|
required
|
||||||
<Label htmlFor="current_password">Current password</Label>
|
autocomplete="current-password"
|
||||||
<PasswordInput
|
autofocus
|
||||||
id="current_password"
|
:disabled="busy"
|
||||||
name="current_password"
|
:invalid="Boolean(form.errors.current_password)"
|
||||||
class="mt-1 block w-full"
|
aria-describedby="current-password-error"
|
||||||
required
|
/>
|
||||||
autocomplete="current-password"
|
<InputError
|
||||||
autofocus
|
id="current-password-error"
|
||||||
placeholder="Current password"
|
:message="form.errors.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>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div class="gap-stack grid">
|
||||||
</AuthLayout>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -1,65 +1,89 @@
|
|||||||
<script setup lang="ts">
|
<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 InputError from '@/components/InputError.vue';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
import TextLink from '@/components/TextLink.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
import { login } from '@/routes/index';
|
import { login } from '@/routes/index';
|
||||||
import { email } from '@/routes/password';
|
import { email } from '@/routes/password';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{ status?: string }>();
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Forgot password"
|
:title="t('auth.forgot.title')"
|
||||||
description="Enter your email to receive a password reset link"
|
:description="t('auth.forgot.description')"
|
||||||
|
:icon="Mail"
|
||||||
>
|
>
|
||||||
<Head title="Forgot password" />
|
<Head :title="t('auth.forgot.title')" />
|
||||||
|
<FeedbackState
|
||||||
<div
|
|
||||||
v-if="status"
|
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 }}
|
<AuthFormFeedback
|
||||||
</div>
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
<div class="space-y-6">
|
:loading-title="t('auth.forgot.pending')"
|
||||||
<Form v-bind="email.form()" v-slot="{ errors, processing }">
|
@retry="submit"
|
||||||
<div class="grid gap-2">
|
/>
|
||||||
<Label for="email">Email address</Label>
|
<div class="gap-stack grid">
|
||||||
<Input
|
<Label for="email">{{ t('auth.forgot.email') }}</Label>
|
||||||
id="email"
|
<Input
|
||||||
type="email"
|
id="email"
|
||||||
name="email"
|
v-model="form.email"
|
||||||
autocomplete="off"
|
name="email"
|
||||||
autofocus
|
type="email"
|
||||||
placeholder="email@example.com"
|
inputmode="email"
|
||||||
/>
|
autocomplete="email"
|
||||||
<InputError :message="errors.email" />
|
autocapitalize="none"
|
||||||
</div>
|
:spellcheck="false"
|
||||||
|
required
|
||||||
<div class="my-6 flex items-center justify-start">
|
autofocus
|
||||||
<Button
|
:disabled="form.processing"
|
||||||
class="w-full"
|
:placeholder="t('auth.forgot.emailPlaceholder')"
|
||||||
:disabled="processing"
|
:invalid="Boolean(form.errors.email)"
|
||||||
data-test="email-password-reset-link-button"
|
aria-describedby="email-hint email-error"
|
||||||
>
|
/>
|
||||||
<Spinner v-if="processing" />
|
<p id="email-hint" class="text-caption text-muted-foreground">
|
||||||
Email password reset link
|
{{ t('auth.forgot.emailHint') }}
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
<InputError id="email-error" :message="form.errors.email" />
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="text-muted-foreground space-x-1 text-center text-sm">
|
|
||||||
<span>Or, return to</span>
|
|
||||||
<TextLink :href="login()">log in</TextLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
</AuthLayout>
|
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>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<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 InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
import TextLink from '@/components/TextLink.vue';
|
||||||
@@ -7,94 +11,114 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
import { store } from '@/routes/login';
|
import { store } from '@/routes/login';
|
||||||
import { request } from '@/routes/password';
|
import { request } from '@/routes/password';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{ status?: string; canResetPassword: boolean }>();
|
||||||
status?: string;
|
const { t } = useLocalization();
|
||||||
canResetPassword: boolean;
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthBase
|
<AuthFlowLayout
|
||||||
title="Log in to your account"
|
:title="t('auth.login.title')"
|
||||||
description="Enter your email and password below to log in"
|
:description="t('auth.login.description')"
|
||||||
|
:icon="LogIn"
|
||||||
>
|
>
|
||||||
<Head title="Log in" />
|
<Head :title="t('auth.login.submit')" />
|
||||||
|
<FeedbackState
|
||||||
<div
|
|
||||||
v-if="status"
|
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 }}
|
<AuthFormFeedback
|
||||||
</div>
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
<Form
|
:loading-title="t('auth.login.pending')"
|
||||||
v-bind="store.form()"
|
@retry="submit"
|
||||||
:reset-on-success="['password']"
|
/>
|
||||||
v-slot="{ errors, processing }"
|
<div class="gap-stack grid">
|
||||||
class="flex flex-col gap-6"
|
<Label for="email">{{ t('auth.login.email') }}</Label>
|
||||||
>
|
<Input
|
||||||
<div class="grid gap-6">
|
id="email"
|
||||||
<div class="grid gap-2">
|
v-model="form.email"
|
||||||
<Label for="email">Email address</Label>
|
name="email"
|
||||||
<Input
|
type="email"
|
||||||
id="email"
|
inputmode="email"
|
||||||
type="email"
|
autocomplete="username"
|
||||||
name="email"
|
autocapitalize="none"
|
||||||
required
|
:spellcheck="false"
|
||||||
autofocus
|
required
|
||||||
:tabindex="1"
|
autofocus
|
||||||
autocomplete="email"
|
:disabled="form.processing"
|
||||||
placeholder="email@example.com"
|
:placeholder="t('auth.login.emailPlaceholder')"
|
||||||
/>
|
:invalid="Boolean(form.errors.email)"
|
||||||
<InputError :message="errors.email" />
|
aria-describedby="email-error"
|
||||||
</div>
|
/>
|
||||||
|
<InputError id="email-error" :message="form.errors.email" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div class="gap-stack grid">
|
||||||
</AuthBase>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -1,88 +1,136 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ref } from 'vue';
|
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 InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { update } from '@/routes/password';
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
|
import { request, update } from '@/routes/password';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ token: string; email: string }>();
|
||||||
token: string;
|
const { t } = useLocalization();
|
||||||
email: string;
|
const form = useForm({
|
||||||
}>();
|
token: props.token,
|
||||||
|
email: props.email,
|
||||||
const inputEmail = ref(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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Reset password"
|
:title="t('auth.reset.title')"
|
||||||
description="Please enter your new password below"
|
:description="t('auth.reset.description')"
|
||||||
|
:icon="KeyRound"
|
||||||
>
|
>
|
||||||
<Head title="Reset password" />
|
<Head :title="t('auth.reset.title')" />
|
||||||
|
<FeedbackState
|
||||||
<Form
|
v-if="invalidLink"
|
||||||
v-bind="update.form()"
|
state="error"
|
||||||
:transform="(data) => ({ ...data, token, email })"
|
:title="t('auth.reset.invalidTitle')"
|
||||||
:reset-on-success="['password', 'password_confirmation']"
|
:description="form.errors.email"
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-6">
|
<Button as-child variant="outline" class="w-full">
|
||||||
<div class="grid gap-2">
|
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
|
||||||
<Label for="email">Email</Label>
|
</Button>
|
||||||
<Input
|
</FeedbackState>
|
||||||
id="email"
|
<form
|
||||||
type="email"
|
:action="update.url()"
|
||||||
name="email"
|
method="post"
|
||||||
autocomplete="email"
|
class="grid gap-5"
|
||||||
v-model="inputEmail"
|
@submit.prevent="submit"
|
||||||
class="mt-1 block w-full"
|
>
|
||||||
readonly
|
<AuthFormFeedback
|
||||||
/>
|
:state="feedbackState"
|
||||||
<InputError :message="errors.email" class="mt-2" />
|
:response-status="protection.responseStatus.value"
|
||||||
</div>
|
:loading-title="t('auth.reset.pending')"
|
||||||
|
@retry="submit"
|
||||||
<div class="grid gap-2">
|
/>
|
||||||
<Label for="password">Password</Label>
|
<input type="hidden" name="token" :value="form.token" />
|
||||||
<PasswordInput
|
<div class="gap-stack grid">
|
||||||
id="password"
|
<Label for="email">{{ t('auth.reset.email') }}</Label>
|
||||||
name="password"
|
<Input
|
||||||
autocomplete="new-password"
|
id="email"
|
||||||
class="mt-1 block w-full"
|
v-model="form.email"
|
||||||
autofocus
|
name="email"
|
||||||
placeholder="Password"
|
type="email"
|
||||||
/>
|
autocomplete="username"
|
||||||
<InputError :message="errors.password" />
|
readonly
|
||||||
</div>
|
:disabled="form.processing"
|
||||||
|
:invalid="Boolean(form.errors.email)"
|
||||||
<div class="grid gap-2">
|
aria-describedby="email-error"
|
||||||
<Label for="password_confirmation">
|
/>
|
||||||
Confirm password
|
<InputError id="email-error" :message="form.errors.email" />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div class="gap-stack grid">
|
||||||
</AuthLayout>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -1,133 +1,173 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, ref } from 'vue';
|
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 InputError from '@/components/InputError.vue';
|
||||||
|
import TextLink from '@/components/TextLink.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import { Label } from '@/components/ui/label';
|
||||||
InputOTP,
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
InputOTPGroup,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
InputOTPSlot,
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
} from '@/components/ui/input-otp';
|
import { login } from '@/routes/index';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
|
||||||
import { store } from '@/routes/two-factor/login';
|
import { store } from '@/routes/two-factor/login';
|
||||||
import type { TwoFactorConfigContent } from '@/types';
|
|
||||||
|
|
||||||
const authConfigContent = computed<TwoFactorConfigContent>(() => {
|
const { t } = useLocalization();
|
||||||
if (showRecoveryInput.value) {
|
const recovery = ref(false);
|
||||||
return {
|
const form = useForm({ code: '', recovery_code: '' }).dontRemember(
|
||||||
title: 'Recovery code',
|
'code',
|
||||||
description:
|
'recovery_code',
|
||||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
);
|
||||||
buttonText: 'login using an authentication 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;
|
||||||
}
|
}
|
||||||
|
recovery.value = !recovery.value;
|
||||||
return {
|
form.resetAndClearErrors();
|
||||||
title: 'Authentication code',
|
protection.clearFeedback();
|
||||||
description:
|
await nextTick();
|
||||||
'Enter the authentication code provided by your authenticator application.',
|
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
|
||||||
buttonText: 'login using a recovery code',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const showRecoveryInput = ref<boolean>(false);
|
|
||||||
|
|
||||||
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
|
||||||
showRecoveryInput.value = !showRecoveryInput.value;
|
|
||||||
clearErrors();
|
|
||||||
code.value = '';
|
|
||||||
};
|
};
|
||||||
|
const pasteCode = (event: ClipboardEvent): void => {
|
||||||
const code = ref<string>('');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
:title="authConfigContent.title"
|
:title="
|
||||||
:description="authConfigContent.description"
|
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" />
|
<Head :title="t('auth.twoFactor.title')" />
|
||||||
|
<form
|
||||||
<div class="space-y-6">
|
:action="store.url()"
|
||||||
<template v-if="!showRecoveryInput">
|
method="post"
|
||||||
<Form
|
class="grid gap-5"
|
||||||
v-bind="store.form()"
|
@submit.prevent="submit"
|
||||||
class="space-y-4"
|
>
|
||||||
reset-on-error
|
<AuthFormFeedback
|
||||||
@error="code = ''"
|
:state="protection.state.value"
|
||||||
#default="{ errors, processing, clearErrors }"
|
: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" />
|
{{ t('auth.twoFactor.recoveryHint') }}
|
||||||
<div
|
</p>
|
||||||
class="flex flex-col items-center justify-center space-y-3 text-center"
|
<InputError id="recovery-error" :message="fieldError" />
|
||||||
>
|
</div>
|
||||||
<div class="flex w-full items-center justify-center">
|
<Button
|
||||||
<InputOTP
|
type="submit"
|
||||||
id="otp"
|
class="w-full"
|
||||||
v-model="code"
|
:loading="form.processing"
|
||||||
:maxlength="6"
|
:loading-label="t('auth.twoFactor.pending')"
|
||||||
:disabled="processing"
|
>{{ t('auth.twoFactor.submit') }}</Button
|
||||||
autofocus
|
>
|
||||||
>
|
<Button
|
||||||
<InputOTPGroup>
|
type="button"
|
||||||
<InputOTPSlot
|
variant="outline"
|
||||||
v-for="index in 6"
|
class="w-full whitespace-normal"
|
||||||
:key="index"
|
:disabled="form.processing"
|
||||||
:index="index - 1"
|
@click="toggleMode"
|
||||||
/>
|
>{{
|
||||||
</InputOTPGroup>
|
t(
|
||||||
</InputOTP>
|
recovery
|
||||||
</div>
|
? 'auth.twoFactor.useCode'
|
||||||
<InputError :message="errors.code" />
|
: 'auth.twoFactor.useRecovery',
|
||||||
</div>
|
)
|
||||||
<Button type="submit" class="w-full" :disabled="processing"
|
}}</Button
|
||||||
>Continue</Button
|
>
|
||||||
>
|
</form>
|
||||||
<div class="text-muted-foreground text-center text-sm">
|
<template #footer
|
||||||
<span>or you can </span>
|
><TextLink :href="login()">{{
|
||||||
<button
|
t('auth.backToLogin')
|
||||||
type="button"
|
}}</TextLink></template
|
||||||
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)"
|
</AuthFlowLayout>
|
||||||
>
|
|
||||||
{{ 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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,49 +1,124 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, router, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { logout } from '@/routes/index';
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
|
import { dashboard, logout } from '@/routes/index';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{ status?: string }>();
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Verify email"
|
:title="t('auth.verify.title')"
|
||||||
description="Please verify your email address by clicking on the link we just emailed to you."
|
:description="t('auth.verify.description')"
|
||||||
|
:icon="Mail"
|
||||||
>
|
>
|
||||||
<Head title="Email verification" />
|
<Head :title="t('auth.verify.title')" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="status === 'verification-link-sent'"
|
v-if="email"
|
||||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
class="gap-stack bg-muted p-card grid rounded-md border"
|
||||||
>
|
>
|
||||||
A new verification link has been sent to the email address you
|
<p class="text-label text-muted-foreground uppercase tracking-wide">
|
||||||
provided during registration.
|
{{ t('auth.verify.emailLabel') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-body break-all font-medium">{{ email }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackState
|
||||||
<Form
|
v-if="sent"
|
||||||
v-bind="send.form()"
|
state="success"
|
||||||
class="space-y-6 text-center"
|
:title="t('auth.verify.sentTitle')"
|
||||||
v-slot="{ processing }"
|
:description="t('auth.verify.sentDescription')"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
:action="send.url()"
|
||||||
|
method="post"
|
||||||
|
class="gap-column grid"
|
||||||
|
@submit.prevent="resend"
|
||||||
>
|
>
|
||||||
<Button :disabled="processing" variant="secondary">
|
<AuthFormFeedback
|
||||||
<Spinner v-if="processing" />
|
:state="
|
||||||
Resend verification email
|
resendProtection.state.value === 'success'
|
||||||
</Button>
|
? 'idle'
|
||||||
|
: resendProtection.state.value
|
||||||
<TextLink
|
"
|
||||||
:href="logout()"
|
:response-status="resendProtection.responseStatus.value"
|
||||||
as="button"
|
:loading-title="t('auth.verify.pending')"
|
||||||
class="mx-auto block text-sm"
|
@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
|
</form>
|
||||||
</TextLink>
|
<div class="gap-stack pt-column grid border-t">
|
||||||
</Form>
|
<p class="text-body text-muted-foreground">
|
||||||
</AuthLayout>
|
{{ 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>
|
</template>
|
||||||
|
|||||||
@@ -1,90 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Columns3,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import BacklogTicketTree from '@/components/BacklogTicketTree.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 {
|
||||||
|
buildBacklogTicketTree,
|
||||||
|
countBacklogTreeTickets,
|
||||||
|
expandableBacklogTicketIds,
|
||||||
|
filterBacklogTicketTree,
|
||||||
|
} from '@/lib/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
import {
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
create as createTicket,
|
import type {
|
||||||
show as showTicket,
|
BacklogFilterState,
|
||||||
} from '@/routes/projects/tickets';
|
BreadcrumbItem,
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
ProjectBacklogPageProps,
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
} from '@/types';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectBacklogPageProps>();
|
const props = defineProps<ProjectBacklogPageProps>();
|
||||||
|
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.backlog'), href: backlogIndex(props.project.id) },
|
||||||
|
]);
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const filters = reactive<BacklogFilterState>({
|
||||||
{
|
assigneeId: '',
|
||||||
title: 'Projects',
|
priorityId: '',
|
||||||
href: index(),
|
query: '',
|
||||||
},
|
statusId: '',
|
||||||
{
|
typeId: '',
|
||||||
title: props.project.name,
|
});
|
||||||
href: show(props.project.id),
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
},
|
const pendingTicketId = ref<number | null>(null);
|
||||||
{
|
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
||||||
title: 'Backlog',
|
const filteredTicketTree = computed(() =>
|
||||||
href: backlogIndex(props.project.id),
|
filterBacklogTicketTree(ticketTree.value, filters),
|
||||||
},
|
);
|
||||||
];
|
const visibleTicketCount = computed(() =>
|
||||||
|
countBacklogTreeTickets(filteredTicketTree.value),
|
||||||
|
);
|
||||||
|
const expandableTicketIds = computed(() =>
|
||||||
|
expandableBacklogTicketIds(filteredTicketTree.value),
|
||||||
|
);
|
||||||
|
const hasActiveFilters = computed(
|
||||||
|
() =>
|
||||||
|
filters.query.trim() !== '' ||
|
||||||
|
filters.typeId !== '' ||
|
||||||
|
filters.priorityId !== '' ||
|
||||||
|
filters.statusId !== '' ||
|
||||||
|
filters.assigneeId !== '',
|
||||||
|
);
|
||||||
|
const allVisibleBranchesCollapsed = computed(
|
||||||
|
() =>
|
||||||
|
expandableTicketIds.value.length > 0 &&
|
||||||
|
expandableTicketIds.value.every((ticketId) =>
|
||||||
|
collapsedTicketIds.has(ticketId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
function clearFilters(): void {
|
||||||
if (ticketStatus === null) {
|
filters.query = '';
|
||||||
return 'Unknown status';
|
filters.typeId = '';
|
||||||
|
filters.priorityId = '';
|
||||||
|
filters.statusId = '';
|
||||||
|
filters.assigneeId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTicket(ticketId: number): void {
|
||||||
|
if (collapsedTicketIds.has(ticketId)) {
|
||||||
|
collapsedTicketIds.delete(ticketId);
|
||||||
|
} else {
|
||||||
|
collapsedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllBranches(): void {
|
||||||
|
if (allVisibleBranchesCollapsed.value) {
|
||||||
|
collapsedTicketIds.clear();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
for (const ticketId of expandableTicketIds.value) {
|
||||||
ticketStatus.board_column === null ||
|
collapsedTicketIds.add(ticketId);
|
||||||
ticketStatus.board_column === undefined
|
|
||||||
) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
function finishMove(ticketId: number): void {
|
||||||
return ticketStatus.name;
|
if (pendingTicketId.value === ticketId) {
|
||||||
|
pendingTicketId.value = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
watch(
|
||||||
};
|
() => props.project.id,
|
||||||
|
() => {
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
clearFilters();
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
collapsedTicketIds.clear();
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
pendingTicketId.value = null;
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} backlog`" />
|
<Head :title="t('backlog.title', { name: 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
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
{{ project.key }}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
<Heading
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
:title="`${project.name} backlog`"
|
>
|
||||||
description="All newly created tickets start here before they move onto the board."
|
{{ project.key }}
|
||||||
/>
|
</Badge>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('tickets.count', { count: tickets.length }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="text-headline-mobile md:text-headline wrap-anywhere"
|
||||||
|
>
|
||||||
|
{{ t('backlog.title', { name: project.name }) }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-caption text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('backlog.description') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -94,203 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Create ticket
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('navigation.createTicket') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
<Link :href="boardIndex(project.id)">
|
||||||
|
<Columns3 aria-hidden="true" />
|
||||||
|
{{ t('backlog.openBoard') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Button as-child variant="outline">
|
<Link :href="show(project.id)">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<ArrowLeft aria-hidden="true" />
|
||||||
|
{{ t('backlog.backToProject') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="backlog-filter-title"
|
||||||
|
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<h2 id="backlog-filter-title" class="text-title">
|
||||||
|
{{ t('backlog.filters.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('backlog.filters.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!hasActiveFilters"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{ t('backlog.filters.clear') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="backlog-query"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.search') }}
|
||||||
|
</label>
|
||||||
|
<div class="relative min-w-0">
|
||||||
|
<Search
|
||||||
|
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="backlog-query"
|
||||||
|
v-model="filters.query"
|
||||||
|
type="search"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
:placeholder="
|
||||||
|
t('backlog.filters.searchPlaceholder')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-type"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.type') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-type"
|
||||||
|
v-model="filters.typeId"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allTypes') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="ticketType in ticketTypes"
|
||||||
|
:key="ticketType.id"
|
||||||
|
:value="ticketType.id"
|
||||||
|
>
|
||||||
|
{{ ticketType.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-priority"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.priority') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-priority"
|
||||||
|
v-model="filters.priorityId"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allPriorities') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="priority in ticketPriorities"
|
||||||
|
:key="priority.id"
|
||||||
|
:value="priority.id"
|
||||||
|
>
|
||||||
|
{{ priority.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-status"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.status') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-status"
|
||||||
|
v-model="filters.statusId"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allStatuses') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="ticketStatus in backlogTicketStatuses"
|
||||||
|
:key="ticketStatus.id"
|
||||||
|
:value="ticketStatus.id"
|
||||||
|
>
|
||||||
|
{{ ticketStatus.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-assignee"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.assignee') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-assignee"
|
||||||
|
v-model="filters.assigneeId"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allAssignees') }}
|
||||||
|
</option>
|
||||||
|
<option value="unassigned">
|
||||||
|
{{ t('tickets.unassigned') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="tickets.length === 0"
|
||||||
|
class="grid justify-items-start gap-3"
|
||||||
|
>
|
||||||
|
<FeedbackState
|
||||||
|
state="empty"
|
||||||
|
:title="t('backlog.empty.title')"
|
||||||
|
:description="t('backlog.empty.description')"
|
||||||
|
/>
|
||||||
|
<Button v-if="canCreateTickets" as-child size="sm">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
createTicket(
|
||||||
|
{ project: project.id },
|
||||||
|
{ query: { source: 'backlog' } },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('backlog.empty.action') }}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="status"
|
v-else-if="visibleTicketCount === 0"
|
||||||
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"
|
class="grid justify-items-start gap-3"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<FeedbackState
|
||||||
|
state="empty"
|
||||||
|
:title="t('backlog.filters.empty.title')"
|
||||||
|
:description="t('backlog.filters.empty.description')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{ t('backlog.filters.clear') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
<section
|
||||||
<CardHeader class="border-b">
|
v-else
|
||||||
<CardTitle>Backlog tickets</CardTitle>
|
aria-labelledby="backlog-results-title"
|
||||||
<CardDescription>
|
class="grid min-w-0 gap-3"
|
||||||
Tickets remain here while their status belongs to
|
>
|
||||||
backlog.
|
<div
|
||||||
</CardDescription>
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
</CardHeader>
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
<CardContent class="p-0">
|
<h2 id="backlog-results-title" class="text-title">
|
||||||
<div
|
{{ t('backlog.results.title') }}
|
||||||
v-if="tickets.length === 0"
|
</h2>
|
||||||
class="text-muted-foreground p-6 text-sm"
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t('backlog.results.count', {
|
||||||
|
count: visibleTicketCount,
|
||||||
|
total: tickets.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="expandableTicketIds.length > 0"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="toggleAllBranches"
|
||||||
>
|
>
|
||||||
No backlog tickets exist yet.
|
<ChevronsUpDown
|
||||||
</div>
|
v-if="allVisibleBranchesCollapsed"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronsDownUp v-else aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
allVisibleBranchesCollapsed
|
||||||
|
? 'backlog.tree.expandAll'
|
||||||
|
: 'backlog.tree.collapseAll',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<BacklogTicketTree
|
||||||
<table
|
:nodes="filteredTicketTree"
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
:project-id="project.id"
|
||||||
>
|
:can-edit-tickets="canEditTickets"
|
||||||
<thead
|
:collapsed-ticket-ids="collapsedTicketIds"
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
:pending-ticket-id="pendingTicketId"
|
||||||
>
|
@toggle="toggleTicket"
|
||||||
<tr>
|
@move-start="pendingTicketId = $event"
|
||||||
<th class="px-4 py-3 font-medium">Key</th>
|
@move-finish="finishMove"
|
||||||
<th class="px-4 py-3 font-medium">Title</th>
|
/>
|
||||||
<th class="px-4 py-3 font-medium">Type</th>
|
</section>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
v-if="
|
|
||||||
allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground text-xs sm:basis-full"
|
|
||||||
>
|
|
||||||
This status does not allow
|
|
||||||
moving anywhere else.
|
|
||||||
</p>
|
|
||||||
</Form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,91 +1,248 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { TicketCard } from '@/components/tickets';
|
||||||
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 {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useBoardDrag } from '@/composables/useBoardDrag';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
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 { index as boardIndex } from '@/routes/projects/board';
|
||||||
import {
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
create as createTicket,
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
show as showTicket,
|
|
||||||
} from '@/routes/projects/tickets';
|
|
||||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectBoardPageProps>();
|
const props = defineProps<ProjectBoardPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: projectIndex(),
|
{ 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];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{ immediate: true },
|
||||||
title: props.project.name,
|
);
|
||||||
href: show(props.project.id),
|
const columns = computed(() =>
|
||||||
},
|
props.boardColumns.map((column) => {
|
||||||
{
|
const ticketsByStatus = new Map<number | null, TicketModel[]>();
|
||||||
title: 'Board',
|
for (const ticket of column.tickets ?? []) {
|
||||||
href: boardIndex(props.project.id),
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
|
||||||
if (ticketStatus === null) {
|
const feedback = useFormProtection(moveForm, { isDirty: () => false });
|
||||||
return 'Unknown status';
|
const menuPending = ref<number | null>(null);
|
||||||
}
|
const busy = computed(() => moveForm.processing || menuPending.value !== null);
|
||||||
|
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
|
||||||
if (
|
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
|
||||||
ticketStatus.board_column === null ||
|
const findTicket = (id: number) =>
|
||||||
ticketStatus.board_column === undefined
|
props.boardColumns
|
||||||
) {
|
.flatMap((column) => column.tickets ?? [])
|
||||||
return ticketStatus.name;
|
.find((ticket) => ticket.id === id);
|
||||||
}
|
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
|
||||||
|
const statusIds =
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
props.boardColumns
|
||||||
return ticketStatus.name;
|
.find((column) => column.id === columnId)
|
||||||
}
|
?.ticket_statuses?.map((status) => status.id) ?? [];
|
||||||
|
return allowedTargetStatuses(ticket).filter((status) =>
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
statusIds.includes(status.id),
|
||||||
};
|
|
||||||
|
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const choiceTicket = computed(() =>
|
||||||
|
choice.value ? findTicket(choice.value.ticketId) : undefined,
|
||||||
|
);
|
||||||
|
const choiceTargets = computed(() =>
|
||||||
|
choice.value && choiceTicket.value
|
||||||
|
? targetsForColumn(choiceTicket.value, choice.value.columnId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const choiceOpen = computed({
|
||||||
|
get: () => choice.value !== null,
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
choice.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitMove(ticketId: number, statusId: number): void {
|
||||||
|
const ticket = findTicket(ticketId);
|
||||||
|
if (
|
||||||
|
busy.value ||
|
||||||
|
!props.canEditTickets ||
|
||||||
|
!ticket ||
|
||||||
|
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMove.value = { ticketId, statusId };
|
||||||
|
moveForm.ticket_status_id = statusId;
|
||||||
|
moveForm.clearErrors();
|
||||||
|
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
|
||||||
|
errorBag: `board-ticket-${ticketId}`,
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const destination = props.boardColumns.find((column) =>
|
||||||
|
column.ticket_statuses?.some(
|
||||||
|
(status) => status.id === statusId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (destination) {
|
||||||
|
selectedColumnId.value = destination.id;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
document
|
||||||
|
.getElementById(`ticket-status-${ticketId}`)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTicket(ticket: TicketModel, columnId: number): void {
|
||||||
|
const current = findTicket(ticket.id);
|
||||||
|
if (!current || busy.value || !props.canEditTickets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = targetsForColumn(current, columnId);
|
||||||
|
if (targets.length === 1) {
|
||||||
|
submitMove(current.id, targets[0].id);
|
||||||
|
} else if (targets.length > 1) {
|
||||||
|
moveForm.ticket_status_id = targets[0].id;
|
||||||
|
choice.value = { ticketId: current.id, columnId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
draggedTicket,
|
||||||
|
columnId: dropColumnId,
|
||||||
|
x: dragX,
|
||||||
|
y: dragY,
|
||||||
|
start: startDrag,
|
||||||
|
cancel: cancelDrag,
|
||||||
|
} = useBoardDrag({
|
||||||
|
canStart: (ticket) =>
|
||||||
|
props.canEditTickets &&
|
||||||
|
!busy.value &&
|
||||||
|
!choice.value &&
|
||||||
|
allowedTargetStatuses(ticket).length > 0,
|
||||||
|
drop: dropTicket,
|
||||||
|
});
|
||||||
|
const canDrop = (columnId: number) =>
|
||||||
|
!!draggedTicket.value &&
|
||||||
|
targetsForColumn(draggedTicket.value, columnId).length > 0;
|
||||||
|
const dropClasses = (columnId: number) =>
|
||||||
|
!draggedTicket.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'border-primary border-dashed': canDrop(columnId),
|
||||||
|
'ring-2 ring-primary bg-primary/10':
|
||||||
|
canDrop(columnId) && dropColumnId.value === columnId,
|
||||||
|
'opacity-40': !canDrop(columnId),
|
||||||
|
};
|
||||||
|
function confirmMove(): void {
|
||||||
|
if (
|
||||||
|
!choice.value ||
|
||||||
|
!choiceTargets.value.some(
|
||||||
|
(status) => status.id === moveForm.ticket_status_id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticketId = choice.value.ticketId;
|
||||||
|
choice.value = null;
|
||||||
|
submitMove(ticketId, moveForm.ticket_status_id);
|
||||||
|
}
|
||||||
|
function focusStatus(ticketId: number): void {
|
||||||
|
document.getElementById(`ticket-status-${ticketId}`)?.focus();
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => [props.project.id, props.boardColumns, props.canEditTickets],
|
||||||
|
() => {
|
||||||
|
cancelDrag();
|
||||||
|
choice.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<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="gap-column 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 min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
{{ project.key }}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
<Heading
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
:title="`${project.name} board`"
|
>{{ project.key }}</Badge
|
||||||
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
>
|
||||||
/>
|
<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>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -95,245 +252,427 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Create ticket
|
<Plus aria-hidden="true" />{{
|
||||||
|
t('navigation.createTicket')
|
||||||
|
}}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="backlogIndex(project.id)"
|
<Link :href="backlogIndex(project.id)"
|
||||||
>Open backlog</Link
|
><ListTodo aria-hidden="true" />{{
|
||||||
|
t('board.openBacklog')
|
||||||
|
}}</Link
|
||||||
>
|
>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Button as-child variant="outline">
|
<Link :href="show(project.id)"
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
><ArrowLeft aria-hidden="true" />{{
|
||||||
|
t('board.backToProject')
|
||||||
|
}}</Link
|
||||||
|
>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
v-if="status"
|
<FormFeedback
|
||||||
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"
|
:state="feedback.state.value"
|
||||||
>
|
:description="moveForm.errors.ticket_status_id"
|
||||||
{{ status }}
|
@retry="
|
||||||
</div>
|
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<FeedbackState
|
||||||
|
v-if="columns.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('board.empty.title')"
|
||||||
|
:description="t('board.empty.description')"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card v-if="boardColumns.length === 0">
|
<template v-else>
|
||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
<div class="grid min-w-0 gap-2 md:hidden">
|
||||||
<p class="text-muted-foreground">
|
<label for="board-column" class="text-body font-semibold">{{
|
||||||
The board has no workflow columns yet, so no tickets can
|
t('board.selectColumn')
|
||||||
appear here.
|
}}</label>
|
||||||
</p>
|
<select
|
||||||
<div v-if="canEditTickets">
|
id="board-column"
|
||||||
<Button as-child variant="outline">
|
v-model="selectedColumnId"
|
||||||
<Link :href="boardSettings(project.id)">
|
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"
|
||||||
Open board settings
|
:aria-controls="`board-column-${selectedColumnId}`"
|
||||||
</Link>
|
>
|
||||||
</Button>
|
<option
|
||||||
</div>
|
v-for="column in columns"
|
||||||
</CardContent>
|
:key="column.id"
|
||||||
</Card>
|
:value="column.id"
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
No tickets are currently in this column.
|
{{
|
||||||
</div>
|
t('board.columnOption', {
|
||||||
|
name: column.name,
|
||||||
<div v-else class="flex flex-col gap-4">
|
count: column.count,
|
||||||
<Card
|
})
|
||||||
v-for="ticket in boardColumn.tickets"
|
}}
|
||||||
:key="ticket.id"
|
</option>
|
||||||
:class="[
|
</select>
|
||||||
'gap-0',
|
</div>
|
||||||
ticket.is_blocked
|
<p
|
||||||
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
id="board-scroll-hint"
|
||||||
: '',
|
class="text-caption text-muted-foreground hidden md:block"
|
||||||
]"
|
>
|
||||||
|
{{ t('board.scrollHint') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTickets"
|
||||||
|
id="board-drag-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.dragHint') }}
|
||||||
|
</p>
|
||||||
|
<section
|
||||||
|
:aria-label="t('board.columns')"
|
||||||
|
aria-describedby="board-scroll-hint"
|
||||||
|
tabindex="0"
|
||||||
|
data-board-drag-scroll
|
||||||
|
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}`"
|
||||||
|
:data-board-drop-column="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',
|
||||||
|
dropClasses(column.id),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<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">
|
{{ column.name }}
|
||||||
<div class="flex flex-wrap gap-2">
|
</h2>
|
||||||
<Badge variant="outline">
|
<Badge
|
||||||
{{ ticket.ticket_key }}
|
variant="secondary"
|
||||||
</Badge>
|
class="shrink-0 tabular-nums"
|
||||||
<Badge
|
:aria-label="
|
||||||
v-if="ticket.is_blocked"
|
t('tickets.count', { count: column.count })
|
||||||
variant="destructive"
|
"
|
||||||
>
|
>{{ column.count }}</Badge
|
||||||
Blocked
|
>
|
||||||
</Badge>
|
</header>
|
||||||
<Badge variant="secondary">
|
<p
|
||||||
{{ ticket.ticket_type.name }}
|
v-if="column.count === 0"
|
||||||
</Badge>
|
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
|
||||||
<Badge variant="secondary">
|
>
|
||||||
{{ ticket.ticket_priority.name }}
|
{{ t('board.emptyColumn') }}
|
||||||
</Badge>
|
</p>
|
||||||
<Badge variant="outline">
|
<p
|
||||||
{{
|
v-if="column.groups.length === 0"
|
||||||
ticketStatusLabel(
|
class="text-caption text-muted-foreground"
|
||||||
ticket.ticket_status,
|
>
|
||||||
)
|
{{ t('board.noStatuses') }}
|
||||||
}}
|
</p>
|
||||||
</Badge>
|
<section
|
||||||
</div>
|
v-for="group in column.groups"
|
||||||
<CardTitle class="text-base">
|
:key="group.status.id"
|
||||||
<Link
|
:aria-labelledby="`board-status-${group.status.id}`"
|
||||||
:href="
|
class="grid min-w-0 gap-3"
|
||||||
showTicket({
|
>
|
||||||
project: project.id,
|
<div
|
||||||
ticket: ticket.id,
|
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
|
||||||
})
|
>
|
||||||
"
|
<h3
|
||||||
class="hover:text-primary transition"
|
:id="`board-status-${group.status.id}`"
|
||||||
>
|
class="wrap-anywhere font-semibold"
|
||||||
{{ ticket.title }}
|
>
|
||||||
</Link>
|
{{ group.status.name }}
|
||||||
</CardTitle>
|
</h3>
|
||||||
<CardDescription
|
<span
|
||||||
v-if="ticket.description"
|
class="shrink-0 tabular-nums"
|
||||||
class="line-clamp-3"
|
: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"
|
||||||
|
:class="{
|
||||||
|
'opacity-50':
|
||||||
|
draggedTicket?.id === ticket.id,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<TicketCard
|
||||||
|
:project-id="project.id"
|
||||||
|
:ticket="ticket"
|
||||||
|
:show-status="false"
|
||||||
>
|
>
|
||||||
{{ ticket.description }}
|
<template
|
||||||
</CardDescription>
|
v-if="canEditTickets"
|
||||||
<CardDescription
|
#actions
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Move ticket
|
<Button
|
||||||
</label>
|
type="button"
|
||||||
<div
|
variant="ghost"
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
>
|
|
||||||
<select
|
|
||||||
:id="`ticket-status-${ticket.id}`"
|
|
||||||
name="ticket_status_id"
|
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0 || processing
|
).length === 0
|
||||||
"
|
"
|
||||||
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"
|
:aria-label="
|
||||||
|
t('board.dragTicket', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
aria-describedby="board-drag-hint"
|
||||||
|
@pointerdown="
|
||||||
|
startDrag($event, ticket)
|
||||||
|
"
|
||||||
|
@click.prevent
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
><GripVertical
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ t('board.drag') }}</Button
|
||||||
>
|
>
|
||||||
<option
|
<Form
|
||||||
|
v-bind="
|
||||||
|
move.form({
|
||||||
|
project: project.id,
|
||||||
|
ticket: ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-slot="{ processing, errors }"
|
||||||
|
:error-bag="`board-ticket-${ticket.id}`"
|
||||||
|
:options="{
|
||||||
|
preserveScroll: true,
|
||||||
|
}"
|
||||||
|
:on-before="
|
||||||
|
() =>
|
||||||
|
!busy && !draggedTicket
|
||||||
|
"
|
||||||
|
@start="menuPending = ticket.id"
|
||||||
|
@finish="menuPending = null"
|
||||||
|
class="grid min-w-0 gap-2"
|
||||||
|
>
|
||||||
|
<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="
|
||||||
|
busy ||
|
||||||
|
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="
|
||||||
|
busy ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
processing
|
||||||
|
? 'board.updatingStatus'
|
||||||
|
: 'board.updateStatus',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0
|
).length === 0
|
||||||
"
|
"
|
||||||
value=""
|
class="text-caption text-muted-foreground"
|
||||||
>
|
|
||||||
No allowed moves
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
)"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="ticketStatus.id"
|
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
ticketStatusLabel(
|
t(
|
||||||
ticketStatus,
|
'board.noMovesDescription',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</option>
|
</p>
|
||||||
</select>
|
</Form>
|
||||||
<Button
|
</template>
|
||||||
type="submit"
|
</TicketCard>
|
||||||
size="sm"
|
</li>
|
||||||
:disabled="
|
</ul>
|
||||||
processing ||
|
</section>
|
||||||
allowedTargetStatuses(
|
</section>
|
||||||
ticket,
|
</section>
|
||||||
).length === 0
|
</template>
|
||||||
"
|
<div
|
||||||
>
|
v-if="draggedTicket"
|
||||||
Update status
|
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
|
||||||
</Button>
|
data-board-mobile-targets
|
||||||
</div>
|
>
|
||||||
<p
|
<p class="text-caption mb-2 font-semibold">
|
||||||
v-if="
|
{{ t('board.dropHint') }}
|
||||||
allowedTargetStatuses(ticket)
|
</p>
|
||||||
.length === 0
|
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
|
||||||
"
|
<div
|
||||||
class="text-muted-foreground text-xs"
|
v-for="column in columns"
|
||||||
>
|
:key="column.id"
|
||||||
This status does not allow moving
|
:data-board-drop-column="column.id"
|
||||||
anywhere else.
|
:class="dropClasses(column.id)"
|
||||||
</p>
|
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
|
||||||
</Form>
|
>
|
||||||
</CardContent>
|
{{ column.name }}
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.max(0, dragX - 80)}px`,
|
||||||
|
top: `${Math.max(0, dragY - 56)}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ draggedTicket.ticket_key }}
|
||||||
|
</div>
|
||||||
|
<p class="sr-only" aria-live="polite">
|
||||||
|
{{
|
||||||
|
draggedTicket
|
||||||
|
? t('board.dragging', { key: draggedTicket.ticket_key })
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Dialog v-model:open="choiceOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('board.chooseStatusDescription', {
|
||||||
|
key: choiceTicket?.ticket_key ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form class="grid gap-4" @submit.prevent="confirmMove">
|
||||||
|
<label
|
||||||
|
for="board-drop-status"
|
||||||
|
class="text-body font-semibold"
|
||||||
|
>{{ t('board.targetStatus') }}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="board-drop-status"
|
||||||
|
v-model="moveForm.ticket_status_id"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="target in choiceTargets"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="choiceOpen = false"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || choiceTargets.length === 0"
|
||||||
|
>{{ t('board.updateStatus') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,349 +1,633 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
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 {
|
import {
|
||||||
Card,
|
ArrowLeft,
|
||||||
CardContent,
|
Check,
|
||||||
CardDescription,
|
Copy,
|
||||||
CardHeader,
|
FolderPlus,
|
||||||
CardTitle,
|
LayoutTemplate,
|
||||||
} from '@/components/ui/card';
|
} 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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
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';
|
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectsCreatePageProps>();
|
const props = defineProps<ProjectsCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: t('projects.create'), href: create() },
|
||||||
href: index(),
|
]);
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'New project',
|
|
||||||
href: create(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultTemplateId =
|
const defaultTemplateId =
|
||||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||||
|
const form = useForm(
|
||||||
const form = useForm({
|
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
|
||||||
copy_areas: [] as string[],
|
{
|
||||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||||
key: '',
|
name: '',
|
||||||
name: '',
|
key: '',
|
||||||
source_project_id: props.sourceProjects[0]?.id
|
source_project_id: props.sourceProjects[0]?.id
|
||||||
? String(props.sourceProjects[0].id)
|
? String(props.sourceProjects[0].id)
|
||||||
: '',
|
: '',
|
||||||
source_template_id:
|
source_template_id:
|
||||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||||
});
|
copy_areas: [] as string[],
|
||||||
|
},
|
||||||
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
);
|
||||||
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
const protection = useFormProtection(form);
|
||||||
|
const modes = computed(() => [
|
||||||
const copyAreasError = computed(() => {
|
{
|
||||||
return (
|
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 ??
|
form.errors.copy_areas ??
|
||||||
Object.entries(form.errors).find(([key]) =>
|
Object.entries(form.errors).find(([key]) =>
|
||||||
key.startsWith('copy_areas.'),
|
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 => {
|
watch(
|
||||||
if (form.creation_mode !== 'duplicate') {
|
() => form.creation_mode,
|
||||||
form.source_project_id = '';
|
() => {
|
||||||
form.copy_areas = [];
|
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') {
|
function submit(): void {
|
||||||
form.source_template_id = '';
|
form.transform((data) => ({
|
||||||
}
|
...data,
|
||||||
|
source_project_id:
|
||||||
form.post(ProjectController.store.url(), {
|
data.creation_mode === 'duplicate' ? data.source_project_id : '',
|
||||||
preserveScroll: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<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">
|
<form
|
||||||
<Heading
|
class="gap-page grid min-w-0"
|
||||||
title="Create project"
|
:aria-busy="form.processing"
|
||||||
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
@submit.prevent="submit"
|
||||||
/>
|
>
|
||||||
|
<FormFeedback
|
||||||
<Card>
|
:state="protection.state.value"
|
||||||
<CardHeader>
|
:title="
|
||||||
<CardTitle>Project details</CardTitle>
|
form.processing
|
||||||
<CardDescription>
|
? t('projectCreation.creating')
|
||||||
The key is uppercased automatically, must be unique, and
|
: undefined
|
||||||
can contain up to 5 characters.
|
"
|
||||||
</CardDescription>
|
@retry="submit"
|
||||||
</CardHeader>
|
/>
|
||||||
|
<fieldset
|
||||||
<CardContent>
|
class="gap-column grid min-w-0"
|
||||||
<form class="space-y-6" @submit.prevent="submit">
|
:disabled="form.processing"
|
||||||
<div class="grid gap-3">
|
aria-describedby="creation-mode-error"
|
||||||
<Label>How do you want to start?</Label>
|
>
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<legend class="text-title mb-3">
|
||||||
<Label
|
{{ t('projectCreation.start') }}
|
||||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
</legend>
|
||||||
>
|
<div class="gap-column grid md:grid-cols-3">
|
||||||
<input
|
<label
|
||||||
v-model="form.creation_mode"
|
v-for="mode in modes"
|
||||||
type="radio"
|
:key="mode.value"
|
||||||
value="scratch"
|
class="relative min-w-0"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<input
|
||||||
<Label for="source_project_id"
|
v-model="form.creation_mode"
|
||||||
>Source project</Label
|
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
|
<select
|
||||||
id="source_project_id"
|
id="source_project_id"
|
||||||
v-model="form.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"
|
required
|
||||||
:disabled="!hasSourceProjects"
|
:class="selectClass"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.source_project_id
|
||||||
|
"
|
||||||
|
aria-describedby="source-project-error"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Select a source project
|
{{ t('projectCreation.selectProject') }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="sourceProject in sourceProjects"
|
v-for="project in sourceProjects"
|
||||||
:key="sourceProject.id"
|
:key="project.id"
|
||||||
:value="String(sourceProject.id)"
|
:value="String(project.id)"
|
||||||
>
|
>
|
||||||
{{ sourceProject.name }} ({{
|
{{ project.name }} ({{ project.key }})
|
||||||
sourceProject.key
|
|
||||||
}})
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
The new project keeps its own identity, key,
|
|
||||||
and future ticket numbering.
|
|
||||||
</p>
|
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-project-error"
|
||||||
:message="form.errors.source_project_id"
|
:message="form.errors.source_project_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset
|
||||||
<div class="grid gap-3">
|
class="min-w-0"
|
||||||
<div>
|
:disabled="form.processing"
|
||||||
<Label>Copy options</Label>
|
aria-describedby="copy-help copy-areas-error"
|
||||||
<p class="text-muted-foreground text-sm">
|
>
|
||||||
Choose which settings should be copied
|
<legend class="mb-2 font-semibold">
|
||||||
into the new project.
|
{{ t('projectCreation.copyOptions') }}
|
||||||
</p>
|
</legend>
|
||||||
</div>
|
<p
|
||||||
|
id="copy-help"
|
||||||
<div class="grid gap-3">
|
class="text-caption text-muted-foreground mb-3"
|
||||||
<Label
|
>
|
||||||
v-for="copyAreaOption in copyAreaOptions"
|
{{ t('projectCreation.copyDefaults') }}
|
||||||
:key="copyAreaOption.value"
|
</p>
|
||||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
<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
|
<input
|
||||||
v-model="form.copy_areas"
|
v-model="form.copy_areas"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="copyAreaOption.value"
|
:value="area.value"
|
||||||
class="mt-1"
|
: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">
|
<span class="gap-stack grid min-w-0">
|
||||||
<div class="font-medium">
|
<span
|
||||||
{{ copyAreaOption.label }}
|
:id="`copy-${area.value}-label`"
|
||||||
</div>
|
class="text-body font-semibold"
|
||||||
<p
|
>{{
|
||||||
class="text-muted-foreground text-sm"
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'label',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
{{ copyAreaOption.description }}
|
<span
|
||||||
</p>
|
:id="`copy-${area.value}-description`"
|
||||||
</div>
|
class="text-caption text-muted-foreground"
|
||||||
</Label>
|
>{{
|
||||||
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'description',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="copy-areas-error"
|
||||||
|
class="mt-3"
|
||||||
|
:message="copyAreasError"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="text-muted-foreground text-sm">
|
<section
|
||||||
Operational ticket data is always left
|
|
||||||
behind so the new project starts clean.
|
|
||||||
</p>
|
|
||||||
<InputError :message="copyAreasError" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="form.creation_mode === 'template'"
|
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">
|
<h2 id="template-title" class="text-title">
|
||||||
<Label for="source_template_id">Template</Label>
|
{{ t('projectCreation.chooseTemplate') }}
|
||||||
|
</h2>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<Label for="source_template_id">{{
|
||||||
|
t('projectCreation.templateLabel')
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="form.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"
|
required
|
||||||
:disabled="!hasSourceTemplates"
|
: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
|
<option
|
||||||
v-for="sourceTemplate in sourceTemplates"
|
v-for="template in sourceTemplates"
|
||||||
:key="sourceTemplate.id"
|
:key="template.id"
|
||||||
:value="String(sourceTemplate.id)"
|
:value="String(template.id)"
|
||||||
>
|
>
|
||||||
{{ sourceTemplate.name }} ({{
|
{{ template.name }} ({{
|
||||||
sourceTemplate.slug
|
template.slug
|
||||||
}})
|
}})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p
|
||||||
Templates apply their saved configuration
|
id="template-help"
|
||||||
groups to the new project.
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.templateHelp') }}
|
||||||
</p>
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-template-error"
|
||||||
:message="form.errors.source_template_id"
|
:message="form.errors.source_template_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<aside
|
||||||
v-else-if="
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
!hasSourceProjects && !hasSourceTemplates
|
aria-labelledby="summary-title"
|
||||||
"
|
>
|
||||||
class="text-muted-foreground text-sm"
|
<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
|
{{ t('projectCreation.cleanStart') }}
|
||||||
available yet.
|
</p>
|
||||||
</div>
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<footer
|
||||||
<Button type="submit" :disabled="form.processing">
|
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
|
||||||
Create project
|
>
|
||||||
</Button>
|
<Button
|
||||||
|
as-child
|
||||||
<Button as-child variant="outline">
|
variant="outline"
|
||||||
<Link :href="index()">Cancel</Link>
|
:disabled="form.processing"
|
||||||
</Button>
|
><Link :href="index()">{{
|
||||||
</div>
|
t('common.cancel')
|
||||||
</form>
|
}}</Link></Button
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, ref } from 'vue';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
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 {
|
||||||
@@ -13,32 +16,30 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
buildAcceptanceCriteria,
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
parseChecklistLines,
|
|
||||||
} from '@/lib/acceptance-criteria';
|
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketCreatePageProps>();
|
const props = defineProps<ProjectTicketCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const returnHref =
|
const returnHref =
|
||||||
props.source === 'board'
|
props.source === 'board'
|
||||||
? boardIndex(props.project.id)
|
? boardIndex(props.project.id)
|
||||||
: backlogIndex(props.project.id);
|
: backlogIndex(props.project.id);
|
||||||
const returnLabel =
|
const returnLabel =
|
||||||
props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
props.source === 'board'
|
||||||
|
? t('ticketForm.backToBoard')
|
||||||
|
: t('ticketForm.backToBacklog');
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Projects',
|
title: t('navigation.projects'),
|
||||||
href: index(),
|
href: index(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,28 +47,31 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
href: show(props.project.id),
|
href: show(props.project.id),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
title:
|
||||||
|
props.source === 'board'
|
||||||
|
? t('navigation.board')
|
||||||
|
: t('navigation.backlog'),
|
||||||
href: returnHref,
|
href: returnHref,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Create ticket',
|
title: t('ticketForm.create'),
|
||||||
href: ProjectTicketController.create({
|
href: ProjectTicketController.create({
|
||||||
project: props.project.id,
|
project: props.project.id,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasTicketMetadata =
|
const hasTicketMetadata = computed(
|
||||||
props.ticketTypes.length > 0 &&
|
() =>
|
||||||
props.ticketPriorities.length > 0 &&
|
props.ticketTypes.length > 0 &&
|
||||||
props.ticketStatusOptions.length > 0;
|
props.ticketPriorities.length > 0 &&
|
||||||
|
props.ticketStatusOptions.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const acceptanceCriteriaChecklist = ref('');
|
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||||
|
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
|
||||||
const form = useForm({
|
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
acceptance_criteria: [] as AcceptanceCriteriaModel,
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
),
|
|
||||||
assignee_user_id: '',
|
assignee_user_id: '',
|
||||||
description: '',
|
description: '',
|
||||||
parent_ticket_id: '',
|
parent_ticket_id: '',
|
||||||
@@ -80,9 +84,16 @@ const form = useForm({
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
const isDirty = computed(() => form.isDirty);
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
const protection = useFormProtection(form);
|
||||||
);
|
const reset = (): void => {
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
const acceptanceCriteriaError = computed(() => {
|
const acceptanceCriteriaError = computed(() => {
|
||||||
return (
|
return (
|
||||||
@@ -95,36 +106,35 @@ const acceptanceCriteriaError = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
if (!hasTicketMetadata.value || form.processing) {
|
||||||
acceptanceCriteriaChecklist.value,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
ProjectTicketController.store({ project: props.project.id }),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
// The source page is unmounted before this callback, so its storage watcher has stopped.
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
`${draftKey}:generation`,
|
||||||
|
String(draftGeneration.value + 1),
|
||||||
|
);
|
||||||
|
draftGeneration.value++;
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
|
||||||
preserveScroll: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|
||||||
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}`;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Create ${project.name} ticket`" />
|
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
|
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -132,10 +142,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Badge variant="outline" class="px-3 py-1">
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading
|
<h1 class="text-headline font-semibold tracking-tight">
|
||||||
title="Create ticket"
|
{{ t('ticketForm.create') }}
|
||||||
:description="`Add a ticket to ${project.name}.`"
|
</h1>
|
||||||
/>
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t('ticketForm.createDescription', {
|
||||||
|
project: project.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
@@ -143,7 +159,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<Link :href="show(project.id)">{{
|
||||||
|
t('ticketDetail.backToProject')
|
||||||
|
}}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,261 +175,70 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
|
|
||||||
<Card v-if="hasTicketMetadata">
|
<Card v-if="hasTicketMetadata">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ticket details</CardTitle>
|
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The ticket key is assigned after creation.
|
{{ t('ticketForm.keyAssigned') }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="grid gap-6" @submit.prevent="submit">
|
<form class="grid gap-6" @submit.prevent="submit">
|
||||||
<div class="grid gap-2">
|
<FormFeedback
|
||||||
<Label for="title">Title</Label>
|
:state="protection.state.value"
|
||||||
<Input
|
@retry="submit"
|
||||||
id="title"
|
/>
|
||||||
v-model="form.title"
|
<TicketFormFields
|
||||||
required
|
v-model:title="form.title"
|
||||||
autofocus
|
v-model:description="form.description"
|
||||||
:disabled="form.processing"
|
v-model:type="form.ticket_type_id"
|
||||||
placeholder="Implement repository sync"
|
v-model:priority="form.ticket_priority_id"
|
||||||
/>
|
v-model:status="form.ticket_status_id"
|
||||||
<InputError :message="form.errors.title" />
|
v-model:assignee="form.assignee_user_id"
|
||||||
</div>
|
v-model:parent="form.parent_ticket_id"
|
||||||
|
v-model:points="form.story_points"
|
||||||
<div
|
:project="project"
|
||||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
:ticket-types="ticketTypes"
|
||||||
|
:ticket-priorities="ticketPriorities"
|
||||||
|
:ticket-status-options="ticketStatusOptions"
|
||||||
|
:users="users"
|
||||||
|
:parent-ticket-options="parentTicketOptions"
|
||||||
|
:errors="form.errors"
|
||||||
|
:disabled="form.processing"
|
||||||
|
autofocus
|
||||||
>
|
>
|
||||||
<div class="grid gap-6">
|
<template #content>
|
||||||
<div class="grid gap-2">
|
<AcceptanceCriteriaChecklist
|
||||||
<Label for="description">Description</Label>
|
v-model="form.acceptance_criteria"
|
||||||
<textarea
|
:disabled="form.processing"
|
||||||
id="description"
|
:error="acceptanceCriteriaError"
|
||||||
v-model="form.description"
|
/>
|
||||||
rows="4"
|
</template>
|
||||||
:disabled="form.processing"
|
</TicketFormFields>
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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"
|
<div
|
||||||
placeholder="Describe the expected behavior and relevant context."
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||||
/>
|
>
|
||||||
<InputError
|
<Button type="submit" :disabled="form.processing">{{
|
||||||
:message="form.errors.description"
|
form.processing
|
||||||
/>
|
? t('ticketForm.creating')
|
||||||
</div>
|
: t('ticketForm.create')
|
||||||
|
}}</Button>
|
||||||
<div class="grid gap-4">
|
<Button
|
||||||
<div class="grid gap-2">
|
type="button"
|
||||||
<Label
|
variant="outline"
|
||||||
for="acceptance_criteria_checklist"
|
:disabled="form.processing || !isDirty"
|
||||||
>
|
@click="reset"
|
||||||
Acceptance criteria checklist
|
>{{ t('ticketForm.reset') }}</Button
|
||||||
</Label>
|
>
|
||||||
<textarea
|
<p
|
||||||
id="acceptance_criteria_checklist"
|
role="status"
|
||||||
v-model="
|
class="text-muted-foreground text-sm"
|
||||||
acceptanceCriteriaChecklist
|
>
|
||||||
"
|
{{
|
||||||
rows="6"
|
isDirty
|
||||||
:disabled="form.processing"
|
? t('ticketForm.unsaved')
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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"
|
: t('ticketForm.ready')
|
||||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
}}
|
||||||
/>
|
</p>
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Use <code>[ ]</code> /
|
|
||||||
<code>[x]</code> for checklist
|
|
||||||
items. Indent with two spaces for
|
|
||||||
nested steps.
|
|
||||||
</p>
|
|
||||||
<InputError
|
|
||||||
:message="acceptanceCriteriaError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
acceptanceCriteriaPreviewItems.length >
|
|
||||||
0
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border p-4 text-sm"
|
|
||||||
>
|
|
||||||
<p class="mb-3 font-medium">
|
|
||||||
Checklist preview
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="(
|
|
||||||
item, index
|
|
||||||
) in acceptanceCriteriaPreviewItems"
|
|
||||||
:key="index"
|
|
||||||
:style="{
|
|
||||||
marginLeft: `${item.indent * 1.25}rem`,
|
|
||||||
}"
|
|
||||||
class="flex items-start gap-2"
|
|
||||||
>
|
|
||||||
<span class="mt-0.5">{{
|
|
||||||
item.checked ? '☑' : '☐'
|
|
||||||
}}</span>
|
|
||||||
<span>{{ item.text }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid h-fit gap-6">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_type_id">Type</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_type_id"
|
|
||||||
v-model="form.ticket_type_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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-for="ticketType in ticketTypes"
|
|
||||||
:key="ticketType.id"
|
|
||||||
:value="String(ticketType.id)"
|
|
||||||
>
|
|
||||||
{{ ticketType.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.ticket_type_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_priority_id">
|
|
||||||
Priority
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_priority_id"
|
|
||||||
v-model="form.ticket_priority_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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-for="ticketPriority in ticketPriorities"
|
|
||||||
:key="ticketPriority.id"
|
|
||||||
:value="String(ticketPriority.id)"
|
|
||||||
>
|
|
||||||
{{ ticketPriority.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
form.errors.ticket_priority_id
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_status_id">Status</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_status_id"
|
|
||||||
v-model="form.ticket_status_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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-for="ticketStatus in ticketStatusOptions"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="String(ticketStatus.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(ticketStatus)
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.ticket_status_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="assignee_user_id">
|
|
||||||
Assignee
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="assignee_user_id"
|
|
||||||
v-model="form.assignee_user_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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 value="">Unassigned</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="String(user.id)"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.assignee_user_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="parent_ticket_id">
|
|
||||||
Parent ticket
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="parent_ticket_id"
|
|
||||||
v-model="form.parent_ticket_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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 value="">
|
|
||||||
No parent ticket
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="parentTicketOption in parentTicketOptions"
|
|
||||||
:key="parentTicketOption.id"
|
|
||||||
:value="
|
|
||||||
String(parentTicketOption.id)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.parent_ticket_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="project.show_story_points"
|
|
||||||
class="grid gap-2"
|
|
||||||
>
|
|
||||||
<Label for="story_points">
|
|
||||||
Estimated story points
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="story_points"
|
|
||||||
v-model="form.story_points"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
:disabled="form.processing"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.story_points"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="form.processing">
|
|
||||||
Create ticket
|
|
||||||
</Button>
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="returnHref">
|
|
||||||
{{ returnLabel }}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -420,13 +247,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Card v-else>
|
<Card v-else>
|
||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Ticket creation needs at least one type, one priority,
|
{{ t('ticketForm.metadataRequired') }}
|
||||||
and one status.
|
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="metadataSettings(project.id)">
|
<Link :href="metadataSettings(project.id)">
|
||||||
Open ticket settings
|
{{ t('ticketForm.openSettings') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
|
||||||
|
|
||||||
|
export type BacklogFilterState = {
|
||||||
|
assigneeId: BacklogAssigneeFilter;
|
||||||
|
priorityId: '' | number;
|
||||||
|
query: string;
|
||||||
|
statusId: '' | number;
|
||||||
|
typeId: '' | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeNode = {
|
||||||
|
children: BacklogTicketTreeNode[];
|
||||||
|
matchesFilters: boolean;
|
||||||
|
ticket: TicketModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeProps = {
|
||||||
|
canEditTickets: boolean;
|
||||||
|
collapsedTicketIds: Set<number>;
|
||||||
|
nodes: BacklogTicketTreeNode[];
|
||||||
|
pendingTicketId: number | null;
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeEmits = {
|
||||||
|
moveFinish: [ticketId: number];
|
||||||
|
moveStart: [ticketId: number];
|
||||||
|
toggle: [ticketId: number];
|
||||||
|
};
|
||||||
Vendored
+2
@@ -1,4 +1,5 @@
|
|||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
|
import type { AppNavigation } from '@/types/navigation';
|
||||||
import type { LocalizationSettings } from '@/types/localization';
|
import type { LocalizationSettings } from '@/types/localization';
|
||||||
import type { Appearance } from '@/types/ui';
|
import type { Appearance } from '@/types/ui';
|
||||||
import type { Page, Router } from '@inertiajs/core';
|
import type { Page, Router } from '@inertiajs/core';
|
||||||
@@ -25,6 +26,7 @@ declare module '@inertiajs/core' {
|
|||||||
appearance: Appearance;
|
appearance: Appearance;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
navigation: AppNavigation;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './backlog';
|
||||||
export * from './navigation';
|
export * from './navigation';
|
||||||
export * from './page-props';
|
export * from './page-props';
|
||||||
|
export * from './ticket-tree';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
@@ -11,4 +11,17 @@ export type NavItem = {
|
|||||||
href: NonNullable<InertiaLinkProps['href']>;
|
href: NonNullable<InertiaLinkProps['href']>;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
match?: 'exact' | 'section';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationProject = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
canManage: boolean;
|
||||||
|
canCreateTickets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppNavigation = {
|
||||||
|
project: NavigationProject | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AssignedTicketsProjectGroup = {
|
||||||
|
project: ProjectModel;
|
||||||
|
tickets: TicketModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardPageProps = {
|
||||||
|
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectsIndexPageProps = {
|
export type ProjectsIndexPageProps = {
|
||||||
projects: ProjectModel[];
|
projects: ProjectModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||||
|
export type TicketDetailHeaderProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
|
>;
|
||||||
|
export type TicketDetailContentProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'canManageProject'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
| 'ticketFieldDefinitions'
|
||||||
|
| 'ticketPriorities'
|
||||||
|
| 'ticketTypes'
|
||||||
|
| 'users'
|
||||||
|
>;
|
||||||
|
export type TicketDetailMetadataProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||||
|
>;
|
||||||
|
export type TicketDetailGitProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'repositoryBranches'
|
||||||
|
| 'repositoryPullRequests'
|
||||||
|
| 'suggestedBranchName'
|
||||||
|
>;
|
||||||
|
export type TicketDetailRelationshipsProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
| 'childTicketTree'
|
||||||
|
| 'linkedTickets'
|
||||||
|
| 'ticketLinkTypes'
|
||||||
|
| 'ticketLinkOptions'
|
||||||
|
>;
|
||||||
|
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
|
||||||
|
export type TicketBranchFormData = {
|
||||||
|
branch_name: string;
|
||||||
|
repository_connection_id: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export type TicketPresentationProps = {
|
||||||
|
layout?: 'card' | 'list';
|
||||||
|
projectId: number;
|
||||||
|
showDescription?: boolean;
|
||||||
|
showStatus?: boolean;
|
||||||
|
ticket: TicketModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketPresentationEmits = {
|
||||||
|
open: [ticketId: number];
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
|
|
||||||
|
export type TicketTreeListProps = {
|
||||||
|
nodes: TicketTreeNodeModel[];
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user