Compare commits
73
Commits
2e2ddd5b74
...
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2807a28c | ||
|
|
8a9113a7af | ||
|
|
0a974cfd49 | ||
|
|
538b17b9b8 | ||
|
|
b2789b4670 | ||
|
|
7c6981dc0a | ||
|
|
b8409a702c | ||
|
|
2acbdb4760 | ||
|
|
9faf02e2c4 | ||
|
|
0941745883 | ||
|
|
5a10c85e61 | ||
|
|
fb4faa6070 | ||
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
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
|
||||
**/.DS_Store
|
||||
.editorconfig
|
||||
.env.example
|
||||
.env.mariadb
|
||||
.env.mariadb.example
|
||||
.env.production
|
||||
LICENSE
|
||||
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:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -11,9 +17,19 @@ env:
|
||||
REGISTRY: git.twyco.de
|
||||
|
||||
jobs:
|
||||
test_and_check_app:
|
||||
name: test_and_check_app
|
||||
preconditions:
|
||||
if: >
|
||||
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||
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:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -27,6 +43,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
needs:
|
||||
- preconditions
|
||||
- test_and_check_app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+5
-3
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
||||
COPY . /var/www
|
||||
|
||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
&& corepack enable pnpm \
|
||||
&& pnpm install --frozen-lockfile \
|
||||
&& pnpm run build
|
||||
|
||||
FROM build AS test
|
||||
|
||||
RUN touch .env \
|
||||
RUN cp .env.example .env \
|
||||
&& php artisan key:generate\
|
||||
&& composer audit --locked --no-interaction \
|
||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
||||
'tickets' => TicketResource::collection(
|
||||
Ticket::query()
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
]),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||
];
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -60,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
||||
'user' => $user,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'navigation' => fn (): array => [
|
||||
'project' => $this->projectNavigation($request),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||
*/
|
||||
private function projectNavigation(Request $request): ?array
|
||||
{
|
||||
$project = $request->route('project');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $project instanceof Project || $user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||
|
||||
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'key' => $project->key,
|
||||
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
Rule::requiredIf($requiresImpactConfirmation),
|
||||
'accepted',
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
|
||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->dontFlash(['api_token']);
|
||||
})->create();
|
||||
|
||||
+6
-6
@@ -47,15 +47,15 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
"pnpm install",
|
||||
"pnpm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"dev:ssr": [
|
||||
"npm run build:ssr",
|
||||
"pnpm run build:ssr",
|
||||
"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"
|
||||
],
|
||||
@@ -67,9 +67,9 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm audit --audit-level=high",
|
||||
"npm run lint:check",
|
||||
"npm run types:check",
|
||||
"pnpm audit --audit-level=high",
|
||||
"pnpm run lint:check",
|
||||
"pnpm run types:check",
|
||||
"@test"
|
||||
],
|
||||
"test": [
|
||||
|
||||
@@ -25,6 +25,7 @@ const prettierOptions = {
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [
|
||||
'bootstrap/ssr/**',
|
||||
'**/dist/**',
|
||||
'node_modules/**',
|
||||
'.github/**',
|
||||
|
||||
Generated
-7269
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,13 @@
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:ssr": "vite build && vite build --ssr",
|
||||
@@ -39,6 +46,7 @@
|
||||
"vue-tsc": "^2.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.18",
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
||||
<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">
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { Columns3 } from 'lucide-vue-next';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
||||
>
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||
<span class="mb-0.5 truncate font-semibold leading-tight"
|
||||
>Laravel Starter Kit</span
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
</div>
|
||||
<Columns3 class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span
|
||||
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
|
||||
>KanbanGit</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
BookOpen,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
LayoutGrid,
|
||||
Layers3,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { Settings2, X } from 'lucide-vue-next';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -22,93 +13,98 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||
import { index as projectsIndex } from '@/routes/projects';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { NavItem } from '@/types';
|
||||
import { edit as profile } from '@/routes/profile';
|
||||
import { show } from '@/routes/projects';
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectsIndex(),
|
||||
icon: Folder,
|
||||
},
|
||||
];
|
||||
|
||||
if (page.props.auth.permissions.projectCreate) {
|
||||
items.push({
|
||||
title: 'Project templates',
|
||||
href: projectTemplatesIndex(),
|
||||
icon: Layers3,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.globalAdmin) {
|
||||
items.push({
|
||||
title: 'Users',
|
||||
href: adminUsers(),
|
||||
icon: Shield,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.canManageTeams) {
|
||||
items.push({
|
||||
title: 'Teams',
|
||||
href: teamsIndex(),
|
||||
icon: Users,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
const { t } = useLocalization();
|
||||
const { currentUrl } = useCurrentUrl();
|
||||
const { project, workspaceItems, projectItems, projectSettingsItems } =
|
||||
useAppNavigation();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const accountItems = computed(() => [
|
||||
{
|
||||
title: t('navigation.settings'),
|
||||
href: profile(),
|
||||
icon: Settings2,
|
||||
isActive: currentUrl.value.startsWith('/settings/'),
|
||||
},
|
||||
]);
|
||||
let removeNavigationListener: (() => void) | undefined;
|
||||
onMounted(() => {
|
||||
removeNavigationListener = router.on('navigate', () =>
|
||||
setOpenMobile(false),
|
||||
);
|
||||
});
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
onUnmounted(() => removeNavigationListener?.());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<Link :href="dashboard()">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarMenu class="min-w-0">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
as-child
|
||||
tooltip="KanbanGit"
|
||||
>
|
||||
<Link :href="dashboard()" aria-label="KanbanGit"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('navigation.close')"
|
||||
@click="setOpenMobile(false)"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<Link
|
||||
v-if="project"
|
||||
:href="show(project.id)"
|
||||
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<span
|
||||
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
|
||||
>{{ project.key }}</span
|
||||
>
|
||||
<span class="truncate font-semibold" :title="project.name">{{
|
||||
project.name
|
||||
}}</span>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
<nav :aria-label="t('navigation.main')">
|
||||
<NavMain
|
||||
:items="workspaceItems"
|
||||
:label="t('navigation.workspace')"
|
||||
/>
|
||||
<NavMain
|
||||
v-if="projectItems.length"
|
||||
:items="projectItems"
|
||||
:label="t('navigation.project')"
|
||||
/>
|
||||
<NavMain
|
||||
v-if="projectSettingsItems.length"
|
||||
:items="projectSettingsItems"
|
||||
:label="t('navigation.projectSettings')"
|
||||
/>
|
||||
</nav>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter :items="footerNavItems" />
|
||||
<NavUser />
|
||||
<SidebarFooter class="border-sidebar-border border-t p-0">
|
||||
<NavMain :items="accountItems" :label="t('navigation.account')" />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
import { UserAvatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||
import {
|
||||
normalizeAppearance,
|
||||
useAppearance,
|
||||
} from '@/composables/useAppearance';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { create as createProject } from '@/routes/projects';
|
||||
import { index as board } from '@/routes/projects/board';
|
||||
import { create as createTicket } from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}>(),
|
||||
{
|
||||
breadcrumbs: () => [],
|
||||
},
|
||||
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
const page = usePage();
|
||||
const { project } = useAppNavigation();
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useLocalization();
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const themeIcon = computed(
|
||||
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||
);
|
||||
const createAction = computed(() => {
|
||||
if (project.value?.canCreateTickets) {
|
||||
return {
|
||||
title: t('navigation.createTicket'),
|
||||
href: createTicket(project.value.id, {
|
||||
query: {
|
||||
source: isCurrentUrl(board(project.value.id))
|
||||
? 'board'
|
||||
: 'backlog',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!project.value && page.props.auth.permissions.projectCreate) {
|
||||
return { title: t('navigation.createProject'), href: createProject() };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
|
||||
class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SidebarTrigger class="shrink-0" />
|
||||
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
|
||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<Button
|
||||
v-if="createAction"
|
||||
as-child
|
||||
:aria-label="createAction.title"
|
||||
class="px-3"
|
||||
>
|
||||
<Link :href="createAction.href"
|
||||
><Plus aria-hidden="true" /><span
|
||||
class="hidden sm:inline"
|
||||
>{{ createAction.title }}</span
|
||||
></Link
|
||||
>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('appearance.label')"
|
||||
><component :is="themeIcon" aria-hidden="true"
|
||||
/></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{{
|
||||
t('appearance.label')
|
||||
}}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
:model-value="appearance"
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
updateAppearance(normalizeAppearance(value))
|
||||
"
|
||||
>
|
||||
<DropdownMenuRadioItem value="light"
|
||||
><Sun aria-hidden="true" />{{
|
||||
t('appearance.light')
|
||||
}}</DropdownMenuRadioItem
|
||||
>
|
||||
<DropdownMenuRadioItem value="dark"
|
||||
><Moon aria-hidden="true" />{{
|
||||
t('appearance.dark')
|
||||
}}</DropdownMenuRadioItem
|
||||
>
|
||||
<DropdownMenuRadioItem value="system"
|
||||
><Monitor aria-hidden="true" />{{
|
||||
t('appearance.system')
|
||||
}}</DropdownMenuRadioItem
|
||||
>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('navigation.userMenu', {
|
||||
name: page.props.auth.user.name,
|
||||
})
|
||||
"
|
||||
data-test="sidebar-menu-button"
|
||||
>
|
||||
<UserAvatar :user="page.props.auth.user" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="min-w-60">
|
||||
<UserMenuContent :user="page.props.auth.user" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const tabs = [
|
||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
||||
] as const;
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
value: 'light' as const,
|
||||
Icon: Sun,
|
||||
label: t('appearance.light'),
|
||||
description: t('appearance.lightDescription'),
|
||||
},
|
||||
{
|
||||
value: 'dark' as const,
|
||||
Icon: Moon,
|
||||
label: t('appearance.dark'),
|
||||
description: t('appearance.darkDescription'),
|
||||
},
|
||||
{
|
||||
value: 'system' as const,
|
||||
Icon: Monitor,
|
||||
label: t('appearance.system'),
|
||||
description: t('appearance.systemDescription'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
:aria-label="t('appearance.label')"
|
||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
||||
>
|
||||
<button
|
||||
v-for="{ value, Icon, label } in tabs"
|
||||
:key="value"
|
||||
type="button"
|
||||
:aria-pressed="appearance === value"
|
||||
@click="updateAppearance(value)"
|
||||
:class="[
|
||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
appearance === value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
]"
|
||||
>
|
||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t(label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<fieldset class="min-w-0">
|
||||
<legend class="sr-only">{{ t('appearance.label') }}</legend>
|
||||
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||
<label
|
||||
v-for="{ value, Icon, label, description } in tabs"
|
||||
:key="value"
|
||||
class="relative min-w-0"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="appearance"
|
||||
:value="value"
|
||||
:checked="appearance === value"
|
||||
:aria-describedby="`appearance-${value}-description`"
|
||||
class="peer sr-only"
|
||||
@change="updateAppearance(value)"
|
||||
/>
|
||||
<span
|
||||
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
|
||||
>
|
||||
<component
|
||||
:is="Icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<Check
|
||||
v-if="appearance === value"
|
||||
class="text-primary size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span class="font-semibold">{{ label }}</span>
|
||||
<span
|
||||
:id="`appearance-${value}-description`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</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,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
|
||||
<BreadcrumbList class="flex-nowrap">
|
||||
<template v-for="(item, index) in breadcrumbs" :key="index">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbItem
|
||||
:class="[
|
||||
'min-w-0',
|
||||
index !== breadcrumbs.length - 1
|
||||
? 'hidden md:inline-flex'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<template v-if="index === breadcrumbs.length - 1">
|
||||
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
|
||||
<BreadcrumbPage class="truncate" :title="item.title">{{
|
||||
item.title
|
||||
}}</BreadcrumbPage>
|
||||
</template>
|
||||
<template v-else>
|
||||
<BreadcrumbLink as-child>
|
||||
<Link :href="item.href">{{ item.title }}</Link>
|
||||
<Link
|
||||
:href="item.href"
|
||||
class="max-w-40 truncate"
|
||||
:title="item.title"
|
||||
>{{ item.title }}</Link
|
||||
>
|
||||
</BreadcrumbLink>
|
||||
</template>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
|
||||
<BreadcrumbSeparator
|
||||
v-if="index !== breadcrumbs.length - 1"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
</template>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -17,97 +17,104 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const passwordInput = useTemplateRef('passwordInput');
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Delete account"
|
||||
description="Delete your account and all of its resources"
|
||||
/>
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
|
||||
>
|
||||
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||
<p class="font-medium">Warning</p>
|
||||
<p class="text-sm">
|
||||
Please proceed with caution, this cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="destructive" data-test="delete-user-button"
|
||||
>Delete account</Button
|
||||
>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form
|
||||
v-bind="ProfileController.destroy.form()"
|
||||
reset-on-success
|
||||
@error="() => passwordInput?.focus()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing, reset, clearErrors }"
|
||||
>
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle
|
||||
>Are you sure you want to delete your
|
||||
account?</DialogTitle
|
||||
>
|
||||
<DialogDescription>
|
||||
Once your account is deleted, all of its
|
||||
resources and data will also be permanently
|
||||
deleted. Please enter your password to confirm
|
||||
you would like to permanently delete your
|
||||
account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Card
|
||||
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||
>
|
||||
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('profile.delete.description') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="p-5 sm:p-6">
|
||||
<div
|
||||
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<p class="text-destructive font-semibold">
|
||||
{{ t('profile.delete.warning') }}
|
||||
</p>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('profile.delete.warningDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="justify-self-start"
|
||||
data-test="delete-user-button"
|
||||
>{{ t('profile.delete.action') }}</Button
|
||||
>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form
|
||||
v-bind="ProfileController.destroy.form()"
|
||||
reset-on-success
|
||||
@error="() => passwordInput?.focus()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing, reset, clearErrors }"
|
||||
>
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle>{{
|
||||
t('profile.delete.confirmTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('profile.delete.confirmDescription') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password" class="sr-only"
|
||||
>Password</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
ref="passwordInput"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password" class="sr-only">{{
|
||||
t('profile.delete.password')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
ref="passwordInput"
|
||||
:placeholder="t('profile.delete.password')"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click="
|
||||
() => {
|
||||
clearErrors();
|
||||
reset();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click="
|
||||
() => {
|
||||
clearErrors();
|
||||
reset();
|
||||
}
|
||||
"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
data-test="confirm-delete-user-button"
|
||||
>
|
||||
Cancel
|
||||
{{ t('profile.delete.confirmAction') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
data-test="confirm-delete-user-button"
|
||||
>
|
||||
Delete account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: 'empty' | 'loading' | 'error' | 'success';
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: false },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const icons = {
|
||||
empty: Inbox,
|
||||
loading: LoaderCircle,
|
||||
error: CircleAlert,
|
||||
success: CircleCheck,
|
||||
};
|
||||
const defaultTitles = {
|
||||
empty: 'feedback.empty',
|
||||
loading: 'common.loading',
|
||||
error: 'common.error',
|
||||
success: 'feedback.saved',
|
||||
} as const;
|
||||
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:role="state === 'error' ? 'alert' : 'status'"
|
||||
:aria-busy="state === 'loading' || undefined"
|
||||
:data-feedback="state"
|
||||
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
|
||||
:class="{
|
||||
'border-destructive/40': state === 'error',
|
||||
'border-success/40': state === 'success',
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="icons[state]"
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 size-5 shrink-0"
|
||||
:class="{
|
||||
'text-primary animate-spin motion-reduce:animate-none':
|
||||
state === 'loading',
|
||||
'text-destructive': state === 'error',
|
||||
'text-success-foreground': state === 'success',
|
||||
'text-muted-foreground': state === 'empty',
|
||||
}"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0 flex-1 break-words">
|
||||
<p class="font-semibold">{{ heading }}</p>
|
||||
<p v-if="description" class="text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div v-if="$slots.default"><slot /></div>
|
||||
<Button
|
||||
v-if="retryable && state === 'error'"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="$emit('retry')"
|
||||
>{{ t('common.retry') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: FormFeedbackState;
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: true },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const feedback = useTemplateRef<HTMLDivElement>('feedback');
|
||||
watch(
|
||||
() => props.state,
|
||||
async (state) => {
|
||||
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
|
||||
await nextTick();
|
||||
if (props.state === state) {
|
||||
feedback.value?.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
const titles = {
|
||||
idle: 'feedback.saved',
|
||||
loading: 'forms.saving',
|
||||
success: 'feedback.saved',
|
||||
validation: 'forms.validationTitle',
|
||||
network: 'forms.networkTitle',
|
||||
server: 'forms.serverTitle',
|
||||
cancelled: 'forms.cancelledTitle',
|
||||
} as const;
|
||||
const descriptions = {
|
||||
validation: 'forms.validationDescription',
|
||||
network: 'forms.networkDescription',
|
||||
server: 'forms.serverDescription',
|
||||
cancelled: 'forms.cancelledDescription',
|
||||
} as const;
|
||||
const description = computed(() =>
|
||||
props.state in descriptions
|
||||
? t(descriptions[props.state as keyof typeof descriptions])
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="feedback"
|
||||
v-show="state !== 'idle'"
|
||||
:data-form-state="state"
|
||||
tabindex="-1"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
>
|
||||
<FeedbackState
|
||||
v-if="state !== 'idle'"
|
||||
:state="
|
||||
state === 'loading' || state === 'success' ? state : 'error'
|
||||
"
|
||||
:title="props.title ?? t(titles[state])"
|
||||
:description="props.description ?? description"
|
||||
:retryable="
|
||||
props.retryable !== false &&
|
||||
(state === 'network' ||
|
||||
state === 'server' ||
|
||||
state === 'cancelled')
|
||||
"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,8 +5,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-600 dark:text-red-500">
|
||||
<div v-show="message" role="alert">
|
||||
<p class="text-body text-destructive">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { KeyRound, UserRound } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
save,
|
||||
store,
|
||||
} from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'create' | 'edit';
|
||||
user?: UserModel | null;
|
||||
}>(),
|
||||
{ user: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ cancel: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const isEdit = computed(() => props.mode === 'edit');
|
||||
const confirmationOpen = ref(false);
|
||||
const form = useForm(
|
||||
'ManagedUser:' + props.mode + ':' + (props.user?.id ?? 'new'),
|
||||
{
|
||||
name: props.user?.name ?? '',
|
||||
email: props.user?.email ?? '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
must_change_password_on_next_login:
|
||||
props.user?.must_change_password_on_next_login ?? true,
|
||||
},
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
|
||||
function performSubmit(): void {
|
||||
confirmationOpen.value = false;
|
||||
protection.submit(
|
||||
isEdit.value && props.user ? save(props.user.id) : store(),
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}
|
||||
|
||||
function requestSubmit(): void {
|
||||
if (isEdit.value) {
|
||||
confirmationOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
performSubmit();
|
||||
}
|
||||
|
||||
function updateConfirmation(open: boolean): void {
|
||||
if (!form.processing) {
|
||||
confirmationOpen.value = open;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<UserRound class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<h2 class="text-title">
|
||||
{{ t('adminUsers.form.detailsTitle') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{
|
||||
t(
|
||||
isEdit
|
||||
? 'adminUsers.form.editDescription'
|
||||
: 'adminUsers.form.createDescription',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-5 sm:p-6">
|
||||
<form
|
||||
class="gap-page grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="requestSubmit"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="performSubmit"
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
class="gap-column grid min-w-0"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<legend class="sr-only">
|
||||
{{ t('adminUsers.form.detailsTitle') }}
|
||||
</legend>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="managed-user-name">{{
|
||||
t('adminUsers.form.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="managed-user-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
:placeholder="
|
||||
t('adminUsers.form.namePlaceholder')
|
||||
"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="managed-user-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="managed-user-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="managed-user-email">{{
|
||||
t('adminUsers.form.email')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="managed-user-email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
:placeholder="
|
||||
t('adminUsers.form.emailPlaceholder')
|
||||
"
|
||||
:aria-invalid="!!form.errors.email"
|
||||
aria-describedby="managed-user-email-error"
|
||||
/>
|
||||
<InputError
|
||||
id="managed-user-email-error"
|
||||
:message="form.errors.email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="managed-user-password-title"
|
||||
class="gap-column grid min-w-0 border-t pt-6"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<KeyRound
|
||||
class="text-muted-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<h3
|
||||
id="managed-user-password-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('adminUsers.form.passwordTitle') }}
|
||||
</h3>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{
|
||||
t(
|
||||
isEdit
|
||||
? 'adminUsers.form.passwordEditHelp'
|
||||
: 'adminUsers.form.passwordCreateHelp',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="managed-user-password">{{
|
||||
t(
|
||||
isEdit
|
||||
? 'adminUsers.form.newPassword'
|
||||
: 'adminUsers.form.password',
|
||||
)
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="managed-user-password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
:required="!isEdit"
|
||||
autocomplete="new-password"
|
||||
:placeholder="
|
||||
t(
|
||||
isEdit
|
||||
? 'adminUsers.form.passwordEditPlaceholder'
|
||||
: 'adminUsers.form.passwordPlaceholder',
|
||||
)
|
||||
"
|
||||
:aria-invalid="!!form.errors.password"
|
||||
aria-describedby="managed-user-password-error"
|
||||
/>
|
||||
<InputError
|
||||
id="managed-user-password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
for="managed-user-password-confirmation"
|
||||
>{{
|
||||
t('adminUsers.form.confirmPassword')
|
||||
}}</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="managed-user-password-confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
name="password_confirmation"
|
||||
:required="!isEdit"
|
||||
autocomplete="new-password"
|
||||
:placeholder="
|
||||
t(
|
||||
'adminUsers.form.confirmPasswordPlaceholder',
|
||||
)
|
||||
"
|
||||
:aria-invalid="
|
||||
!!form.errors.password_confirmation
|
||||
"
|
||||
aria-describedby="managed-user-password-confirmation-error"
|
||||
/>
|
||||
<InputError
|
||||
id="managed-user-password-confirmation-error"
|
||||
:message="form.errors.password_confirmation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-2 border-t pt-6">
|
||||
<Label
|
||||
for="managed-user-force-password-change"
|
||||
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Checkbox
|
||||
id="managed-user-force-password-change"
|
||||
v-model="
|
||||
form.must_change_password_on_next_login
|
||||
"
|
||||
name="must_change_password_on_next_login"
|
||||
:invalid="
|
||||
!!form.errors
|
||||
.must_change_password_on_next_login
|
||||
"
|
||||
aria-describedby="managed-user-force-password-change-help managed-user-force-password-change-error"
|
||||
/>
|
||||
<span class="grid gap-1">
|
||||
<span>{{
|
||||
t('adminUsers.form.forcePasswordChange')
|
||||
}}</span>
|
||||
<span
|
||||
id="managed-user-force-password-change-help"
|
||||
class="text-caption text-muted-foreground font-normal"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'adminUsers.form.forcePasswordChangeHelp',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
id="managed-user-force-password-change-error"
|
||||
:message="
|
||||
form.errors.must_change_password_on_next_login
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="form.processing"
|
||||
class="sm:min-w-36"
|
||||
data-test="managed-user-submit"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
isEdit
|
||||
? 'adminUsers.form.reviewChanges'
|
||||
: 'adminUsers.form.createAction',
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog :open="confirmationOpen" @update:open="updateConfirmation">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
t('adminUsers.form.confirmTitle', {
|
||||
name: form.name || props.user?.name || '',
|
||||
})
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('adminUsers.form.confirmDescription') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
|
||||
>
|
||||
{{ t('adminUsers.form.confirmWarning') }}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="form.processing"
|
||||
@click="confirmationOpen = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
:loading="form.processing"
|
||||
data-test="confirm-managed-user-update"
|
||||
@click="performSubmit"
|
||||
>
|
||||
{{ t('adminUsers.form.confirmAction') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
items: NavItem[];
|
||||
label: string;
|
||||
}>();
|
||||
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarGroup class="px-3 py-2">
|
||||
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
|
||||
label
|
||||
}}</SidebarGroupLabel>
|
||||
<SidebarMenu role="list" :aria-label="label">
|
||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="isCurrentUrl(item.href)"
|
||||
:is-active="
|
||||
item.isActive ??
|
||||
isCurrentUrl(
|
||||
item.href,
|
||||
undefined,
|
||||
item.match === 'section',
|
||||
)
|
||||
"
|
||||
:tooltip="item.title"
|
||||
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
|
||||
>
|
||||
<Link :href="item.href">
|
||||
<component :is="item.icon" />
|
||||
<Link
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
(item.isActive ??
|
||||
isCurrentUrl(
|
||||
item.href,
|
||||
undefined,
|
||||
item.match === 'section',
|
||||
))
|
||||
? 'page'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<component :is="item.icon" aria-hidden="true" />
|
||||
<span>{{ item.title }}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Eye, EyeOff } from 'lucide-vue-next';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | number | null>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
const inputRef = useTemplateRef('inputRef');
|
||||
const { t } = useLocalization();
|
||||
|
||||
defineExpose({
|
||||
$el: inputRef,
|
||||
@@ -28,7 +31,8 @@ defineExpose({
|
||||
ref="inputRef"
|
||||
v-model="model"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:class="cn('pr-10', props.class)"
|
||||
:class="cn('pr-12', props.class)"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<button
|
||||
@@ -36,11 +40,14 @@ defineExpose({
|
||||
@click="showPassword = !showPassword"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
)
|
||||
"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
:tabindex="-1"
|
||||
:aria-label="
|
||||
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
||||
"
|
||||
:aria-pressed="showPassword"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ChevronDown, Settings2 } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const { projectItems, projectSettingsItems } = useAppNavigation();
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useLocalization();
|
||||
const settingsActive = computed(() =>
|
||||
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="projectItems.length"
|
||||
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
|
||||
>
|
||||
<nav
|
||||
:aria-label="t('navigation.project')"
|
||||
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
|
||||
>
|
||||
<Link
|
||||
v-for="item in projectItems"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
|
||||
:class="[
|
||||
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
|
||||
isCurrentUrl(item.href)
|
||||
? 'border-brand text-primary'
|
||||
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>{{ item.title }}
|
||||
</Link>
|
||||
</nav>
|
||||
<DropdownMenu v-if="projectSettingsItems.length">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="t('navigation.projectSettings')"
|
||||
:class="{
|
||||
'bg-accent text-accent-foreground': settingsActive,
|
||||
}"
|
||||
>
|
||||
<Settings2 aria-hidden="true" /><span
|
||||
class="hidden sm:inline"
|
||||
>{{ t('navigation.projectSettings') }}</span
|
||||
><ChevronDown class="hidden sm:block" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-for="item in projectSettingsItems"
|
||||
:key="item.title"
|
||||
as-child
|
||||
>
|
||||
<Link
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
isCurrentUrl(item.href) ? 'page' : undefined
|
||||
"
|
||||
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
|
||||
>
|
||||
<component :is="item.icon" aria-hidden="true" />{{
|
||||
item.title
|
||||
}}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,45 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||
import { reactive } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
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<{
|
||||
nodes: TicketTreeNodeModel[];
|
||||
projectId: number;
|
||||
}>();
|
||||
defineProps<TicketTreeListProps>();
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="node in nodes" :key="node.id">
|
||||
<div class="border-border rounded-xl border px-4 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: projectId,
|
||||
ticket: node.id,
|
||||
})
|
||||
<ul class="grid min-w-0 gap-3">
|
||||
<li v-for="node in nodes" :key="node.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.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 }}
|
||||
</Link>
|
||||
|
||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||
{{ node.ticket_type_name }}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
v-if="collapsedTicketIds.has(node.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="text-foreground mt-2 text-sm">
|
||||
{{ node.title }}
|
||||
</div>
|
||||
<Link
|
||||
:href="
|
||||
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
|
||||
v-if="node.children.length > 0"
|
||||
class="border-border mt-3 border-l pl-4"
|
||||
v-if="
|
||||
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
|
||||
:nodes="node.children"
|
||||
|
||||
@@ -1,123 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
|
||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
||||
const { t } = useLocalization();
|
||||
const {
|
||||
recoveryCodesList,
|
||||
fetchRecoveryCodes,
|
||||
clearRecoveryCodes,
|
||||
errors,
|
||||
loading,
|
||||
} = useTwoFactorAuth();
|
||||
const visible = ref(false);
|
||||
const showRegenerate = ref(false);
|
||||
const form = useForm({});
|
||||
const protection = useFormProtection(form);
|
||||
|
||||
const toggleRecoveryCodesVisibility = async () => {
|
||||
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
|
||||
await fetchRecoveryCodes();
|
||||
function hide(): void {
|
||||
visible.value = false;
|
||||
clearRecoveryCodes();
|
||||
}
|
||||
function toggle(): void {
|
||||
if (visible.value) {
|
||||
hide();
|
||||
} else {
|
||||
visible.value = true;
|
||||
void fetchRecoveryCodes();
|
||||
}
|
||||
|
||||
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
|
||||
|
||||
if (isRecoveryCodesVisible.value) {
|
||||
await nextTick();
|
||||
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
function closeRegenerate(open: boolean): void {
|
||||
if (!form.processing) {
|
||||
showRegenerate.value = open;
|
||||
protection.clearFeedback();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!recoveryCodesList.value.length) {
|
||||
await fetchRecoveryCodes();
|
||||
}
|
||||
});
|
||||
}
|
||||
function regenerate(): void {
|
||||
protection.submit(regenerateRecoveryCodes(), {
|
||||
preserveScroll: true,
|
||||
onBefore: hide,
|
||||
onSuccess: () => {
|
||||
showRegenerate.value = false;
|
||||
visible.value = true;
|
||||
return fetchRecoveryCodes();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex gap-3">
|
||||
<LockKeyhole class="size-4" />2FA recovery codes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recovery codes let you regain access if you lose your 2FA
|
||||
device. Store them in a secure password manager.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
<section
|
||||
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||
aria-labelledby="recovery-title"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<h3
|
||||
id="recovery-title"
|
||||
class="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
|
||||
<component
|
||||
:is="isRecoveryCodesVisible ? EyeOff : Eye"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
|
||||
codes
|
||||
</Button>
|
||||
|
||||
<Form
|
||||
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
|
||||
v-bind="regenerateRecoveryCodes.form()"
|
||||
method="post"
|
||||
:options="{ preserveScroll: true }"
|
||||
@success="fetchRecoveryCodes"
|
||||
#default="{ processing }"
|
||||
<LockKeyhole class="text-primary size-4" />{{
|
||||
t('security.recoveryTitle')
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('security.recoveryDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
:aria-expanded="visible"
|
||||
aria-controls="recovery-codes"
|
||||
:disabled="form.processing"
|
||||
@click="toggle"
|
||||
><component :is="visible ? EyeOff : Eye" />{{
|
||||
t(
|
||||
visible
|
||||
? 'security.hideRecovery'
|
||||
: 'security.viewRecovery',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
<Button
|
||||
v-if="visible && recoveryCodesList.length"
|
||||
variant="secondary"
|
||||
@click="showRegenerate = true"
|
||||
><RefreshCw />{{ t('security.regenerate') }}</Button
|
||||
>
|
||||
</div>
|
||||
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
|
||||
<p
|
||||
v-if="loading"
|
||||
role="status"
|
||||
class="bg-muted animate-pulse rounded-lg p-4"
|
||||
>
|
||||
{{ t('security.loadingRecovery') }}
|
||||
</p>
|
||||
<template v-else-if="errors.length">
|
||||
<AlertError :errors="errors" />
|
||||
<Button
|
||||
class="justify-self-start"
|
||||
variant="outline"
|
||||
@click="fetchRecoveryCodes"
|
||||
>{{ t('common.retry') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
>
|
||||
<RefreshCw /> Regenerate codes
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'relative overflow-hidden transition-all duration-300',
|
||||
isRecoveryCodesVisible
|
||||
? 'h-auto opacity-100'
|
||||
: 'h-0 opacity-0',
|
||||
]"
|
||||
</template>
|
||||
<template v-else>
|
||||
<ul
|
||||
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
|
||||
:aria-label="t('security.recoveryTitle')"
|
||||
>
|
||||
<li v-for="code in recoveryCodesList" :key="code">
|
||||
{{ code }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('security.recoveryOnce') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<div v-if="errors?.length" class="mt-6">
|
||||
<AlertError :errors="errors" />
|
||||
</div>
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div
|
||||
ref="recoveryCodeSectionRef"
|
||||
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
t('security.regenerateTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('security.regenerateDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
@retry="regenerate"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="closeRegenerate(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<div v-if="!recoveryCodesList.length" class="space-y-2">
|
||||
<div
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(code, index) in recoveryCodesList"
|
||||
:key="index"
|
||||
>
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground select-none text-xs">
|
||||
Each recovery code can be used once to access your
|
||||
account and will be removed after use. If you need more,
|
||||
click
|
||||
<span class="font-bold">Regenerate codes</span> above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button :loading="form.processing" @click="regenerate">{{
|
||||
t('security.regenerate')
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,297 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
type Props = {
|
||||
const props = defineProps<{
|
||||
requiresConfirmation: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
const { resolvedAppearance } = useAppearance();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
}>();
|
||||
const isOpen = defineModel<boolean>('isOpen');
|
||||
const { t } = useLocalization();
|
||||
const {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
fetchSetupData,
|
||||
errors,
|
||||
loading,
|
||||
} = useTwoFactorAuth();
|
||||
const form = useForm({ code: '' }).dontRemember('code');
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const verification = ref(false);
|
||||
const copied = ref(false);
|
||||
const copyError = ref(false);
|
||||
const codeInput = useTemplateRef('codeInput');
|
||||
const title = computed(() =>
|
||||
t(
|
||||
props.twoFactorEnabled
|
||||
? 'security.setupEnabled'
|
||||
: verification.value
|
||||
? 'security.verifyTitle'
|
||||
: 'security.setupTitle',
|
||||
),
|
||||
);
|
||||
const description = computed(() =>
|
||||
t(
|
||||
props.twoFactorEnabled
|
||||
? 'security.setupEnabledDescription'
|
||||
: verification.value
|
||||
? 'security.verifyDescription'
|
||||
: 'security.setupDescription',
|
||||
),
|
||||
);
|
||||
|
||||
const { copy, copied } = useClipboard();
|
||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
||||
useTwoFactorAuth();
|
||||
|
||||
const showVerificationStep = ref(false);
|
||||
const code = ref<string>('');
|
||||
|
||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
||||
|
||||
const modalConfig = computed<TwoFactorConfigContent>(() => {
|
||||
if (props.twoFactorEnabled) {
|
||||
return {
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
||||
buttonText: 'Close',
|
||||
};
|
||||
function close(open: boolean): void {
|
||||
if (!form.processing) {
|
||||
isOpen.value = open;
|
||||
}
|
||||
|
||||
if (showVerificationStep.value) {
|
||||
return {
|
||||
title: 'Verify authentication code',
|
||||
description: 'Enter the 6-digit code from your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Enable two-factor authentication',
|
||||
description:
|
||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
};
|
||||
});
|
||||
|
||||
const handleModalNextStep = () => {
|
||||
if (props.requiresConfirmation) {
|
||||
showVerificationStep.value = true;
|
||||
|
||||
nextTick(() => {
|
||||
pinInputContainerRef.value?.querySelector('input')?.focus();
|
||||
});
|
||||
|
||||
}
|
||||
async function next(): Promise<void> {
|
||||
if (props.twoFactorEnabled || !props.requiresConfirmation) {
|
||||
close(false);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSetupData();
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
if (props.twoFactorEnabled) {
|
||||
clearSetupData();
|
||||
verification.value = true;
|
||||
await nextTick();
|
||||
codeInput.value?.$el?.focus();
|
||||
}
|
||||
function submit(): void {
|
||||
protection.submit(confirm(), {
|
||||
preserveScroll: true,
|
||||
errorBag: 'confirmTwoFactorAuthentication',
|
||||
onSuccess: () => {
|
||||
isOpen.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
}
|
||||
async function copyKey(): Promise<void> {
|
||||
const key = manualSetupKey.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
showVerificationStep.value = false;
|
||||
code.value = '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => isOpen.value,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetModalState();
|
||||
|
||||
return;
|
||||
copyError.value = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
if (isOpen.value && manualSetupKey.value === key) {
|
||||
copied.value = true;
|
||||
}
|
||||
|
||||
if (!qrCodeSvg.value) {
|
||||
await fetchSetupData();
|
||||
} catch {
|
||||
if (isOpen.value) {
|
||||
copyError.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
watch(isOpen, (open) => {
|
||||
verification.value = false;
|
||||
copied.value = false;
|
||||
copyError.value = false;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
clearSetupData();
|
||||
if (open) {
|
||||
void fetchSetupData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="isOpen = $event">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader class="flex items-center justify-center">
|
||||
<div
|
||||
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
|
||||
<Dialog :open="isOpen" @update:open="close">
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<ScanLine class="text-primary size-8" />
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<template v-if="!verification">
|
||||
<p
|
||||
v-if="loading"
|
||||
role="status"
|
||||
class="bg-muted animate-pulse rounded-lg p-6 text-center"
|
||||
>
|
||||
{{ t('security.loadingSetup') }}
|
||||
</p>
|
||||
<div v-else-if="errors.length" class="gap-stack grid">
|
||||
<AlertError :errors="errors" />
|
||||
<Button variant="outline" @click="fetchSetupData">{{
|
||||
t('common.retry')
|
||||
}}</Button>
|
||||
</div>
|
||||
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
|
||||
<div
|
||||
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 grid grid-cols-5 opacity-50"
|
||||
>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="`col-${i}`"
|
||||
class="border-border border-r last:border-r-0"
|
||||
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
|
||||
v-html="qrCodeSvg"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="setup-key">{{
|
||||
t('security.manualKey')
|
||||
}}</Label>
|
||||
<div class="flex min-w-0 gap-2">
|
||||
<Input
|
||||
id="setup-key"
|
||||
:model-value="manualSetupKey"
|
||||
readonly
|
||||
class="min-w-0 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t(
|
||||
copied
|
||||
? 'security.copied'
|
||||
: 'security.copyKey',
|
||||
)
|
||||
"
|
||||
@click="copyKey"
|
||||
><Check v-if="copied" /><Copy v-else
|
||||
/></Button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 grid grid-rows-5 opacity-50"
|
||||
<p
|
||||
v-if="copied"
|
||||
role="status"
|
||||
class="text-caption text-success-foreground"
|
||||
>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="`row-${i}`"
|
||||
class="border-border border-b last:border-b-0"
|
||||
/>
|
||||
</div>
|
||||
<ScanLine
|
||||
class="text-foreground relative z-20 size-6"
|
||||
{{ t('security.copied') }}
|
||||
</p>
|
||||
<InputError
|
||||
v-if="copyError"
|
||||
:message="t('security.copyError')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
|
||||
<DialogDescription class="text-center">
|
||||
{{ modalConfig.description }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
||||
>
|
||||
<template v-if="!showVerificationStep">
|
||||
<AlertError v-if="errors?.length" :errors="errors" />
|
||||
<template v-else>
|
||||
<div
|
||||
class="relative mx-auto flex max-w-md items-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<div
|
||||
v-if="!qrCodeSvg"
|
||||
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
|
||||
>
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative z-10 overflow-hidden border p-5"
|
||||
>
|
||||
<div
|
||||
v-html="qrCodeSvg"
|
||||
class="flex aspect-square size-full items-center justify-center"
|
||||
:style="{
|
||||
filter:
|
||||
resolvedAppearance === 'dark'
|
||||
? 'invert(1) brightness(1.5)'
|
||||
: undefined,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button class="w-full" @click="handleModalNextStep">
|
||||
{{ modalConfig.buttonText }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="bg-border absolute inset-0 top-1/2 h-px w-full"
|
||||
/>
|
||||
<span class="bg-card relative px-2 py-1"
|
||||
>or, enter the code manually</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-center justify-center space-x-2"
|
||||
>
|
||||
<div
|
||||
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div
|
||||
v-if="!manualSetupKey"
|
||||
class="bg-muted flex h-full w-full items-center justify-center p-3"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<template v-else>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="manualSetupKey"
|
||||
class="bg-background text-foreground h-full w-full p-3"
|
||||
/>
|
||||
<button
|
||||
@click="copy(manualSetupKey || '')"
|
||||
class="border-border hover:bg-muted relative block h-auto border-l px-3"
|
||||
>
|
||||
<Check
|
||||
v-if="copied"
|
||||
class="w-4 text-green-500"
|
||||
/>
|
||||
<Copy v-else class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Form
|
||||
v-bind="confirm.form()"
|
||||
error-bag="confirmTwoFactorAuthentication"
|
||||
reset-on-error
|
||||
@finish="code = ''"
|
||||
@success="isOpen = false"
|
||||
v-slot="{ errors, processing }"
|
||||
<p
|
||||
v-if="requiresConfirmation && !twoFactorEnabled"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('security.setupPendingDescription') }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="close(false)">{{
|
||||
t('common.close')
|
||||
}}</Button>
|
||||
<Button v-if="hasSetupData" @click="next">{{
|
||||
t(
|
||||
twoFactorEnabled || !requiresConfirmation
|
||||
? 'common.done'
|
||||
: 'security.continue',
|
||||
)
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
<form v-else class="gap-column grid" @submit.prevent="submit">
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="authentication-code">{{
|
||||
t('security.authenticationCode')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="authentication-code"
|
||||
ref="codeInput"
|
||||
v-model="form.code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="!!form.errors.code"
|
||||
aria-describedby="authentication-code-error"
|
||||
class="font-mono text-lg tracking-widest"
|
||||
/>
|
||||
<InputError
|
||||
id="authentication-code-error"
|
||||
:message="form.errors.code"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="
|
||||
verification = false;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
"
|
||||
>{{ t('common.back') }}</Button
|
||||
>
|
||||
<input type="hidden" name="code" :value="code" />
|
||||
<div
|
||||
ref="pinInputContainerRef"
|
||||
class="relative w-full space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
|
||||
>
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<InputError :message="errors?.code" />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-auto flex-1"
|
||||
@click="showVerificationStep = false"
|
||||
:disabled="processing"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-auto flex-1"
|
||||
:disabled="processing || code.length < 6"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.code.length !== 6"
|
||||
:loading="form.processing"
|
||||
>{{ t('security.confirm') }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useInitials } from '@/composables/useInitials';
|
||||
import type { User } from '@/types';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import type { User } from '@/types';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
showEmail?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
|
||||
showEmail: false,
|
||||
});
|
||||
|
||||
const { getInitials } = useInitials();
|
||||
|
||||
// Compute whether we should show the avatar image
|
||||
const showAvatar = computed(
|
||||
() => props.user.avatar && props.user.avatar !== '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserAvatar />
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
|
||||
user.email
|
||||
}}</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar :user="user" />
|
||||
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span
|
||||
v-if="showEmail"
|
||||
class="text-muted-foreground truncate text-xs"
|
||||
>{{ user.email }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
|
||||
import { logout } from '@/routes/index';
|
||||
import { edit } from '@/routes/profile/index';
|
||||
import type { User } from '@/types';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -21,6 +22,7 @@ const handleLogout = () => {
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +36,7 @@ defineProps<Props>();
|
||||
<DropdownMenuItem :as-child="true">
|
||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
{{ t('navigation.settings') }}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
@@ -48,7 +50,7 @@ defineProps<Props>();
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
{{ t('navigation.logout') }}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
destroyPriority,
|
||||
destroyType,
|
||||
storePriority,
|
||||
storeType,
|
||||
updatePriority,
|
||||
updateType,
|
||||
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
target: MetadataEditorTarget | null;
|
||||
types: TicketTypeModel[];
|
||||
priorities: TicketPriorityModel[];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
position: 1,
|
||||
is_parent: false,
|
||||
replacement_ticket_type_id: '',
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deletionError = ref<string | null>(null);
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const entries = computed(() =>
|
||||
props.target?.kind === 'type' ? props.types : props.priorities,
|
||||
);
|
||||
const replacements = computed(() =>
|
||||
props.types.filter((type) => type.id !== props.target?.entry?.id),
|
||||
);
|
||||
const needsReplacement = computed(
|
||||
() =>
|
||||
deleting.value &&
|
||||
props.target?.kind === 'type' &&
|
||||
(props.target.entry?.tickets_count ?? 0) > 0,
|
||||
);
|
||||
const title = computed(() => {
|
||||
const target = props.target;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.action === 'create') {
|
||||
return t(
|
||||
target.kind === 'type'
|
||||
? 'metadata.addType'
|
||||
: 'metadata.addPriority',
|
||||
);
|
||||
}
|
||||
return t(
|
||||
target.action === 'edit'
|
||||
? 'metadata.editEntry'
|
||||
: 'metadata.deleteEntry',
|
||||
{ name: target.entry.name },
|
||||
);
|
||||
});
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
form.defaults({
|
||||
name: target.entry?.name ?? '',
|
||||
position: target.entry?.position ?? 1,
|
||||
is_parent: target.entry?.is_parent ?? false,
|
||||
replacement_ticket_type_id: '',
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
deletionError.value = null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (
|
||||
!target ||
|
||||
form.processing ||
|
||||
(deleting.value && entries.value.length <= 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'type'
|
||||
? target.action === 'create'
|
||||
? storeType(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateType([props.projectId, target.entry.id])
|
||||
: destroyType([props.projectId, target.entry.id])
|
||||
: target.action === 'create'
|
||||
? storePriority(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updatePriority([props.projectId, target.entry.id])
|
||||
: destroyPriority([props.projectId, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? target.kind === 'type'
|
||||
? {
|
||||
replacement_ticket_type_id:
|
||||
data.replacement_ticket_type_id || null,
|
||||
}
|
||||
: {}
|
||||
: target.action === 'edit'
|
||||
? { name: data.name, position: data.position }
|
||||
: target.kind === 'type'
|
||||
? { name: data.name, is_parent: data.is_parent }
|
||||
: { name: data.name },
|
||||
);
|
||||
deletionError.value = null;
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: (page) => {
|
||||
if (page.props.error) {
|
||||
deletionError.value =
|
||||
typeof page.props.status === 'string'
|
||||
? page.props.status
|
||||
: t('common.error');
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t(
|
||||
deleting
|
||||
? target?.kind === 'type'
|
||||
? 'metadata.deleteTypeHelp'
|
||||
: 'metadata.deletePriorityHelp'
|
||||
: 'metadata.editHelp',
|
||||
)
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="deletionError ? 'idle' : protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<p
|
||||
v-if="deletionError"
|
||||
role="alert"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ deletionError }}
|
||||
</p>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<template v-if="!deleting">
|
||||
<div class="grid gap-2">
|
||||
<Label for="metadata-name">{{
|
||||
t('metadata.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="metadata-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="metadata-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="metadata-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="target?.action === 'edit'"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label for="metadata-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="metadata-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="entries.length"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="metadata-position-help metadata-position-error"
|
||||
/>
|
||||
<p
|
||||
id="metadata-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('metadata.positionHelp', {
|
||||
count: entries.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="metadata-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="target?.kind === 'type'" class="grid gap-2">
|
||||
<Label
|
||||
v-if="target.action === 'create'"
|
||||
for="metadata-parent"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id="metadata-parent"
|
||||
v-model="form.is_parent"
|
||||
name="is_parent"
|
||||
:invalid="!!form.errors.is_parent"
|
||||
aria-describedby="metadata-parent-help metadata-parent-error"
|
||||
/>{{ t('metadata.allowChildren') }}
|
||||
</Label>
|
||||
<p v-else class="text-sm font-medium">
|
||||
{{
|
||||
t(
|
||||
form.is_parent
|
||||
? 'metadata.parentType'
|
||||
: 'metadata.standardType',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
id="metadata-parent-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.parentFixed') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="metadata-parent-error"
|
||||
:message="form.errors.is_parent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="needsReplacement" class="grid gap-2">
|
||||
<p class="text-sm">
|
||||
{{
|
||||
t('metadata.replacementHelp', {
|
||||
count: target?.entry?.tickets_count ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<Label for="metadata-replacement">{{
|
||||
t('metadata.replacement')
|
||||
}}</Label>
|
||||
<select
|
||||
id="metadata-replacement"
|
||||
v-model="form.replacement_ticket_type_id"
|
||||
name="replacement_ticket_type_id"
|
||||
required
|
||||
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||
:aria-invalid="
|
||||
!!form.errors.replacement_ticket_type_id
|
||||
"
|
||||
aria-describedby="metadata-replacement-help metadata-replacement-error"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('metadata.chooseType') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="entry in replacements"
|
||||
:key="entry.id"
|
||||
:value="entry.id"
|
||||
>
|
||||
{{ entry.name
|
||||
}}{{
|
||||
entry.is_parent
|
||||
? ` (${t('metadata.parentType')})`
|
||||
: ''
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<p
|
||||
id="metadata-replacement-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.parentReplacement') }}
|
||||
</p>
|
||||
</div>
|
||||
<InputError
|
||||
id="metadata-replacement-error"
|
||||
:message="form.errors.replacement_ticket_type_id"
|
||||
/>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
:disabled="deleting && entries.length <= 1"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type {
|
||||
MetadataEditorTarget,
|
||||
MetadataEntry,
|
||||
MetadataKind,
|
||||
} from '@/types/project-metadata';
|
||||
|
||||
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
|
||||
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||
:aria-labelledby="`metadata-${kind}`"
|
||||
>
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 :id="`metadata-${kind}`" class="font-semibold">
|
||||
{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.types'
|
||||
: 'metadata.priorities',
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{ entries.length }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto"
|
||||
@click="$emit('edit', { kind, action: 'create' })"
|
||||
>
|
||||
<Plus class="size-4" aria-hidden="true" />{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.addType'
|
||||
: 'metadata.addPriority',
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.typesDescription'
|
||||
: 'metadata.prioritiesDescription',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ol
|
||||
class="divide-y"
|
||||
:aria-label="
|
||||
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
|
||||
:data-metadata-entry="`${kind}-${entry.id}`"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||
:aria-label="
|
||||
t('workflow.positionValue', {
|
||||
position: entry.position,
|
||||
})
|
||||
"
|
||||
>{{ entry.position }}</span
|
||||
>
|
||||
<div class="grid min-w-0 flex-1 basis-28 gap-1">
|
||||
<p class="break-words font-medium">{{ entry.name }}</p>
|
||||
<div
|
||||
v-if="kind === 'type'"
|
||||
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">{{
|
||||
t(
|
||||
entry.is_parent
|
||||
? 'metadata.parentType'
|
||||
: 'metadata.standardType',
|
||||
)
|
||||
}}</Badge>
|
||||
<span>{{
|
||||
t('metadata.assignedTickets', {
|
||||
count: entry.tickets_count ?? 0,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('metadata.editEntry', { name: entry.name })
|
||||
"
|
||||
@click="$emit('edit', { kind, action: 'edit', entry })"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:disabled="entries.length <= 1"
|
||||
:aria-label="
|
||||
t('metadata.deleteEntry', { name: entry.name })
|
||||
"
|
||||
:aria-describedby="
|
||||
entries.length <= 1
|
||||
? `metadata-${kind}-protection`
|
||||
: undefined
|
||||
"
|
||||
@click="
|
||||
$emit('edit', { kind, action: 'delete', entry })
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p
|
||||
v-if="entries.length <= 1"
|
||||
:id="`metadata-${kind}-protection`"
|
||||
class="text-caption text-muted-foreground flex items-start gap-2"
|
||||
>
|
||||
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.lastType'
|
||||
: 'metadata.lastPriority',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,632 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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 { notificationLabel } from '@/lib/notification-labels';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
serializeNotificationFilters,
|
||||
supportsFilterValue,
|
||||
} from '@/lib/notification-filters';
|
||||
import type { ProjectNotificationSettingsPageProps } from '@/types';
|
||||
import type {
|
||||
NotificationEditorTarget,
|
||||
NotificationFilterFormState,
|
||||
} from '@/types/project-notifications';
|
||||
|
||||
const props = defineProps<{
|
||||
target: NotificationEditorTarget | null;
|
||||
settings: ProjectNotificationSettingsPageProps;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
event_type: '',
|
||||
channel: '',
|
||||
enabled: true,
|
||||
delivery_url: '',
|
||||
message_template: '',
|
||||
allowed_mentions: [] as string[],
|
||||
filters: [] as NotificationFilterFormState[],
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() =>
|
||||
t(
|
||||
deleting.value
|
||||
? 'notifications.delete'
|
||||
: props.target?.action === 'edit'
|
||||
? 'notifications.edit'
|
||||
: 'notifications.create',
|
||||
),
|
||||
);
|
||||
const inputClass =
|
||||
'border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2';
|
||||
let nextFilterId = 0;
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const entry = target.entry;
|
||||
const mentions = entry?.channel_configuration.allowed_mentions;
|
||||
form.defaults({
|
||||
name: entry?.name ?? '',
|
||||
event_type:
|
||||
entry?.event_type ??
|
||||
props.settings.eventTypeOptions[0]?.value ??
|
||||
'',
|
||||
channel:
|
||||
entry?.channel ??
|
||||
props.settings.channelOptions.find(
|
||||
(option) => option.value === 'webhook',
|
||||
)?.value ??
|
||||
props.settings.channelOptions[0]?.value ??
|
||||
'',
|
||||
enabled: entry?.enabled ?? true,
|
||||
delivery_url: '',
|
||||
message_template: entry?.message_template ?? '',
|
||||
allowed_mentions: Array.isArray(mentions)
|
||||
? mentions.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
)
|
||||
: [],
|
||||
filters: (entry?.filters ?? []).map((filter) => ({
|
||||
id: nextFilterId++,
|
||||
attribute: filter.attribute,
|
||||
operator: filter.operator,
|
||||
value: Array.isArray(filter.value)
|
||||
? filter.value.join(', ')
|
||||
: (filter.value ?? ''),
|
||||
})),
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function addFilter(): void {
|
||||
form.filters.push({
|
||||
id: nextFilterId++,
|
||||
attribute: props.settings.filterAttributeOptions[0]?.value ?? '',
|
||||
operator: 'equals',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
function removeFilter(index: number): void {
|
||||
form.filters.splice(index, 1);
|
||||
form.clearErrors(
|
||||
...(Object.keys(form.errors).filter((key) =>
|
||||
key.startsWith('filters'),
|
||||
) as (keyof typeof form.errors)[]),
|
||||
);
|
||||
}
|
||||
function errorFor(prefix: string): string | undefined {
|
||||
return Object.entries(form.errors).find(
|
||||
([key]) => key === prefix || key.startsWith(`${prefix}.`),
|
||||
)?.[1];
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (!target || form.processing) {
|
||||
return;
|
||||
}
|
||||
const project = props.settings.project.id;
|
||||
const route =
|
||||
target.action === 'create'
|
||||
? store(project)
|
||||
: target.action === 'edit'
|
||||
? update([project, target.entry.id])
|
||||
: destroy([project, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
deleting.value
|
||||
? {}
|
||||
: {
|
||||
name: data.name,
|
||||
event_type: data.event_type,
|
||||
channel: data.channel,
|
||||
enabled: data.enabled,
|
||||
delivery_url: data.delivery_url || null,
|
||||
channel_configuration:
|
||||
data.channel === 'discord'
|
||||
? { allowed_mentions: data.allowed_mentions }
|
||||
: {},
|
||||
message_template:
|
||||
data.channel === 'discord' ? data.message_template : null,
|
||||
filters: serializeNotificationFilters(data.filters),
|
||||
},
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
class="sm:max-w-2xl"
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
deleting
|
||||
? t('notifications.deleteHelp', {
|
||||
name: target?.entry?.name ?? '',
|
||||
})
|
||||
: t('notifications.editorHelp')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-5"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-5"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="notification-name">{{
|
||||
t('notifications.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="notification-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="notification-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="notification-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="notification-event">{{
|
||||
t('notifications.event')
|
||||
}}</Label>
|
||||
<select
|
||||
id="notification-event"
|
||||
v-model="form.event_type"
|
||||
name="event_type"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.event_type"
|
||||
aria-describedby="notification-event-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.eventTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'event',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
id="notification-event-error"
|
||||
:message="form.errors.event_type"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="notification-channel">{{
|
||||
t('notifications.channel')
|
||||
}}</Label>
|
||||
<select
|
||||
id="notification-channel"
|
||||
v-model="form.channel"
|
||||
name="channel"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.channel"
|
||||
aria-describedby="notification-channel-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.channelOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'channel',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
id="notification-channel-error"
|
||||
:message="form.errors.channel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-4"
|
||||
aria-labelledby="notification-delivery-title"
|
||||
>
|
||||
<h3
|
||||
id="notification-delivery-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('notifications.delivery') }}
|
||||
</h3>
|
||||
<p v-if="target?.action === 'edit'" class="text-sm">
|
||||
{{
|
||||
t(
|
||||
target.entry.has_delivery_url
|
||||
? 'notifications.keepUrl'
|
||||
: 'notifications.urlMissing',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Label for="notification-url">{{
|
||||
t(
|
||||
target?.action === 'edit'
|
||||
? 'notifications.replaceUrl'
|
||||
: 'notifications.url',
|
||||
)
|
||||
}}</Label>
|
||||
<Input
|
||||
id="notification-url"
|
||||
v-model="form.delivery_url"
|
||||
name="delivery_url"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:spellcheck="false"
|
||||
:required="target?.action === 'create'"
|
||||
maxlength="2048"
|
||||
:aria-invalid="!!form.errors.delivery_url"
|
||||
aria-describedby="notification-url-help notification-url-error"
|
||||
/>
|
||||
<p
|
||||
id="notification-url-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.secretHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-url-error"
|
||||
:message="form.errors.delivery_url"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-if="form.channel === 'discord'"
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="notification-message-title"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
id="notification-message-title"
|
||||
for="notification-message"
|
||||
>{{ t('notifications.message') }}</Label
|
||||
>
|
||||
<textarea
|
||||
id="notification-message"
|
||||
v-model="form.message_template"
|
||||
name="message_template"
|
||||
rows="4"
|
||||
required
|
||||
maxlength="2000"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.message_template"
|
||||
aria-describedby="notification-message-help notification-message-error"
|
||||
/>
|
||||
<p
|
||||
id="notification-message-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.messageHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-message-error"
|
||||
:message="form.errors.message_template"
|
||||
/>
|
||||
</div>
|
||||
<details class="rounded-lg border p-3">
|
||||
<summary
|
||||
class="focus-visible:ring-ring cursor-pointer rounded font-medium focus-visible:ring-2"
|
||||
>
|
||||
{{ t('notifications.variables') }}
|
||||
</summary>
|
||||
<dl class="mt-3 grid min-w-0 gap-3 text-sm">
|
||||
<div
|
||||
v-for="variable in settings.templateVariables"
|
||||
:key="variable.value"
|
||||
class="grid min-w-0 gap-1"
|
||||
>
|
||||
<dt class="break-all font-mono">
|
||||
{{ variable.value }}
|
||||
</dt>
|
||||
<dd class="text-muted-foreground">
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'variable',
|
||||
variable.value.slice(1),
|
||||
variable.description,
|
||||
)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
<fieldset
|
||||
class="grid gap-2"
|
||||
aria-describedby="notification-mentions-help notification-mentions-error"
|
||||
>
|
||||
<legend class="mb-2 text-sm font-medium">
|
||||
{{ t('notifications.mentions') }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="option in settings.discordAllowedMentionOptions"
|
||||
:key="option.value"
|
||||
class="flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowed_mentions"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
:aria-invalid="
|
||||
!!errorFor('channel_configuration')
|
||||
"
|
||||
class="accent-primary size-4"
|
||||
/>{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'mention',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p
|
||||
id="notification-mentions-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.mentionsHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-mentions-error"
|
||||
:message="errorFor('channel_configuration')"
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<section
|
||||
class="grid min-w-0 gap-3 border-t pt-4"
|
||||
aria-labelledby="notification-filters-title"
|
||||
>
|
||||
<h3
|
||||
id="notification-filters-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('notifications.filters') }}
|
||||
</h3>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('notifications.filtersHelp') }}
|
||||
</p>
|
||||
<p v-if="!form.filters.length" class="text-sm">
|
||||
{{ t('notifications.noFilters') }}
|
||||
</p>
|
||||
<fieldset
|
||||
v-for="(filter, index) in form.filters"
|
||||
:key="filter.id"
|
||||
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<legend class="px-1 text-sm font-medium">
|
||||
{{
|
||||
t('notifications.filterNumber', {
|
||||
number: index + 1,
|
||||
})
|
||||
}}
|
||||
</legend>
|
||||
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
:for="`filter-attribute-${filter.id}`"
|
||||
>{{
|
||||
t('notifications.attribute')
|
||||
}}</Label
|
||||
>
|
||||
<select
|
||||
:id="`filter-attribute-${filter.id}`"
|
||||
v-model="filter.attribute"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.filterAttributeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'attribute',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
:for="`filter-operator-${filter.id}`"
|
||||
>{{
|
||||
t('notifications.operator')
|
||||
}}</Label
|
||||
>
|
||||
<select
|
||||
:id="`filter-operator-${filter.id}`"
|
||||
v-model="filter.operator"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.filterOperatorOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'operator',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label :for="`filter-value-${filter.id}`">{{
|
||||
t(
|
||||
filter.operator === 'in'
|
||||
? 'notifications.values'
|
||||
: 'notifications.value',
|
||||
)
|
||||
}}</Label>
|
||||
<textarea
|
||||
:id="`filter-value-${filter.id}`"
|
||||
v-model="filter.value"
|
||||
rows="2"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
/>
|
||||
</div>
|
||||
<InputError
|
||||
:id="`filter-error-${filter.id}`"
|
||||
:message="errorFor(`filters.${index}`)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-end"
|
||||
:aria-label="
|
||||
t('notifications.removeFilterNumber', {
|
||||
number: index + 1,
|
||||
})
|
||||
"
|
||||
@click="removeFilter(index)"
|
||||
>{{ t('notifications.removeFilter') }}</Button
|
||||
>
|
||||
</fieldset>
|
||||
<InputError :message="form.errors.filters" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="addFilter"
|
||||
>{{ t('notifications.addFilter') }}</Button
|
||||
>
|
||||
</section>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label
|
||||
for="notification-enabled"
|
||||
class="flex min-h-11 items-center gap-3"
|
||||
><Checkbox
|
||||
id="notification-enabled"
|
||||
v-model="form.enabled"
|
||||
:invalid="!!form.errors.enabled"
|
||||
aria-describedby="notification-enabled-help notification-enabled-error"
|
||||
/>{{ t('notifications.enabled') }}</Label
|
||||
>
|
||||
<p
|
||||
id="notification-enabled-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.enabledHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-enabled-error"
|
||||
:message="form.errors.enabled"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<InputError v-else :message="errorFor('notificationAction')" />
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
>{{
|
||||
deleting
|
||||
? t('notifications.confirmDelete')
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { Layers3, LockKeyhole } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||
import { index, update } from '@/routes/project-templates';
|
||||
import { show } from '@/routes/projects';
|
||||
import { store } from '@/routes/projects/templates';
|
||||
import type { ProjectDuplicateCopyAreaOption } from '@/types';
|
||||
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||
|
||||
const props = defineProps<{
|
||||
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||
project?: ProjectModel;
|
||||
template?: ProjectTemplateModel;
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const { areaText } = useProjectTemplateAreas();
|
||||
const source = computed(() => props.template?.source_project ?? props.project);
|
||||
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
|
||||
const form = useForm(
|
||||
`ProjectTemplate:${props.template ? `edit:${props.template.id}` : `create:${props.project?.id}`}`,
|
||||
{
|
||||
name:
|
||||
props.template?.name ??
|
||||
t('templates.defaultName', { project: props.project?.name ?? '' }),
|
||||
slug:
|
||||
props.template?.slug ??
|
||||
`${props.project?.key.toLowerCase()}-template`,
|
||||
copy_areas: props.template
|
||||
? [...props.template.copy_areas]
|
||||
: props.copyAreaOptions.map((area) => area.value),
|
||||
confirm_linked_projects_update: false,
|
||||
},
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
const copyAreasError = computed(
|
||||
() =>
|
||||
form.errors.copy_areas ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('copy_areas.'),
|
||||
)?.[1],
|
||||
);
|
||||
const cancelTarget = computed(() =>
|
||||
props.template ? index() : show(props.project!.id),
|
||||
);
|
||||
function submit(): void {
|
||||
if (
|
||||
!source.value ||
|
||||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
form.transform((data) =>
|
||||
props.template
|
||||
? {
|
||||
name: data.name,
|
||||
copy_areas: data.copy_areas,
|
||||
...(linkedCount.value > 0
|
||||
? {
|
||||
confirm_linked_projects_update:
|
||||
data.confirm_linked_projects_update,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
|
||||
);
|
||||
protection.submit(
|
||||
props.template ? update(props.template.id) : store(props.project!.id),
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="gap-page grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<FeedbackState
|
||||
v-if="!source"
|
||||
state="error"
|
||||
:title="t('templates.missingSource')"
|
||||
:description="t('templates.missingSourceHelp')"
|
||||
/>
|
||||
<div
|
||||
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
|
||||
>
|
||||
<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="template-details-title"
|
||||
>
|
||||
<h2 id="template-details-title" class="text-title">
|
||||
{{ t('templates.details') }}
|
||||
</h2>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="name">{{ t('templates.name') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="form.processing || !source"
|
||||
:placeholder="t('templates.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="slug"
|
||||
><LockKeyhole
|
||||
v-if="template"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>{{ t('templates.slug') }}</Label
|
||||
>
|
||||
<Input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
name="slug"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="!!template || form.processing || !source"
|
||||
:placeholder="t('templates.slugPlaceholder')"
|
||||
:invalid="!!form.errors.slug"
|
||||
aria-describedby="slug-help slug-error"
|
||||
:spellcheck="false"
|
||||
autocapitalize="none"
|
||||
/>
|
||||
<p
|
||||
id="slug-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
template
|
||||
? 'templates.slugLocked'
|
||||
: 'templates.slugHelp',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="slug-error"
|
||||
:message="form.errors.slug"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<fieldset
|
||||
class="bg-card min-w-0 rounded-md border p-5"
|
||||
:disabled="form.processing || !source"
|
||||
aria-describedby="areas-help copy-areas-error"
|
||||
>
|
||||
<legend class="text-title px-1">
|
||||
{{ t('templates.groups') }}
|
||||
</legend>
|
||||
<p
|
||||
id="areas-help"
|
||||
class="text-caption text-muted-foreground mb-4"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
template
|
||||
? 'templates.editGroupsHelp'
|
||||
: 'templates.createGroupsHelp',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="gap-stack grid sm:grid-cols-2">
|
||||
<label
|
||||
v-for="area in copyAreaOptions"
|
||||
:key="area.value"
|
||||
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
|
||||
>
|
||||
<input
|
||||
v-model="form.copy_areas"
|
||||
type="checkbox"
|
||||
:value="area.value"
|
||||
:aria-labelledby="`area-${area.value}`"
|
||||
:aria-describedby="`area-${area.value}-help copy-areas-error`"
|
||||
:aria-invalid="!!copyAreasError"
|
||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<span class="gap-stack grid min-w-0">
|
||||
<span
|
||||
:id="`area-${area.value}`"
|
||||
class="text-body font-semibold"
|
||||
>{{
|
||||
areaText(
|
||||
area.value,
|
||||
'label',
|
||||
area.label,
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
:id="`area-${area.value}-help`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>{{
|
||||
areaText(
|
||||
area.value,
|
||||
'description',
|
||||
area.description,
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
id="copy-areas-error"
|
||||
class="mt-3"
|
||||
:message="copyAreasError"
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<aside
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="template-impact-title"
|
||||
>
|
||||
<h2
|
||||
id="template-impact-title"
|
||||
class="text-title flex items-center gap-2"
|
||||
>
|
||||
<Layers3 class="text-primary size-5" aria-hidden="true" />{{
|
||||
t('templates.impact')
|
||||
}}
|
||||
</h2>
|
||||
<dl class="gap-column grid">
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.sourceProject') }}
|
||||
</dt>
|
||||
<dd class="break-words font-semibold">
|
||||
{{
|
||||
source?.name ?? t('templates.sourceUnavailable')
|
||||
}}
|
||||
</dd>
|
||||
<dd v-if="source">
|
||||
<Badge variant="outline">{{ source.key }}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="gap-stack grid" aria-live="polite">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('templates.groups') }}
|
||||
</dt>
|
||||
<dd>
|
||||
{{
|
||||
t('projectCreation.selectedCount', {
|
||||
count: form.copy_areas.length,
|
||||
})
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="template" class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('templates.inheritance') }}
|
||||
</dt>
|
||||
<dd>
|
||||
{{
|
||||
t('templates.linkedCount', {
|
||||
count: linkedCount,
|
||||
})
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-caption text-muted-foreground border-t pt-4">
|
||||
{{
|
||||
t(
|
||||
template
|
||||
? 'templates.refreshHelp'
|
||||
: 'templates.cleanStart',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
<section
|
||||
v-if="linkedCount > 0"
|
||||
class="border-warning/50 bg-warning/10 gap-column grid rounded-md border p-5"
|
||||
aria-labelledby="linked-title"
|
||||
>
|
||||
<h2 id="linked-title" class="text-title">
|
||||
{{ t('templates.linkedWarning') }}
|
||||
</h2>
|
||||
<p id="linked-help" class="text-body">
|
||||
{{ t('templates.linkedImpact', { count: linkedCount }) }}
|
||||
</p>
|
||||
<label class="flex min-h-11 cursor-pointer items-start gap-3">
|
||||
<input
|
||||
id="confirm-linked"
|
||||
v-model="form.confirm_linked_projects_update"
|
||||
type="checkbox"
|
||||
:disabled="form.processing || !source"
|
||||
:aria-invalid="!!form.errors.confirm_linked_projects_update"
|
||||
aria-describedby="linked-help linked-error"
|
||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<span>{{ t('templates.acknowledge') }}</span>
|
||||
</label>
|
||||
<InputError
|
||||
id="linked-error"
|
||||
:message="form.errors.confirm_linked_projects_update"
|
||||
/>
|
||||
</section>
|
||||
<footer
|
||||
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<Button as-child variant="outline" :disabled="form.processing"
|
||||
><Link :href="cancelTarget">{{
|
||||
t('common.cancel')
|
||||
}}</Link></Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('forms.saving')"
|
||||
:disabled="
|
||||
!source ||
|
||||
(linkedCount > 0 && !form.confirm_linked_projects_update)
|
||||
"
|
||||
>{{
|
||||
t(template ? 'templates.saveChanges' : 'templates.saveNew')
|
||||
}}</Button
|
||||
>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,26 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import { Layers3, RotateCcw } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { resetTemplate } 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 { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
areas: string[];
|
||||
projectId: number;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
}>();
|
||||
|
||||
const { t } = useLocalization();
|
||||
const form = useForm<{
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | undefined>;
|
||||
@@ -28,51 +34,82 @@ const form = useForm<{
|
||||
areas: [],
|
||||
status_replacements: {},
|
||||
});
|
||||
|
||||
const activeArea = computed(() => form.areas[0]);
|
||||
|
||||
const protection = useFormProtection(form);
|
||||
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
|
||||
const inheritedAreas = computed(
|
||||
() =>
|
||||
props.templateState?.inheritedAreas.filter((area) =>
|
||||
props.areas.includes(area.value),
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const overriddenAreas = computed(
|
||||
() =>
|
||||
props.templateState?.overriddenAreas.filter((area) =>
|
||||
props.areas.includes(area.value),
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const statusRequirement = computed(
|
||||
() => props.templateState?.resetRequirements?.ticket_statuses,
|
||||
const statusRequirement = computed(() =>
|
||||
pendingArea.value?.value === 'ticket_statuses'
|
||||
? props.templateState?.resetRequirements?.ticket_statuses
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const canReset = (area: ProjectTemplateStateArea): boolean => {
|
||||
if (area.value !== 'ticket_statuses') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
statusRequirement.value?.removedStatuses.every(
|
||||
(status) =>
|
||||
form.status_replacements[String(status.id)] !== undefined,
|
||||
) ?? true
|
||||
);
|
||||
};
|
||||
|
||||
const resetArea = (area: ProjectTemplateStateArea): void => {
|
||||
if (!canReset(area)) {
|
||||
const canReset = computed(
|
||||
() =>
|
||||
statusRequirement.value?.removedStatuses.every((status) =>
|
||||
statusRequirement.value?.replacementOptions.some(
|
||||
(option) =>
|
||||
option.id === form.status_replacements[String(status.id)],
|
||||
),
|
||||
) ?? true,
|
||||
);
|
||||
const areaKeys = {
|
||||
project_settings: 'inheritance.area.projectSettings',
|
||||
board_columns: 'inheritance.area.boardColumns',
|
||||
ticket_statuses: 'inheritance.area.ticketStatuses',
|
||||
status_transitions: 'inheritance.area.statusTransitions',
|
||||
ticket_types: 'inheritance.area.ticketTypes',
|
||||
ticket_priorities: 'inheritance.area.ticketPriorities',
|
||||
ticket_fields: 'inheritance.area.ticketFields',
|
||||
ticket_link_types: 'inheritance.area.ticketLinkTypes',
|
||||
repository_connections: 'inheritance.area.repositoryConnections',
|
||||
notification_actions: 'inheritance.area.notificationActions',
|
||||
} as const;
|
||||
function areaLabel(area: ProjectTemplateStateArea): string {
|
||||
const key = areaKeys[area.value as keyof typeof areaKeys];
|
||||
return key ? t(key) : area.label;
|
||||
}
|
||||
function openReset(area: ProjectTemplateStateArea): void {
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
pendingArea.value = area;
|
||||
}
|
||||
function closeReset(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.areas = [area.value];
|
||||
form.post(ProjectController.resetTemplate.url(props.projectId), {
|
||||
pendingArea.value = null;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
function resetArea(): void {
|
||||
if (
|
||||
!pendingArea.value ||
|
||||
!canReset.value ||
|
||||
!overriddenAreas.value.some(
|
||||
(area) => area.value === pendingArea.value?.value,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
form.areas = [pendingArea.value.value];
|
||||
protection.submit(resetTemplate(props.projectId), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
pendingArea.value = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
||||
templateState?.inheritsFromTemplate &&
|
||||
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
||||
"
|
||||
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Every setting area inherits independently. Local changes stay
|
||||
unchanged when the template is updated until that area is reset.
|
||||
</CardDescription>
|
||||
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Layers3
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
|
||||
</div>
|
||||
<p class="text-muted-foreground break-words">
|
||||
{{
|
||||
t('inheritance.description', {
|
||||
template: templateState.templateName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="area in inheritedAreas"
|
||||
:key="`inherited-${area.value}`"
|
||||
variant="outline"
|
||||
>
|
||||
{{ area.label }} · inherited
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="area in overriddenAreas"
|
||||
:key="`overridden-${area.value}`"
|
||||
class="space-y-3 rounded-lg border p-4"
|
||||
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('inheritance.help') }}
|
||||
</p>
|
||||
<ul
|
||||
class="grid min-w-0 divide-y"
|
||||
:aria-label="t('inheritance.areas')"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Badge>{{ area.label }} · template overridden</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="form.processing || !canReset(area)"
|
||||
@click="resetArea(area)"
|
||||
>
|
||||
{{
|
||||
activeArea === area.value && form.processing
|
||||
? 'Resetting…'
|
||||
: 'Reset to template'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="area.value === 'ticket_statuses' && statusRequirement"
|
||||
class="space-y-3"
|
||||
<li
|
||||
v-for="area in inheritedAreas"
|
||||
:key="area.value"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose where tickets in local statuses should move
|
||||
before those statuses are removed.
|
||||
</p>
|
||||
<div
|
||||
v-for="status in statusRequirement.removedStatuses"
|
||||
:key="status.id"
|
||||
class="grid gap-2 sm:grid-cols-2 sm:items-center"
|
||||
<span class="min-w-0 break-words font-medium">{{
|
||||
areaLabel(area)
|
||||
}}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-primary/25 text-primary"
|
||||
>{{ t('projectSettings.inherited') }}</Badge
|
||||
>
|
||||
<Label :for="`replacement-${status.id}`">
|
||||
{{ status.name }} ({{ status.ticketCount }} tickets)
|
||||
</Label>
|
||||
<div class="grid gap-1">
|
||||
<select
|
||||
:id="`replacement-${status.id}`"
|
||||
v-model="
|
||||
form.status_replacements[String(status.id)]
|
||||
"
|
||||
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 focus-visible:ring-[3px]"
|
||||
>
|
||||
<option :value="undefined" disabled>
|
||||
Select replacement status
|
||||
</option>
|
||||
<option
|
||||
v-for="option in statusRequirement.replacementOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors[
|
||||
`status_replacements.${status.id}`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
v-for="area in overriddenAreas"
|
||||
:key="area.value"
|
||||
class="gap-stack grid min-w-0 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<span class="min-w-0 break-words font-medium">{{
|
||||
areaLabel(area)
|
||||
}}</span>
|
||||
<Badge variant="secondary">{{
|
||||
t('projectSettings.override')
|
||||
}}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputError :message="form.errors.areas" />
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('inheritance.overrideHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
:disabled="form.processing"
|
||||
:aria-label="
|
||||
t('inheritance.resetArea', {
|
||||
area: areaLabel(area),
|
||||
})
|
||||
"
|
||||
@click="openReset(area)"
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />{{
|
||||
t('inheritance.reset')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<Dialog :open="pendingArea !== null" @update:open="closeReset">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
t('inheritance.confirmTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('inheritance.confirmDescription', {
|
||||
area: pendingArea ? areaLabel(pendingArea) : '',
|
||||
template: templateState.templateName,
|
||||
})
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="gap-column grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="resetArea"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
form.processing
|
||||
? t('inheritance.resetting')
|
||||
: undefined
|
||||
"
|
||||
@retry="resetArea"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.areas ?? form.errors['areas.0']"
|
||||
/>
|
||||
<fieldset
|
||||
class="gap-column grid min-w-0"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<legend class="sr-only">
|
||||
{{ t('inheritance.confirmTitle') }}
|
||||
</legend>
|
||||
<div
|
||||
v-if="statusRequirement"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('inheritance.statusHelp') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="status in statusRequirement.removedStatuses"
|
||||
:key="status.id"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label :for="'replacement-' + status.id">{{
|
||||
t('inheritance.statusLabel', {
|
||||
status: status.name,
|
||||
count: status.ticketCount,
|
||||
})
|
||||
}}</Label>
|
||||
<select
|
||||
:id="'replacement-' + status.id"
|
||||
v-model="
|
||||
form.status_replacements[
|
||||
String(status.id)
|
||||
]
|
||||
"
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||
:aria-invalid="
|
||||
!!form.errors[
|
||||
`status_replacements.${status.id}`
|
||||
]
|
||||
"
|
||||
:aria-describedby="
|
||||
'replacement-error-' + status.id
|
||||
"
|
||||
>
|
||||
<option :value="undefined" disabled>
|
||||
{{ t('inheritance.selectStatus') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="option in statusRequirement.replacementOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:id="'replacement-error-' + status.id"
|
||||
:message="
|
||||
form.errors[
|
||||
`status_replacements.${status.id}`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
{{ t('inheritance.resetWarning') }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="closeReset(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button type="submit" :disabled="!canReset">{{
|
||||
t(
|
||||
form.processing
|
||||
? 'inheritance.resetting'
|
||||
: 'inheritance.reset',
|
||||
)
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
|
||||
const props = defineProps<{ project: ProjectModel }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
show_author: props.project.show_author,
|
||||
show_story_points: props.project.show_story_points,
|
||||
});
|
||||
const protection = useFormProtection(form);
|
||||
watch(
|
||||
() => [props.project.show_author, props.project.show_story_points],
|
||||
() => {
|
||||
form.defaults({
|
||||
show_author: props.project.show_author,
|
||||
show_story_points: props.project.show_story_points,
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
},
|
||||
);
|
||||
function submit(): void {
|
||||
protection.submit(update(props.project.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.defaults(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('metadata.displayDescription') }}
|
||||
</p>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<fieldset :disabled="form.processing" class="grid gap-4">
|
||||
<div
|
||||
v-for="option in ['show_author', 'show_story_points'] as const"
|
||||
:key="option"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="option"
|
||||
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Checkbox
|
||||
:id="option"
|
||||
v-model="form[option]"
|
||||
:name="option"
|
||||
:invalid="!!form.errors[option]"
|
||||
:aria-describedby="`${option}-help ${option}-error`"
|
||||
/>
|
||||
<span class="grid gap-1">
|
||||
<span>{{
|
||||
t(
|
||||
option === 'show_author'
|
||||
? 'metadata.author'
|
||||
: 'metadata.storyPoints',
|
||||
)
|
||||
}}</span>
|
||||
<span
|
||||
:id="`${option}-help`"
|
||||
class="text-muted-foreground text-sm font-normal"
|
||||
>{{
|
||||
t(
|
||||
option === 'show_author'
|
||||
? 'metadata.authorHelp'
|
||||
: 'metadata.storyPointsHelp',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:id="`${option}-error`"
|
||||
:message="form.errors[option]"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full sm:w-fit"
|
||||
:loading="form.processing"
|
||||
>{{ t('common.save') }}</Button
|
||||
>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { CircleAlert } from 'lucide-vue-next';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroyFieldDefinition,
|
||||
storeFieldDefinition,
|
||||
updateFieldDefinition,
|
||||
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
|
||||
import type { TicketFieldTypeOption } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
target: TicketFieldDefinitionEditorTarget | null;
|
||||
definitions: TicketFieldDefinitionModel[];
|
||||
fieldTypeOptions: TicketFieldTypeOption[];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
label: '',
|
||||
field_type: '',
|
||||
position: 1,
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() => {
|
||||
if (props.target?.action === 'create') {
|
||||
return t('metadata.addField');
|
||||
}
|
||||
if (props.target?.action === 'edit') {
|
||||
return t('metadata.editField', { name: props.target.entry.label });
|
||||
}
|
||||
return props.target
|
||||
? t('metadata.deleteField', { name: props.target.entry.label })
|
||||
: '';
|
||||
});
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
const values =
|
||||
target?.action === 'edit'
|
||||
? {
|
||||
label: target.entry.label,
|
||||
field_type: target.entry.field_type,
|
||||
position: target.entry.position,
|
||||
}
|
||||
: {
|
||||
label: '',
|
||||
field_type: props.fieldTypeOptions[0]?.value ?? '',
|
||||
position: props.definitions.length + 1,
|
||||
};
|
||||
form.defaults(values);
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (!target || form.processing) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.action === 'create'
|
||||
? storeFieldDefinition(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateFieldDefinition([props.projectId, target.entry.id])
|
||||
: destroyFieldDefinition([props.projectId, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? {}
|
||||
: target.action === 'create'
|
||||
? { label: data.label, field_type: data.field_type }
|
||||
: data,
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{
|
||||
t(
|
||||
deleting
|
||||
? 'metadata.deleteFieldHelp'
|
||||
: 'metadata.fieldEditorHelp',
|
||||
)
|
||||
}}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<div
|
||||
v-if="deleting && target?.entry"
|
||||
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<CircleAlert
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
{{
|
||||
t('metadata.fieldDeletionImpact', {
|
||||
count:
|
||||
target.entry.ticket_field_values_count ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="field-definition-label">{{
|
||||
t('metadata.fieldLabel')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="field-definition-label"
|
||||
v-model="form.label"
|
||||
name="label"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.label"
|
||||
aria-describedby="field-definition-label-error"
|
||||
/>
|
||||
<InputError
|
||||
id="field-definition-label-error"
|
||||
:message="form.errors.label"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="field-definition-type">{{
|
||||
t('metadata.fieldType')
|
||||
}}</Label>
|
||||
<select
|
||||
id="field-definition-type"
|
||||
v-model="form.field_type"
|
||||
name="field_type"
|
||||
required
|
||||
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||
:aria-invalid="!!form.errors.field_type"
|
||||
aria-describedby="field-definition-type-help field-definition-type-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in fieldTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p
|
||||
id="field-definition-type-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.fieldTypeHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="field-definition-type-error"
|
||||
:message="form.errors.field_type"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="target?.action === 'edit'" class="grid gap-2">
|
||||
<Label for="field-definition-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="field-definition-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="definitions.length"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="field-definition-position-help field-definition-position-error"
|
||||
/>
|
||||
<p
|
||||
id="field-definition-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('metadata.positionHelp', {
|
||||
count: definitions.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="field-definition-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { Braces, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TicketFieldDefinitionEditorDialog from '@/components/projects/TicketFieldDefinitionEditorDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
|
||||
import type { TicketFieldTypeOption } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
definitions: TicketFieldDefinitionModel[];
|
||||
fieldTypeOptions: TicketFieldTypeOption[];
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const target = ref<TicketFieldDefinitionEditorTarget | null>(null);
|
||||
const fieldTypeLabels = computed(
|
||||
() =>
|
||||
new Map(
|
||||
props.fieldTypeOptions.map((option) => [
|
||||
option.value,
|
||||
option.label,
|
||||
]),
|
||||
),
|
||||
);
|
||||
function fieldTypeLabel(fieldType: string): string {
|
||||
return fieldTypeLabels.value.get(fieldType) ?? fieldType;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||
aria-labelledby="metadata-custom-fields"
|
||||
>
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Braces
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="metadata-custom-fields" class="font-semibold">
|
||||
{{ t('metadata.customFields') }}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{ definitions.length }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
@click="target = { action: 'create' }"
|
||||
>
|
||||
<Plus class="size-4" aria-hidden="true" />{{
|
||||
t('metadata.addField')
|
||||
}}
|
||||
</Button>
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('metadata.customFieldsDescription') }}
|
||||
</p>
|
||||
<FeedbackState
|
||||
v-if="definitions.length === 0"
|
||||
state="empty"
|
||||
:title="t('metadata.noCustomFields')"
|
||||
:description="t('metadata.noCustomFieldsDescription')"
|
||||
/>
|
||||
<ol v-else class="divide-y" :aria-label="t('metadata.customFields')">
|
||||
<li
|
||||
v-for="definition in definitions"
|
||||
:key="definition.id"
|
||||
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
|
||||
:data-ticket-field-definition="definition.id"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||
:aria-label="
|
||||
t('workflow.positionValue', {
|
||||
position: definition.position,
|
||||
})
|
||||
"
|
||||
>{{ definition.position }}</span
|
||||
>
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<p class="break-words font-medium">
|
||||
{{ definition.label }}
|
||||
</p>
|
||||
<div
|
||||
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">{{
|
||||
fieldTypeLabel(definition.field_type)
|
||||
}}</Badge>
|
||||
<span>{{
|
||||
t('metadata.storedValues', {
|
||||
count:
|
||||
definition.ticket_field_values_count ?? 0,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('metadata.editField', { name: definition.label })
|
||||
"
|
||||
@click="target = { action: 'edit', entry: definition }"
|
||||
>
|
||||
<Pencil class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:aria-label="
|
||||
t('metadata.deleteField', {
|
||||
name: definition.label,
|
||||
})
|
||||
"
|
||||
@click="
|
||||
target = { action: 'delete', entry: definition }
|
||||
"
|
||||
>
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<TicketFieldDefinitionEditorDialog
|
||||
:definitions="definitions"
|
||||
:field-type-options="fieldTypeOptions"
|
||||
:project-id="projectId"
|
||||
:target="target"
|
||||
@close="target = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ArrowRight, CircleAlert } from 'lucide-vue-next';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroyLinkType,
|
||||
storeLinkType,
|
||||
updateLinkType,
|
||||
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
target: TicketLinkTypeEditorTarget | null;
|
||||
linkTypes: TicketLinkTypeModel[];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
counterpart_name: '',
|
||||
position: 1,
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() => {
|
||||
if (props.target?.action === 'create') {
|
||||
return t('metadata.addLinkType');
|
||||
}
|
||||
if (props.target?.action === 'edit') {
|
||||
return t('metadata.editLinkType', { name: props.target.entry.label });
|
||||
}
|
||||
return props.target
|
||||
? t('metadata.deleteLinkType', { name: props.target.entry.label })
|
||||
: '';
|
||||
});
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
const values =
|
||||
target?.action === 'edit'
|
||||
? {
|
||||
name: target.entry.label,
|
||||
counterpart_name: target.entry.counterpart_label,
|
||||
position: target.entry.position,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
counterpart_name: '',
|
||||
position: props.linkTypes.length + 1,
|
||||
};
|
||||
form.defaults(values);
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (!target || form.processing) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.action === 'create'
|
||||
? storeLinkType(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateLinkType([props.projectId, target.entry.id])
|
||||
: destroyLinkType([props.projectId, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? {}
|
||||
: target.action === 'create'
|
||||
? {
|
||||
name: data.name,
|
||||
counterpart_name: data.counterpart_name,
|
||||
}
|
||||
: data,
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{
|
||||
t(
|
||||
deleting
|
||||
? 'metadata.deleteLinkTypeHelp'
|
||||
: 'metadata.linkTypeEditorHelp',
|
||||
)
|
||||
}}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<div
|
||||
v-if="deleting && target?.entry"
|
||||
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<CircleAlert
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
{{
|
||||
t('metadata.linkTypeDeletionImpact', {
|
||||
count: target.entry.ticket_links_count ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="link-type-source-label">{{
|
||||
t('metadata.sourceLabel')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="link-type-source-label"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="link-type-source-label-help link-type-source-label-error"
|
||||
/>
|
||||
<p
|
||||
id="link-type-source-label-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.sourceLabelHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="link-type-source-label-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<ArrowRight
|
||||
class="text-primary mx-auto size-5 rotate-90 sm:rotate-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="link-type-counterpart-label">{{
|
||||
t('metadata.counterpartLabel')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="link-type-counterpart-label"
|
||||
v-model="form.counterpart_name"
|
||||
name="counterpart_name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.counterpart_name"
|
||||
aria-describedby="link-type-counterpart-label-help link-type-counterpart-label-error"
|
||||
/>
|
||||
<p
|
||||
id="link-type-counterpart-label-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.counterpartLabelHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="link-type-counterpart-label-error"
|
||||
:message="form.errors.counterpart_name"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="target?.action === 'edit'" class="grid gap-2">
|
||||
<Label for="link-type-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="link-type-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="linkTypes.length"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="link-type-position-help link-type-position-error"
|
||||
/>
|
||||
<p
|
||||
id="link-type-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('metadata.positionHelp', {
|
||||
count: linkTypes.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="link-type-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, Link2, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TicketLinkTypeEditorDialog from '@/components/projects/TicketLinkTypeEditorDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
|
||||
|
||||
defineProps<{ projectId: number; linkTypes: TicketLinkTypeModel[] }>();
|
||||
const { t } = useLocalization();
|
||||
const target = ref<TicketLinkTypeEditorTarget | null>(null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||
aria-labelledby="metadata-link-types"
|
||||
>
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Link2
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="metadata-link-types" class="font-semibold">
|
||||
{{ t('metadata.linkTypes') }}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{ linkTypes.length }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
@click="target = { action: 'create' }"
|
||||
>
|
||||
<Plus class="size-4" aria-hidden="true" />{{
|
||||
t('metadata.addLinkType')
|
||||
}}
|
||||
</Button>
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('metadata.linkTypesDescription') }}
|
||||
</p>
|
||||
<FeedbackState
|
||||
v-if="linkTypes.length === 0"
|
||||
state="empty"
|
||||
:title="t('metadata.noLinkTypes')"
|
||||
:description="t('metadata.noLinkTypesDescription')"
|
||||
/>
|
||||
<ol v-else class="divide-y" :aria-label="t('metadata.linkTypes')">
|
||||
<li
|
||||
v-for="linkType in linkTypes"
|
||||
:key="linkType.id"
|
||||
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
|
||||
:data-ticket-link-type="linkType.id"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||
:aria-label="
|
||||
t('workflow.positionValue', {
|
||||
position: linkType.position,
|
||||
})
|
||||
"
|
||||
>{{ linkType.position }}</span
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
|
||||
:aria-label="
|
||||
t('metadata.linkDirection', {
|
||||
source: linkType.label,
|
||||
counterpart: linkType.counterpart_label,
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="break-words font-medium">{{
|
||||
linkType.label
|
||||
}}</span>
|
||||
<ArrowRight
|
||||
class="text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="break-words font-medium">{{
|
||||
linkType.counterpart_label
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{
|
||||
t('metadata.ticketRelationships', {
|
||||
count: linkType.ticket_links_count ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('metadata.editLinkType', {
|
||||
name: linkType.label,
|
||||
})
|
||||
"
|
||||
@click="target = { action: 'edit', entry: linkType }"
|
||||
>
|
||||
<Pencil class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:aria-label="
|
||||
t('metadata.deleteLinkType', {
|
||||
name: linkType.label,
|
||||
})
|
||||
"
|
||||
@click="target = { action: 'delete', entry: linkType }"
|
||||
>
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<TicketLinkTypeEditorDialog
|
||||
:link-types="linkTypes"
|
||||
:project-id="projectId"
|
||||
:target="target"
|
||||
@close="target = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroy,
|
||||
destroyStatus,
|
||||
store,
|
||||
storeStatus,
|
||||
update,
|
||||
updateStatus,
|
||||
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
columnCount: number;
|
||||
target: WorkflowEditorTarget | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({ name: '', position: 1 });
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() => {
|
||||
if (props.target?.kind === 'column') {
|
||||
return t(
|
||||
props.target.action === 'create'
|
||||
? 'workflow.addColumn'
|
||||
: props.target.action === 'edit'
|
||||
? 'workflow.editColumn'
|
||||
: 'workflow.deleteColumn',
|
||||
);
|
||||
}
|
||||
return t(
|
||||
props.target?.action === 'create'
|
||||
? 'workflow.addStatus'
|
||||
: props.target?.action === 'edit'
|
||||
? 'workflow.editStatus'
|
||||
: 'workflow.deleteStatus',
|
||||
);
|
||||
});
|
||||
const description = computed(() => {
|
||||
const target = props.target;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.kind === 'column') {
|
||||
return t(
|
||||
target.action === 'delete'
|
||||
? 'workflow.deleteColumnDescription'
|
||||
: target.action === 'create'
|
||||
? 'workflow.addColumnDescription'
|
||||
: 'workflow.editColumnDescription',
|
||||
{ name: target.column?.name ?? '' },
|
||||
);
|
||||
}
|
||||
return t(
|
||||
target.action === 'delete'
|
||||
? 'workflow.deleteStatusDescription'
|
||||
: 'workflow.statusContext',
|
||||
{
|
||||
name: target.status?.name ?? '',
|
||||
context: target.column?.name ?? t('workflow.backlog'),
|
||||
replacement: target.replacement?.name ?? '',
|
||||
},
|
||||
);
|
||||
});
|
||||
const otherErrors = computed(() =>
|
||||
Object.entries(form.errors)
|
||||
.filter(([key]) => !['name', 'position'].includes(key))
|
||||
.map(([, message]) => message),
|
||||
);
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
form.defaults({
|
||||
name:
|
||||
target.kind === 'column'
|
||||
? (target.column?.name ?? '')
|
||||
: (target.status?.name ?? ''),
|
||||
position: target.column?.position ?? 1,
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (
|
||||
!target ||
|
||||
form.processing ||
|
||||
(target.kind === 'status' &&
|
||||
target.action === 'delete' &&
|
||||
!target.replacement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'column'
|
||||
? target.action === 'create'
|
||||
? store(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? update([props.projectId, target.column.id])
|
||||
: destroy([props.projectId, target.column.id])
|
||||
: target.action === 'create'
|
||||
? storeStatus(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateStatus([props.projectId, target.status.id])
|
||||
: destroyStatus([props.projectId, target.status.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? {}
|
||||
: target.kind === 'column' && target.action === 'edit'
|
||||
? data
|
||||
: target.kind === 'status' && target.action === 'create'
|
||||
? {
|
||||
name: data.name,
|
||||
board_column_id: target.column?.id ?? null,
|
||||
}
|
||||
: { name: data.name },
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription class="break-words">{{
|
||||
description
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="gap-column grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<InputError
|
||||
v-for="message in otherErrors"
|
||||
:key="message"
|
||||
:message="message"
|
||||
/>
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="workflow-name">{{
|
||||
t(
|
||||
target?.kind === 'column'
|
||||
? 'workflow.columnName'
|
||||
: 'workflow.statusName',
|
||||
)
|
||||
}}</Label>
|
||||
<Input
|
||||
id="workflow-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
:aria-describedby="
|
||||
form.errors.name
|
||||
? 'workflow-name-error'
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
id="workflow-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
target?.kind === 'column' &&
|
||||
target.action === 'edit'
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label for="workflow-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="workflow-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="columnCount"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="workflow-position-help workflow-position-error"
|
||||
/>
|
||||
<p
|
||||
id="workflow-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('workflow.positionHelp', {
|
||||
count: columnCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="workflow-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
defineProps<{
|
||||
column: BoardColumnModel | null;
|
||||
statuses: TicketStatusModel[];
|
||||
}>();
|
||||
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-3">
|
||||
<ul
|
||||
class="divide-y"
|
||||
:aria-label="
|
||||
t('workflow.statusesIn', {
|
||||
context: column?.name ?? t('workflow.backlog'),
|
||||
})
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
|
||||
>{{ status.name }}</span
|
||||
>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('workflow.renameStatus', { name: status.name })
|
||||
"
|
||||
@click="
|
||||
$emit('edit', {
|
||||
kind: 'status',
|
||||
action: 'edit',
|
||||
column,
|
||||
status,
|
||||
})
|
||||
"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:disabled="statuses.length <= 1"
|
||||
:aria-label="
|
||||
t('workflow.removeStatus', { name: status.name })
|
||||
"
|
||||
:aria-describedby="
|
||||
statuses.length <= 1
|
||||
? `status-protection-${column?.id ?? 'backlog'}`
|
||||
: undefined
|
||||
"
|
||||
@click="
|
||||
$emit('edit', {
|
||||
kind: 'status',
|
||||
action: 'delete',
|
||||
column,
|
||||
status,
|
||||
replacement: statuses.find(
|
||||
(item) => item.id !== status.id,
|
||||
),
|
||||
})
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-if="statuses.length <= 1"
|
||||
:id="`status-protection-${column?.id ?? 'backlog'}`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('workflow.lastStatus') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-fit"
|
||||
:aria-label="
|
||||
t('workflow.addStatusTo', {
|
||||
context: column?.name ?? t('workflow.backlog'),
|
||||
})
|
||||
"
|
||||
@click="$emit('edit', { kind: 'status', action: 'create', column })"
|
||||
><Plus class="size-4" aria-hidden="true" />{{
|
||||
t('workflow.addStatus')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ArrowRightLeft } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
statuses: TicketStatusModel[];
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const sourceId = ref(props.statuses[0]?.id ?? null);
|
||||
const source = computed(() =>
|
||||
props.statuses.find((status) => status.id === sourceId.value),
|
||||
);
|
||||
const targets = computed(() =>
|
||||
props.statuses.filter((status) => status.id !== sourceId.value),
|
||||
);
|
||||
const savedTargets = computed(
|
||||
() =>
|
||||
source.value?.allowed_target_statuses
|
||||
?.map((status) => status.id)
|
||||
.sort((a, b) => a - b) ?? [],
|
||||
);
|
||||
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||
const protection = useFormProtection(form);
|
||||
const saved = ref(false);
|
||||
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
|
||||
|
||||
function reset(): void {
|
||||
saved.value = false;
|
||||
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.statuses,
|
||||
() => {
|
||||
if (!source.value) {
|
||||
sourceId.value = props.statuses[0]?.id ?? null;
|
||||
reset();
|
||||
} else if (!form.isDirty && !form.processing) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function selectSource(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
select.value = String(sourceId.value);
|
||||
return;
|
||||
}
|
||||
sourceId.value = Number(select.value);
|
||||
reset();
|
||||
}
|
||||
|
||||
function toggleTarget(id: number, event: Event): void {
|
||||
saved.value = false;
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
form.allowed_ticket_status_ids = checked
|
||||
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
|
||||
: form.allowed_ticket_status_ids.filter((target) => target !== id);
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
if (!source.value || !form.isDirty || form.processing) {
|
||||
return;
|
||||
}
|
||||
protection.submit(
|
||||
updateStatusTransitions([props.projectId, source.value.id]),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
saved.value = true;
|
||||
form.defaults({
|
||||
allowed_ticket_status_ids: [...savedTargets.value],
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||
aria-labelledby="workflow-transitions"
|
||||
>
|
||||
<header class="grid gap-3 border-b p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ArrowRightLeft
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="workflow-transitions" class="font-semibold">
|
||||
{{ t('workflow.transitions') }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.transitionsDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
||||
<p v-if="!source" class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.noStatuses') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="workflow-source">{{
|
||||
t('workflow.sourceStatus')
|
||||
}}</Label>
|
||||
<select
|
||||
id="workflow-source"
|
||||
:value="sourceId"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
@change="selectSource"
|
||||
>
|
||||
<option
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
:value="status.id"
|
||||
>
|
||||
{{ ticketStatusLabel(status, '') }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.directionHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-3"
|
||||
aria-describedby="workflow-target-help workflow-transition-errors"
|
||||
:aria-invalid="hasErrors"
|
||||
>
|
||||
<legend class="mb-3 break-words font-medium">
|
||||
{{
|
||||
t('workflow.targetsFrom', { name: source.name })
|
||||
}}
|
||||
</legend>
|
||||
<p
|
||||
id="workflow-target-help"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.targetHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="targets.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noTargets') }}
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="target in targets"
|
||||
:key="target.id"
|
||||
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
|
||||
:class="
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
? 'border-primary/40 bg-accent/40'
|
||||
: 'border-input'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="target.id"
|
||||
:checked="
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
"
|
||||
class="accent-primary size-4 shrink-0"
|
||||
@change="toggleTarget(target.id, $event)"
|
||||
/>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span
|
||||
class="break-words text-sm font-medium"
|
||||
>{{
|
||||
ticketStatusLabel(target, '')
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground text-xs"
|
||||
>{{
|
||||
t(
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
? 'workflow.allowed'
|
||||
: 'workflow.notAllowed',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p
|
||||
v-if="
|
||||
targets.length > 0 &&
|
||||
form.allowed_ticket_status_ids.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noMovesHelp') }}
|
||||
</p>
|
||||
<div id="workflow-transition-errors" role="alert">
|
||||
<InputError
|
||||
v-for="(message, key) in form.errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
<FormFeedback
|
||||
:state="
|
||||
!saved && protection.state.value === 'success'
|
||||
? 'idle'
|
||||
: protection.state.value
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
||||
>
|
||||
<Badge
|
||||
:variant="form.isDirty ? 'warning' : 'secondary'"
|
||||
role="status"
|
||||
>{{
|
||||
t(
|
||||
form.isDirty
|
||||
? 'workflow.unsavedMoves'
|
||||
: 'workflow.savedMoves',
|
||||
{ count: savedTargets.length },
|
||||
)
|
||||
}}</Badge
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!form.isDirty || form.processing"
|
||||
@click="reset"
|
||||
>{{ t('workflow.resetMoves') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="!form.isDirty"
|
||||
:loading="form.processing"
|
||||
>{{ t('workflow.saveMoves') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</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,416 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
GitBranchPlus,
|
||||
LoaderCircle,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
store,
|
||||
validate as validateBranch,
|
||||
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||
import type {
|
||||
TicketBranchCreateDialogProps,
|
||||
TicketBranchFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketBranchCreateDialogProps>();
|
||||
const { t } = useLocalization();
|
||||
const appName = String(usePage().props.name);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
|
||||
const isOpen = ref(false);
|
||||
const isCheckingBranchName = ref(false);
|
||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||
|
||||
const defaultRepositoryConnectionId = computed(() =>
|
||||
String(
|
||||
props.repositoryConnections.find(
|
||||
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||
)?.id ??
|
||||
props.repositoryConnections[0]?.id ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
|
||||
const form = useForm<TicketBranchFormData>(draftKey, {
|
||||
branch_name: props.suggestedBranchName,
|
||||
repository_connection_id: defaultRepositoryConnectionId.value,
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => isOpen.value && form.isDirty,
|
||||
});
|
||||
|
||||
const selectedRepositoryConnection = computed(
|
||||
() =>
|
||||
props.repositoryConnections.find(
|
||||
(repositoryConnection) =>
|
||||
String(repositoryConnection.id) ===
|
||||
form.repository_connection_id,
|
||||
) ?? null,
|
||||
);
|
||||
const selectedRepositoryHasPersonalAccess = computed(
|
||||
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
|
||||
);
|
||||
const validationStateClasses = computed(() => {
|
||||
if (isCheckingBranchName.value) {
|
||||
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||
}
|
||||
|
||||
if (branchNameValidation.value?.is_valid) {
|
||||
return 'border-success/40 bg-success/10 text-success-foreground';
|
||||
}
|
||||
|
||||
if (branchNameValidation.value !== null) {
|
||||
return 'border-warning/40 bg-warning/10 text-warning-foreground';
|
||||
}
|
||||
|
||||
return 'border-border bg-muted/30 text-muted-foreground';
|
||||
});
|
||||
|
||||
function updateOpen(open: boolean): void {
|
||||
if (!open && !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = open;
|
||||
}
|
||||
|
||||
function initializeForm(): void {
|
||||
if (form.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
form.branch_name = props.suggestedBranchName;
|
||||
form.repository_connection_id = defaultRepositoryConnectionId.value;
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
initializeForm();
|
||||
});
|
||||
|
||||
watch(
|
||||
[isOpen, () => form.repository_connection_id, () => form.branch_name],
|
||||
(
|
||||
[open, repositoryConnectionId, branchName],
|
||||
_previousValues,
|
||||
onCleanup,
|
||||
) => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repositoryConnection = selectedRepositoryConnection.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;
|
||||
}
|
||||
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = true;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
validateBranch.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 },
|
||||
);
|
||||
|
||||
function submit(): void {
|
||||
if (
|
||||
!selectedRepositoryHasPersonalAccess.value ||
|
||||
branchNameValidation.value?.is_valid !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
protection.submit(
|
||||
store({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
isOpen.value = false;
|
||||
},
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="updateOpen">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="isOpen = true"
|
||||
>
|
||||
<GitBranchPlus class="size-4" aria-hidden="true" />
|
||||
{{ 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="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
protection.state.value === 'loading'
|
||||
? t('ticketDetail.creatingBranch')
|
||||
: undefined
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="repository_connection_id">{{
|
||||
t('ticketDetail.repository')
|
||||
}}</Label>
|
||||
<select
|
||||
id="repository_connection_id"
|
||||
v-model="form.repository_connection_id"
|
||||
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 repositoryConnections"
|
||||
:key="repositoryConnection.id"
|
||||
:value="String(repositoryConnection.id)"
|
||||
>
|
||||
{{ repositoryConnection.repository_full_name
|
||||
}}{{
|
||||
repositoryConnection.has_personal_access
|
||||
? ''
|
||||
: ` — ${t('ticketDetail.personalAccessRequired')}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.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="form.branch_name"
|
||||
:disabled="!selectedRepositoryHasPersonalAccess"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<InputError :message="form.errors.branch_name" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
:aria-busy="isCheckingBranchName || undefined"
|
||||
class="rounded-lg border px-4 py-3 transition-colors"
|
||||
:class="validationStateClasses"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<LoaderCircle
|
||||
v-if="isCheckingBranchName"
|
||||
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircle2
|
||||
v-else-if="branchNameValidation?.is_valid"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CircleAlert
|
||||
v-else-if="branchNameValidation !== null"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{{
|
||||
isCheckingBranchName
|
||||
? t('ticketDetail.checkingBranch')
|
||||
: (branchNameValidation?.message ??
|
||||
t('ticketDetail.selectRepository'))
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!selectedRepositoryHasPersonalAccess &&
|
||||
selectedRepositoryConnection !== null
|
||||
"
|
||||
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
|
||||
>
|
||||
<div class="grid gap-1">
|
||||
<p class="font-semibold">
|
||||
{{ t('ticketDetail.gitAccessRequired') }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.branchAccess') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="form.processing"
|
||||
@click="updateOpen(false)"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
isCheckingBranchName ||
|
||||
branchNameValidation?.is_valid !== true
|
||||
"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('ticketDetail.creatingBranch')"
|
||||
>
|
||||
{{ t('ticketDetail.createNow') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketDetailBlockerProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const savedFormData = () => ({
|
||||
acceptance_criteria: props.ticket.acceptance_criteria,
|
||||
assignee_user_id: props.ticket.assignee_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 form = useForm(
|
||||
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
|
||||
savedFormData(),
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
const isBlocked = computed(() => Boolean(form.is_blocked));
|
||||
|
||||
const reset = (): void => {
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!props.canEditTicket || form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
protection.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
Object.assign(form, savedFormData());
|
||||
form.defaults();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-blocker"
|
||||
aria-labelledby="ticket-blocker-title"
|
||||
class="min-w-0 scroll-mt-6"
|
||||
>
|
||||
<Card :class="{ 'border-warning/40': isBlocked }">
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.blocker') }}
|
||||
</h2>
|
||||
<Badge :variant="isBlocked ? 'warning' : 'outline'">
|
||||
{{
|
||||
isBlocked
|
||||
? t('ticketDetail.blockerActive')
|
||||
: t('ticketDetail.blockerInactive')
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{{ t('ticketDetail.blockerDescription') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent v-if="canEditTicket">
|
||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<input
|
||||
id="is_blocked"
|
||||
v-model="form.is_blocked"
|
||||
type="checkbox"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||
aria-describedby="ticket-blocker-state-description is_blocked-error"
|
||||
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<Label for="is_blocked">
|
||||
{{ t('ticketDetail.blocked') }}
|
||||
</Label>
|
||||
<p
|
||||
id="ticket-blocker-state-description"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{
|
||||
isBlocked
|
||||
? t(
|
||||
'ticketDetail.blockerActiveDescription',
|
||||
)
|
||||
: t(
|
||||
'ticketDetail.blockerInactiveDescription',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<InputError
|
||||
id="is_blocked-error"
|
||||
:message="form.errors.is_blocked"
|
||||
/>
|
||||
|
||||
<TicketFormField
|
||||
v-if="isBlocked"
|
||||
id="blocked_comment"
|
||||
v-model="form.blocked_comment"
|
||||
kind="textarea"
|
||||
:rows="3"
|
||||
:label="t('ticketDetail.blockerComment')"
|
||||
:hint="t('ticketDetail.blockerCommentHelp')"
|
||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||
:error="form.errors.blocked_comment"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!form.isDirty || form.processing"
|
||||
@click="reset"
|
||||
>
|
||||
{{ t('ticketDetail.resetBlocker') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('ticketDetail.savingBlocker')"
|
||||
:disabled="!form.isDirty"
|
||||
>
|
||||
{{
|
||||
isBlocked
|
||||
? t('ticketDetail.saveBlocker')
|
||||
: t('ticketDetail.unblockTicket')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardContent v-else>
|
||||
<p
|
||||
class="whitespace-pre-wrap text-sm leading-6"
|
||||
:class="{ 'text-muted-foreground': !isBlocked }"
|
||||
>
|
||||
{{
|
||||
isBlocked
|
||||
? ticket.blocked_comment
|
||||
: t('ticketDetail.blockerInactiveDescription')
|
||||
}}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,497 @@
|
||||
<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,
|
||||
watch,
|
||||
} 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 { 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());
|
||||
|
||||
watch(
|
||||
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
|
||||
([isBlocked, blockedComment]) => {
|
||||
form.is_blocked = isBlocked;
|
||||
form.blocked_comment = blockedComment ?? '';
|
||||
form.defaults('is_blocked', isBlocked);
|
||||
form.defaults('blocked_comment', blockedComment ?? '');
|
||||
},
|
||||
);
|
||||
|
||||
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('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,35 @@
|
||||
<script setup lang="ts">
|
||||
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
|
||||
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketDetailGitProps } from '@/types/ticket-detail';
|
||||
|
||||
defineProps<TicketDetailGitProps>();
|
||||
|
||||
const { t } = useLocalization();
|
||||
</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">
|
||||
<TicketGitBranches
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:can-edit-ticket="canEditTicket"
|
||||
:repository-branches="repositoryBranches"
|
||||
:suggested-branch-name="suggestedBranchName"
|
||||
/>
|
||||
<TicketGitPullRequests
|
||||
:repository-pull-requests="repositoryPullRequests"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<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',
|
||||
'blocker',
|
||||
'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,119 @@
|
||||
<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 { UserAvatar } from '@/components/ui/avatar';
|
||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
|
||||
defineProps<TicketDetailHistoryProps>();
|
||||
const { t, date } = useLocalization();
|
||||
const formatDateTime = (value: string): string =>
|
||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
||||
created: 'ticketDetail.historyType.created',
|
||||
link_removed: 'ticketDetail.historyType.linkRemoved',
|
||||
link_updated: 'ticketDetail.historyType.linkUpdated',
|
||||
status_changed: 'ticketDetail.historyType.statusChanged',
|
||||
updated: 'ticketDetail.historyType.updated',
|
||||
};
|
||||
</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>
|
||||
|
||||
<ol v-else class="relative grid gap-0" role="list">
|
||||
<li
|
||||
v-for="worklogEntry in ticket.worklog_entries"
|
||||
:key="worklogEntry.id"
|
||||
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
|
||||
>
|
||||
<div
|
||||
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
||||
>
|
||||
<UserAvatar
|
||||
v-if="worklogEntry.actor"
|
||||
:user="worklogEntry.actor"
|
||||
size="lg"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
</div>
|
||||
<article
|
||||
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="text-foreground text-sm font-semibold"
|
||||
>
|
||||
{{
|
||||
worklogEntry.actor?.name ??
|
||||
t('ticketDetail.system')
|
||||
}}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
t(
|
||||
entryTypeLabels[
|
||||
worklogEntry.entry_type
|
||||
],
|
||||
)
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<time
|
||||
:datetime="worklogEntry.created_at"
|
||||
class="text-muted-foreground shrink-0 text-xs"
|
||||
>
|
||||
{{
|
||||
formatDateTime(worklogEntry.created_at)
|
||||
}}
|
||||
</time>
|
||||
</div>
|
||||
<p class="text-foreground mt-3 text-sm leading-6">
|
||||
{{ worklogEntry.summary }}
|
||||
</p>
|
||||
</article>
|
||||
</li>
|
||||
</ol>
|
||||
</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,178 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ExternalLink, GitBranch } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketGitBranchesProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const visibleRepositories = computed(() =>
|
||||
props.repositoryBranches.filter(
|
||||
(repositoryBranch) =>
|
||||
repositoryBranch.branches.length > 0 ||
|
||||
repositoryBranch.error !== null ||
|
||||
!repositoryBranch.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||
props.repositoryBranches.map(
|
||||
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||
),
|
||||
);
|
||||
const canCreateBranch = computed(
|
||||
() => props.canEditTicket && repositoryConnections.value.length > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-1.5">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<GitBranch
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('ticketDetail.branches') }}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>{ t('ticketDetail.branchesDescription') }}</CardDescription
|
||||
>
|
||||
</div>
|
||||
|
||||
<TicketBranchCreateDialog
|
||||
v-if="canCreateBranch"
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:repository-connections="repositoryConnections"
|
||||
:suggested-branch-name="suggestedBranchName"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 pt-6">
|
||||
<FeedbackState
|
||||
v-if="repositoryBranches.length === 0"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.noLinkedRepositories')"
|
||||
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="visibleRepositories.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="t('tickets.noBranchesDescription')"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="repositoryBranch in visibleRepositories"
|
||||
v-else
|
||||
:key="repositoryBranch.repository_connection.id"
|
||||
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<header class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<a
|
||||
:href="
|
||||
repositoryBranch.repository_connection
|
||||
.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openRepositoryExternal', {
|
||||
repository:
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name,
|
||||
provider:
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ repositoryBranch.repository_connection.base_url }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="
|
||||
!repositoryBranch.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.gitAccessRequired')"
|
||||
:description="t('ticketDetail.inspectBranches')"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="repositoryBranch.error"
|
||||
state="error"
|
||||
:title="t('ticketDetail.branchesUnavailable')"
|
||||
:description="repositoryBranch.error"
|
||||
/>
|
||||
|
||||
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<li
|
||||
v-for="branch in repositoryBranch.branches"
|
||||
:key="branch.name"
|
||||
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<GitBranch
|
||||
class="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="wrap-anywhere font-mono text-xs">{{
|
||||
branch.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketGitPullRequestsProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const visibleRepositories = computed(() =>
|
||||
props.repositoryPullRequests.filter(
|
||||
(repositoryPullRequest) =>
|
||||
repositoryPullRequest.pull_requests.length > 0 ||
|
||||
repositoryPullRequest.error !== null ||
|
||||
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<GitPullRequest
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('ticketDetail.pullRequests') }}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 pt-6">
|
||||
<FeedbackState
|
||||
v-if="repositoryPullRequests.length === 0"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.noLinkedRepositories')"
|
||||
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="visibleRepositories.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="t('tickets.noPullRequestsDescription')"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="repositoryPullRequest in visibleRepositories"
|
||||
v-else
|
||||
:key="repositoryPullRequest.repository_connection.id"
|
||||
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<header class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<a
|
||||
:href="
|
||||
repositoryPullRequest.repository_connection
|
||||
.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openRepositoryExternal', {
|
||||
repository:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.repository_full_name,
|
||||
provider:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.repository_full_name
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection.base_url
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="
|
||||
!repositoryPullRequest.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.gitAccessRequired')"
|
||||
:description="t('ticketDetail.inspectPullRequests')"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="repositoryPullRequest.error"
|
||||
state="error"
|
||||
:title="t('ticketDetail.pullRequestsUnavailable')"
|
||||
:description="repositoryPullRequest.error"
|
||||
/>
|
||||
|
||||
<ul v-else class="grid min-w-0 gap-3">
|
||||
<li
|
||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||
:key="pullRequest.html_url"
|
||||
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 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="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openPullRequestExternal', {
|
||||
number: pullRequest.number,
|
||||
provider:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
pullRequest.title
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
t('ticketDetail.sourceBranch', {
|
||||
name: pullRequest.source_branch,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DialogContentProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
showCloseButton: true,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,59 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = defineProps<
|
||||
DialogContentProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import type {
|
||||
DropdownMenuCheckboxItemEmits,
|
||||
DropdownMenuCheckboxItemProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Check } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
const props = defineProps<
|
||||
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import type {
|
||||
DropdownMenuContentEmits,
|
||||
DropdownMenuContentProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
defineProps<
|
||||
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
||||
>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DropdownMenuItemProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(),
|
||||
{
|
||||
variant: "default",
|
||||
},
|
||||
);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import type {
|
||||
DropdownMenuRadioItemEmits,
|
||||
DropdownMenuRadioItemProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Circle } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<
|
||||
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type {
|
||||
DropdownMenuSubContentEmits,
|
||||
DropdownMenuSubContentProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
const props = defineProps<
|
||||
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
const props = defineProps<
|
||||
DropdownMenuSubTriggerProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
inset?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,62 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import SheetOverlay from "./SheetOverlay.vue"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SheetOverlay from "./SheetOverlay.vue";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
class?: HTMLAttributes["class"];
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: "right",
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
side: "right",
|
||||
});
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||
const delegatedProps = reactiveOmit(props, "class", "side");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right'
|
||||
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left'
|
||||
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top'
|
||||
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom'
|
||||
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<DialogClose
|
||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,96 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
||||
import type { SidebarProps } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
|
||||
import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
|
||||
import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas",
|
||||
})
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas",
|
||||
});
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
const { t } = useLocalization();
|
||||
|
||||
function restoreTriggerFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
|
||||
?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="collapsible === 'none'"
|
||||
data-slot="sidebar"
|
||||
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
:side="side"
|
||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
}"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="group peer text-sidebar-foreground hidden md:block"
|
||||
data-slot="sidebar"
|
||||
:data-state="state"
|
||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||
:data-variant="variant"
|
||||
:data-side="side"
|
||||
>
|
||||
<!-- This is what handles the sidebar gap on desktop -->
|
||||
<div
|
||||
:class="cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
)"
|
||||
/>
|
||||
<div
|
||||
:class="cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="$attrs"
|
||||
v-if="collapsible === 'none'"
|
||||
data-slot="sidebar"
|
||||
:class="
|
||||
cn(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
|
||||
<SheetContent
|
||||
v-bind="$attrs"
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
:side="side"
|
||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
|
||||
@close-auto-focus="restoreTriggerFocus"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
}"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
|
||||
<SheetDescription>{{
|
||||
t("navigation.mobileDescription")
|
||||
}}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex min-h-0 h-full w-full flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="group peer text-sidebar-foreground hidden md:block"
|
||||
data-slot="sidebar"
|
||||
:data-state="state"
|
||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||
:data-variant="variant"
|
||||
:data-side="side"
|
||||
>
|
||||
<!-- This is what handles the sidebar gap on desktop -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
:class="cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background relative flex min-w-0 w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
|
||||
import { useSidebar } from "./utils"
|
||||
import type { Component } from "vue";
|
||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
|
||||
import { useSidebar } from "./utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
||||
tooltip?: string | Component
|
||||
}>(), {
|
||||
as: "button",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
SidebarMenuButtonProps & {
|
||||
tooltip?: string | Component;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
as: "button",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
);
|
||||
|
||||
const { isMobile, state } = useSidebar()
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "tooltip")
|
||||
const delegatedProps = reactiveOmit(props, "tooltip");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
|
||||
<Tooltip v-else>
|
||||
<TooltipTrigger as-child>
|
||||
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
:hidden="state !== 'collapsed' || isMobile"
|
||||
<SidebarMenuButtonChild
|
||||
v-if="!tooltip || isMobile || state !== 'collapsed'"
|
||||
v-bind="{ ...delegatedProps, ...$attrs }"
|
||||
>
|
||||
<template v-if="typeof tooltip === 'string'">
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
<component :is="tooltip" v-else />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
|
||||
<Tooltip v-else>
|
||||
<TooltipTrigger as-child>
|
||||
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
:hidden="state !== 'collapsed' || isMobile"
|
||||
>
|
||||
<template v-if="typeof tooltip === 'string'">
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
<component :is="tooltip" v-else />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
@@ -1,82 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes, Ref } from "vue"
|
||||
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
|
||||
import { TooltipProvider } from "reka-ui"
|
||||
import { computed, ref } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
|
||||
import type { HTMLAttributes, Ref } from "vue";
|
||||
import {
|
||||
defaultDocument,
|
||||
useEventListener,
|
||||
useMediaQuery,
|
||||
useVModel,
|
||||
} from "@vueuse/core";
|
||||
import { TooltipProvider } from "reka-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
provideSidebarContext,
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SIDEBAR_KEYBOARD_SHORTCUT,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_WIDTH_ICON,
|
||||
} from "./utils";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
||||
open: undefined,
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>(),
|
||||
{
|
||||
defaultOpen: !defaultDocument?.cookie.includes(
|
||||
`${SIDEBAR_COOKIE_NAME}=false`,
|
||||
),
|
||||
open: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
"update:open": [open: boolean]
|
||||
}>()
|
||||
"update:open": [open: boolean];
|
||||
}>();
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
const openMobile = ref(false)
|
||||
const isMobile = useMediaQuery("(width < 768px)");
|
||||
const openMobile = ref(false);
|
||||
watch(isMobile, () => {
|
||||
openMobile.value = false;
|
||||
});
|
||||
|
||||
const open = useVModel(props, "open", emits, {
|
||||
defaultValue: props.defaultOpen ?? false,
|
||||
passive: (props.open === undefined) as false,
|
||||
}) as Ref<boolean>
|
||||
defaultValue: props.defaultOpen ?? false,
|
||||
passive: (props.open === undefined) as false,
|
||||
}) as Ref<boolean>;
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
open.value = value // emits('update:open', value)
|
||||
open.value = value; // emits('update:open', value)
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
}
|
||||
|
||||
function setOpenMobile(value: boolean) {
|
||||
openMobile.value = value
|
||||
openMobile.value = value;
|
||||
}
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
function toggleSidebar() {
|
||||
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
||||
return isMobile.value
|
||||
? setOpenMobile(!openMobile.value)
|
||||
: setOpen(!open.value);
|
||||
}
|
||||
|
||||
useEventListener("keydown", (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
})
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = computed(() => open.value ? "expanded" : "collapsed")
|
||||
const state = computed(() => (open.value ? "expanded" : "collapsed"));
|
||||
|
||||
provideSidebarContext({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
})
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
}"
|
||||
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
}"
|
||||
:class="
|
||||
cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSidebar } from "./utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "./utils";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
|
||||
const { isMobile, state, toggleSidebar } = useSidebar()
|
||||
const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="cn('h-7 w-7', props.class)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
|
||||
<PanelLeftClose v-else />
|
||||
<span class="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
aria-controls="app-navigation"
|
||||
:aria-expanded="isMobile ? openMobile : state === 'expanded'"
|
||||
:aria-label="
|
||||
isMobile
|
||||
? t(openMobile ? 'navigation.close' : 'navigation.open')
|
||||
: t(
|
||||
state === 'collapsed'
|
||||
? 'navigation.expand'
|
||||
: 'navigation.collapse',
|
||||
)
|
||||
"
|
||||
:class="cn('size-control', props.class)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<PanelLeftOpen
|
||||
v-if="isMobile || state === 'collapsed'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PanelLeftClose v-else aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext } from "reka-ui"
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { createContext } from "reka-ui";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export type SidebarContext = {
|
||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
||||
state: ComputedRef<"expanded" | "collapsed">;
|
||||
open: Ref<boolean>;
|
||||
setOpen: (value: boolean) => void;
|
||||
isMobile: Ref<boolean>;
|
||||
@@ -11,11 +11,12 @@ export type SidebarContext = {
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
export const SIDEBAR_WIDTH = "16rem"
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
export const SIDEBAR_WIDTH = "16rem";
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
export const SIDEBAR_WIDTH_ICON = "4rem";
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
||||
export const [useSidebar, provideSidebarContext] =
|
||||
createContext<SidebarContext>("Sidebar");
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
projectNavigation,
|
||||
projectSettingsNavigation,
|
||||
workspaceNavigation,
|
||||
} from '@/lib/navigation';
|
||||
|
||||
export function useAppNavigation() {
|
||||
const page = usePage();
|
||||
const { t } = useLocalization();
|
||||
const project = computed(() => page.props.navigation?.project ?? null);
|
||||
|
||||
return {
|
||||
project,
|
||||
workspaceItems: computed(() =>
|
||||
workspaceNavigation(page.props.auth.permissions, t),
|
||||
),
|
||||
projectItems: computed(() => projectNavigation(project.value, t)),
|
||||
projectSettingsItems: computed(() =>
|
||||
projectSettingsNavigation(project.value, t),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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 { computed, readonly } from 'vue';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { matchesNavigationUrl } from '@/lib/navigation';
|
||||
|
||||
export type UseCurrentUrlReturn = {
|
||||
currentUrl: DeepReadonly<ComputedRef<string>>;
|
||||
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
|
||||
) => T | F;
|
||||
};
|
||||
|
||||
const page = usePage();
|
||||
const currentUrlReactive = computed(
|
||||
() =>
|
||||
new URL(
|
||||
page.url,
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: 'http://localhost',
|
||||
).pathname,
|
||||
);
|
||||
|
||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
const page = usePage();
|
||||
const currentUrlReactive = computed(
|
||||
() => new URL(page.url, 'http://localhost').pathname,
|
||||
);
|
||||
function isCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
startsWith: boolean = false,
|
||||
) {
|
||||
const urlToCompare = currentUrl ?? currentUrlReactive.value;
|
||||
const urlString = toUrl(urlToCheck);
|
||||
|
||||
const comparePath = (path: string): boolean =>
|
||||
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare;
|
||||
|
||||
if (!urlString.startsWith('http')) {
|
||||
return comparePath(urlString);
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(urlString);
|
||||
|
||||
return comparePath(absoluteUrl.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return matchesNavigationUrl(
|
||||
toUrl(urlToCheck),
|
||||
currentUrl ?? currentUrlReactive.value,
|
||||
startsWith,
|
||||
);
|
||||
}
|
||||
|
||||
function isCurrentOrParentUrl(
|
||||
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
return isCurrentUrl(urlToCheck, currentUrl, true);
|
||||
}
|
||||
|
||||
function whenCurrentUrl(
|
||||
function whenCurrentUrl<T, F = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: any,
|
||||
ifFalse: any = null,
|
||||
) {
|
||||
ifTrue: T,
|
||||
ifFalse: F = null as F,
|
||||
): T | F {
|
||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { injectDialogRootContext } from 'reka-ui';
|
||||
import { watch } from 'vue';
|
||||
|
||||
/** Reka restores registered triggers; also support dialogs opened programmatically. */
|
||||
export function useDialogFocus() {
|
||||
const context = injectDialogRootContext();
|
||||
let previousFocus: HTMLElement | null = null;
|
||||
|
||||
watch(
|
||||
context.open,
|
||||
(open) => {
|
||||
if (open && typeof document !== 'undefined') {
|
||||
previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
}
|
||||
},
|
||||
{ flush: 'sync', immediate: true },
|
||||
);
|
||||
|
||||
function restoreFocus(event: Event): void {
|
||||
if (
|
||||
!event.defaultPrevented &&
|
||||
!context.triggerElement.value &&
|
||||
previousFocus?.isConnected
|
||||
) {
|
||||
event.preventDefault();
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { restoreFocus };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
PendingVisit,
|
||||
UrlMethodPair,
|
||||
UseFormSubmitOptions,
|
||||
} from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import type { InertiaForm } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
|
||||
import {
|
||||
formRequestHeader,
|
||||
isFormNetworkFailure,
|
||||
matchesFormRequest,
|
||||
} from '@/lib/form-feedback';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
|
||||
export function useFormProtection<T extends object>(
|
||||
form: InertiaForm<T>,
|
||||
options: { isDirty?: () => boolean } = {},
|
||||
) {
|
||||
const failure = ref<FormFeedbackState | null>(null);
|
||||
const responseStatus = ref<number | null>(null);
|
||||
let ownVisit: PendingVisit | null = null;
|
||||
let requestId: string | null = null;
|
||||
const removeListeners: Array<() => void> = [];
|
||||
|
||||
const { confirmLeave } = useUnsavedChanges(
|
||||
() => form.processing || (options.isDirty?.() ?? form.isDirty),
|
||||
(visit) => visit === ownVisit,
|
||||
);
|
||||
|
||||
const state = computed<FormFeedbackState>(() => {
|
||||
if (form.processing) {
|
||||
return 'loading';
|
||||
}
|
||||
if (failure.value) {
|
||||
return failure.value;
|
||||
}
|
||||
if (Object.keys(form.errors).length > 0) {
|
||||
return 'validation';
|
||||
}
|
||||
return form.recentlySuccessful ? 'success' : 'idle';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
removeListeners.push(
|
||||
router.on('exception', (event) => {
|
||||
if (isFormNetworkFailure(event.detail.exception, requestId)) {
|
||||
event.preventDefault();
|
||||
failure.value = 'network';
|
||||
}
|
||||
}),
|
||||
router.on('invalid', (event) => {
|
||||
if (
|
||||
matchesFormRequest(event.detail.response.config, requestId)
|
||||
) {
|
||||
event.preventDefault();
|
||||
failure.value = 'server';
|
||||
responseStatus.value = event.detail.response.status;
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => removeListeners.forEach((remove) => remove()));
|
||||
|
||||
function submit(
|
||||
target: UrlMethodPair,
|
||||
visitOptions: UseFormSubmitOptions = {},
|
||||
): void {
|
||||
if (form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequestId = crypto.randomUUID();
|
||||
form.submit(target, {
|
||||
...visitOptions,
|
||||
preserveState: true,
|
||||
headers: {
|
||||
...visitOptions.headers,
|
||||
[formRequestHeader]: nextRequestId,
|
||||
},
|
||||
onBefore: (visit) => {
|
||||
if (visitOptions.onBefore?.(visit) === false) {
|
||||
return false;
|
||||
}
|
||||
ownVisit = visit;
|
||||
},
|
||||
onStart: (visit) => {
|
||||
requestId = nextRequestId;
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
visitOptions.onStart?.(visit);
|
||||
},
|
||||
onError: (errors) => {
|
||||
failure.value = 'validation';
|
||||
return visitOptions.onError?.(errors);
|
||||
},
|
||||
onSuccess: (page) => {
|
||||
failure.value = null;
|
||||
return visitOptions.onSuccess?.(page);
|
||||
},
|
||||
onCancel: () => {
|
||||
failure.value = 'cancelled';
|
||||
visitOptions.onCancel?.();
|
||||
},
|
||||
onFinish: (visit) => {
|
||||
requestId = null;
|
||||
ownVisit = null;
|
||||
visitOptions.onFinish?.(visit);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
}
|
||||
|
||||
return { state, responseStatus, submit, confirmLeave, clearFeedback };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
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;
|
||||
|
||||
export function useProjectTemplateAreas() {
|
||||
const { t } = useLocalization();
|
||||
function areaText(
|
||||
value: string,
|
||||
part: 'label' | 'description',
|
||||
fallback = value,
|
||||
): string {
|
||||
const key = areaKeys[value as keyof typeof areaKeys];
|
||||
return key ? t(`${key}.${part}`) : fallback;
|
||||
}
|
||||
return { areaText };
|
||||
}
|
||||
@@ -1,120 +1,107 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onScopeDispose, ref } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
|
||||
export type UseTwoFactorAuthReturn = {
|
||||
qrCodeSvg: Ref<string | null>;
|
||||
manualSetupKey: Ref<string | null>;
|
||||
recoveryCodesList: Ref<string[]>;
|
||||
errors: Ref<string[]>;
|
||||
hasSetupData: ComputedRef<boolean>;
|
||||
clearSetupData: () => void;
|
||||
clearErrors: () => void;
|
||||
clearTwoFactorAuthData: () => void;
|
||||
fetchQrCode: () => Promise<void>;
|
||||
fetchSetupKey: () => Promise<void>;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
};
|
||||
/** Keep secrets within the consuming component and discard late responses after hiding them. */
|
||||
export function useTwoFactorAuth() {
|
||||
const { t } = useLocalization();
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
let pending: AbortController | null = null;
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || response.redirected) {
|
||||
throw new Error(String(response.status));
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
function clearErrors(): void {
|
||||
errors.value = [];
|
||||
}
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
|
||||
const hasSetupData = computed<boolean>(
|
||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
||||
);
|
||||
|
||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
||||
const fetchQrCode = async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
||||
qrCode.url(),
|
||||
);
|
||||
|
||||
qrCodeSvg.value = svg;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch QR code');
|
||||
qrCodeSvg.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupKey = async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
);
|
||||
|
||||
manualSetupKey.value = key;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch a setup key');
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSetupData = (): void => {
|
||||
function clearTwoFactorAuthData(): void {
|
||||
pending?.abort();
|
||||
pending = null;
|
||||
loading.value = false;
|
||||
manualSetupKey.value = null;
|
||||
qrCodeSvg.value = null;
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const clearErrors = (): void => {
|
||||
errors.value = [];
|
||||
};
|
||||
|
||||
const clearTwoFactorAuthData = (): void => {
|
||||
clearSetupData();
|
||||
clearErrors();
|
||||
recoveryCodesList.value = [];
|
||||
};
|
||||
clearErrors();
|
||||
}
|
||||
|
||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
||||
async function load(kind: 'setup' | 'recovery'): Promise<void> {
|
||||
clearTwoFactorAuthData();
|
||||
const request = new AbortController();
|
||||
pending = request;
|
||||
loading.value = true;
|
||||
try {
|
||||
clearErrors();
|
||||
recoveryCodesList.value = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
);
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch recovery codes');
|
||||
recoveryCodesList.value = [];
|
||||
if (kind === 'setup') {
|
||||
const [qr, key] = await Promise.all([
|
||||
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
|
||||
fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
request.signal,
|
||||
),
|
||||
]);
|
||||
if (pending !== request) {
|
||||
return;
|
||||
}
|
||||
qrCodeSvg.value = qr.svg;
|
||||
manualSetupKey.value = key.secretKey;
|
||||
} else {
|
||||
const codes = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
request.signal,
|
||||
);
|
||||
if (pending === request) {
|
||||
recoveryCodesList.value = codes;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (pending === request && !request.signal.aborted) {
|
||||
const status = error instanceof Error ? error.message : '';
|
||||
errors.value = [
|
||||
t(
|
||||
status === '423' || status === '401' || status === '419'
|
||||
? 'security.confirmAgain'
|
||||
: kind === 'setup'
|
||||
? 'security.setupError'
|
||||
: 'security.recoveryError',
|
||||
),
|
||||
];
|
||||
}
|
||||
} finally {
|
||||
if (pending === request) {
|
||||
pending = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupData = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
qrCodeSvg.value = null;
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onScopeDispose(clearTwoFactorAuthData);
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
errors,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
clearErrors,
|
||||
loading,
|
||||
hasSetupData: computed(() =>
|
||||
Boolean(qrCodeSvg.value && manualSetupKey.value),
|
||||
),
|
||||
clearSetupData: clearTwoFactorAuthData,
|
||||
clearRecoveryCodes: clearTwoFactorAuthData,
|
||||
clearTwoFactorAuthData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
clearErrors,
|
||||
fetchSetupData: () => load('setup'),
|
||||
fetchRecoveryCodes: () => load('recovery'),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PendingVisit } from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { onMounted, onUnmounted, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
export function useUnsavedChanges(
|
||||
dirty: MaybeRefOrGetter<boolean>,
|
||||
allowVisit: (visit: PendingVisit) => boolean = () => false,
|
||||
) {
|
||||
const { t } = useLocalization();
|
||||
let removeBeforeListener: (() => void) | undefined;
|
||||
|
||||
const confirmLeave = (): boolean =>
|
||||
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
|
||||
|
||||
const beforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
if (toValue(dirty)) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
removeBeforeListener = router.on('before', (event) => {
|
||||
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the original visit and its callbacks; never replay a mutation.
|
||||
return confirmLeave();
|
||||
});
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeBeforeListener?.();
|
||||
window.removeEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
return { confirmLeave };
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import AppContent from '@/components/AppContent.vue';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||
import ProjectNavigation from '@/components/ProjectNavigation.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
@@ -12,14 +14,27 @@ type Props = {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell variant="sidebar">
|
||||
<a
|
||||
href="#main-content"
|
||||
class="bg-primary text-primary-foreground sr-only fixed left-4 top-4 z-50 rounded-md p-3 focus:not-sr-only"
|
||||
>{{ t('navigation.skipToContent') }}</a
|
||||
>
|
||||
<AppSidebar />
|
||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
||||
<AppContent variant="sidebar" class="min-w-0">
|
||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||
<slot />
|
||||
<ProjectNavigation />
|
||||
<div
|
||||
id="main-content"
|
||||
tabindex="-1"
|
||||
class="flex min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import type { Component } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header
|
||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
||||
>
|
||||
<Link
|
||||
:href="home()"
|
||||
:aria-label="t('auth.home')"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
</header>
|
||||
<main
|
||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="auth-title"
|
||||
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
|
||||
>
|
||||
<div class="gap-column grid">
|
||||
<span
|
||||
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="icon ?? ShieldCheck"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="gap-stack grid">
|
||||
<p
|
||||
class="text-label text-muted-foreground uppercase tracking-wide"
|
||||
>
|
||||
{{ t('auth.accountAccess') }}
|
||||
</p>
|
||||
<h1
|
||||
id="auth-title"
|
||||
class="text-headline-mobile sm:text-headline"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="px-card text-body text-muted-foreground text-center"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pb-page flex justify-center px-4">
|
||||
<AppearanceTabs />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { Settings2 } from 'lucide-vue-next';
|
||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
|
||||
defineProps<{ projectName: string; projectKey: string }>();
|
||||
const { projectSettingsItems } = useAppNavigation();
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
|
||||
>
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<div class="text-primary flex items-center gap-2">
|
||||
<Settings2 class="size-4" aria-hidden="true" />
|
||||
<p class="text-label text-muted-foreground break-all uppercase">
|
||||
{{ projectKey }}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{ t('navigation.projectSettings') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground break-words">
|
||||
{{ t('projectSettings.description', { project: projectName }) }}
|
||||
</p>
|
||||
</header>
|
||||
<div
|
||||
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||
>
|
||||
<aside
|
||||
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||
>
|
||||
<nav
|
||||
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||
:aria-label="t('navigation.projectSettings')"
|
||||
>
|
||||
<Link
|
||||
v-for="item in projectSettingsItems"
|
||||
:key="toUrl(item.href)"
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
isCurrentUrl(item.href) ? 'page' : undefined
|
||||
"
|
||||
:class="[
|
||||
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||
isCurrentUrl(item.href)
|
||||
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user