Compare commits

...
42 Commits
Author SHA1 Message Date
Twyco fed0b657c0 Merge pull request 'Feature/kan 33 ticketbeziehungen und hierarchie' (#128) from feature/KAN-33-ticketbeziehungen-und-hierarchie into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 20m52s
CI/CD / Publish immutable image (push) Successful in 3m39s
Reviewed-on: #128
2026-09-14 09:45:54 +00:00
Twyco 1fe0c0b2d9 feat(KAN-33): redesign ticket relationships and hierarchy
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 5s
2026-09-14 09:45:45 +00:00
Twyco 463cb7fe36 Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #127
2026-09-14 09:45:39 +00:00
Twyco 3bf5a0c508 feat(KAN-32): add hierarchical acceptance criteria checklist
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 31s
2026-09-14 09:45:28 +00:00
Twyco 32de1f425a docker test env fix
CI/CD / preconditions (push) Successful in 1m2s
CI/CD / test_and_check_app (push) Successful in 11m39s
CI/CD / Publish immutable image (push) Successful in 3m47s
2026-09-14 10:52:07 +02:00
Twyco 1af98cd56e docker env example fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Failing after 1m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:48:29 +02:00
Twyco aadf159ac5 docker test fix
CI/CD / preconditions (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 3m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:36:56 +02:00
Twyco 5c47453d4d rollback test app action
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Failing after 8m23s
CI/CD / Publish immutable image (push) Skipped
2026-09-12 14:23:18 +02:00
Twyco ba7e174e82 Merge pull request 'Feature/kan 31 ticket create edit forms' (#126) from feature/KAN-31-ticket-create-edit-forms into main
CI/CD / Publish immutable image (push) Successful in 4m13s
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 8m40s
Reviewed-on: #126
2026-09-12 11:34:10 +00:00
Eric d646f8b537 feat: KAN-30
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-12 13:33:53 +02:00
Twyco 509d7abf82 Merge pull request 'KAN-30 Responsive ticket detail view' (#125) from feature/KAN-30-ticket-detail-layout into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #125
2026-09-12 06:22:31 +00:00
Twyco 5cc1840592 feat(KAN-30): rebuild responsive ticket detail view 2026-09-12 06:22:31 +00:00
Twyco 11b06d9b8c Merge pull request 'KAN-29 Backlog and ticket tree' (#124) from feature/KAN-29-backlog-ticket-tree into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #124
2026-09-12 06:22:20 +00:00
Twyco b76b4adaec feat(KAN-29): rework backlog ticket tree
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m3s
2026-09-12 06:18:47 +00:00
Twyco c8fa76e08a fix Docker
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 7m43s
CI/CD / Publish immutable image (push) Successful in 3m51s
2026-09-12 02:43:19 +02:00
Twyco 87ae19f972 docker build npm fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 7m18s
CI/CD / Publish immutable image (push) Successful in 3m24s
2026-09-12 02:41:42 +02:00
Twyco a97451edfa fix
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Successful in 7m40s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-12 02:23:44 +02:00
Twyco 17b3e02bd8 fix
CI/CD / preconditions (push) Successful in 1m5s
CI/CD / test_and_check_app (push) Successful in 8m46s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-12 02:06:10 +02:00
Twyco d58fbd21ad fix: enable pnpm
CI/CD / preconditions (push) Successful in 57s
CI/CD / test_and_check_app (push) Successful in 8m40s
CI/CD / Publish immutable image (push) Failing after 1m23s
2026-09-11 18:40:54 +02:00
Twyco 002de1b293 fix: use pnpm in docker
CI/CD / preconditions (push) Successful in 59s
CI/CD / Publish immutable image (push) Failing after 1m25s
CI/CD / test_and_check_app (push) Successful in 8m24s
2026-09-11 16:25:02 +00:00
Twyco 707ebe6e48 switch to pnpm
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 13m54s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-11 17:39:12 +02:00
Twyco 4e29fc4526 Merge pull request 'fix: adjusted drag and drop' (#122) from feature/KAN-28-drag-and-drop-status into main
CI/CD / test_and_check_app (push) Successful in 5m42s
CI/CD / Publish immutable image (push) Successful in 2m30s
Reviewed-on: #122
2026-09-11 14:40:58 +00:00
Eric fab1e9c7a8 fix: adjusted drag and drop
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (push) Successful in 6m29s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Successful in 6m52s
2026-09-11 15:55:13 +02:00
Twyco ccd199a695 Merge pull request 'KAN-27 Responsive board' (#121) from feature/KAN-27-responsive-board into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
Reviewed-on: #121
2026-09-11 12:21:06 +00:00
Twyco f7b50e8715 feat(KAN-27): rebuild responsive project board
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:56 +02:00
Twyco 5147c8ed8d Merge pull request 'KAN-25 Personal dashboard' (#119) from feature/KAN-25-personal-dashboard into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #119
2026-09-11 12:20:42 +00:00
Twyco 3914fd5432 feat(KAN-25): build personal dashboard
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:27 +02:00
Twyco 4d6da2c5f5 Merge pull request 'KAN-26 Ticket cards and lists' (#120) from feature/KAN-26-ticket-cards-and-lists into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #120
2026-09-11 12:20:12 +00:00
Twyco 313e2d2396 feat(KAN-26): add reusable ticket presentations
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:02 +02:00
Twyco 810d1850ae Merge pull request 'KAN-24 Project creation' (#118) from feature/KAN-24-project-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #118
2026-09-11 12:19:50 +00:00
Twyco cd7fae3841 feat(KAN-24): rebuild project creation flow
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
2026-09-11 14:19:32 +02:00
Twyco 32e3496248 Merge pull request 'KAN-23 Project overview' (#117) from feature/KAN-23-project-overview into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #117
2026-09-11 12:19:16 +00:00
Twyco 0e78be0eae feat(KAN-23): rebuild project overview and entry pages
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:19:07 +02:00
Twyco 17caabbb3e Merge pull request 'KAN-22 Password confirmation pages' (#116) from feature/KAN-22-password-confirmation-pages into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 37s
Reviewed-on: #116
2026-09-11 12:18:52 +00:00
Twyco 3f4945c20d feat(KAN-22): rebuild password confirmation flows
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
2026-09-11 14:18:45 +02:00
Twyco 65bf75ed77 Merge pull request 'KAN-21 Login, verification, and two-factor authentication' (#115) from feature/KAN-21-login-verification-two-factor into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #115
2026-09-11 12:18:31 +00:00
Twyco 802294d6a2 feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (pull_request) Canceled after 6s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:18:23 +02:00
Twyco 5e52e4fe78 Merge pull request 'KAN-20 Dialogs, feedback, and input protection' (#114) from feature/KAN-20-dialogs-feedback-input-protection into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
Reviewed-on: #114
2026-09-11 12:18:04 +00:00
Twyco f4a8bdd134 feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
2026-09-11 14:17:10 +02:00
Twyco 850ae337ca Merge pull request 'KAN-19 Responsive app navigation' (#113) from feature/KAN-19-responsive-app-navigation into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
Reviewed-on: #113
2026-09-11 12:16:54 +00:00
Twyco 4adda6e53b feat(KAN-19): add responsive permission-aware navigation
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:16:43 +02:00
Twyco de6f4e5b2f Merge pull request 'KAN-18 Basic UI components' (#112) from feature/KAN-18-basic-ui-components into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #112
2026-09-11 12:14:09 +00:00
153 changed files with 20911 additions and 11591 deletions
-3
View File
@@ -33,9 +33,6 @@
vendor vendor
**/.DS_Store **/.DS_Store
.editorconfig .editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production .env.production
LICENSE LICENSE
README.md README.md
-4
View File
@@ -1,4 +0,0 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+19 -2
View File
@@ -2,7 +2,13 @@ name: CI/CD
on: on:
push: push:
branches:
- main
tags:
- "v*"
pull_request: pull_request:
branches:
- main
permissions: permissions:
contents: read contents: read
@@ -11,9 +17,19 @@ env:
REGISTRY: git.twyco.de REGISTRY: git.twyco.de
jobs: jobs:
test_and_check_app: preconditions:
name: test_and_check_app if: >
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- name: Conditions met
run: |
echo "Conditions for CI/CD are met. Proceeding with the workflow."
test_and_check_app:
runs-on: ubuntu-latest
needs:
- preconditions
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -27,6 +43,7 @@ jobs:
github.event_name == 'push' && github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
needs: needs:
- preconditions
- test_and_check_app - test_and_check_app
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+5 -3
View File
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \ RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \ && corepack enable pnpm \
&& npm run build && pnpm install --frozen-lockfile \
&& pnpm run build
FROM build AS test FROM build AS test
RUN touch .env \ RUN cp .env.example .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \ && composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check && COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
'tickets' => TicketResource::collection( 'tickets' => TicketResource::collection(
Ticket::query() Ticket::query()
->with([ ->with([
'assignee',
'ticketPriority', 'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
'ticketStatuses', 'ticketStatuses',
'tickets' => fn ($query) => $query 'tickets' => fn ($query) => $query
->with([ ->with([
'assignee',
'ticketPriority', 'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id) ->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds) ->whereIn('project_id', $visibleProjectIds)
->with([ ->with([
'acceptanceCriteria',
'assignee', 'assignee',
'project',
'ticketPriority', 'ticketPriority',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
'ticketType', 'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -60,6 +61,36 @@ class HandleInertiaRequests extends Middleware
'user' => $user, 'user' => $user,
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation' => fn (): array => [
'project' => $this->projectNavigation($request),
],
];
}
/**
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
*/
private function projectNavigation(Request $request): ?array
{
$project = $request->route('project');
$user = $request->user();
if (! $project instanceof Project || $user === null) {
return null;
}
$permissions = $this->permissionService->effectivePermissions($user, $project);
if (! in_array(Permission::ProjectRead, $permissions, true)) {
return null;
}
return [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
]; ];
} }
} }
+6 -6
View File
@@ -47,15 +47,15 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate", "@php artisan key:generate",
"@php artisan migrate --force", "@php artisan migrate --force",
"npm install", "pnpm install",
"npm run build" "pnpm run build"
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@php artisan dev" "@php artisan dev"
], ],
"dev:ssr": [ "dev:ssr": [
"npm run build:ssr", "pnpm run build:ssr",
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
], ],
@@ -67,9 +67,9 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high", "pnpm audit --audit-level=high",
"npm run lint:check", "pnpm run lint:check",
"npm run types:check", "pnpm run types:check",
"@test" "@test"
], ],
"test": [ "test": [
+1
View File
@@ -25,6 +25,7 @@ const prettierOptions = {
const eslintConfig = [ const eslintConfig = [
{ {
ignores: [ ignores: [
'bootstrap/ssr/**',
'**/dist/**', '**/dist/**',
'node_modules/**', 'node_modules/**',
'.github/**', '.github/**',
-7269
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -2,6 +2,13 @@
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
"onFail": "error"
}
},
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:ssr": "vite build && vite build --ssr", "build:ssr": "vite build && vite build --ssr",
@@ -39,6 +46,7 @@
"vue-tsc": "^2.2.4" "vue-tsc": "^2.2.4"
}, },
"dependencies": { "dependencies": {
"@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7", "@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
-1
View File
@@ -19,7 +19,6 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="AVATAR_FILESYSTEM_DISK" value="public"/> <env name="AVATAR_FILESYSTEM_DISK" value="public"/>
<env name="AVATAR_URLS_TEMPORARY" value="false"/> <env name="AVATAR_URLS_TEMPORARY" value="false"/>
+4832
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
minimumReleaseAge: 10080
allowBuilds:
unrs-resolver: true
vue-demi: true
+11 -10
View File
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import { Columns3 } from 'lucide-vue-next';
</script> </script>
<template> <template>
<div <span class="flex min-w-0 items-center gap-3">
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" <span
> class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
> >
</div> <Columns3 class="size-5" aria-hidden="true" />
</span>
<span
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
>KanbanGit</span
>
</span>
</template> </template>
+86 -90
View File
@@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { import { Settings2, X } from 'lucide-vue-next';
BookOpen, import { computed, onMounted, onUnmounted } from 'vue';
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue'; import { Button } from '@/components/ui/button';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -22,93 +13,98 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users'; import { edit as profile } from '@/routes/profile';
import { index as projectTemplatesIndex } from '@/routes/project-templates'; import { show } from '@/routes/projects';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const page = usePage(); const { t } = useLocalization();
const { currentUrl } = useCurrentUrl();
const mainNavItems = computed<NavItem[]>(() => { const { project, workspaceItems, projectItems, projectSettingsItems } =
const items: NavItem[] = [ useAppNavigation();
{ const { isMobile, setOpenMobile } = useSidebar();
title: 'Dashboard', const accountItems = computed(() => [
href: dashboard(), {
icon: LayoutGrid, title: t('navigation.settings'),
}, href: profile(),
{ icon: Settings2,
title: 'Projects', isActive: currentUrl.value.startsWith('/settings/'),
href: projectsIndex(), },
icon: Folder, ]);
}, let removeNavigationListener: (() => void) | undefined;
]; onMounted(() => {
removeNavigationListener = router.on('navigate', () =>
if (page.props.auth.permissions.projectCreate) { setOpenMobile(false),
items.push({ );
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
}); });
onUnmounted(() => removeNavigationListener?.());
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script> </script>
<template> <template>
<Sidebar collapsible="icon" variant="inset"> <Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
<SidebarHeader> <SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
<SidebarMenu> <div class="flex items-center gap-2">
<SidebarMenuItem> <SidebarMenu class="min-w-0">
<SidebarMenuButton size="lg" as-child> <SidebarMenuItem>
<Link :href="dashboard()"> <SidebarMenuButton
<AppLogo /> size="lg"
</Link> as-child
</SidebarMenuButton> tooltip="KanbanGit"
</SidebarMenuItem> >
</SidebarMenu> <Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Button
v-if="isMobile"
variant="ghost"
size="icon"
:aria-label="t('navigation.close')"
@click="setOpenMobile(false)"
>
<X aria-hidden="true" />
</Button>
</div>
<Link
v-if="project"
:href="show(project.id)"
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
>
<span
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
>{{ project.key }}</span
>
<span class="truncate font-semibold" :title="project.name">{{
project.name
}}</span>
</Link>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain :items="mainNavItems" /> <nav :aria-label="t('navigation.main')">
<NavMain
:items="workspaceItems"
:label="t('navigation.workspace')"
/>
<NavMain
v-if="projectItems.length"
:items="projectItems"
:label="t('navigation.project')"
/>
<NavMain
v-if="projectSettingsItems.length"
:items="projectSettingsItems"
:label="t('navigation.projectSettings')"
/>
</nav>
</SidebarContent> </SidebarContent>
<SidebarFooter class="border-sidebar-border border-t p-0">
<SidebarFooter> <NavMain :items="accountItems" :label="t('navigation.account')" />
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<slot />
</template> </template>
+131 -13
View File
@@ -1,27 +1,145 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import {
normalizeAppearance,
useAppearance,
} from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
withDefaults( withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
defineProps<{ breadcrumbs: () => [],
breadcrumbs?: BreadcrumbItem[]; });
}>(), const page = usePage();
{ const { project } = useAppNavigation();
breadcrumbs: () => [], const { isCurrentUrl } = useCurrentUrl();
}, const { t } = useLocalization();
const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
); );
const createAction = computed(() => {
if (project.value?.canCreateTickets) {
return {
title: t('navigation.createTicket'),
href: createTicket(project.value.id, {
query: {
source: isCurrentUrl(board(project.value.id))
? 'board'
: 'backlog',
},
}),
};
}
if (!project.value && page.props.auth.permissions.projectCreate) {
return { title: t('navigation.createProject'), href: createProject() };
}
return null;
});
</script> </script>
<template> <template>
<header <header
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4" class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
> >
<div class="flex items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger class="-ml-1" /> <SidebarTrigger class="shrink-0" />
<template v-if="breadcrumbs && breadcrumbs.length > 0"> <Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
<Breadcrumbs :breadcrumbs="breadcrumbs" /> <span v-else class="truncate font-semibold">KanbanGit</span>
</template> </div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="createAction"
as-child
:aria-label="createAction.title"
class="px-3"
>
<Link :href="createAction.href"
><Plus aria-hidden="true" /><span
class="hidden sm:inline"
>{{ createAction.title }}</span
></Link
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="t('appearance.label')"
><component :is="themeIcon" aria-hidden="true"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{
t('appearance.label')
}}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
:model-value="appearance"
@update:model-value="
(value) =>
updateAppearance(normalizeAppearance(value))
"
>
<DropdownMenuRadioItem value="light"
><Sun aria-hidden="true" />{{
t('appearance.light')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="dark"
><Moon aria-hidden="true" />{{
t('appearance.dark')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="system"
><Monitor aria-hidden="true" />{{
t('appearance.system')
}}</DropdownMenuRadioItem
>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="
t('navigation.userMenu', {
name: page.props.auth.user.name,
})
"
data-test="sidebar-menu-button"
>
<UserAvatar :user="page.props.auth.user" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-60">
<UserMenuContent :user="page.props.auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</header> </header>
</template> </template>
@@ -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>
+25 -6
View File
@@ -9,29 +9,48 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
breadcrumbs: BreadcrumbItemType[]; breadcrumbs: BreadcrumbItemType[];
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Breadcrumb> <Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
<BreadcrumbList> <BreadcrumbList class="flex-nowrap">
<template v-for="(item, index) in breadcrumbs" :key="index"> <template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem> <BreadcrumbItem
:class="[
'min-w-0',
index !== breadcrumbs.length - 1
? 'hidden md:inline-flex'
: '',
]"
>
<template v-if="index === breadcrumbs.length - 1"> <template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage> <BreadcrumbPage class="truncate" :title="item.title">{{
item.title
}}</BreadcrumbPage>
</template> </template>
<template v-else> <template v-else>
<BreadcrumbLink as-child> <BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link> <Link
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
</BreadcrumbLink> </BreadcrumbLink>
</template> </template>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" /> <BreadcrumbSeparator
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
</template> </template>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
+72
View File
@@ -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>
+76
View File
@@ -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>
+2 -2
View File
@@ -5,8 +5,8 @@ defineProps<{
</script> </script>
<template> <template>
<div v-show="message"> <div v-show="message" role="alert">
<p class="text-sm text-red-600 dark:text-red-500"> <p class="text-body text-destructive">
{{ message }} {{ message }}
</p> </p>
</div> </div>
+29 -6
View File
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
defineProps<{ defineProps<{
items: NavItem[]; items: NavItem[];
label: string;
}>(); }>();
const { isCurrentUrl } = useCurrentUrl(); const { isCurrentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<SidebarGroup class="px-2 py-0"> <SidebarGroup class="px-3 py-2">
<SidebarGroupLabel>Platform</SidebarGroupLabel> <SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
<SidebarMenu> label
}}</SidebarGroupLabel>
<SidebarMenu role="list" :aria-label="label">
<SidebarMenuItem v-for="item in items" :key="item.title"> <SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton <SidebarMenuButton
as-child as-child
:is-active="isCurrentUrl(item.href)" :is-active="
item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
)
"
:tooltip="item.title" :tooltip="item.title"
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
> >
<Link :href="item.href"> <Link
<component :is="item.icon" /> :href="item.href"
:aria-current="
(item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
))
? 'page'
: undefined
"
>
<component :is="item.icon" aria-hidden="true" />
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
+11 -4
View File
@@ -2,6 +2,7 @@
import { Eye, EyeOff } from 'lucide-vue-next'; import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue'; import type { HTMLAttributes } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class']; class?: HTMLAttributes['class'];
disabled?: boolean;
}>(); }>();
const model = defineModel<string | number | null>(); const model = defineModel<string | number | null>();
const showPassword = ref(false); const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef'); const inputRef = useTemplateRef('inputRef');
const { t } = useLocalization();
defineExpose({ defineExpose({
$el: inputRef, $el: inputRef,
@@ -28,7 +31,8 @@ defineExpose({
ref="inputRef" ref="inputRef"
v-model="model" v-model="model"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)" :class="cn('pr-12', props.class)"
:disabled="disabled"
v-bind="$attrs" v-bind="$attrs"
/> />
<button <button
@@ -36,11 +40,14 @@ defineExpose({
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
:class=" :class="
cn( cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]', 'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
) )
" "
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-label="
:tabindex="-1" t(showPassword ? 'auth.password.hide' : 'auth.password.show')
"
:aria-pressed="showPassword"
:disabled="disabled"
> >
<EyeOff v-if="showPassword" class="size-4" /> <EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" /> <Eye v-else class="size-4" />
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, Settings2 } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
const { projectItems, projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const settingsActive = computed(() =>
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
);
</script>
<template>
<div
v-if="projectItems.length"
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
>
<nav
:aria-label="t('navigation.project')"
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
>
<Link
v-for="item in projectItems"
:key="item.title"
:href="item.href"
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
:class="[
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
isCurrentUrl(item.href)
? 'border-brand text-primary'
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4"
aria-hidden="true"
/>{{ item.title }}
</Link>
</nav>
<DropdownMenu v-if="projectSettingsItems.length">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
:aria-label="t('navigation.projectSettings')"
:class="{
'bg-accent text-accent-foreground': settingsActive,
}"
>
<Settings2 aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.projectSettings') }}</span
><ChevronDown class="hidden sm:block" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="item in projectSettingsItems"
:key="item.title"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
>
<component :is="item.icon" aria-hidden="true" />{{
item.title
}}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+75 -27
View File
@@ -1,45 +1,93 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model'; import type { TicketTreeListProps } from '@/types/ticket-tree';
defineProps<{ defineProps<TicketTreeListProps>();
nodes: TicketTreeNodeModel[];
projectId: number; const { t } = useLocalization();
}>(); const collapsedTicketIds = reactive(new Set<number>());
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script> </script>
<template> <template>
<ul class="space-y-3"> <ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.id"> <li v-for="node in nodes" :key="node.id" class="min-w-0">
<div class="border-border rounded-xl border px-4 py-4"> <div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex justify-center pt-1">
<Link <button
:href=" v-if="node.children.length > 0"
showTicket({ type="button"
project: projectId, class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
ticket: node.id, :aria-expanded="!collapsedTicketIds.has(node.id)"
}) :aria-controls="`ticket-tree-children-${node.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
" "
class="text-foreground hover:text-primary font-medium transition" @click="toggleTicket(node.id)"
> >
{{ node.ticket_key }} <ChevronRight
</Link> v-if="collapsedTicketIds.has(node.id)"
class="size-4"
<Badge v-if="node.ticket_type_name" variant="secondary"> aria-hidden="true"
{{ node.ticket_type_name }} />
</Badge> <ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div> </div>
<div class="text-foreground mt-2 text-sm"> <Link
{{ node.title }} :href="
</div> showTicket({
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div> </div>
<div <div
v-if="node.children.length > 0" v-if="
class="border-border mt-3 border-l pl-4" node.children.length > 0 && !collapsedTicketIds.has(node.id)
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
> >
<TicketTreeList <TicketTreeList
:nodes="node.children" :nodes="node.children"
+12 -23
View File
@@ -1,33 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { User } from '@/types';
type Props = { withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
showEmail: false, showEmail: false,
}); });
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script> </script>
<template> <template>
<UserAvatar /> <div class="flex min-w-0 items-center gap-2">
<UserAvatar :user="user" />
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span> <span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{ <span
user.email v-if="showEmail"
}}</span> class="text-muted-foreground truncate text-xs"
>{{ user.email }}</span
>
</div>
</div> </div>
</template> </template>
+4 -2
View File
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes/index'; import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import type { User } from '@/types'; import type { User } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
user: User; user: User;
@@ -21,6 +22,7 @@ const handleLogout = () => {
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -34,7 +36,7 @@ defineProps<Props>();
<DropdownMenuItem :as-child="true"> <DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch> <Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" /> <Settings class="mr-2 h-4 w-4" />
Settings {{ t('navigation.settings') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@@ -48,7 +50,7 @@ defineProps<Props>();
data-test="logout-button" data-test="logout-button"
> >
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-2 h-4 w-4" />
Log out {{ t('navigation.logout') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
@@ -0,0 +1,320 @@
<script setup lang="ts">
import {
ClipboardCheck,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, useId, watch } from 'vue';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
canIndentAcceptanceCriterion,
indentAcceptanceCriterion,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(
defineProps<{
modelValue: AcceptanceCriteriaModel;
readonly?: boolean;
disabled?: boolean;
persistToggles?: boolean;
allowEditing?: boolean;
saving?: boolean;
dirty?: boolean;
error?: string;
}>(),
{
readonly: false,
disabled: false,
persistToggles: false,
allowEditing: true,
saving: undefined,
dirty: false,
error: '',
},
);
const emit = defineEmits<{
'update:modelValue': [items: AcceptanceCriteriaModel];
toggle: [index: number, checked: boolean];
}>();
const { t } = useLocalization();
const id = useId();
const root = ref<HTMLElement>();
const editing = ref(!props.persistToggles);
const completed = computed(
() => props.modelValue.filter((item) => item.checked).length,
);
const locked = computed(() => props.readonly || props.disabled);
const update = (items: AcceptanceCriteriaModel): void => {
if (!locked.value) {
emit('update:modelValue', items);
}
};
const focusRow = async (index: number): Promise<void> => {
await nextTick();
const inputs =
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
if (inputs?.[index]) {
inputs[index].focus();
} else {
root.value
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
?.focus();
}
};
const add = (): void => {
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
void focusRow(props.modelValue.length);
};
const remove = (index: number): void => {
update(removeAcceptanceCriterion(props.modelValue, index));
void focusRow(Math.max(0, index - 1));
};
watch(
() => props.modelValue,
async () => {
await nextTick();
root.value
?.querySelectorAll<HTMLTextAreaElement>('textarea')
.forEach((input) => {
input.setCustomValidity(
input.value.trim() === '' ? t('criteria.required') : '',
);
});
},
);
const changeText = (index: number, event: Event): void => {
const text = (event.target as HTMLTextAreaElement).value;
update(
props.modelValue.map((item, position) =>
position === index ? { ...item, text } : item,
),
);
};
const toggle = (index: number, event: Event): void => {
const target = event.target as HTMLInputElement;
const checked = target.checked;
target.checked = props.modelValue[index].checked;
if (locked.value) {
return;
}
if (props.persistToggles && !editing.value && !props.dirty) {
emit('toggle', index, checked);
} else {
update(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
checked,
),
);
}
};
const onKeydown = (index: number, event: KeyboardEvent): void => {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
event.preventDefault();
update(
indentAcceptanceCriterion(
props.modelValue,
index,
event.key === 'ArrowRight' ? 1 : -1,
),
);
};
</script>
<template>
<section
ref="root"
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
:aria-labelledby="`${id}-title`"
:aria-busy="disabled"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3
:id="`${id}-title`"
class="flex items-center gap-2 font-semibold"
>
<ClipboardCheck
class="text-primary size-5"
aria-hidden="true"
/>
{{ t('criteria.title') }}
</h3>
<span class="text-muted-foreground text-sm" aria-live="polite">{{
t('criteria.progress', { completed, total: modelValue.length })
}}</span>
<Button
v-if="persistToggles && allowEditing && !readonly"
type="button"
variant="outline"
:disabled="
disabled ||
(editing &&
modelValue.some((item) => item.text.trim() === ''))
"
:aria-pressed="editing"
@click="editing = !editing"
>
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
</Button>
</div>
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
{{ t('criteria.empty') }}
</p>
<ol v-else class="space-y-3">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `new-${index}`"
:data-indent="item.indent"
class="border-border min-w-0"
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
>
<div class="flex min-w-0 items-start gap-2">
<label
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
:class="{ 'cursor-not-allowed opacity-60': locked }"
>
<input
:checked="item.checked"
type="checkbox"
:disabled="locked"
:aria-label="
t('criteria.complete', {
number: index + 1,
text: item.text,
})
"
class="border-input accent-primary size-5 rounded"
@change="toggle(index, $event)"
/>
</label>
<div class="min-w-0 flex-1">
<textarea
v-if="editing && !readonly"
:value="item.text"
:aria-label="
t('criteria.item', { number: index + 1 })
"
:aria-describedby="`${id}-hint ${id}-error`"
:disabled="disabled"
:aria-invalid="Boolean(error)"
required
rows="2"
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
@input="changeText(index, $event)"
@keydown="onKeydown(index, $event)"
/>
<p
v-else
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
:class="{
'text-muted-foreground line-through':
item.checked,
}"
>
{{ item.text }}
</p>
<div
v-if="editing && !readonly"
class="flex flex-wrap gap-1"
>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled || item.indent === 0"
:aria-label="
t('criteria.outdent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
-1,
),
)
"
><IndentDecrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
disabled ||
!canIndentAcceptanceCriterion(
modelValue,
index,
)
"
:aria-label="
t('criteria.indent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
1,
),
)
"
><IndentIncrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled"
:aria-label="
t('criteria.remove', { number: index + 1 })
"
@click="remove(index)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</div>
</div>
</li>
</ol>
<template v-if="editing && !readonly">
<Button
data-add-criterion
type="button"
variant="outline"
:disabled="disabled"
@click="add"
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{ t('criteria.hint') }}
</p>
</template>
<p
v-if="persistToggles && dirty"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.unsaved') }}
</p>
<p
v-if="saving ?? disabled"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.saving') }}
</p>
<InputError :id="`${id}-error`" :message="error" role="alert" />
</section>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<article
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div
:class="
layout === 'list'
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
: ''
"
>
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
:class="[
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
:class="[
'border-t p-4',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div>
</div>
</article>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { ProjectTicketCreatePageProps } from '@/types';
const props = defineProps<
Pick<
ProjectTicketCreatePageProps,
| 'project'
| 'ticketTypes'
| 'ticketPriorities'
| 'users'
| 'parentTicketOptions'
> & {
errors: Record<string, string>;
disabled?: boolean;
autofocus?: boolean;
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
}
>();
const title = defineModel<string>('title', { required: true });
const description = defineModel<string>('description', { required: true });
const type = defineModel<string | number>('type', { required: true });
const priority = defineModel<string | number>('priority', { required: true });
const assignee = defineModel<string | number>('assignee', { required: true });
const parent = defineModel<string | number>('parent', { required: true });
const points = defineModel<string | number | null>('points', {
required: true,
});
const status = defineModel<string | number>('status');
const { t } = useLocalization();
const typeOptions = computed(() =>
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
);
const priorityOptions = computed(() =>
props.ticketPriorities.map((item) => ({
value: item.id,
label: item.name,
})),
);
const userOptions = computed(() => [
{ value: '', label: t('tickets.unassigned') },
...props.users.map((item) => ({ value: item.id, label: item.name })),
]);
const parentOptions = computed(() => [
{ value: '', label: t('ticketDetail.noParent') },
...props.parentTicketOptions.map((item) => ({
value: item.id,
label: `${item.ticket_key} - ${item.title}`,
})),
]);
const statusOptions = computed(
() =>
props.ticketStatusOptions?.map((item) => ({
value: item.id,
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
})) ?? [],
);
</script>
<template>
<div class="grid min-w-0 gap-6">
<TicketFormField
id="title"
v-model="title"
:label="t('ticketDetail.title')"
:error="errors.title"
:disabled="disabled"
:autofocus="autofocus"
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
:placeholder="t('ticketForm.descriptionPlaceholder')"
/>
<slot name="content" />
<fieldset
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
:disabled="disabled"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.metadata') }}
</legend>
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
<TicketFormField
id="ticket_type_id"
v-model="type"
kind="select"
:label="t('ticketDetail.type')"
:options="typeOptions"
:error="errors.ticket_type_id"
:disabled="disabled"
required
/>
<TicketFormField
id="ticket_priority_id"
v-model="priority"
kind="select"
:label="t('ticketDetail.priority')"
:options="priorityOptions"
:error="errors.ticket_priority_id"
:disabled="disabled"
required
/>
<TicketFormField
v-if="ticketStatusOptions && status !== undefined"
id="ticket_status_id"
v-model="status"
kind="select"
:label="t('ticketDetail.status')"
:options="statusOptions"
:error="errors.ticket_status_id"
:disabled="disabled"
required
/>
<TicketFormField
id="assignee_user_id"
v-model="assignee"
kind="select"
:label="t('ticketDetail.assignee')"
:options="userOptions"
:error="errors.assignee_user_id"
:disabled="disabled"
/>
<TicketFormField
id="parent_ticket_id"
v-model="parent"
kind="select"
:label="t('ticketDetail.parent')"
:options="parentOptions"
:error="errors.parent_ticket_id"
:disabled="disabled"
/>
<TicketFormField
v-if="project.show_story_points"
id="story_points"
v-model="points"
kind="number"
:label="t('ticketDetail.storyPoints')"
:error="errors.story_points"
:disabled="disabled"
:min="0"
step="1"
/>
<slot name="metadata" />
</div>
</fieldset>
</div>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<li
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
>
{{ ticket.title }}
</h3>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
>
<slot name="actions" />
</div>
</div>
</li>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketModel } from '@/types/models/ticket-model';
const props = withDefaults(
defineProps<{
showStatus?: boolean;
ticket: TicketModel;
}>(),
{
showStatus: true,
},
);
const { t } = useLocalization();
const statusLabel = computed(() =>
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
);
</script>
<template>
<div class="gap-stack flex min-w-0 flex-wrap items-center">
<Badge
v-if="ticket.is_blocked"
variant="warning"
:aria-label="t('tickets.blocked')"
>
<OctagonAlert aria-hidden="true" />
{{ t('tickets.blocked') }}
</Badge>
<Badge
variant="secondary"
:aria-label="
t('tickets.typeLabel', { name: ticket.ticket_type.name })
"
:data-ticket-type-id="ticket.ticket_type.id"
>
{{ ticket.ticket_type.name }}
</Badge>
<Badge
variant="outline"
:aria-label="
t('tickets.priorityLabel', {
name: ticket.ticket_priority.name,
})
"
:data-ticket-priority-id="ticket.ticket_priority.id"
>
{{ ticket.ticket_priority.name }}
</Badge>
<Badge
v-if="showStatus"
variant="outline"
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
:data-ticket-status-id="ticket.ticket_status?.id"
>
{{ statusLabel }}
</Badge>
<span
v-if="ticket.assignee"
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
:aria-label="
t('tickets.assignedTo', { name: ticket.assignee.name })
"
>
<UserAvatar
:user="ticket.assignee"
size="sm"
:label="ticket.assignee.name"
/>
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
</span>
<span
v-else
class="text-caption text-muted-foreground flex items-center gap-1.5"
>
<CircleUserRound class="size-4" aria-hidden="true" />
{{ t('tickets.unassigned') }}
</span>
</div>
</template>
@@ -0,0 +1,518 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import {
normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailContentProps>();
const { t } = useLocalization();
const editor = useTemplateRef<HTMLDetailsElement>('editor');
async function editParent(): Promise<void> {
if (!props.canEditTicket || !editor.value) {
return;
}
editor.value.open = true;
await nextTick();
editor.value
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
?.focus();
}
defineExpose({ editParent });
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
`${draftKey}:criteria`,
);
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({
acceptance_criteria: normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
const currentFieldValue = props.ticket.field_values?.find(
(fieldValue) =>
fieldValue.ticket_field_definition_id ===
ticketFieldDefinition.id,
);
return {
ticket_field_definition_id: ticketFieldDefinition.id,
value_number: currentFieldValue?.value_number ?? '',
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
story_points: props.ticket.story_points,
});
const form = useForm(draftKey, savedFormData());
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
JSON.stringify(acceptanceCriteriaItems.value) !==
JSON.stringify(savedAcceptanceCriteria.value),
);
const isDirty = computed(
() =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
);
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const reset = (): void => {
if (
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
!protection.confirmLeave()
) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.'),
)?.[1] ??
''
);
});
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
acceptance_criteria: acceptanceCriteria,
assignee_user_id: props.ticket.assignee_user_id,
author_user_id: props.ticket.author_user_id,
blocked_comment: props.ticket.blocked_comment,
description: props.ticket.description,
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
value_number: fieldValue.value_number,
value_user_id: fieldValue.value_user_id,
})),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id,
story_points: props.ticket.story_points,
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
});
const persistAcceptanceCriteriaToggle = async (
index: number,
checked: boolean,
): Promise<void> => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value
) {
return;
}
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
savedAcceptanceCriteria.value,
index,
checked,
);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
isPersistingAcceptanceCriteriaToggle.value = true;
acceptanceCriteriaToggleError.value = '';
try {
const response = await fetch(
ProjectTicketController.update.url({
project: props.project.id,
ticket: props.ticket.id,
}),
{
method: 'PATCH',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken ?? '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(
buildSavedTicketPayload(nextAcceptanceCriteria),
),
},
);
if (!response.ok) {
throw new Error('Checklist toggle update failed.');
}
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
nextAcceptanceCriteria,
);
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError');
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const submit = (): void => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value
) {
return;
}
form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
onSuccess: () => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
form.defaults();
},
},
);
};
</script>
<template>
<div class="flex min-w-0 flex-col gap-6">
<section
id="ticket-description"
aria-labelledby="ticket-description-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-description-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.description') }}
</h2></CardHeader
>
<CardContent>
<p
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
:class="{
'text-muted-foreground': !ticket.description,
}"
>
{{
ticket.description ||
t('ticketDetail.noDescription')
}}
</p>
</CardContent>
</Card>
</section>
<div id="ticket-checklist" class="scroll-mt-6">
<AcceptanceCriteriaChecklist
:model-value="savedAcceptanceCriteria"
:readonly="!canEditTicket"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
:saving="
form.processing || isPersistingAcceptanceCriteriaToggle
"
:allow-editing="false"
persist-toggles
:dirty="hasUnsavedAcceptanceCriteriaDraft"
:error="acceptanceCriteriaToggleError"
@toggle="persistAcceptanceCriteriaToggle"
/>
</div>
<details
v-if="canEditTicket"
id="ticket-edit"
ref="editor"
class="bg-card group rounded-xl border shadow-sm"
>
<summary
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
>
{{ t('ticketDetail.edit') }}
</summary>
<div class="border-t p-4 sm:p-6">
<p class="text-muted-foreground mb-6 text-sm">
{{ t('ticketDetail.editDescription') }}
</p>
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<TicketFormFields
v-model:title="form.title"
v-model:description="form.description"
v-model:type="form.ticket_type_id"
v-model:priority="form.ticket_priority_id"
v-model:assignee="form.assignee_user_id"
v-model:parent="form.parent_ticket_id"
v-model:points="form.story_points"
:project="project"
:ticket-types="ticketTypes"
:ticket-priorities="ticketPriorities"
:users="users"
:parent-ticket-options="parentTicketOptions"
:errors="form.errors"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<template #content>
<AcceptanceCriteriaChecklist
v-model="acceptanceCriteriaItems"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
:error="acceptanceCriteriaError"
/>
</template>
<template #metadata>
<TicketFormField
v-if="canViewAuthor"
id="author_user_id"
v-model="form.author_user_id"
kind="select"
:label="t('ticketDetail.author')"
:hint="
!canEditAuthor
? t('ticketForm.authorRestricted')
: undefined
"
:options="[
{
value: '',
label: t('ticketDetail.noAuthor'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="form.errors.author_user_id"
:disabled="!canEditAuthor || form.processing"
/>
</template>
</TicketFormFields>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketForm.blocker') }}
</legend>
<div class="min-h-control flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="is_blocked-error"
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
/>
<Label for="is_blocked">{{
t('ticketDetail.blocked')
}}</Label>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:required="form.is_blocked"
/>
</fieldset>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.customFields') }}
</legend>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.customFieldsDescription') }}
</p>
<InputError :message="form.errors.field_values" />
<p
v-if="!ticketFieldDefinitions.length"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.noCustomFields') }}
</p>
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
<template
v-for="(
definition, index
) in ticketFieldDefinitions"
:key="definition.id"
>
<TicketFormField
v-if="definition.field_type === 'user'"
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_user_id
"
kind="select"
:label="definition.label"
:options="[
{
value: '',
label: t('tickets.unassigned'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="
form.errors[
`field_values.${index}.value_user_id`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
<TicketFormField
v-else
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_number
"
kind="number"
step="0.1"
:label="definition.label"
:error="
form.errors[
`field_values.${index}.value_number`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
</template>
</div>
</fieldset>
<div
class="flex flex-wrap items-center gap-3 border-t pt-5"
>
<Button
type="submit"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>{{
form.processing
? t('ticketForm.saving')
: t('ticketDetail.saveChanges')
}}</Button
>
<Button
type="button"
variant="outline"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
!isDirty
"
@click="reset"
>{{ t('ticketForm.reset') }}</Button
>
<p role="status" class="text-muted-foreground text-sm">
{{
isDirty
? t('ticketForm.unsaved')
: t('ticketForm.saved')
}}
</p>
</div>
</form>
</div>
</details>
</div>
</template>
@@ -0,0 +1,658 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Link, useForm, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type {
TicketDetailGitProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailGitProps>();
const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() =>
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
(
[isOpen, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchProtection.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
</script>
<template>
<section
id="ticket-git"
aria-labelledby="ticket-git-title"
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
>
<h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }}
</h2>
<div class="grid min-w-0 gap-6">
<Card class="h-fit">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.branchesDescription')
}}</CardDescription>
</div>
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />{{
t('ticketDetail.createBranch')
}}</Button
>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="branchProtection.state.value"
@retry="submitBranchCreation"
/>
<div class="grid min-w-0 gap-2">
<Label for="repository_connection_id">{{
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(repositoryConnection.id)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="branchCreationForm.branch_name"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div class="flex items-start gap-3">
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !== null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t(
'ticketDetail.checkingBranch',
)
: (branchNameValidation?.message ??
t(
'ticketDetail.selectRepository',
))
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="border-border rounded-xl border border-dashed px-4 py-4"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="updateBranchDialogOpen(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>{{
t('ticketDetail.createNow')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryBranches.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="repositoryBranch.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryBranch.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectBranches') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.pullRequestsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryPullRequests.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="
repositoryPullRequest.repository_connection.id
"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryPullRequest.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectPullRequests') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 rounded-xl border px-4 py-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="text-foreground hover:text-primary font-medium transition"
>
{{ pullRequest.title }}
</a>
</div>
<p
class="text-muted-foreground mt-2 text-sm"
>
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { show } from '@/routes/projects';
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
defineProps<TicketDetailHeaderProps>();
const { t } = useLocalization();
const sections = [
'description',
'checklist',
'metadata',
'relationships',
'history',
'git',
] as const;
</script>
<template>
<header class="flex min-w-0 flex-col gap-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<Button as-child variant="ghost" class="max-w-full"
><Link :href="show(project.id)"
><ArrowLeft aria-hidden="true" />{{
t('ticketDetail.backToProject')
}}</Link
></Button
>
<div class="flex flex-wrap gap-2">
<Button as-child variant="outline"
><Link :href="backlogIndex(project.id)"
><ListTree aria-hidden="true" />{{
t('ticketDetail.openBacklog')
}}</Link
></Button
>
<Button as-child variant="outline"
><Link :href="boardIndex(project.id)"
><Columns3 aria-hidden="true" />{{
t('ticketDetail.openBoard')
}}</Link
></Button
>
</div>
</div>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-primary font-mono text-sm font-semibold">{{
ticket.ticket_key
}}</span
><Badge v-if="!canEditTicket" variant="outline">{{
t('ticketDetail.readOnly')
}}</Badge>
</div>
<h1
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
>
{{ ticket.title }}
</h1>
<TicketMetadata :ticket="ticket" />
</div>
<nav
:aria-label="t('ticketDetail.sections')"
class="flex flex-wrap gap-1 border-b pb-3"
>
<a
v-for="section in sections"
:key="section"
:href="`#ticket-${section}`"
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
>{{ t(`ticketDetail.${section}`) }}</a
>
</nav>
</header>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization();
const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
</script>
<template>
<section
id="ticket-history"
aria-labelledby="ticket-history-title"
class="wrap-anywhere min-w-0 scroll-mt-6"
>
<Card class="h-fit">
<CardHeader>
<h2 id="ticket-history-title" class="text-lg font-semibold">
{{ t('ticketDetail.history') }}
</h2>
<CardDescription>{{
t('ticketDetail.historyDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
{{ t('ticketDetail.noHistory') }}
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span class="text-foreground text-sm font-medium">
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
</span>
</div>
<p class="text-foreground mt-3 text-sm">
{{ worklogEntry.summary }}
</p>
<p class="text-muted-foreground mt-2 text-xs">
{{ formatDateTime(worklogEntry.created_at) }}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailMetadataProps>();
const { t, date, number } = useLocalization();
const fields = computed(() =>
props.ticketFieldDefinitions.map((definition) => {
const value = props.ticket.field_values?.find(
(field) => field.ticket_field_definition_id === definition.id,
);
return {
id: definition.id,
label: definition.label,
value:
definition.field_type === 'user'
? (value?.user_value?.name ?? t('tickets.unassigned'))
: value?.value_number != null
? number(value.value_number)
: t('ticketDetail.notSet'),
};
}),
);
</script>
<template>
<section
id="ticket-metadata"
aria-labelledby="ticket-metadata-title"
class="min-w-0 scroll-mt-6"
>
<Card class="bg-muted/30">
<CardHeader
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
{{ t('ticketDetail.metadata') }}
</h2></CardHeader
>
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
<dl
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.status') }}
</dt>
<dd>
{{
ticketStatusLabel(
ticket.ticket_status,
t('tickets.noStatus'),
)
}}
</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.type') }}
</dt>
<dd>{{ ticket.ticket_type.name }}</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.priority') }}
</dt>
<dd>
<Badge variant="outline">{{
ticket.ticket_priority.name
}}</Badge>
</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.assignee') }}
</dt>
<dd class="flex items-center gap-2">
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
size="sm"
/>{{
ticket.assignee?.name ?? t('tickets.unassigned')
}}
</dd>
</div>
<div
v-if="project.show_author || canManageProject"
class="space-y-2"
>
<dt class="text-muted-foreground">
{{ t('ticketDetail.author') }}
</dt>
<dd class="flex items-center gap-2">
<UserAvatar
v-if="ticket.author"
:user="ticket.author"
size="sm"
/>{{
ticket.author?.name ??
t('ticketDetail.noAuthor')
}}
</dd>
</div>
<div v-if="project.show_story_points" class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.storyPoints') }}
</dt>
<dd>
{{
ticket.story_points != null
? number(ticket.story_points)
: t('ticketDetail.notSet')
}}
</dd>
</div>
<div
v-for="field in fields"
:key="field.id"
class="space-y-2"
>
<dt class="text-muted-foreground">{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</div>
</dl>
<div
v-if="ticket.is_blocked"
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
>
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
<p class="whitespace-pre-wrap">
{{ ticket.blocked_comment }}
</p>
</div>
<dl
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
>
<div class="space-y-1">
<dt>{{ t('ticketDetail.created') }}</dt>
<dd>
<time :datetime="ticket.created_at">{{
date(new Date(ticket.created_at), {
dateStyle: 'medium',
timeStyle: 'short',
})
}}</time>
</dd>
</div>
<div class="space-y-1">
<dt>{{ t('ticketDetail.updated') }}</dt>
<dd>
<time :datetime="ticket.updated_at">{{
date(new Date(ticket.updated_at), {
dateStyle: 'medium',
timeStyle: 'short',
})
}}</time>
</dd>
</div>
</dl>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
import { computed, nextTick, useTemplateRef } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>();
const { t } = useLocalization();
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
const currentParentTicket = computed(
() =>
props.ticket.parent_ticket ??
props.parentTicketOptions.find(
(parent) => parent.id === props.ticket.parent_ticket_id,
) ??
null,
);
const canAddLink = computed(
() =>
props.canEditTicket &&
props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0,
);
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
linked_ticket_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty,
});
const selectedType = computed(() =>
props.ticketLinkTypes.find(
(type) => String(type.id) === String(form.ticket_link_type_id),
),
);
const selectedTicket = computed(() =>
props.ticketLinkOptions.find(
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
),
);
async function focusLinks(): Promise<void> {
await nextTick();
linksHeading.value?.focus();
}
function submit(): void {
if (!canAddLink.value) {
return;
}
protection.submit(
store({ project: props.project.id, ticket: props.ticket.id }),
{
preserveScroll: true,
onSuccess: () => {
form.linked_ticket_id = '';
form.clearErrors();
void focusLinks();
},
},
);
}
</script>
<template>
<section
id="ticket-relationships"
aria-labelledby="ticket-relationships-title"
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
>
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
{{ t('ticketDetail.relationships') }}
</h2>
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader
class="flex flex-wrap items-center justify-between gap-3"
>
<h3 class="flex items-center gap-2 font-semibold">
<GitFork class="text-primary size-5" aria-hidden="true" />{{
t('ticketRelationships.hierarchy')
}}
</h3>
<Button
v-if="canEditTicket"
type="button"
variant="outline"
size="sm"
@click="$emit('editParent')"
>{{ t('ticketRelationships.editParent') }}</Button
>
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<div class="grid min-w-0 gap-2">
<h4
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
>
{{ t('ticketDetail.parent') }}
</h4>
<Link
v-if="currentParentTicket"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
>
<span
class="text-primary font-mono text-xs font-semibold"
>{{ currentParentTicket.ticket_key }}</span
>
<span
class="group-hover:text-primary text-sm font-medium"
>{{ currentParentTicket.title }}</span
>
</Link>
<p
v-else
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noParent') }}
</p>
<p
v-if="canEditTicket"
class="text-muted-foreground text-xs"
>
{{ t('ticketRelationships.parentHelp') }}
</p>
</div>
<div
v-if="ticket.ticket_type?.is_parent"
class="grid min-w-0 gap-3 border-t pt-5"
>
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold">
{{ t('ticketDetail.children') }}
</h4>
<Badge variant="secondary">{{
childTicketTree.length
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.childrenHelp') }}
</p>
<p
v-if="childTicketTree.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noChildren') }}
</p>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</div>
</CardContent>
</Card>
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader>
<h3
ref="linksHeading"
tabindex="-1"
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
>
<Link2 class="text-primary size-5" aria-hidden="true" />{{
t('ticketDetail.linkedTickets')
}}<Badge variant="secondary">{{
linkedTickets.length
}}</Badge>
</h3>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.linkedTicketsDescription') }}
</p>
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<p
v-if="linkedTickets.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noLinks') }}
</p>
<ul v-else class="grid min-w-0 gap-3">
<TicketRelationshipLink
v-for="link in linkedTickets"
:key="link.id"
:link="link"
:project-id="project.id"
:ticket-id="ticket.id"
:ticket-key="ticket.ticket_key"
:can-edit="canEditTicket"
@removed="focusLinks"
/>
</ul>
<form
v-if="canAddLink"
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
@submit.prevent="submit"
>
<h4 class="flex items-center gap-2 text-sm font-semibold">
<Plus class="size-4" aria-hidden="true" />{{
t('ticketRelationships.addLink')
}}
</h4>
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
>
<TicketFormField
id="ticket_link_type_id"
v-model="form.ticket_link_type_id"
kind="select"
:label="t('ticketDetail.linkType')"
:options="
ticketLinkTypes.map((type) => ({
value: type.id,
label: type.label,
}))
"
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="linked_ticket_id"
v-model="form.linked_ticket_id"
kind="select"
:label="t('ticketDetail.linkedTicket')"
:options="[
{
value: '',
label: t(
'ticketRelationships.selectTicket',
),
},
...ticketLinkOptions.map((ticket) => ({
value: ticket.id,
label: `${ticket.ticket_key} — ${ticket.title}`,
})),
]"
:error="form.errors.linked_ticket_id"
:disabled="form.processing"
required
/>
</fieldset>
<p
v-if="selectedType && selectedTicket"
aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-mono text-xs">{{
ticket.ticket_key
}}</span
><span>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
>
</p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button
type="submit"
:disabled="form.processing || !selectedTicket"
class="sm:justify-self-start"
>{{
form.processing
? t('forms.saving')
: t('ticketDetail.saveLink')
}}</Button
>
</form>
<p
v-else-if="canEditTicket"
class="text-muted-foreground text-sm"
>
{{
t(
ticketLinkTypes.length === 0
? 'ticketRelationships.noTypes'
: 'ticketRelationships.noTargets',
)
}}
</p>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show } from '@/routes/projects/tickets';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
const props = defineProps<{
link: TicketLinkModel;
projectId: number;
ticketId: number;
ticketKey: string;
canEdit: boolean;
}>();
const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({});
const protection = useFormProtection(form);
function remove(): void {
if (!props.canEdit) {
return;
}
protection.submit(
destroy({
project: props.projectId,
ticket: props.ticketId,
ticketLink: props.link.id,
}),
{
preserveScroll: true,
onSuccess: () => emit('removed'),
},
);
}
</script>
<template>
<li
class="grid min-w-0 gap-3 rounded-xl border p-4"
:data-ticket-link="link.id"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{
ticketKey
}}</span>
<span class="text-sm font-medium">{{
link.ticket_link_type.label
}}</span>
</div>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<Link
:href="
show({ project: projectId, ticket: link.linked_ticket.id })
"
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
>
<span class="text-primary font-mono text-xs font-semibold">{{
link.linked_ticket.ticket_key
}}</span>
<span class="wrap-anywhere text-sm font-medium">{{
link.linked_ticket.title
}}</span>
</Link>
<form v-if="canEdit" @submit.prevent="remove">
<Button
type="submit"
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-destructive"
:aria-label="
t('ticketRelationships.removeLink', {
key: link.linked_ticket.ticket_key,
})
"
:disabled="form.processing"
><Unlink class="size-4" aria-hidden="true" />{{
t('ticketDetail.removeLink')
}}</Button
>
</form>
</div>
<FormFeedback
v-if="canEdit"
:state="protection.state.value"
@retry="remove"
/>
</li>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as TicketCard } from './TicketCard.vue';
export { default as TicketListItem } from './TicketListItem.vue';
@@ -1,53 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue" import DialogOverlay from "./DialogOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), { const props = withDefaults(
showCloseButton: true, defineProps<
}) DialogContentProps & {
const emits = defineEmits<DialogContentEmits>() class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true,
},
);
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" @close-auto-focus="restoreFocus"
cn( :class="
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', cn(
props.class, 'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
)" props.class,
> )
<slot /> "
>
<slot />
<DialogClose <DialogClose
v-if="showCloseButton" v-if="showCloseButton"
data-slot="dialog-close" data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<X /> <X />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>
@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="dialog-header" data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)" :class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
> >
<slot /> <slot />
</div> </div>
</template> </template>
@@ -1,59 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DialogContentEmits>() DialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
> >
<X class="w-4 h-4" /> <DialogContent
<span class="sr-only">Close</span> :class="
</DialogClose> cn(
</DialogContent> 'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
</DialogOverlay> props.class,
</DialogPortal> )
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<X class="w-4 h-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template> </template>
@@ -1,39 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuCheckboxItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuCheckboxItemProps,
import { Check } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuCheckboxItemEmits>() DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded" v-bind="forwarded"
:class=" cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class,
> )
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> "
<DropdownMenuItemIndicator> >
<slot name="indicator-icon"> <span
<Check class="size-4" /> class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
</slot> >
</DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
</span> <slot name="indicator-icon">
<slot /> <Check class="size-4" />
</DropdownMenuCheckboxItem> </slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template> </template>
@@ -1,39 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuPortal, DropdownMenuPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults( const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<
{ DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
sideOffset: 4, >(),
}, {
) sideOffset: 4,
const emits = defineEmits<DropdownMenuContentEmits>() },
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)" :class="
> cn(
<slot /> 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
</DropdownMenuContent> props.class,
</DropdownMenuPortal> )
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template> </template>
@@ -1,31 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui" import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui" import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<DropdownMenuItemProps & { const props = withDefaults(
class?: HTMLAttributes["class"] defineProps<
inset?: boolean DropdownMenuItemProps & {
variant?: "default" | "destructive" class?: HTMLAttributes["class"];
}>(), { inset?: boolean;
variant: "default", variant?: "default" | "destructive";
}) }
>(),
{
variant: "default",
},
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class") const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuItem <DropdownMenuItem
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined" :data-inset="inset ? '' : undefined"
:data-variant="variant" :data-variant="variant"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)" :class="
> cn(
<slot /> 'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
</DropdownMenuItem> props.class,
)
"
>
<slot />
</DropdownMenuItem>
</template> </template>
@@ -1,40 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuRadioItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuRadioItemProps,
import { Circle } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import { import {
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
DropdownMenuRadioItem, DropdownMenuRadioItem,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>() const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuRadioItem <DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class,
> )
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> "
<DropdownMenuItemIndicator> >
<slot name="indicator-icon"> <span
<Circle class="size-2 fill-current" /> class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
</slot> >
</DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
</span> <slot name="indicator-icon">
<slot /> <Circle class="size-2 fill-current" />
</DropdownMenuRadioItem> </slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template> </template>
@@ -1,27 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuSubContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuSubContentProps,
import { } from "reka-ui";
DropdownMenuSubContent, import type { HTMLAttributes } from "vue";
useForwardPropsEmits, import { reactiveOmit } from "@vueuse/core";
} from "reka-ui" import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuSubContentEmits>() DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuSubContent <DropdownMenuSubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
v-bind="forwarded" v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)" :class="
> cn(
<slot /> 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
</DropdownMenuSubContent> props.class,
)
"
>
<slot />
</DropdownMenuSubContent>
</template> </template>
@@ -1,30 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui" import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next" import { ChevronRight } from "lucide-vue-next";
import { import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
DropdownMenuSubTrigger, import { cn } from "@/lib/utils";
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>() const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset") const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
)" props.class,
> )
<slot /> "
<ChevronRight class="ml-auto size-4" /> >
</DropdownMenuSubTrigger> <slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template> </template>
@@ -1,62 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue" import SheetOverlay from "./SheetOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
interface SheetContentProps extends DialogContentProps { interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
} }
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SheetContentProps>(), { const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right", side: "right",
}) });
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side") const delegatedProps = reactiveOmit(props, "class", "side");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<SheetOverlay /> <SheetOverlay />
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:class="cn( :class="
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', cn(
side === 'right' 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', side === 'right' &&
side === 'left' 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', side === 'left' &&
side === 'top' 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', side === 'top' &&
side === 'bottom' 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', side === 'bottom' &&
props.class)" 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
v-bind="{ ...$attrs, ...forwarded }" props.class,
> )
<slot /> "
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
>
<slot />
<DialogClose <DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
> >
<X class="size-4" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>
+104 -82
View File
@@ -1,96 +1,118 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SidebarProps } from "." import type { SidebarProps } from ".";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from "@/components/ui/sheet";
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue' import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue' import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue' import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils" import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarProps>(), { const props = withDefaults(defineProps<SidebarProps>(), {
side: "left", side: "left",
variant: "sidebar", variant: "sidebar",
collapsible: "offcanvas", collapsible: "offcanvas",
}) });
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useLocalization();
function restoreTriggerFocus(event: Event) {
event.preventDefault();
document
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
?.focus();
}
</script> </script>
<template> <template>
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div <div
:class="cn( v-if="collapsible === 'none'"
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', data-slot="sidebar"
'group-data-[collapsible=offcanvas]:w-0', :class="
'group-data-[side=right]:rotate-180', cn(
variant === 'floating' || variant === 'inset' 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' props.class,
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', )
)" "
/> v-bind="$attrs"
<div
:class="cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
> >
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot /> <slot />
</div>
</div> </div>
</div>
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
<SheetContent
v-bind="$attrs"
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
@close-auto-focus="restoreTriggerFocus"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
<SheetDescription>{{
t("navigation.mobileDescription")
}}</SheetDescription>
</SheetHeader>
<div class="flex min-h-0 h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)
"
/>
<div
:class="
cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)
"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
</div>
</div>
</template> </template>
@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)" :class="
> cn(
<slot /> 'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
</div> props.class,
)
"
>
<slot />
</div>
</template> </template>
@@ -1,21 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
:class="cn( :class="
'bg-background relative flex w-full flex-1 flex-col', cn(
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', 'bg-background relative flex min-w-0 w-full flex-1 flex-col',
props.class, 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
)" props.class,
> )
<slot /> "
</main> >
<slot />
</main>
</template> </template>
@@ -1,48 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from "vue" import type { Component } from "vue";
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue" import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import {
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue" Tooltip,
import { useSidebar } from "./utils" TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
import { useSidebar } from "./utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarMenuButtonProps & { const props = withDefaults(
tooltip?: string | Component defineProps<
}>(), { SidebarMenuButtonProps & {
as: "button", tooltip?: string | Component;
variant: "default", }
size: "default", >(),
}) {
as: "button",
variant: "default",
size: "default",
},
);
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const delegatedProps = reactiveOmit(props, "tooltip") const delegatedProps = reactiveOmit(props, "tooltip");
</script> </script>
<template> <template>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }"> <SidebarMenuButtonChild
<slot /> v-if="!tooltip || isMobile || state !== 'collapsed'"
</SidebarMenuButtonChild> v-bind="{ ...delegatedProps, ...$attrs }"
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
> >
<template v-if="typeof tooltip === 'string'"> <slot />
{{ tooltip }} </SidebarMenuButtonChild>
</template>
<component :is="tooltip" v-else /> <Tooltip v-else>
</TooltipContent> <TooltipTrigger as-child>
</Tooltip> <SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template> </template>
@@ -1,82 +1,112 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue" import type { HTMLAttributes, Ref } from "vue";
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core" import {
import { TooltipProvider } from "reka-ui" defaultDocument,
import { computed, ref } from "vue" useEventListener,
import { cn } from "@/lib/utils" useMediaQuery,
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils" useVModel,
} from "@vueuse/core";
import { TooltipProvider } from "reka-ui";
import { computed, ref, watch } from "vue";
import { cn } from "@/lib/utils";
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./utils";
const props = withDefaults(defineProps<{ const props = withDefaults(
defaultOpen?: boolean defineProps<{
open?: boolean defaultOpen?: boolean;
class?: HTMLAttributes["class"] open?: boolean;
}>(), { class?: HTMLAttributes["class"];
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`), }>(),
open: undefined, {
}) defaultOpen: !defaultDocument?.cookie.includes(
`${SIDEBAR_COOKIE_NAME}=false`,
),
open: undefined,
},
);
const emits = defineEmits<{ const emits = defineEmits<{
"update:open": [open: boolean] "update:open": [open: boolean];
}>() }>();
const isMobile = useMediaQuery("(max-width: 768px)") const isMobile = useMediaQuery("(width < 768px)");
const openMobile = ref(false) const openMobile = ref(false);
watch(isMobile, () => {
openMobile.value = false;
});
const open = useVModel(props, "open", emits, { const open = useVModel(props, "open", emits, {
defaultValue: props.defaultOpen ?? false, defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false, passive: (props.open === undefined) as false,
}) as Ref<boolean> }) as Ref<boolean>;
function setOpen(value: boolean) { function setOpen(value: boolean) {
open.value = value // emits('update:open', value) open.value = value; // emits('update:open', value)
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
} }
function setOpenMobile(value: boolean) { function setOpenMobile(value: boolean) {
openMobile.value = value openMobile.value = value;
} }
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
function toggleSidebar() { function toggleSidebar() {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value) return isMobile.value
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener("keydown", (event: KeyboardEvent) => { useEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { if (
event.preventDefault() event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
toggleSidebar() (event.metaKey || event.ctrlKey)
} ) {
}) event.preventDefault();
toggleSidebar();
}
});
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => open.value ? "expanded" : "collapsed") const state = computed(() => (open.value ? "expanded" : "collapsed"));
provideSidebarContext({ provideSidebarContext({
state, state,
open, open,
setOpen, setOpen,
isMobile, isMobile,
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}) });
</script> </script>
<template> <template>
<TooltipProvider :delay-duration="0"> <TooltipProvider :delay-duration="0">
<div <div
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
:style="{ :style="{
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)" :class="
v-bind="$attrs" cn(
> 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
<slot /> props.class,
</div> )
</TooltipProvider> "
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
</template> </template>
@@ -1,28 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next" import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { useSidebar } from "./utils" import { useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
const { isMobile, state, toggleSidebar } = useSidebar() const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Button <Button
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="cn('h-7 w-7', props.class)" type="button"
@click="toggleSidebar" aria-controls="app-navigation"
> :aria-expanded="isMobile ? openMobile : state === 'expanded'"
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" /> :aria-label="
<PanelLeftClose v-else /> isMobile
<span class="sr-only">Toggle sidebar</span> ? t(openMobile ? 'navigation.close' : 'navigation.open')
</Button> : t(
state === 'collapsed'
? 'navigation.expand'
: 'navigation.collapse',
)
"
:class="cn('size-control', props.class)"
@click="toggleSidebar"
>
<PanelLeftOpen
v-if="isMobile || state === 'collapsed'"
aria-hidden="true"
/>
<PanelLeftClose v-else aria-hidden="true" />
</Button>
</template> </template>
+11 -10
View File
@@ -1,8 +1,8 @@
import { createContext } from "reka-ui" import { createContext } from "reka-ui";
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from "vue";
export type SidebarContext = { export type SidebarContext = {
state: ComputedRef<'expanded' | 'collapsed'>; state: ComputedRef<"expanded" | "collapsed">;
open: Ref<boolean>; open: Ref<boolean>;
setOpen: (value: boolean) => void; setOpen: (value: boolean) => void;
isMobile: Ref<boolean>; isMobile: Ref<boolean>;
@@ -11,11 +11,12 @@ export type SidebarContext = {
toggleSidebar: () => void; toggleSidebar: () => void;
}; };
export const SIDEBAR_COOKIE_NAME = "sidebar_state" export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem" export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem" export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem" export const SIDEBAR_WIDTH_ICON = "4rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b" export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar") export const [useSidebar, provideSidebarContext] =
createContext<SidebarContext>("Sidebar");
@@ -0,0 +1,25 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import {
projectNavigation,
projectSettingsNavigation,
workspaceNavigation,
} from '@/lib/navigation';
export function useAppNavigation() {
const page = usePage();
const { t } = useLocalization();
const project = computed(() => page.props.navigation?.project ?? null);
return {
project,
workspaceItems: computed(() =>
workspaceNavigation(page.props.auth.permissions, t),
),
projectItems: computed(() => projectNavigation(project.value, t)),
projectSettingsItems: computed(() =>
projectSettingsNavigation(project.value, t),
),
};
}
+154
View File
@@ -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 };
}
+14 -32
View File
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue'; import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue'; import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { matchesNavigationUrl } from '@/lib/navigation';
export type UseCurrentUrlReturn = { export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>; currentUrl: DeepReadonly<ComputedRef<string>>;
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
) => T | F; ) => T | F;
}; };
const page = usePage();
const currentUrlReactive = computed(
() =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn { export function useCurrentUrl(): UseCurrentUrlReturn {
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, 'http://localhost').pathname,
);
function isCurrentUrl( function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string, currentUrl?: string,
startsWith: boolean = false, startsWith: boolean = false,
) { ) {
const urlToCompare = currentUrl ?? currentUrlReactive.value; return matchesNavigationUrl(
const urlString = toUrl(urlToCheck); toUrl(urlToCheck),
currentUrl ?? currentUrlReactive.value,
const comparePath = (path: string): boolean => startsWith,
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare; );
if (!urlString.startsWith('http')) {
return comparePath(urlString);
}
try {
const absoluteUrl = new URL(urlString);
return comparePath(absoluteUrl.pathname);
} catch {
return false;
}
} }
function isCurrentOrParentUrl( function isCurrentOrParentUrl(
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
return isCurrentUrl(urlToCheck, currentUrl, true); return isCurrentUrl(urlToCheck, currentUrl, true);
} }
function whenCurrentUrl( function whenCurrentUrl<T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any, ifTrue: T,
ifFalse: any = null, ifFalse: F = null as F,
) { ): T | F {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse; return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
} }
@@ -0,0 +1,34 @@
import { injectDialogRootContext } from 'reka-ui';
import { watch } from 'vue';
/** Reka restores registered triggers; also support dialogs opened programmatically. */
export function useDialogFocus() {
const context = injectDialogRootContext();
let previousFocus: HTMLElement | null = null;
watch(
context.open,
(open) => {
if (open && typeof document !== 'undefined') {
previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
},
{ flush: 'sync', immediate: true },
);
function restoreFocus(event: Event): void {
if (
!event.defaultPrevented &&
!context.triggerElement.value &&
previousFocus?.isConnected
) {
event.preventDefault();
previousFocus.focus({ preventScroll: true });
}
}
return { restoreFocus };
}
@@ -0,0 +1,122 @@
import type {
PendingVisit,
UrlMethodPair,
UseFormSubmitOptions,
} from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import type { InertiaForm } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
import {
formRequestHeader,
isFormNetworkFailure,
matchesFormRequest,
} from '@/lib/form-feedback';
import type { FormFeedbackState } from '@/lib/form-feedback';
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
export function useFormProtection<T extends object>(
form: InertiaForm<T>,
options: { isDirty?: () => boolean } = {},
) {
const failure = ref<FormFeedbackState | null>(null);
const responseStatus = ref<number | null>(null);
let ownVisit: PendingVisit | null = null;
let requestId: string | null = null;
const removeListeners: Array<() => void> = [];
const { confirmLeave } = useUnsavedChanges(
() => form.processing || (options.isDirty?.() ?? form.isDirty),
(visit) => visit === ownVisit,
);
const state = computed<FormFeedbackState>(() => {
if (form.processing) {
return 'loading';
}
if (failure.value) {
return failure.value;
}
if (Object.keys(form.errors).length > 0) {
return 'validation';
}
return form.recentlySuccessful ? 'success' : 'idle';
});
onMounted(() => {
removeListeners.push(
router.on('exception', (event) => {
if (isFormNetworkFailure(event.detail.exception, requestId)) {
event.preventDefault();
failure.value = 'network';
}
}),
router.on('invalid', (event) => {
if (
matchesFormRequest(event.detail.response.config, requestId)
) {
event.preventDefault();
failure.value = 'server';
responseStatus.value = event.detail.response.status;
}
}),
);
});
onUnmounted(() => removeListeners.forEach((remove) => remove()));
function submit(
target: UrlMethodPair,
visitOptions: UseFormSubmitOptions = {},
): void {
if (form.processing) {
return;
}
const nextRequestId = crypto.randomUUID();
form.submit(target, {
...visitOptions,
preserveState: true,
headers: {
...visitOptions.headers,
[formRequestHeader]: nextRequestId,
},
onBefore: (visit) => {
if (visitOptions.onBefore?.(visit) === false) {
return false;
}
ownVisit = visit;
},
onStart: (visit) => {
requestId = nextRequestId;
failure.value = null;
responseStatus.value = null;
visitOptions.onStart?.(visit);
},
onError: (errors) => {
failure.value = 'validation';
return visitOptions.onError?.(errors);
},
onSuccess: (page) => {
failure.value = null;
return visitOptions.onSuccess?.(page);
},
onCancel: () => {
failure.value = 'cancelled';
visitOptions.onCancel?.();
},
onFinish: (visit) => {
requestId = null;
ownVisit = null;
visitOptions.onFinish?.(visit);
},
});
}
function clearFeedback(): void {
failure.value = null;
responseStatus.value = null;
}
return { state, responseStatus, submit, confirmLeave, clearFeedback };
}
@@ -0,0 +1,42 @@
import type { PendingVisit } from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import { onMounted, onUnmounted, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
export function useUnsavedChanges(
dirty: MaybeRefOrGetter<boolean>,
allowVisit: (visit: PendingVisit) => boolean = () => false,
) {
const { t } = useLocalization();
let removeBeforeListener: (() => void) | undefined;
const confirmLeave = (): boolean =>
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
const beforeUnload = (event: BeforeUnloadEvent): void => {
if (toValue(dirty)) {
event.preventDefault();
event.returnValue = '';
}
};
onMounted(() => {
removeBeforeListener = router.on('before', (event) => {
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
return;
}
// Keep the original visit and its callbacks; never replay a mutation.
return confirmLeave();
});
window.addEventListener('beforeunload', beforeUnload);
});
onUnmounted(() => {
removeBeforeListener?.();
window.removeEventListener('beforeunload', beforeUnload);
});
return { confirmLeave };
}
+17 -2
View File
@@ -3,6 +3,8 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue'; import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import ProjectNavigation from '@/components/ProjectNavigation.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
type Props = { type Props = {
@@ -12,14 +14,27 @@ type Props = {
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
breadcrumbs: () => [], breadcrumbs: () => [],
}); });
const { t } = useLocalization();
</script> </script>
<template> <template>
<AppShell variant="sidebar"> <AppShell variant="sidebar">
<a
href="#main-content"
class="bg-primary text-primary-foreground sr-only fixed left-4 top-4 z-50 rounded-md p-3 focus:not-sr-only"
>{{ t('navigation.skipToContent') }}</a
>
<AppSidebar /> <AppSidebar />
<AppContent variant="sidebar" class="overflow-x-hidden"> <AppContent variant="sidebar" class="min-w-0">
<AppSidebarHeader :breadcrumbs="breadcrumbs" /> <AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot /> <ProjectNavigation />
<div
id="main-content"
tabindex="-1"
class="flex min-w-0 flex-1 flex-col"
>
<slot />
</div>
</AppContent> </AppContent>
</AppShell> </AppShell>
</template> </template>
@@ -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>
+27 -17
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance/index'; import { edit as editAppearance } from '@/routes/appearance/index';
import { edit as editApplications } from '@/routes/applications/index'; import { edit as editApplications } from '@/routes/applications/index';
@@ -12,28 +14,29 @@ import { edit as editProfile } from '@/routes/profile/index';
import { edit as editSecurity } from '@/routes/security/index'; import { edit as editSecurity } from '@/routes/security/index';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [ const { t } = useLocalization();
const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: 'Profile', title: t('navigation.profile'),
href: editProfile(), href: editProfile(),
}, },
{ {
title: 'Security', title: t('navigation.security'),
href: editSecurity(), href: editSecurity(),
}, },
{ {
title: 'Git connections', title: t('navigation.gitConnections'),
href: editGitConnections(), href: editGitConnections(),
}, },
{ {
title: 'Applications', title: t('navigation.applications'),
href: editApplications(), href: editApplications(),
}, },
{ {
title: 'Appearance', title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
}, },
]; ]);
const { isCurrentOrParentUrl } = useCurrentUrl(); const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
<template> <template>
<div class="px-4 py-6"> <div class="px-4 py-6">
<Heading <Heading
title="Settings" :title="t('navigation.settings')"
description="Manage your profile, security, and Git integration settings" :description="t('navigation.settingsDescription')"
/> />
<div class="flex flex-col lg:flex-row lg:space-x-12"> <div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
<aside class="w-full max-w-xl lg:w-48"> <aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<nav <nav
class="flex flex-col space-x-0 space-y-1" class="flex gap-1 overflow-x-auto lg:flex-col"
aria-label="Settings" :aria-label="t('navigation.settings')"
> >
<Button <Button
v-for="item in sidebarNavItems" v-for="item in sidebarNavItems"
:key="toUrl(item.href)" :key="toUrl(item.href)"
variant="ghost" variant="ghost"
:class="[ :class="[
'w-full justify-start', 'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) }, { 'bg-muted': isCurrentOrParentUrl(item.href) },
]" ]"
as-child as-child
> >
<Link :href="item.href"> <Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" /> <component :is="item.icon" class="h-4 w-4" />
{{ item.title }} {{ item.title }}
</Link> </Link>
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</nav> </nav>
</aside> </aside>
<Separator class="my-6 lg:hidden" /> <Separator class="lg:hidden" />
<div class="flex-1 md:max-w-2xl"> <div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12"> <section class="max-w-xl space-y-12">
<slot /> <slot />
</section> </section>
+70
View File
@@ -5,6 +5,76 @@ import type {
const MAX_INDENT_LEVEL = 8; const MAX_INDENT_LEVEL = 8;
export const canIndentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): boolean => {
const item = items[index];
const previous = items[index - 1];
if (!item || !previous || item.indent > previous.indent) {
return false;
}
return items
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
};
const acceptanceCriteriaSubtreeEnd = (
items: AcceptanceCriteriaModel,
index: number,
): number => {
let end = index + 1;
while (end < items.length && items[end].indent > items[index].indent) {
end++;
}
return end;
};
export const indentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
direction: 1 | -1,
): AcceptanceCriteriaModel => {
if (
!items[index] ||
(direction === 1
? !canIndentAcceptanceCriterion(items, index)
: items[index].indent === 0)
) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.map((item, position) =>
position >= index && position < end
? { ...item, indent: item.indent + direction }
: item,
);
};
/** Remove only the selected criterion, promoting its children without losing them. */
export const removeAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): AcceptanceCriteriaModel => {
if (!items[index]) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.flatMap((item, position) =>
position === index
? []
: [
{
...item,
indent:
position > index && position < end
? item.indent - 1
: item.indent,
},
],
);
};
export type AcceptanceCriteriaEditorState = { export type AcceptanceCriteriaEditorState = {
checklist: string; checklist: string;
}; };
+114
View File
@@ -0,0 +1,114 @@
import type {
BacklogFilterState,
BacklogTicketTreeNode,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
export function buildBacklogTicketTree(
tickets: TicketModel[],
): BacklogTicketTreeNode[] {
const nodesByTicketId = new Map<number, BacklogTicketTreeNode>();
for (const ticket of tickets) {
nodesByTicketId.set(ticket.id, {
children: [],
matchesFilters: true,
ticket,
});
}
const roots: BacklogTicketTreeNode[] = [];
for (const ticket of tickets) {
const node = nodesByTicketId.get(ticket.id);
if (!node) {
continue;
}
const parentNode =
ticket.parent_ticket_id === null
? undefined
: nodesByTicketId.get(ticket.parent_ticket_id);
if (parentNode) {
parentNode.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export function filterBacklogTicketTree(
nodes: BacklogTicketTreeNode[],
filters: BacklogFilterState,
): BacklogTicketTreeNode[] {
return nodes.flatMap((node) => {
const children = filterBacklogTicketTree(node.children, filters);
const matchesFilters = ticketMatchesFilters(node.ticket, filters);
if (!matchesFilters && children.length === 0) {
return [];
}
return [
{
...node,
children,
matchesFilters,
},
];
});
}
export function countBacklogTreeTickets(
nodes: BacklogTicketTreeNode[],
): number {
return nodes.reduce(
(count, node) => count + 1 + countBacklogTreeTickets(node.children),
0,
);
}
export function expandableBacklogTicketIds(
nodes: BacklogTicketTreeNode[],
): number[] {
return nodes.flatMap((node) => [
...(node.children.length > 0 ? [node.ticket.id] : []),
...expandableBacklogTicketIds(node.children),
]);
}
function ticketMatchesFilters(
ticket: TicketModel,
filters: BacklogFilterState,
): boolean {
const query = filters.query.trim().toLocaleLowerCase();
const searchableText = [
ticket.ticket_key,
ticket.title,
ticket.description,
ticket.ticket_type.name,
ticket.ticket_priority.name,
ticket.ticket_status?.name,
ticket.assignee?.name,
]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLocaleLowerCase();
return (
(query === '' || searchableText.includes(query)) &&
(filters.typeId === '' || ticket.ticket_type_id === filters.typeId) &&
(filters.priorityId === '' ||
ticket.ticket_priority_id === filters.priorityId) &&
(filters.statusId === '' ||
ticket.ticket_status_id === filters.statusId) &&
(filters.assigneeId === '' ||
(filters.assigneeId === 'unassigned'
? ticket.assignee_user_id === null
: ticket.assignee_user_id === filters.assigneeId))
);
}
+42
View File
@@ -0,0 +1,42 @@
export type FormFeedbackState =
| 'idle'
| 'loading'
| 'success'
| 'validation'
| 'network'
| 'server'
| 'cancelled';
export const formRequestHeader = 'X-KanbanGit-Form-Request';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/** Match failures to one submission, without intercepting other forms or polling. */
export function matchesFormRequest(
config: unknown,
requestId: string | null,
): boolean {
if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
return false;
}
return config.headers[formRequestHeader] === requestId;
}
/** Runtime exceptions and cancellations are not network failures. */
export function isFormNetworkFailure(
error: unknown,
requestId: string | null,
): boolean {
return (
isRecord(error) &&
error.isAxiosError === true &&
!error.response &&
['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
String(error.code),
) &&
matchesFormRequest(error.config, requestId)
);
}
+166
View File
@@ -0,0 +1,166 @@
import {
Bell,
Columns3,
Folder,
FolderGit2,
House,
KeyRound,
Layers3,
LayoutGrid,
ListTodo,
Settings2,
Shield,
Tags,
Users,
} from 'lucide-vue-next';
import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users';
import { index as templates } from '@/routes/project-templates';
import { access, index as projects, show } from '@/routes/projects';
import { index as backlog } from '@/routes/projects/backlog';
import { index as board } from '@/routes/projects/board';
import {
board as boardSettings,
general,
metadata,
notifications,
repositories,
} from '@/routes/projects/settings';
import { index as teams } from '@/routes/teams';
import type { Localization } from '@/lib/localization';
import type { AuthPermissions, NavItem, NavigationProject } from '@/types';
export function workspaceNavigation(
permissions: AuthPermissions,
t: Localization['t'],
): NavItem[] {
const items: NavItem[] = [
{
title: t('navigation.dashboard'),
href: dashboard(),
icon: LayoutGrid,
},
{
title: t('navigation.projects'),
href: projects(),
icon: Folder,
match: 'section',
},
];
if (permissions.projectCreate) {
items.push({
title: t('navigation.templates'),
href: templates(),
icon: Layers3,
match: 'section',
});
}
if (permissions.canManageTeams) {
items.push({
title: t('navigation.teams'),
href: teams(),
icon: Users,
match: 'section',
});
}
if (permissions.globalAdmin) {
items.push({
title: t('navigation.users'),
href: adminUsers(),
icon: Shield,
match: 'section',
});
}
return items;
}
export function projectNavigation(
project: NavigationProject | null,
t: Localization['t'],
): NavItem[] {
if (!project) {
return [];
}
return [
{
title: t('navigation.overview'),
href: show(project.id),
icon: House,
},
{
title: t('navigation.board'),
href: board(project.id),
icon: Columns3,
match: 'section',
},
{
title: t('navigation.backlog'),
href: backlog(project.id),
icon: ListTodo,
match: 'section',
},
];
}
export function projectSettingsNavigation(
project: NavigationProject | null,
t: Localization['t'],
): NavItem[] {
if (!project?.canManage) {
return [];
}
return [
{
title: t('navigation.general'),
href: general(project.id),
icon: Settings2,
},
{
title: t('navigation.boardSettings'),
href: boardSettings(project.id),
icon: Columns3,
},
{
title: t('navigation.ticketSettings'),
href: metadata(project.id),
icon: Tags,
},
{
title: t('navigation.repositories'),
href: repositories(project.id),
icon: FolderGit2,
},
{
title: t('navigation.notifications'),
href: notifications(project.id),
icon: Bell,
},
{
title: t('navigation.access'),
href: access(project.id),
icon: KeyRound,
},
];
}
export function matchesNavigationUrl(
target: string,
current: string,
section = false,
): boolean {
try {
const base = 'http://localhost';
const targetPath =
new URL(target, base).pathname.replace(/\/+$/, '') || '/';
const currentPath =
new URL(current, base).pathname.replace(/\/+$/, '') || '/';
return (
currentPath === targetPath ||
(section &&
targetPath !== '/' &&
currentPath.startsWith(`${targetPath}/`))
);
} catch {
return false;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export function ticketStatusLabel(
ticketStatus: TicketStatusModel | null,
fallback: string,
): string {
if (ticketStatus === null) {
return fallback;
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined ||
ticketStatus.board_column.name === ticketStatus.name
) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
}
+546
View File
@@ -1,9 +1,416 @@
export const en = { export const en = {
'criteria.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.',
'criteria.required': 'Enter a criterion or remove this row.',
'criteria.edit': 'Edit checklist',
'criteria.preview': 'Preview checklist',
'criteria.item': 'Criterion {number}',
'criteria.complete': 'Complete criterion {number}: {text}',
'criteria.indent': 'Indent criterion {number}',
'criteria.outdent': 'Outdent criterion {number}',
'criteria.remove': 'Remove criterion {number}',
'criteria.add': 'Add criterion',
'criteria.hint':
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
'criteria.saving': 'Saving checklist…',
'criteria.saveError':
'Checklist status could not be saved. Please try again.',
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
'board.columns': 'Workflow columns',
'board.selectColumn': 'Choose a column',
'board.columnOption': '{name} ({count})',
'board.scrollHint': 'Scroll horizontally to explore all columns.',
'board.openBacklog': 'Open backlog',
'board.backToProject': 'Project overview',
'board.empty.title': 'No workflow columns yet',
'board.empty.description':
'Set up columns to start tracking work on this board.',
'board.emptyColumn': 'No tickets in this column.',
'board.emptyStatus': 'No tickets in this status.',
'board.noStatuses': 'No statuses configured for this column.',
'board.moveTicket': 'Move {key}',
'board.updateStatus': 'Update status',
'board.updatingStatus': 'Updating…',
'board.noMoves': 'No allowed moves',
'board.drag': 'Drag to move',
'board.dragTicket': 'Drag {key} to change status',
'board.dragHint':
'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.',
'board.dropHint': 'Drop on a column. Hold near an edge to scroll.',
'board.dragging':
'Dragging {key}. Drop on an allowed column or press Escape to cancel.',
'board.chooseStatus': 'Choose a destination status',
'board.chooseStatusDescription':
'More than one status is available for {key} in this column.',
'board.targetStatus': 'Destination status',
'board.noMovesDescription':
'This status does not allow moving anywhere else.',
'backlog.title': '{name} backlog',
'backlog.description':
'Shape incoming work without losing sight of parent and child tickets.',
'backlog.openBoard': 'Open board',
'backlog.backToProject': 'Project overview',
'backlog.moveTicket': 'Move {key}',
'backlog.filters.title': 'Find backlog tickets',
'backlog.filters.description':
'Filter this view locally. Matching child tickets keep their parent context visible.',
'backlog.filters.search': 'Search',
'backlog.filters.searchPlaceholder': 'Key, title, description, or metadata',
'backlog.filters.type': 'Type',
'backlog.filters.allTypes': 'All types',
'backlog.filters.priority': 'Priority',
'backlog.filters.allPriorities': 'All priorities',
'backlog.filters.status': 'Status',
'backlog.filters.allStatuses': 'All backlog statuses',
'backlog.filters.assignee': 'Assignee',
'backlog.filters.allAssignees': 'All assignees',
'backlog.filters.clear': 'Clear filters',
'backlog.filters.parentContext': 'Parent context',
'backlog.filters.empty.title': 'No tickets match these filters',
'backlog.filters.empty.description':
'Try a broader search or clear the filters to see the complete backlog.',
'backlog.results.title': 'Backlog tickets',
'backlog.results.count': 'Showing {count} of {total} tickets',
'backlog.empty.title': 'Your backlog is clear',
'backlog.empty.description':
'New tickets without a workflow column will appear here.',
'backlog.empty.action': 'Create the first ticket',
'backlog.tree.expand': 'Expand children of {key}',
'backlog.tree.collapse': 'Collapse children of {key}',
'backlog.tree.expandAll': 'Expand all',
'backlog.tree.collapseAll': 'Collapse all',
'projectCreation.title': 'Create a project',
'projectCreation.description':
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
'projectCreation.start': 'How would you like to start?',
'projectCreation.scratch': 'Start from scratch',
'projectCreation.scratchDescription':
'Create an empty project with the default setup.',
'projectCreation.duplicate': 'Duplicate a project',
'projectCreation.duplicateDescription':
'Choose an existing project and copy selected settings.',
'projectCreation.template': 'Use a template',
'projectCreation.templateDescription':
'Start with a saved, reusable project configuration.',
'projectCreation.noProjects':
'No accessible projects are available to copy yet.',
'projectCreation.noTemplates': 'No published templates are available yet.',
'projectCreation.details': 'Project details',
'projectCreation.name': 'Project name',
'projectCreation.namePlaceholder': 'Platform engineering',
'projectCreation.key': 'Project key',
'projectCreation.keyPlaceholder': 'CORE',
'projectCreation.keyHelp':
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
'projectCreation.copyTitle': 'Choose what to copy',
'projectCreation.copyHelp':
'Copy configuration into an independent project. Select only what you need.',
'projectCreation.sourceProject': 'Source project',
'projectCreation.selectProject': 'Select a source project',
'projectCreation.copyOptions': 'Configuration areas',
'projectCreation.copyDefaults':
'Unselected areas keep the default setup. You can also continue without copying any settings.',
'projectCreation.chooseTemplate': 'Choose a template',
'projectCreation.templateLabel': 'Template',
'projectCreation.selectTemplate': 'Select a template',
'projectCreation.templateHelp':
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
'projectCreation.includedAreas': 'Included configuration',
'projectCreation.templateDefaults':
'This template uses the default project setup.',
'projectCreation.summary': 'Your new project',
'projectCreation.untitled': 'Add a project name',
'projectCreation.ticketPreview': 'First ticket key',
'projectCreation.setup': 'Starting point',
'projectCreation.cleanStart':
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
'projectCreation.submit': 'Create project',
'projectCreation.creating': 'Creating project…',
'projectCreation.areas.projectSettings.label': 'Project settings',
'projectCreation.areas.projectSettings.description':
'Story points and author visibility.',
'projectCreation.areas.boardColumns.label': 'Board columns',
'projectCreation.areas.boardColumns.description':
'Columns and their order on the board.',
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
'projectCreation.areas.ticketStatuses.description':
'Statuses and their board columns.',
'projectCreation.areas.statusTransitions.label': 'Status transitions',
'projectCreation.areas.statusTransitions.description':
'Allowed moves between statuses.',
'projectCreation.areas.ticketTypes.label': 'Ticket types',
'projectCreation.areas.ticketTypes.description':
'Available types, including parent types.',
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
'projectCreation.areas.ticketPriorities.description':
'Available priority levels.',
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
'projectCreation.areas.ticketFields.description':
'Field definitions without ticket values.',
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
'projectCreation.areas.ticketLinkTypes.description':
'Relationship labels between tickets.',
'projectCreation.areas.repositories.label': 'Repository connections',
'projectCreation.areas.repositories.description':
'Repositories linked to the project.',
'projectCreation.areas.notifications.label': 'Notification actions',
'projectCreation.areas.notifications.description':
'Notification rules and delivery settings.',
'projectCreation.selectedCount': {
one: '{count} area selected',
other: '{count} areas selected',
},
'projects.workspace': 'Workspace',
'projects.description':
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
'projects.create': 'New project',
'projects.updated': 'Project update',
'projects.available': {
one: '{count} project available',
other: '{count} projects available',
},
'projects.open': 'Open {name} overview',
'projects.openBoard': 'Open {name} board',
'projects.openBacklog': 'Open {name} backlog',
'projects.overview': 'Project overview',
'projects.empty.title': 'No projects to show',
'projects.empty.createDescription':
'Create a project to give your team a place to plan and track work.',
'projects.empty.accessDescription':
'Projects appear here when you have access. Ask a workspace or project administrator to add you.',
'projects.empty.create': 'Create your first project',
'projects.back': 'All projects',
'projects.hubDescription':
'Follow work on the board or explore tickets in the backlog.',
'projects.workAreas': 'Project work areas',
'projects.boardDescription':
'See work across the project’s workflow and open tickets in their current status.',
'projects.backlogDescription':
'Explore incoming work, review ticket details, and plan what comes next.',
'projects.boardAction': 'Open board',
'projects.backlogAction': 'Open backlog',
'projects.settingsDescription':
'Manage this project’s configuration, integrations, and access.',
'projects.saveTemplate': 'Save as new template',
'projects.templateTitle': 'Based on {name}',
'projects.templateDescription':
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
'projects.inheritedArea': '{name} · inherited',
'projects.overriddenArea': '{name} · overridden',
'dashboard.eyebrow': 'Personal workspace',
'dashboard.greeting': 'Welcome back, {name}',
'dashboard.description':
'A current view of tickets assigned to you in projects you can access.',
'dashboard.summary': 'Work summary',
'dashboard.summary.assignedTickets': 'Assigned tickets',
'dashboard.summary.projects': 'Projects with assigned work',
'dashboard.summary.blockedTickets': 'Blocked tickets',
'dashboard.assignedWork': 'My assigned tickets',
'dashboard.assignedWorkDescription':
'Your work is grouped by project and ordered with the newest tickets first.',
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
'dashboard.openProject': 'Open {name}',
'dashboard.openProjectAction': 'Open project',
'dashboard.empty.title': 'No tickets assigned to you',
'dashboard.empty.description':
'Tickets assigned to you in projects you can access will appear here.',
'dashboard.empty.action': 'Browse projects',
'auth.home': 'KanbanGit home',
'auth.accountAccess': 'Account access',
'auth.backToLogin': 'Return to sign in',
'auth.signOut': 'Sign out',
'auth.signingOut': 'Signing out…',
'auth.password.show': 'Show password',
'auth.password.hide': 'Hide password',
'auth.login.title': 'Welcome back',
'auth.login.description': 'Sign in to continue to your workspace.',
'auth.login.email': 'Email address',
'auth.login.emailPlaceholder': 'you@example.com',
'auth.login.password': 'Password',
'auth.login.remember': 'Remember me',
'auth.login.forgot': 'Forgot password?',
'auth.login.submit': 'Sign in',
'auth.login.pending': 'Signing in…',
'auth.login.help': 'Need an account? Contact your workspace administrator.',
'auth.login.statusTitle': 'Account update',
'auth.forgot.title': 'Reset your password',
'auth.forgot.description':
'Enter your account email and we will send you a secure reset link.',
'auth.forgot.email': 'Email address',
'auth.forgot.emailPlaceholder': 'you@example.com',
'auth.forgot.emailHint':
'Use the email address associated with your KanbanGit account.',
'auth.forgot.submit': 'Send reset link',
'auth.forgot.pending': 'Sending reset link…',
'auth.forgot.sentTitle': 'Reset link sent',
'auth.reset.title': 'Choose a new password',
'auth.reset.description':
'Create a new password for the account linked below.',
'auth.reset.email': 'Account email',
'auth.reset.password': 'New password',
'auth.reset.passwordHint':
'Use a password you do not use for another account.',
'auth.reset.passwordConfirmation': 'Confirm new password',
'auth.reset.submit': 'Save new password',
'auth.reset.pending': 'Updating your password…',
'auth.reset.invalidTitle': 'This reset link is no longer valid',
'auth.reset.requestNew': 'Request a new reset link',
'auth.reset.footer':
'For your security, reset links expire and can only be used once.',
'auth.confirm.title': 'Confirm it is you',
'auth.confirm.description':
'This action affects sensitive account information. Enter your password to continue.',
'auth.confirm.password': 'Current password',
'auth.confirm.passwordHint':
'Your password is checked securely and is never saved in a draft.',
'auth.confirm.submit': 'Confirm and continue',
'auth.confirm.pending': 'Confirming your password…',
'auth.confirm.footer':
'Password confirmation expires automatically after a short time.',
'auth.forceChange.title': 'Secure your account',
'auth.forceChange.description':
'You need to replace your temporary password before continuing to KanbanGit.',
'auth.forceChange.currentPassword': 'Temporary password',
'auth.forceChange.password': 'New password',
'auth.forceChange.passwordHint':
'Choose a password that differs from your temporary password.',
'auth.forceChange.passwordConfirmation': 'Confirm new password',
'auth.forceChange.submit': 'Save password and continue',
'auth.forceChange.pending': 'Securing your account…',
'auth.forceChange.statusTitle': 'Password update',
'auth.forceChange.wrongAccount': 'Not the account you expected?',
'auth.forceChange.footer':
'Your password entries are kept in memory only and are cleared when you leave.',
'auth.verify.title': 'Check your inbox',
'auth.verify.description':
'Open the verification link in your email to finish confirming your account.',
'auth.verify.emailLabel': 'Verification email address',
'auth.verify.resend': 'Resend verification email',
'auth.verify.pending': 'Sending verification email…',
'auth.verify.sentTitle': 'Verification link sent',
'auth.verify.sentDescription':
'Check your inbox for a new link. If it does not arrive, check your spam folder.',
'auth.verify.help': 'Already verified in another tab?',
'auth.verify.continue': 'Continue to workspace',
'auth.verify.wrongAccount':
'Wrong account? Sign out and use a different email address.',
'auth.twoFactor.title': 'Two-step verification',
'auth.twoFactor.description':
'Enter the six-digit code from your authenticator app to finish signing in.',
'auth.twoFactor.code': 'Authentication code',
'auth.twoFactor.codeHint':
'Enter all six digits, including any leading zero.',
'auth.twoFactor.recoveryTitle': 'Use a recovery code',
'auth.twoFactor.recoveryDescription':
'Enter one of the recovery codes you saved when you set up two-factor authentication.',
'auth.twoFactor.recoveryCode': 'Recovery code',
'auth.twoFactor.recoveryHint': 'Each recovery code can only be used once.',
'auth.twoFactor.useRecovery': 'Use a recovery code instead',
'auth.twoFactor.useCode': 'Use an authenticator code instead',
'auth.twoFactor.submit': 'Verify and sign in',
'auth.twoFactor.pending': 'Verifying your code…',
'auth.feedback.pending': 'Please wait…',
'auth.feedback.completed': 'Request completed',
'auth.feedback.validationTitle': 'Check your details',
'auth.feedback.validationDescription':
'Review the message below and try again. Your entries have been kept.',
'auth.feedback.expiredTitle': 'Your session has expired',
'auth.feedback.expiredDescription':
'Return to sign in to start a new session. You will need to enter your details again.',
'auth.feedback.limitedTitle': 'Too many attempts',
'auth.feedback.limitedDescription':
'Please wait a moment before trying again.',
'auth.feedback.networkDescription':
'We could not confirm the result. Your entries are still here. Check your connection before trying again.',
'auth.feedback.serverDescription':
'The server returned an unexpected response. Your entries are still here. Please try again in a moment.',
'auth.feedback.cancelledDescription':
'The request was interrupted. Your entries are still here if you want to try again.',
'navigation.main': 'Main navigation',
'navigation.workspace': 'Workspace',
'navigation.project': 'Project navigation',
'navigation.projectSettings': 'Project settings',
'navigation.account': 'Account',
'navigation.dashboard': 'Dashboard',
'navigation.projects': 'Projects',
'navigation.templates': 'Project templates',
'navigation.users': 'Users',
'navigation.teams': 'Teams',
'navigation.overview': 'Overview',
'navigation.board': 'Board',
'navigation.backlog': 'Backlog',
'navigation.general': 'General',
'navigation.boardSettings': 'Board settings',
'navigation.ticketSettings': 'Ticket settings',
'navigation.repositories': 'Repositories',
'navigation.notifications': 'Notifications',
'navigation.access': 'Access',
'navigation.settings': 'Settings',
'navigation.profile': 'Profile',
'navigation.security': 'Security',
'navigation.gitConnections': 'Git connections',
'navigation.applications': 'Applications',
'navigation.appearance': 'Appearance',
'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.open': 'Open navigation',
'navigation.close': 'Close navigation',
'navigation.expand': 'Expand sidebar',
'navigation.collapse': 'Collapse sidebar',
'navigation.mobileDescription':
'Navigate your workspace and current project.',
'navigation.skipToContent': 'Skip to content',
'navigation.userMenu': 'Account menu for {name}',
'navigation.logout': 'Log out',
'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme', 'appearance.label': 'Color theme',
'appearance.light': 'Light', 'appearance.light': 'Light',
'appearance.dark': 'Dark', 'appearance.dark': 'Dark',
'appearance.system': 'System', 'appearance.system': 'System',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close',
'common.retry': 'Try again',
'feedback.empty': 'Nothing here yet',
'feedback.saved': 'Changes saved',
'forms.saving': 'Saving changes…',
'forms.leaveConfirm': 'You have unsaved changes. Leave without saving?',
'forms.validationTitle': 'Check your entries',
'forms.validationDescription':
'The server rejected some fields. Your entries are still here. Correct the highlighted errors and save again.',
'forms.networkTitle': 'Connection interrupted',
'forms.networkDescription':
'Your entries are still here, but we could not confirm whether the server saved them. Check your connection and verify the result before trying again.',
'forms.serverTitle': 'The request could not be completed',
'forms.serverDescription':
'Your entries are still here. The server returned an unexpected response; your session may have expired. Verify the result before trying again.',
'forms.cancelledTitle': 'Saving was interrupted',
'forms.cancelledDescription':
'Your entries are still here. Check whether the changes were saved before trying again.',
'tickets.noBranches': 'No matching branches',
'tickets.noBranchesDescription':
'No matching branches were found across the linked repositories.',
'tickets.noPullRequests': 'No matching pull requests',
'tickets.noPullRequestsDescription':
'No matching pull requests were found across the linked repositories.',
'tickets.open': 'Open {key}: {title}',
'tickets.blocked': 'Blocked',
'tickets.blockerComment': 'Blocker: {comment}',
'tickets.typeLabel': 'Type: {name}',
'tickets.priorityLabel': 'Priority: {name}',
'tickets.statusLabel': 'Status: {name}',
'tickets.noStatus': 'No status',
'tickets.assignedTo': 'Assigned to {name}',
'tickets.unassigned': 'Unassigned',
'tickets.tree.expand': 'Expand children of {key}',
'tickets.tree.collapse': 'Collapse children of {key}',
'common.save': 'Save', 'common.save': 'Save',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
'common.error': 'Something went wrong.', 'common.error': 'Something went wrong.',
@@ -12,4 +419,143 @@ export const en = {
one: '{count} ticket', one: '{count} ticket',
other: '{count} tickets', other: '{count} tickets',
}, },
'ticketDetail.description': 'Description',
'ticketDetail.noDescription': 'No description has been added yet.',
'ticketDetail.checklist': 'Acceptance criteria',
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
'ticketDetail.saveChecklistFirst':
'Save checklist text changes before toggling completion directly.',
'ticketDetail.edit': 'Edit ticket',
'ticketDetail.editDescription':
'Tracked ticket changes create worklog entries automatically.',
'ticketDetail.git': 'Git',
'ticketDetail.relationships': 'Relationships',
'ticketDetail.history': 'Worklog',
'ticketDetail.metadata': 'Ticket information',
'ticketDetail.sections': 'Ticket sections',
'ticketDetail.status': 'Status',
'ticketDetail.type': 'Type',
'ticketDetail.priority': 'Priority',
'ticketDetail.assignee': 'Assignee',
'ticketDetail.author': 'Author',
'ticketDetail.noAuthor': 'No author',
'ticketDetail.storyPoints': 'Estimated story points',
'ticketDetail.notSet': 'Not set',
'ticketDetail.created': 'Created',
'ticketDetail.updated': 'Last updated',
'ticketDetail.backToProject': 'Back to project',
'ticketDetail.openBacklog': 'Open backlog',
'ticketDetail.openBoard': 'Open board',
'ticketDetail.readOnly': 'Read only',
'ticketDetail.title': 'Title',
'ticketDetail.parent': 'Parent ticket',
'ticketDetail.noParent': 'No parent ticket',
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
'ticketDetail.checklistHelp':
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
'ticketDetail.checklistPlaceholder':
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
'ticketDetail.blocked': 'Mark ticket as blocked',
'ticketDetail.blockerComment': 'Blocker comment',
'ticketDetail.blockerPlaceholder':
'Describe what currently blocks progress on this ticket.',
'ticketDetail.customFields': 'Custom fields',
'ticketDetail.customFieldsDescription':
'Project-configured fields can be tracked directly on the ticket.',
'ticketDetail.noCustomFields':
'No custom fields are configured for this project yet.',
'ticketDetail.saveChanges': 'Save changes',
'ticketDetail.branches': 'Branches',
'ticketDetail.branchesDescription':
'Matching branches are discovered from linked repositories when the branch name starts with this ticket key.',
'ticketDetail.createBranch': 'Create branch',
'ticketDetail.createBranchTitle': 'Create branch from ticket',
'ticketDetail.createBranchDescription':
'Pick a linked repository, confirm the suggested branch name, and create it directly in Gitea.',
'ticketDetail.repository': 'Repository',
'ticketDetail.branchName': 'Branch name',
'ticketDetail.branchAccess':
'You can only create branches in repositories that match one of your personal Git connections.',
'ticketDetail.openGitConnections': 'Open Git connections',
'ticketDetail.createNow': 'Create now',
'ticketDetail.inspectBranches':
'Connect a matching Git account in your user settings to inspect branches for this repository.',
'ticketDetail.pullRequests': 'Pull requests',
'ticketDetail.pullRequestsDescription':
'Matching pull requests are detected from linked repositories by checking whether the source branch starts with this ticket key.',
'ticketDetail.inspectPullRequests':
'Connect a matching Git account in your user settings to inspect pull requests for this repository.',
'ticketDetail.checkingBranch':
'Checking whether this branch name is valid and still available...',
'ticketDetail.selectRepository':
'Select a linked repository to validate the branch name.',
'ticketDetail.connectGitFirst':
'Connect a matching Git connection in your user settings first.',
'ticketDetail.branchRequired': 'Branch name is required.',
'ticketDetail.branchValidationFailed':
'{app} could not validate this branch name right now.',
'ticketDetail.checklistSaveFailed':
'Checklist status could not be saved right now.',
'ticketDetail.children': 'Child tickets',
'ticketDetail.childrenDescription':
'Parent tickets expose their full child tree here, including nested parent chains.',
'ticketDetail.noChildren':
'No child tickets are assigned to this ticket yet.',
'ticketDetail.parentDescription':
'This ticket belongs to the parent shown here.',
'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkType': 'Link type',
'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks':
'No ticket links are configured for this ticket yet.',
'ticketDetail.removeLink': 'Remove',
'ticketRelationships.hierarchy': 'Hierarchy',
'ticketRelationships.editParent': 'Change parent',
'ticketRelationships.parentHelp':
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
'ticketRelationships.childrenHelp':
'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes':
'No link types are configured for this project.',
'ticketRelationships.noTargets':
'No other tickets are available to link in this project.',
'ticketDetail.historyDescription':
'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System',
'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket',
'ticketForm.createDescription': 'Add a ticket to {project}.',
'ticketForm.backToBoard': 'Back to board',
'ticketForm.backToBacklog': 'Back to backlog',
'ticketForm.details': 'Ticket details',
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
'ticketForm.titlePlaceholder': 'Implement repository sync',
'ticketForm.descriptionPlaceholder':
'Describe the expected behavior and relevant context.',
'ticketForm.checklistHelp':
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
'ticketForm.checklistPreview': 'Checklist preview',
'ticketForm.metadataRequired':
'Ticket creation needs at least one type, one priority, and one status.',
'ticketForm.openSettings': 'Open ticket settings',
'ticketForm.creating': 'Creating ticket…',
'ticketForm.saving': 'Saving changes…',
'ticketForm.reset': 'Discard changes',
'ticketForm.unsaved': 'Unsaved changes',
'ticketForm.ready': 'Ready for a new ticket',
'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker',
} as const; } as const;
+197 -33
View File
@@ -1,47 +1,211 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue'; import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index'; import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types'; import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [ const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
{ {
title: 'Dashboard', label: t('dashboard.summary.assignedTickets'),
href: dashboard(), value: assignedTickets.value.length,
icon: ListTodo,
}, },
]; {
label: t('dashboard.summary.projects'),
value: props.assignedTicketsByProject.length,
icon: FolderGit2,
},
{
label: t('dashboard.summary.blockedTickets'),
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
.length,
icon: OctagonAlert,
},
]);
</script> </script>
<template> <template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<div <Head :title="t('navigation.dashboard')" />
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div class="grid auto-rows-min gap-4 md:grid-cols-3"> <header class="gap-stack grid min-w-0">
<div <p class="text-label text-muted-foreground uppercase">
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border" {{ t('dashboard.eyebrow') }}
> </p>
<PlaceholderPattern /> <h1 class="text-headline-mobile md:text-headline break-words">
</div> {{
<div t('dashboard.greeting', {
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border" name: $page.props.auth.user.name,
> })
<PlaceholderPattern /> }}
</div> </h1>
<div <p class="text-muted-foreground max-w-2xl">
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border" {{ t('dashboard.description') }}
> </p>
<PlaceholderPattern /> </header>
</div>
</div> <section :aria-label="t('dashboard.summary')">
<div <ul class="grid gap-3 sm:grid-cols-3">
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min" <li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
> >
<PlaceholderPattern /> <header class="gap-stack grid min-w-0">
</div> <h2 id="assigned-work-title" class="text-title">
{{ t('dashboard.assignedWork') }}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.assignedWorkDescription') }}
</p>
</header>
<FeedbackState
v-if="assignedTicketsByProject.length === 0"
state="empty"
:title="t('dashboard.empty.title')"
:description="t('dashboard.empty.description')"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
</div>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+64 -39
View File
@@ -1,52 +1,77 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/password/confirm'; import { store } from '@/routes/password/confirm';
const { t } = useLocalization();
const form = useForm({ password: '' }).dontRemember('password');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), { onSuccess: () => form.reset() });
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Confirm your password" :title="t('auth.confirm.title')"
description="This is a secure area of the application. Please confirm your password before continuing." :description="t('auth.confirm.description')"
:icon="ShieldCheck"
> >
<Head title="Confirm password" /> <Head :title="t('auth.confirm.title')" />
<form
<Form :action="store.url()"
v-bind="store.form()" method="post"
reset-on-success class="grid gap-5"
v-slot="{ errors, processing }" @submit.prevent="submit"
> >
<div class="space-y-6"> <AuthFormFeedback
<div class="grid gap-2"> :state="protection.state.value"
<Label htmlFor="password">Password</Label> :response-status="protection.responseStatus.value"
<PasswordInput :loading-title="t('auth.confirm.pending')"
id="password" @retry="submit"
name="password" />
class="mt-1 block w-full" <div class="gap-stack grid">
required <Label for="password">{{ t('auth.confirm.password') }}</Label>
autocomplete="current-password" <PasswordInput
autofocus id="password"
/> v-model="form.password"
name="password"
<InputError :message="errors.password" /> required
</div> autocomplete="current-password"
autofocus
<div class="flex items-center"> :disabled="form.processing"
<Button :invalid="Boolean(form.errors.password)"
class="w-full" aria-describedby="password-hint password-error"
:disabled="processing" />
data-test="confirm-password-button" <p
> id="password-hint"
<Spinner v-if="processing" /> class="text-caption text-muted-foreground"
Confirm password >
</Button> {{ t('auth.confirm.passwordHint') }}
</div> </p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div> </div>
</Form> <Button
</AuthLayout> type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.confirm.pending')"
data-test="confirm-password-button"
>{{ t('auth.confirm.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.confirm.footer') }}</template>
</AuthFlowLayout>
</template> </template>
+155 -100
View File
@@ -1,115 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { LogOut } from 'lucide-vue-next'; import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { logout } from '@/routes'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { logout } from '@/routes/index';
withDefaults( withDefaults(defineProps<{ status?: string | null }>(), { status: null });
defineProps<{ const { t } = useLocalization();
status?: string | null; const form = useForm({
}>(), current_password: '',
{ password: '',
status: null, password_confirmation: '',
}, }).dontRemember('current_password', 'password', 'password_confirmation');
); const logoutForm = useForm({});
const protection = useFormProtection(form, { isDirty: () => false });
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => form.processing || logoutForm.processing);
const submit = (): void =>
protection.submit(SecurityController.update(), {
onSuccess: () => form.reset(),
});
const signOut = (): void => {
if (busy.value) {
return;
}
form.reset();
logoutProtection.submit(logout());
};
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Change your password" :title="t('auth.forceChange.title')"
description="You must update your password before you can continue using the application." :description="t('auth.forceChange.description')"
:icon="KeyRound"
> >
<Head title="Change your password" /> <Head :title="t('auth.forceChange.title')" />
<FeedbackState
<div class="mb-6 flex justify-end">
<Button as-child variant="ghost" size="sm">
<Link :href="logout()" as="button">
<LogOut class="size-4" />
Log out
</Link>
</Button>
</div>
<div
v-if="status" v-if="status"
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900" state="success"
:title="t('auth.forceChange.statusTitle')"
:description="status"
/>
<form
:action="SecurityController.update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
{{ status }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<Form :loading-title="t('auth.forceChange.pending')"
v-bind="SecurityController.update.form()" @retry="submit"
reset-on-success />
:reset-on-error="[ <div class="gap-stack grid">
'password', <Label for="current_password">{{
'password_confirmation', t('auth.forceChange.currentPassword')
'current_password', }}</Label>
]" <PasswordInput
v-slot="{ errors, processing }" id="current_password"
> v-model="form.current_password"
<div class="space-y-6"> name="current_password"
<div class="grid gap-2"> required
<Label htmlFor="current_password">Current password</Label> autocomplete="current-password"
<PasswordInput autofocus
id="current_password" :disabled="busy"
name="current_password" :invalid="Boolean(form.errors.current_password)"
class="mt-1 block w-full" aria-describedby="current-password-error"
required />
autocomplete="current-password" <InputError
autofocus id="current-password-error"
placeholder="Current password" :message="form.errors.current_password"
/> />
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm new password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="force-password-change-button"
>
<Spinner v-if="processing" />
Save new password
</Button>
</div>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthLayout> <Label for="password">{{
t('auth.forceChange.password')
}}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password)"
aria-describedby="new-password-hint new-password-error"
/>
<p
id="new-password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.forceChange.passwordHint') }}
</p>
<InputError
id="new-password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.forceChange.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="new-password-confirmation-error"
/>
<InputError
id="new-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:disabled="logoutForm.processing"
:loading="form.processing"
:loading-label="t('auth.forceChange.pending')"
data-test="force-password-change-button"
>{{ t('auth.forceChange.submit') }}</Button
>
</form>
<div class="gap-stack pt-column grid border-t">
<p class="text-body text-muted-foreground">
{{ t('auth.forceChange.wrongAccount') }}
</p>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="outline"
class="w-full"
:disabled="form.processing"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
</div>
<template #footer>{{ t('auth.forceChange.footer') }}</template>
</AuthFlowLayout>
</template> </template>
+72 -48
View File
@@ -1,65 +1,89 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { Mail } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue'; import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index'; import { login } from '@/routes/index';
import { email } from '@/routes/password'; import { email } from '@/routes/password';
defineProps<{ defineProps<{ status?: string }>();
status?: string; const { t } = useLocalization();
}>(); const form = useForm({ email: '' }).dontRemember('email');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void => protection.submit(email());
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Forgot password" :title="t('auth.forgot.title')"
description="Enter your email to receive a password reset link" :description="t('auth.forgot.description')"
:icon="Mail"
> >
<Head title="Forgot password" /> <Head :title="t('auth.forgot.title')" />
<FeedbackState
<div
v-if="status" v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600" state="success"
:title="t('auth.forgot.sentTitle')"
:description="status"
/>
<form
:action="email.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
{{ status }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<div class="space-y-6"> :loading-title="t('auth.forgot.pending')"
<Form v-bind="email.form()" v-slot="{ errors, processing }"> @retry="submit"
<div class="grid gap-2"> />
<Label for="email">Email address</Label> <div class="gap-stack grid">
<Input <Label for="email">{{ t('auth.forgot.email') }}</Label>
id="email" <Input
type="email" id="email"
name="email" v-model="form.email"
autocomplete="off" name="email"
autofocus type="email"
placeholder="email@example.com" inputmode="email"
/> autocomplete="email"
<InputError :message="errors.email" /> autocapitalize="none"
</div> :spellcheck="false"
required
<div class="my-6 flex items-center justify-start"> autofocus
<Button :disabled="form.processing"
class="w-full" :placeholder="t('auth.forgot.emailPlaceholder')"
:disabled="processing" :invalid="Boolean(form.errors.email)"
data-test="email-password-reset-link-button" aria-describedby="email-hint email-error"
> />
<Spinner v-if="processing" /> <p id="email-hint" class="text-caption text-muted-foreground">
Email password reset link {{ t('auth.forgot.emailHint') }}
</Button> </p>
</div> <InputError id="email-error" :message="form.errors.email" />
</Form>
<div class="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
</div> </div>
</div> <Button
</AuthLayout> type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.forgot.pending')"
data-test="email-password-reset-link-button"
>{{ t('auth.forgot.submit') }}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
</template> </template>
+105 -81
View File
@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { LogIn } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import TextLink from '@/components/TextLink.vue'; import TextLink from '@/components/TextLink.vue';
@@ -7,94 +11,114 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthBase from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/login'; import { store } from '@/routes/login';
import { request } from '@/routes/password'; import { request } from '@/routes/password';
defineProps<{ defineProps<{ status?: string; canResetPassword: boolean }>();
status?: string; const { t } = useLocalization();
canResetPassword: boolean; // Credentials stay in memory only; no remembered form key or browser storage.
}>(); const form = useForm({ email: '', password: '', remember: false }).dontRemember(
'password',
);
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), {
onSuccess: () => form.reset('password'),
});
onUnmounted(() => form.reset('password'));
</script> </script>
<template> <template>
<AuthBase <AuthFlowLayout
title="Log in to your account" :title="t('auth.login.title')"
description="Enter your email and password below to log in" :description="t('auth.login.description')"
:icon="LogIn"
> >
<Head title="Log in" /> <Head :title="t('auth.login.submit')" />
<FeedbackState
<div
v-if="status" v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600" state="success"
:title="t('auth.login.statusTitle')"
:description="status"
/>
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
{{ status }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<Form :loading-title="t('auth.login.pending')"
v-bind="store.form()" @retry="submit"
:reset-on-success="['password']" />
v-slot="{ errors, processing }" <div class="gap-stack grid">
class="flex flex-col gap-6" <Label for="email">{{ t('auth.login.email') }}</Label>
> <Input
<div class="grid gap-6"> id="email"
<div class="grid gap-2"> v-model="form.email"
<Label for="email">Email address</Label> name="email"
<Input type="email"
id="email" inputmode="email"
type="email" autocomplete="username"
name="email" autocapitalize="none"
required :spellcheck="false"
autofocus required
:tabindex="1" autofocus
autocomplete="email" :disabled="form.processing"
placeholder="email@example.com" :placeholder="t('auth.login.emailPlaceholder')"
/> :invalid="Boolean(form.errors.email)"
<InputError :message="errors.email" /> aria-describedby="email-error"
</div> />
<InputError id="email-error" :message="form.errors.email" />
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="text-sm"
:tabindex="5"
>
Forgot password?
</TextLink>
</div>
<PasswordInput
id="password"
name="password"
required
:tabindex="2"
autocomplete="current-password"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3">
<Checkbox id="remember" name="remember" :tabindex="3" />
<span>Remember me</span>
</Label>
</div>
<Button
type="submit"
class="mt-4 w-full"
:tabindex="4"
:disabled="processing"
data-test="login-button"
>
<Spinner v-if="processing" />
Log in
</Button>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthBase> <Label for="password">{{ t('auth.login.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="current-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-error"
/>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-card flex flex-wrap items-center justify-between">
<Label for="remember" class="min-h-control gap-stack">
<Checkbox
id="remember"
v-model="form.remember"
name="remember"
:disabled="form.processing"
/>
{{ t('auth.login.remember') }}
</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="min-h-control text-body inline-flex items-center"
>{{ t('auth.login.forgot') }}</TextLink
>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.login.pending')"
data-test="login-button"
>{{ t('auth.login.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.login.help') }}</template>
</AuthFlowLayout>
</template> </template>
+122 -74
View File
@@ -1,88 +1,136 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue'; import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { update } from '@/routes/password'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { request, update } from '@/routes/password';
const props = defineProps<{ const props = defineProps<{ token: string; email: string }>();
token: string; const { t } = useLocalization();
email: string; const form = useForm({
}>(); token: props.token,
email: props.email,
const inputEmail = ref(props.email); password: '',
password_confirmation: '',
}).dontRemember('token', 'email', 'password', 'password_confirmation');
const protection = useFormProtection(form, { isDirty: () => false });
const invalidLink = computed(() => Boolean(form.errors.email));
const feedbackState = computed(() =>
invalidLink.value ? 'idle' : protection.state.value,
);
const submit = (): void =>
protection.submit(update(), {
onSuccess: () => form.reset('password', 'password_confirmation'),
});
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Reset password" :title="t('auth.reset.title')"
description="Please enter your new password below" :description="t('auth.reset.description')"
:icon="KeyRound"
> >
<Head title="Reset password" /> <Head :title="t('auth.reset.title')" />
<FeedbackState
<Form v-if="invalidLink"
v-bind="update.form()" state="error"
:transform="(data) => ({ ...data, token, email })" :title="t('auth.reset.invalidTitle')"
:reset-on-success="['password', 'password_confirmation']" :description="form.errors.email"
v-slot="{ errors, processing }"
> >
<div class="grid gap-6"> <Button as-child variant="outline" class="w-full">
<div class="grid gap-2"> <Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
<Label for="email">Email</Label> </Button>
<Input </FeedbackState>
id="email" <form
type="email" :action="update.url()"
name="email" method="post"
autocomplete="email" class="grid gap-5"
v-model="inputEmail" @submit.prevent="submit"
class="mt-1 block w-full" >
readonly <AuthFormFeedback
/> :state="feedbackState"
<InputError :message="errors.email" class="mt-2" /> :response-status="protection.responseStatus.value"
</div> :loading-title="t('auth.reset.pending')"
@retry="submit"
<div class="grid gap-2"> />
<Label for="password">Password</Label> <input type="hidden" name="token" :value="form.token" />
<PasswordInput <div class="gap-stack grid">
id="password" <Label for="email">{{ t('auth.reset.email') }}</Label>
name="password" <Input
autocomplete="new-password" id="email"
class="mt-1 block w-full" v-model="form.email"
autofocus name="email"
placeholder="Password" type="email"
/> autocomplete="username"
<InputError :message="errors.password" /> readonly
</div> :disabled="form.processing"
:invalid="Boolean(form.errors.email)"
<div class="grid gap-2"> aria-describedby="email-error"
<Label for="password_confirmation"> />
Confirm password <InputError id="email-error" :message="form.errors.email" />
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autocomplete="new-password"
class="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-4 w-full"
:disabled="processing"
data-test="reset-password-button"
>
<Spinner v-if="processing" />
Reset password
</Button>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthLayout> <Label for="password">{{ t('auth.reset.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="new-password"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.reset.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.reset.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
autocomplete="new-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.reset.pending')"
data-test="reset-password-button"
>{{ t('auth.reset.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.reset.footer') }}</template>
</AuthFlowLayout>
</template> </template>
+159 -119
View File
@@ -1,133 +1,173 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { KeyRound, ShieldCheck } from 'lucide-vue-next';
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import { Label } from '@/components/ui/label';
InputOTP, import { useFormProtection } from '@/composables/useFormProtection';
InputOTPGroup, import { useLocalization } from '@/composables/useLocalization';
InputOTPSlot, import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
} from '@/components/ui/input-otp'; import { login } from '@/routes/index';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { store } from '@/routes/two-factor/login'; import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const authConfigContent = computed<TwoFactorConfigContent>(() => { const { t } = useLocalization();
if (showRecoveryInput.value) { const recovery = ref(false);
return { const form = useForm({ code: '', recovery_code: '' }).dontRemember(
title: 'Recovery code', 'code',
description: 'recovery_code',
'Please confirm access to your account by entering one of your emergency recovery codes.', );
buttonText: 'login using an authentication code', const protection = useFormProtection(form, { isDirty: () => false });
}; const codeInput = useTemplateRef('codeInput');
const recoveryInput = useTemplateRef('recoveryInput');
const fieldError = computed(() =>
recovery.value
? (form.errors.recovery_code ?? form.errors.code)
: form.errors.code,
);
const toggleMode = async (): Promise<void> => {
if (form.processing) {
return;
} }
recovery.value = !recovery.value;
return { form.resetAndClearErrors();
title: 'Authentication code', protection.clearFeedback();
description: await nextTick();
'Enter the authentication code provided by your authenticator application.', (recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
buttonText: 'login using a recovery code',
};
});
const showRecoveryInput = ref<boolean>(false);
const toggleRecoveryMode = (clearErrors: () => void): void => {
showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
}; };
const pasteCode = (event: ClipboardEvent): void => {
const code = ref<string>(''); const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
if (/^[0-9]{6}$/.test(code)) {
event.preventDefault();
form.code = code;
}
};
const submit = (): void => {
form.transform((data) =>
recovery.value
? { recovery_code: data.recovery_code.trim() }
: { code: data.code },
);
protection.submit(store(), { onSuccess: () => form.reset() });
};
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
:title="authConfigContent.title" :title="
:description="authConfigContent.description" t(
recovery
? 'auth.twoFactor.recoveryTitle'
: 'auth.twoFactor.title',
)
"
:description="
t(
recovery
? 'auth.twoFactor.recoveryDescription'
: 'auth.twoFactor.description',
)
"
:icon="recovery ? KeyRound : ShieldCheck"
> >
<Head title="Two-factor authentication" /> <Head :title="t('auth.twoFactor.title')" />
<form
<div class="space-y-6"> :action="store.url()"
<template v-if="!showRecoveryInput"> method="post"
<Form class="grid gap-5"
v-bind="store.form()" @submit.prevent="submit"
class="space-y-4" >
reset-on-error <AuthFormFeedback
@error="code = ''" :state="protection.state.value"
#default="{ errors, processing, clearErrors }" :response-status="protection.responseStatus.value"
:loading-title="t('auth.twoFactor.pending')"
@retry="submit"
/>
<div v-if="!recovery" class="gap-stack grid">
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
<Input
id="code"
key="code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
minlength="6"
maxlength="6"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="code-hint code-error"
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
@paste="pasteCode"
/>
<p id="code-hint" class="text-caption text-muted-foreground">
{{ t('auth.twoFactor.codeHint') }}
</p>
<InputError id="code-error" :message="fieldError" />
</div>
<div v-else class="gap-stack grid">
<Label for="recovery_code">{{
t('auth.twoFactor.recoveryCode')
}}</Label>
<Input
id="recovery_code"
key="recovery"
ref="recoveryInput"
v-model="form.recovery_code"
name="recovery_code"
type="text"
autocomplete="one-time-code"
autocapitalize="none"
:spellcheck="false"
required
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="recovery-hint recovery-error"
class="font-mono"
/>
<p
id="recovery-hint"
class="text-caption text-muted-foreground"
> >
<input type="hidden" name="code" :value="code" /> {{ t('auth.twoFactor.recoveryHint') }}
<div </p>
class="flex flex-col items-center justify-center space-y-3 text-center" <InputError id="recovery-error" :message="fieldError" />
> </div>
<div class="flex w-full items-center justify-center"> <Button
<InputOTP type="submit"
id="otp" class="w-full"
v-model="code" :loading="form.processing"
:maxlength="6" :loading-label="t('auth.twoFactor.pending')"
:disabled="processing" >{{ t('auth.twoFactor.submit') }}</Button
autofocus >
> <Button
<InputOTPGroup> type="button"
<InputOTPSlot variant="outline"
v-for="index in 6" class="w-full whitespace-normal"
:key="index" :disabled="form.processing"
:index="index - 1" @click="toggleMode"
/> >{{
</InputOTPGroup> t(
</InputOTP> recovery
</div> ? 'auth.twoFactor.useCode'
<InputError :message="errors.code" /> : 'auth.twoFactor.useRecovery',
</div> )
<Button type="submit" class="w-full" :disabled="processing" }}</Button
>Continue</Button >
> </form>
<div class="text-muted-foreground text-center text-sm"> <template #footer
<span>or you can </span> ><TextLink :href="login()">{{
<button t('auth.backToLogin')
type="button" }}</TextLink></template
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500" >
@click="() => toggleRecoveryMode(clearErrors)" </AuthFlowLayout>
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
:autofocus="showRecoveryInput"
required
/>
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-muted-foreground text-center text-sm">
<span>or you can </span>
<button
type="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
</div>
</AuthLayout>
</template> </template>
+110 -35
View File
@@ -1,49 +1,124 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, router, useForm, usePage } from '@inertiajs/vue3';
import TextLink from '@/components/TextLink.vue'; import { Mail } from 'lucide-vue-next';
import { computed } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { logout } from '@/routes/index'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { dashboard, logout } from '@/routes/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
defineProps<{ const props = defineProps<{ status?: string }>();
status?: string; const { t } = useLocalization();
}>(); const page = usePage();
const email = computed(() => page.props.auth.user?.email);
const resendForm = useForm({});
const logoutForm = useForm({});
const resendProtection = useFormProtection(resendForm);
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => resendForm.processing || logoutForm.processing);
const sent = computed(
() =>
resendProtection.state.value === 'success' ||
(resendProtection.state.value === 'idle' &&
props.status === 'verification-link-sent'),
);
const resend = (): void => {
if (!busy.value) {
resendProtection.submit(send(), { preserveScroll: true });
}
};
const signOut = (): void => {
if (!busy.value) {
logoutProtection.submit(logout());
}
};
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Verify email" :title="t('auth.verify.title')"
description="Please verify your email address by clicking on the link we just emailed to you." :description="t('auth.verify.description')"
:icon="Mail"
> >
<Head title="Email verification" /> <Head :title="t('auth.verify.title')" />
<div <div
v-if="status === 'verification-link-sent'" v-if="email"
class="mb-4 text-center text-sm font-medium text-green-600" class="gap-stack bg-muted p-card grid rounded-md border"
> >
A new verification link has been sent to the email address you <p class="text-label text-muted-foreground uppercase tracking-wide">
provided during registration. {{ t('auth.verify.emailLabel') }}
</p>
<p class="text-body break-all font-medium">{{ email }}</p>
</div> </div>
<FeedbackState
<Form v-if="sent"
v-bind="send.form()" state="success"
class="space-y-6 text-center" :title="t('auth.verify.sentTitle')"
v-slot="{ processing }" :description="t('auth.verify.sentDescription')"
/>
<form
:action="send.url()"
method="post"
class="gap-column grid"
@submit.prevent="resend"
> >
<Button :disabled="processing" variant="secondary"> <AuthFormFeedback
<Spinner v-if="processing" /> :state="
Resend verification email resendProtection.state.value === 'success'
</Button> ? 'idle'
: resendProtection.state.value
<TextLink "
:href="logout()" :response-status="resendProtection.responseStatus.value"
as="button" :loading-title="t('auth.verify.pending')"
class="mx-auto block text-sm" @retry="resend"
/>
<Button
type="submit"
class="w-full"
:disabled="busy"
:loading="resendForm.processing"
:loading-label="t('auth.verify.pending')"
>{{ t('auth.verify.resend') }}</Button
> >
Log out </form>
</TextLink> <div class="gap-stack pt-column grid border-t">
</Form> <p class="text-body text-muted-foreground">
</AuthLayout> {{ t('auth.verify.help') }}
</p>
<Button
type="button"
variant="outline"
:disabled="busy"
@click="router.visit(dashboard())"
>{{ t('auth.verify.continue') }}</Button
>
</div>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="ghost"
class="w-full"
:disabled="busy"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
<template #footer>{{ t('auth.verify.wrongAccount') }}</template>
</AuthFlowLayout>
</template> </template>
+394 -246
View File
@@ -1,90 +1,155 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import {
import Heading from '@/components/Heading.vue'; ArrowLeft,
ChevronsDownUp,
ChevronsUpDown,
Columns3,
Plus,
RotateCcw,
Search,
} from 'lucide-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import {
buildBacklogTicketTree,
countBacklogTreeTickets,
expandableBacklogTicketIds,
filterBacklogTicketTree,
} from '@/lib/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index'; import { index as projectIndex, show } from '@/routes/projects/index';
import { import { create as createTicket } from '@/routes/projects/tickets';
create as createTicket, import type {
show as showTicket, BacklogFilterState,
} from '@/routes/projects/tickets'; BreadcrumbItem,
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; ProjectBacklogPageProps,
import type { TicketModel } from '@/types/models/ticket-model'; } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBacklogPageProps>(); const props = defineProps<ProjectBacklogPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
]);
const breadcrumbs: BreadcrumbItem[] = [ const filters = reactive<BacklogFilterState>({
{ assigneeId: '',
title: 'Projects', priorityId: '',
href: index(), query: '',
}, statusId: '',
{ typeId: '',
title: props.project.name, });
href: show(props.project.id), const collapsedTicketIds = reactive(new Set<number>());
}, const pendingTicketId = ref<number | null>(null);
{ const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
title: 'Backlog', const filteredTicketTree = computed(() =>
href: backlogIndex(props.project.id), filterBacklogTicketTree(ticketTree.value, filters),
}, );
]; const visibleTicketCount = computed(() =>
countBacklogTreeTickets(filteredTicketTree.value),
);
const expandableTicketIds = computed(() =>
expandableBacklogTicketIds(filteredTicketTree.value),
);
const hasActiveFilters = computed(
() =>
filters.query.trim() !== '' ||
filters.typeId !== '' ||
filters.priorityId !== '' ||
filters.statusId !== '' ||
filters.assigneeId !== '',
);
const allVisibleBranchesCollapsed = computed(
() =>
expandableTicketIds.value.length > 0 &&
expandableTicketIds.value.every((ticketId) =>
collapsedTicketIds.has(ticketId),
),
);
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => { function clearFilters(): void {
if (ticketStatus === null) { filters.query = '';
return 'Unknown status'; filters.typeId = '';
filters.priorityId = '';
filters.statusId = '';
filters.assigneeId = '';
}
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
function toggleAllBranches(): void {
if (allVisibleBranchesCollapsed.value) {
collapsedTicketIds.clear();
return;
} }
if ( for (const ticketId of expandableTicketIds.value) {
ticketStatus.board_column === null || collapsedTicketIds.add(ticketId);
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
} }
}
if (ticketStatus.board_column.name === ticketStatus.name) { function finishMove(ticketId: number): void {
return ticketStatus.name; if (pendingTicketId.value === ticketId) {
pendingTicketId.value = null;
} }
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`; watch(
}; () => props.project.id,
() => {
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { clearFilters();
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter( collapsedTicketIds.clear();
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id, pendingTicketId.value = null;
); },
}; );
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} backlog`" /> <Head :title="t('backlog.title', { name: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
> >
<div class="space-y-3"> <div class="grid min-w-0 gap-2">
<Badge variant="outline" class="px-3 py-1"> <div class="flex min-w-0 flex-wrap items-center gap-3">
{{ project.key }} <Badge
</Badge> variant="outline"
<Heading class="wrap-anywhere max-w-full whitespace-normal"
:title="`${project.name} backlog`" >
description="All newly created tickets start here before they move onto the board." {{ project.key }}
/> </Badge>
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: tickets.length }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('backlog.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('backlog.description') }}
</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-2">
<Button v-if="canCreateTickets" as-child> <Button v-if="canCreateTickets" as-child>
<Link <Link
:href=" :href="
@@ -94,203 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
) )
" "
> >
Create ticket <Plus aria-hidden="true" />
{{ t('navigation.createTicket') }}
</Link> </Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link> <Link :href="boardIndex(project.id)">
<Columns3 aria-hidden="true" />
{{ t('backlog.openBoard') }}
</Link>
</Button> </Button>
<Button as-child variant="ghost">
<Button as-child variant="outline"> <Link :href="show(project.id)">
<Link :href="show(project.id)">Back to project</Link> <ArrowLeft aria-hidden="true" />
{{ t('backlog.backToProject') }}
</Link>
</Button> </Button>
</div> </div>
</header>
<FeedbackState v-if="status" state="success" :title="status" />
<section
aria-labelledby="backlog-filter-title"
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
>
<div
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-filter-title" class="text-title">
{{ t('backlog.filters.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('backlog.filters.description') }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="!hasActiveFilters"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<div
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
>
<div
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
>
<label
for="backlog-query"
class="text-caption font-semibold"
>
{{ t('backlog.filters.search') }}
</label>
<div class="relative min-w-0">
<Search
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="backlog-query"
v-model="filters.query"
type="search"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
:placeholder="
t('backlog.filters.searchPlaceholder')
"
/>
</div>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-type"
class="text-caption font-semibold"
>
{{ t('backlog.filters.type') }}
</label>
<select
id="backlog-type"
v-model="filters.typeId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allTypes') }}
</option>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-priority"
class="text-caption font-semibold"
>
{{ t('backlog.filters.priority') }}
</label>
<select
id="backlog-priority"
v-model="filters.priorityId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allPriorities') }}
</option>
<option
v-for="priority in ticketPriorities"
:key="priority.id"
:value="priority.id"
>
{{ priority.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-status"
class="text-caption font-semibold"
>
{{ t('backlog.filters.status') }}
</label>
<select
id="backlog-status"
v-model="filters.statusId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allStatuses') }}
</option>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-assignee"
class="text-caption font-semibold"
>
{{ t('backlog.filters.assignee') }}
</label>
<select
id="backlog-assignee"
v-model="filters.assigneeId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allAssignees') }}
</option>
<option value="unassigned">
{{ t('tickets.unassigned') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</div>
</section>
<div
v-if="tickets.length === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.empty.title')"
:description="t('backlog.empty.description')"
/>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
<Plus aria-hidden="true" />
{{ t('backlog.empty.action') }}
</Link>
</Button>
</div> </div>
<div <div
v-if="status" v-else-if="visibleTicketCount === 0"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" class="grid justify-items-start gap-3"
> >
{{ status }} <FeedbackState
state="empty"
:title="t('backlog.filters.empty.title')"
:description="t('backlog.filters.empty.description')"
/>
<Button
type="button"
variant="outline"
size="sm"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div> </div>
<Card class="gap-0 overflow-hidden"> <section
<CardHeader class="border-b"> v-else
<CardTitle>Backlog tickets</CardTitle> aria-labelledby="backlog-results-title"
<CardDescription> class="grid min-w-0 gap-3"
Tickets remain here while their status belongs to >
backlog. <div
</CardDescription> class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
</CardHeader> >
<div class="grid min-w-0 gap-1">
<CardContent class="p-0"> <h2 id="backlog-results-title" class="text-title">
<div {{ t('backlog.results.title') }}
v-if="tickets.length === 0" </h2>
class="text-muted-foreground p-6 text-sm" <p class="text-caption text-muted-foreground">
{{
t('backlog.results.count', {
count: visibleTicketCount,
total: tickets.length,
})
}}
</p>
</div>
<Button
v-if="expandableTicketIds.length > 0"
type="button"
variant="outline"
size="sm"
@click="toggleAllBranches"
> >
No backlog tickets exist yet. <ChevronsUpDown
</div> v-if="allVisibleBranchesCollapsed"
aria-hidden="true"
/>
<ChevronsDownUp v-else aria-hidden="true" />
{{
t(
allVisibleBranchesCollapsed
? 'backlog.tree.expandAll'
: 'backlog.tree.collapseAll',
)
}}
</Button>
</div>
<div v-else class="overflow-x-auto"> <BacklogTicketTree
<table :nodes="filteredTicketTree"
class="divide-border min-w-full divide-y text-sm" :project-id="project.id"
> :can-edit-tickets="canEditTickets"
<thead :collapsed-ticket-ids="collapsedTicketIds"
class="bg-muted/40 text-muted-foreground text-left" :pending-ticket-id="pendingTicketId"
> @toggle="toggleTicket"
<tr> @move-start="pendingTicketId = $event"
<th class="px-4 py-3 font-medium">Key</th> @move-finish="finishMove"
<th class="px-4 py-3 font-medium">Title</th> />
<th class="px-4 py-3 font-medium">Type</th> </section>
<th class="px-4 py-3 font-medium">
Status
</th>
<th class="px-4 py-3 font-medium">
Priority
</th>
<th
v-if="canEditTickets"
class="px-4 py-3 font-medium"
>
Move to status
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="ticket in tickets"
:key="ticket.id"
class="bg-background"
>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
>
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
</Link>
</td>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ ticket.title }}
</Link>
<div
v-if="ticket.description"
class="text-muted-foreground mt-1 line-clamp-2"
>
{{ ticket.description }}
</div>
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_type.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_priority.name }}
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-bind="
ProjectBoardController.move.form(
{
project: project.id,
ticket: ticket.id,
},
)
"
class="flex flex-col gap-2 sm:flex-row"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<select
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
<p
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
class="text-muted-foreground text-xs sm:basis-full"
>
This status does not allow
moving anywhere else.
</p>
</Form>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+615 -276
View File
@@ -1,91 +1,248 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed, nextTick, ref, watch } from 'vue';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { useBoardDrag } from '@/composables/useBoardDrag';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { import { index as projectIndex, show } from '@/routes/projects/index';
create as createTicket, import { create as createTicket } from '@/routes/projects/tickets';
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardPageProps>(); const props = defineProps<ProjectBoardPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: projectIndex() },
title: 'Projects', { title: props.project.name, href: show(props.project.id) },
href: projectIndex(), { title: t('navigation.board'), href: boardIndex(props.project.id) },
]);
const selectedColumnId = ref<number>();
watch(
() =>
[
props.project.id,
props.boardColumns.map((column) => column.id),
] as const,
([projectId, columnIds], previous) => {
if (
projectId !== previous?.[0] ||
!columnIds.includes(selectedColumnId.value ?? -1)
) {
selectedColumnId.value = columnIds[0];
}
}, },
{ { immediate: true },
title: props.project.name, );
href: show(props.project.id), const columns = computed(() =>
}, props.boardColumns.map((column) => {
{ const ticketsByStatus = new Map<number | null, TicketModel[]>();
title: 'Board', for (const ticket of column.tickets ?? []) {
href: boardIndex(props.project.id), const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
}, tickets.push(ticket);
]; ticketsByStatus.set(ticket.ticket_status_id, tickets);
}
return {
...column,
count: column.tickets?.length ?? 0,
groups: (column.ticket_statuses ?? []).map((status) => ({
status,
tickets: ticketsByStatus.get(status.id) ?? [],
})),
};
}),
);
const ticketCount = computed(() =>
columns.value.reduce((count, column) => count + column.count, 0),
);
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => { const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
if (ticketStatus === null) { const feedback = useFormProtection(moveForm, { isDirty: () => false });
return 'Unknown status'; const menuPending = ref<number | null>(null);
} const busy = computed(() => moveForm.processing || menuPending.value !== null);
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
if ( const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
ticketStatus.board_column === null || const findTicket = (id: number) =>
ticketStatus.board_column === undefined props.boardColumns
) { .flatMap((column) => column.tickets ?? [])
return ticketStatus.name; .find((ticket) => ticket.id === id);
} const targetsForColumn = (ticket: TicketModel, columnId: number) => {
const statusIds =
if (ticketStatus.board_column.name === ticketStatus.name) { props.boardColumns
return ticketStatus.name; .find((column) => column.id === columnId)
} ?.ticket_statuses?.map((status) => status.id) ?? [];
return allowedTargetStatuses(ticket).filter((status) =>
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`; statusIds.includes(status.id),
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
); );
}; };
const choiceTicket = computed(() =>
choice.value ? findTicket(choice.value.ticketId) : undefined,
);
const choiceTargets = computed(() =>
choice.value && choiceTicket.value
? targetsForColumn(choiceTicket.value, choice.value.columnId)
: [],
);
const choiceOpen = computed({
get: () => choice.value !== null,
set: (open: boolean) => {
if (!open) {
choice.value = null;
}
},
});
function submitMove(ticketId: number, statusId: number): void {
const ticket = findTicket(ticketId);
if (
busy.value ||
!props.canEditTickets ||
!ticket ||
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
) {
return;
}
lastMove.value = { ticketId, statusId };
moveForm.ticket_status_id = statusId;
moveForm.clearErrors();
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
errorBag: `board-ticket-${ticketId}`,
preserveScroll: true,
onSuccess: async () => {
const destination = props.boardColumns.find((column) =>
column.ticket_statuses?.some(
(status) => status.id === statusId,
),
);
if (destination) {
selectedColumnId.value = destination.id;
}
await nextTick();
document
.getElementById(`ticket-status-${ticketId}`)
?.focus({ preventScroll: true });
},
});
}
function dropTicket(ticket: TicketModel, columnId: number): void {
const current = findTicket(ticket.id);
if (!current || busy.value || !props.canEditTickets) {
return;
}
const targets = targetsForColumn(current, columnId);
if (targets.length === 1) {
submitMove(current.id, targets[0].id);
} else if (targets.length > 1) {
moveForm.ticket_status_id = targets[0].id;
choice.value = { ticketId: current.id, columnId };
}
}
const {
draggedTicket,
columnId: dropColumnId,
x: dragX,
y: dragY,
start: startDrag,
cancel: cancelDrag,
} = useBoardDrag({
canStart: (ticket) =>
props.canEditTickets &&
!busy.value &&
!choice.value &&
allowedTargetStatuses(ticket).length > 0,
drop: dropTicket,
});
const canDrop = (columnId: number) =>
!!draggedTicket.value &&
targetsForColumn(draggedTicket.value, columnId).length > 0;
const dropClasses = (columnId: number) =>
!draggedTicket.value
? {}
: {
'border-primary border-dashed': canDrop(columnId),
'ring-2 ring-primary bg-primary/10':
canDrop(columnId) && dropColumnId.value === columnId,
'opacity-40': !canDrop(columnId),
};
function confirmMove(): void {
if (
!choice.value ||
!choiceTargets.value.some(
(status) => status.id === moveForm.ticket_status_id,
)
) {
return;
}
const ticketId = choice.value.ticketId;
choice.value = null;
submitMove(ticketId, moveForm.ticket_status_id);
}
function focusStatus(ticketId: number): void {
document.getElementById(`ticket-status-${ticketId}`)?.focus();
}
watch(
() => [props.project.id, props.boardColumns, props.canEditTickets],
() => {
cancelDrag();
choice.value = null;
},
);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board`" /> <Head :title="t('board.title', { name: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
> >
<div class="space-y-3"> <div class="grid min-w-0 gap-2">
<Badge variant="outline" class="px-3 py-1"> <div class="flex min-w-0 flex-wrap items-center gap-3">
{{ project.key }} <Badge
</Badge> variant="outline"
<Heading class="wrap-anywhere max-w-full whitespace-normal"
:title="`${project.name} board`" >{{ project.key }}</Badge
description="Tickets only appear here once they are assigned to one of the project's workflow columns." >
/> <p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: ticketCount }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('board.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('board.description') }}
</p>
</div> </div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child> <Button v-if="canCreateTickets" as-child>
<Link <Link
:href=" :href="
@@ -95,245 +252,427 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
) )
" "
> >
Create ticket <Plus aria-hidden="true" />{{
t('navigation.createTicket')
}}
</Link> </Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="backlogIndex(project.id)" <Link :href="backlogIndex(project.id)"
>Open backlog</Link ><ListTodo aria-hidden="true" />{{
t('board.openBacklog')
}}</Link
> >
</Button> </Button>
<Button as-child variant="ghost">
<Button as-child variant="outline"> <Link :href="show(project.id)"
<Link :href="show(project.id)">Back to project</Link> ><ArrowLeft aria-hidden="true" />{{
t('board.backToProject')
}}</Link
>
</Button> </Button>
</div> </div>
</div> </header>
<div <FeedbackState v-if="status" state="success" :title="status" />
v-if="status" <FormFeedback
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" :state="feedback.state.value"
> :description="moveForm.errors.ticket_status_id"
{{ status }} @retry="
</div> lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
"
/>
<FeedbackState
v-if="columns.length === 0"
state="empty"
:title="t('board.empty.title')"
:description="t('board.empty.description')"
/>
<Card v-if="boardColumns.length === 0"> <template v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm"> <div class="grid min-w-0 gap-2 md:hidden">
<p class="text-muted-foreground"> <label for="board-column" class="text-body font-semibold">{{
The board has no workflow columns yet, so no tickets can t('board.selectColumn')
appear here. }}</label>
</p> <select
<div v-if="canEditTickets"> id="board-column"
<Button as-child variant="outline"> v-model="selectedColumnId"
<Link :href="boardSettings(project.id)"> class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
Open board settings :aria-controls="`board-column-${selectedColumnId}`"
</Link> >
</Button> <option
</div> v-for="column in columns"
</CardContent> :key="column.id"
</Card> :value="column.id"
<div v-else class="grid gap-4 xl:grid-cols-3">
<Card
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="flex h-full flex-col gap-0 overflow-hidden"
>
<CardHeader class="bg-muted/20 border-b">
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
<CardDescription>
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<Badge variant="secondary">
#{{ boardColumn.position }}
</Badge>
</div>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-4 p-4">
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
> >
No tickets are currently in this column. {{
</div> t('board.columnOption', {
name: column.name,
<div v-else class="flex flex-col gap-4"> count: column.count,
<Card })
v-for="ticket in boardColumn.tickets" }}
:key="ticket.id" </option>
:class="[ </select>
'gap-0', </div>
ticket.is_blocked <p
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30' id="board-scroll-hint"
: '', class="text-caption text-muted-foreground hidden md:block"
]" >
{{ t('board.scrollHint') }}
</p>
<p
v-if="canEditTickets"
id="board-drag-hint"
class="text-caption text-muted-foreground"
>
{{ t('board.dragHint') }}
</p>
<section
:aria-label="t('board.columns')"
aria-describedby="board-scroll-hint"
tabindex="0"
data-board-drag-scroll
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
>
<section
v-for="column in columns"
:id="`board-column-${column.id}`"
:key="`${project.id}-${column.id}`"
:aria-labelledby="`board-column-title-${column.id}`"
:data-board-drop-column="column.id"
:class="[
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
selectedColumnId === column.id ? 'flex' : 'hidden',
dropClasses(column.id),
]"
>
<header
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
>
<h2
:id="`board-column-title-${column.id}`"
class="text-title wrap-anywhere"
> >
<CardHeader class="pb-4"> {{ column.name }}
<div class="flex flex-wrap gap-2"> </h2>
<Badge variant="outline"> <Badge
{{ ticket.ticket_key }} variant="secondary"
</Badge> class="shrink-0 tabular-nums"
<Badge :aria-label="
v-if="ticket.is_blocked" t('tickets.count', { count: column.count })
variant="destructive" "
> >{{ column.count }}</Badge
Blocked >
</Badge> </header>
<Badge variant="secondary"> <p
{{ ticket.ticket_type.name }} v-if="column.count === 0"
</Badge> class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
<Badge variant="secondary"> >
{{ ticket.ticket_priority.name }} {{ t('board.emptyColumn') }}
</Badge> </p>
<Badge variant="outline"> <p
{{ v-if="column.groups.length === 0"
ticketStatusLabel( class="text-caption text-muted-foreground"
ticket.ticket_status, >
) {{ t('board.noStatuses') }}
}} </p>
</Badge> <section
</div> v-for="group in column.groups"
<CardTitle class="text-base"> :key="group.status.id"
<Link :aria-labelledby="`board-status-${group.status.id}`"
:href=" class="grid min-w-0 gap-3"
showTicket({ >
project: project.id, <div
ticket: ticket.id, class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
}) >
" <h3
class="hover:text-primary transition" :id="`board-status-${group.status.id}`"
> class="wrap-anywhere font-semibold"
{{ ticket.title }} >
</Link> {{ group.status.name }}
</CardTitle> </h3>
<CardDescription <span
v-if="ticket.description" class="shrink-0 tabular-nums"
class="line-clamp-3" :aria-label="
t('tickets.count', {
count: group.tickets.length,
})
"
>{{ group.tickets.length }}</span
>
</div>
<p
v-if="group.tickets.length === 0"
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
>
{{ t('board.emptyStatus') }}
</p>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
:class="{
'opacity-50':
draggedTicket?.id === ticket.id,
}"
>
<TicketCard
:project-id="project.id"
:ticket="ticket"
:show-status="false"
> >
{{ ticket.description }} <template
</CardDescription> v-if="canEditTickets"
<CardDescription #actions
v-if="
ticket.is_blocked &&
ticket.blocked_comment
"
class="mt-2 text-amber-900 dark:text-amber-200"
>
{{ ticket.blocked_comment }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
> >
Move ticket <Button
</label> type="button"
<div variant="ghost"
class="flex flex-col gap-2 sm:flex-row" class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled=" :disabled="
busy ||
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
).length === 0 || processing ).length === 0
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :aria-label="
t('board.dragTicket', {
key: ticket.ticket_key,
})
"
aria-describedby="board-drag-hint"
@pointerdown="
startDrag($event, ticket)
"
@click.prevent
@keydown.enter.prevent="
focusStatus(ticket.id)
"
@keydown.space.prevent="
focusStatus(ticket.id)
"
><GripVertical
aria-hidden="true"
/>{{ t('board.drag') }}</Button
> >
<option <Form
v-bind="
move.form({
project: project.id,
ticket: ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`board-ticket-${ticket.id}`"
:options="{
preserveScroll: true,
}"
:on-before="
() =>
!busy && !draggedTicket
"
@start="menuPending = ticket.id"
@finish="menuPending = null"
class="grid min-w-0 gap-2"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>{{
t('board.moveTicket', {
key: ticket.ticket_key,
})
}}</label
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
:aria-invalid="
!!errors.ticket_status_id ||
undefined
"
:aria-describedby="
errors.ticket_status_id
? `ticket-status-error-${ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t(
'tickets.noStatus',
),
)
}}
</option>
</select>
<InputError
:id="`ticket-status-error-${ticket.id}`"
:message="
errors.ticket_status_id
"
/>
<Button
type="submit"
variant="outline"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}</Button
>
<p
v-if=" v-if="
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
).length === 0 ).length === 0
" "
value="" class="text-caption text-muted-foreground"
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
> >
{{ {{
ticketStatusLabel( t(
ticketStatus, 'board.noMovesDescription',
) )
}} }}
</option> </p>
</select> </Form>
<Button </template>
type="submit" </TicketCard>
size="sm" </li>
:disabled=" </ul>
processing || </section>
allowedTargetStatuses( </section>
ticket, </section>
).length === 0 </template>
" <div
> v-if="draggedTicket"
Update status class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
</Button> data-board-mobile-targets
</div> >
<p <p class="text-caption mb-2 font-semibold">
v-if=" {{ t('board.dropHint') }}
allowedTargetStatuses(ticket) </p>
.length === 0 <div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
" <div
class="text-muted-foreground text-xs" v-for="column in columns"
> :key="column.id"
This status does not allow moving :data-board-drop-column="column.id"
anywhere else. :class="dropClasses(column.id)"
</p> class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
</Form> >
</CardContent> {{ column.name }}
</Card> </div>
</div> </div>
</CardContent>
</Card>
</div> </div>
<div
v-if="draggedTicket"
aria-hidden="true"
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
:style="{
left: `${Math.max(0, dragX - 80)}px`,
top: `${Math.max(0, dragY - 56)}px`,
}"
>
{{ draggedTicket.ticket_key }}
</div>
<p class="sr-only" aria-live="polite">
{{
draggedTicket
? t('board.dragging', { key: draggedTicket.ticket_key })
: ''
}}
</p>
<Dialog v-model:open="choiceOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
<DialogDescription>{{
t('board.chooseStatusDescription', {
key: choiceTicket?.ticket_key ?? '',
})
}}</DialogDescription>
</DialogHeader>
<form class="grid gap-4" @submit.prevent="confirmMove">
<label
for="board-drop-status"
class="text-body font-semibold"
>{{ t('board.targetStatus') }}</label
>
<select
id="board-drop-status"
v-model="moveForm.ticket_status_id"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option
v-for="target in choiceTargets"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="choiceOpen = false"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="busy || choiceTargets.length === 0"
>{{ t('board.updateStatus') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+566 -282
View File
@@ -1,349 +1,633 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { import {
Card, ArrowLeft,
CardContent, Check,
CardDescription, Copy,
CardHeader, FolderPlus,
CardTitle, LayoutTemplate,
} from '@/components/ui/card'; } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/projects/index'; import { create, index } from '@/routes/projects';
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
const props = defineProps<ProjectsCreatePageProps>(); const props = defineProps<ProjectsCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', { title: t('projects.create'), href: create() },
href: index(), ]);
},
{
title: 'New project',
href: create(),
},
];
const defaultTemplateId = const defaultTemplateId =
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null; props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm(
const form = useForm({ `ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
copy_areas: [] as string[], {
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch', creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '', name: '',
name: '', key: '',
source_project_id: props.sourceProjects[0]?.id source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id) ? String(props.sourceProjects[0].id)
: '', : '',
source_template_id: source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '', defaultTemplateId !== null ? String(defaultTemplateId) : '',
}); copy_areas: [] as string[],
},
const hasSourceProjects = computed(() => props.sourceProjects.length > 0); );
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0); const protection = useFormProtection(form);
const modes = computed(() => [
const copyAreasError = computed(() => { {
return ( value: 'scratch',
icon: FolderPlus,
title: t('projectCreation.scratch'),
description: t('projectCreation.scratchDescription'),
disabled: false,
},
{
value: 'duplicate',
icon: Copy,
title: t('projectCreation.duplicate'),
description: t(
props.sourceProjects.length
? 'projectCreation.duplicateDescription'
: 'projectCreation.noProjects',
),
disabled: props.sourceProjects.length === 0,
},
{
value: 'template',
icon: LayoutTemplate,
title: t('projectCreation.template'),
description: t(
props.sourceTemplates.length
? 'projectCreation.templateDescription'
: 'projectCreation.noTemplates',
),
disabled: props.sourceTemplates.length === 0,
},
]);
const areaKeys = {
project_settings: 'projectCreation.areas.projectSettings',
board_columns: 'projectCreation.areas.boardColumns',
ticket_statuses: 'projectCreation.areas.ticketStatuses',
status_transitions: 'projectCreation.areas.statusTransitions',
ticket_types: 'projectCreation.areas.ticketTypes',
ticket_priorities: 'projectCreation.areas.ticketPriorities',
ticket_fields: 'projectCreation.areas.ticketFields',
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
repository_connections: 'projectCreation.areas.repositories',
notification_actions: 'projectCreation.areas.notifications',
} as const;
function areaText(value: string, part: 'label' | 'description'): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key
? t(`${key}.${part}`)
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
value);
}
const selectedProject = computed(() =>
props.sourceProjects.find(
(project) => String(project.id) === form.source_project_id,
),
);
const selectedTemplate = computed(() =>
props.sourceTemplates.find(
(template) => String(template.id) === form.source_template_id,
),
);
const selectedMode = computed(() =>
modes.value.find((mode) => mode.value === form.creation_mode),
);
const ticketKey = computed(
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
);
const copyAreasError = computed(
() =>
form.errors.copy_areas ?? form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'), key.startsWith('copy_areas.'),
)?.[1] ?? )?.[1],
'' );
); const selectClass =
}); 'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
const submit = (): void => { watch(
if (form.creation_mode !== 'duplicate') { () => form.creation_mode,
form.source_project_id = ''; () => {
form.copy_areas = []; form.clearErrors(
} 'creation_mode',
'source_project_id',
'source_template_id',
'copy_areas',
);
for (const key of Object.keys(form.errors)) {
if (key.startsWith('copy_areas.')) {
form.clearErrors(key as 'copy_areas');
}
}
protection.clearFeedback();
},
);
if (form.creation_mode !== 'template') { function submit(): void {
form.source_template_id = ''; form.transform((data) => ({
} ...data,
source_project_id:
form.post(ProjectController.store.url(), { data.creation_mode === 'duplicate' ? data.source_project_id : '',
preserveScroll: true, source_template_id:
}); data.creation_mode === 'template' ? data.source_template_id : '',
}; copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
}));
protection.submit(store(), { preserveScroll: true });
}
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="New project" /> <Head :title="t('projects.create')" />
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
>
<header class="gap-stack grid">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('projectCreation.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projectCreation.description') }}
</p>
</header>
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <form
<Heading class="gap-page grid min-w-0"
title="Create project" :aria-busy="form.processing"
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template." @submit.prevent="submit"
/> >
<FormFeedback
<Card> :state="protection.state.value"
<CardHeader> :title="
<CardTitle>Project details</CardTitle> form.processing
<CardDescription> ? t('projectCreation.creating')
The key is uppercased automatically, must be unique, and : undefined
can contain up to 5 characters. "
</CardDescription> @retry="submit"
</CardHeader> />
<fieldset
<CardContent> class="gap-column grid min-w-0"
<form class="space-y-6" @submit.prevent="submit"> :disabled="form.processing"
<div class="grid gap-3"> aria-describedby="creation-mode-error"
<Label>How do you want to start?</Label> >
<div class="grid gap-3 sm:grid-cols-3"> <legend class="text-title mb-3">
<Label {{ t('projectCreation.start') }}
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4" </legend>
> <div class="gap-column grid md:grid-cols-3">
<input <label
v-model="form.creation_mode" v-for="mode in modes"
type="radio" :key="mode.value"
value="scratch" class="relative min-w-0"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
Create from scratch
</div>
<p
class="text-muted-foreground text-sm"
>
Start with the default project
setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceProjects
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="duplicate"
class="mt-1"
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">
Create from existing project
</div>
<p
class="text-muted-foreground text-sm"
>
Copy only the settings you
explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="template"
class="mt-1"
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">
Create from template
</div>
<p
class="text-muted-foreground text-sm"
>
Reuse a saved configuration
template.
</p>
</div>
</Label>
</div>
</div>
<div class="grid gap-2">
<Label for="name">Project name</Label>
<Input
id="name"
v-model="form.name"
required
autofocus
placeholder="Kanban foundation"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="key">Project key</Label>
<Input
id="key"
v-model="form.key"
required
maxlength="5"
placeholder="CORE"
class="uppercase"
/>
<p class="text-muted-foreground text-sm">
Example ticket keys will look like
<span class="text-foreground font-medium"
>CORE-1</span
>.
</p>
<InputError :message="form.errors.key" />
</div>
<div
v-if="form.creation_mode === 'duplicate'"
class="space-y-6 rounded-xl border border-dashed p-4"
> >
<div class="grid gap-2"> <input
<Label for="source_project_id" v-model="form.creation_mode"
>Source project</Label type="radio"
name="creation_mode"
:value="mode.value"
:disabled="mode.disabled"
:aria-invalid="!!form.errors.creation_mode"
:aria-labelledby="`mode-${mode.value}-title`"
:aria-describedby="`mode-${mode.value}-description`"
class="peer sr-only"
/>
<span
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
:class="
!mode.disabled
? 'hover:border-primary/50'
: ''
"
>
<span
class="flex items-center justify-between gap-3"
> >
<component
:is="mode.icon"
class="text-primary size-5"
aria-hidden="true"
/>
<Check
v-if="form.creation_mode === mode.value"
class="text-primary size-4"
aria-hidden="true"
/>
</span>
<span
:id="`mode-${mode.value}-title`"
class="font-semibold"
>{{ mode.title }}</span
>
<span
:id="`mode-${mode.value}-description`"
class="text-caption text-muted-foreground"
>{{ mode.description }}</span
>
</span>
</label>
</div>
<InputError
id="creation-mode-error"
:message="form.errors.creation_mode"
/>
</fieldset>
<div
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
>
<div class="gap-page grid min-w-0">
<section
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="project-details-title"
>
<h2 id="project-details-title" class="text-title">
{{ t('projectCreation.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{
t('projectCreation.name')
}}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing"
:placeholder="
t('projectCreation.namePlaceholder')
"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="key">{{
t('projectCreation.key')
}}</Label>
<Input
id="key"
v-model="form.key"
name="key"
required
maxlength="5"
:disabled="form.processing"
:placeholder="
t('projectCreation.keyPlaceholder')
"
class="uppercase sm:max-w-48"
:invalid="!!form.errors.key"
aria-describedby="key-help key-error"
autocapitalize="characters"
:spellcheck="false"
/>
<p
id="key-help"
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.keyHelp') }}
</p>
<InputError
id="key-error"
:message="form.errors.key"
/>
</div>
</section>
<section
v-if="form.creation_mode === 'duplicate'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="copy-title"
>
<div class="gap-stack grid">
<h2 id="copy-title" class="text-title">
{{ t('projectCreation.copyTitle') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('projectCreation.copyHelp') }}
</p>
</div>
<div class="gap-stack grid min-w-0">
<Label for="source_project_id">{{
t('projectCreation.sourceProject')
}}</Label>
<select <select
id="source_project_id" id="source_project_id"
v-model="form.source_project_id" v-model="form.source_project_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" required
:disabled="!hasSourceProjects" :class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_project_id
"
aria-describedby="source-project-error"
> >
<option value=""> <option value="">
Select a source project {{ t('projectCreation.selectProject') }}
</option> </option>
<option <option
v-for="sourceProject in sourceProjects" v-for="project in sourceProjects"
:key="sourceProject.id" :key="project.id"
:value="String(sourceProject.id)" :value="String(project.id)"
> >
{{ sourceProject.name }} ({{ {{ project.name }} ({{ project.key }})
sourceProject.key
}})
</option> </option>
</select> </select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key,
and future ticket numbering.
</p>
<InputError <InputError
id="source-project-error"
:message="form.errors.source_project_id" :message="form.errors.source_project_id"
/> />
</div> </div>
<fieldset
<div class="grid gap-3"> class="min-w-0"
<div> :disabled="form.processing"
<Label>Copy options</Label> aria-describedby="copy-help copy-areas-error"
<p class="text-muted-foreground text-sm"> >
Choose which settings should be copied <legend class="mb-2 font-semibold">
into the new project. {{ t('projectCreation.copyOptions') }}
</p> </legend>
</div> <p
id="copy-help"
<div class="grid gap-3"> class="text-caption text-muted-foreground mb-3"
<Label >
v-for="copyAreaOption in copyAreaOptions" {{ t('projectCreation.copyDefaults') }}
:key="copyAreaOption.value" </p>
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4" <div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
> >
<input <input
v-model="form.copy_areas" v-model="form.copy_areas"
type="checkbox" type="checkbox"
:value="copyAreaOption.value" :value="area.value"
class="mt-1" :aria-labelledby="`copy-${area.value}-label`"
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
/> />
<div class="space-y-1"> <span class="gap-stack grid min-w-0">
<div class="font-medium"> <span
{{ copyAreaOption.label }} :id="`copy-${area.value}-label`"
</div> class="text-body font-semibold"
<p >{{
class="text-muted-foreground text-sm" areaText(
area.value,
'label',
)
}}</span
> >
{{ copyAreaOption.description }} <span
</p> :id="`copy-${area.value}-description`"
</div> class="text-caption text-muted-foreground"
</Label> >{{
areaText(
area.value,
'description',
)
}}</span
>
</span>
</label>
</div> </div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</section>
<p class="text-muted-foreground text-sm"> <section
Operational ticket data is always left
behind so the new project starts clean.
</p>
<InputError :message="copyAreasError" />
</div>
</div>
<div
v-else-if="form.creation_mode === 'template'" v-else-if="form.creation_mode === 'template'"
class="space-y-4 rounded-xl border border-dashed p-4" class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-title"
> >
<div class="grid gap-2"> <h2 id="template-title" class="text-title">
<Label for="source_template_id">Template</Label> {{ t('projectCreation.chooseTemplate') }}
</h2>
<div class="gap-stack grid min-w-0">
<Label for="source_template_id">{{
t('projectCreation.templateLabel')
}}</Label>
<select <select
id="source_template_id" id="source_template_id"
v-model="form.source_template_id" v-model="form.source_template_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" required
:disabled="!hasSourceTemplates" :class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_template_id
"
aria-describedby="template-help source-template-error"
> >
<option value="">Select a template</option> <option value="">
{{
t('projectCreation.selectTemplate')
}}
</option>
<option <option
v-for="sourceTemplate in sourceTemplates" v-for="template in sourceTemplates"
:key="sourceTemplate.id" :key="template.id"
:value="String(sourceTemplate.id)" :value="String(template.id)"
> >
{{ sourceTemplate.name }} ({{ {{ template.name }} ({{
sourceTemplate.slug template.slug
}}) }})
</option> </option>
</select> </select>
<p class="text-muted-foreground text-sm"> <p
Templates apply their saved configuration id="template-help"
groups to the new project. class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateHelp') }}
</p> </p>
<InputError <InputError
id="source-template-error"
:message="form.errors.source_template_id" :message="form.errors.source_template_id"
/> />
</div> </div>
</div> <div
v-if="selectedTemplate"
class="gap-stack grid"
aria-live="polite"
>
<h3 class="text-body font-semibold">
{{ t('projectCreation.includedAreas') }}
</h3>
<ul
v-if="selectedTemplate.copy_areas.length"
class="flex flex-wrap gap-2"
>
<li
v-for="area in selectedTemplate.copy_areas"
:key="area"
>
<Badge
variant="secondary"
class="whitespace-normal"
>{{
areaText(area, 'label')
}}</Badge
>
</li>
</ul>
<p
v-else
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateDefaults') }}
</p>
</div>
</section>
</div>
<div <aside
v-else-if=" class="bg-card gap-column grid min-w-0 rounded-md border p-5"
!hasSourceProjects && !hasSourceTemplates aria-labelledby="summary-title"
" >
class="text-muted-foreground text-sm" <h2 id="summary-title" class="text-title">
{{ t('projectCreation.summary') }}
</h2>
<dl class="gap-column grid min-w-0">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.name') }}
</dt>
<dd class="break-words font-semibold">
{{
form.name.trim() ||
t('projectCreation.untitled')
}}
</dd>
</div>
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.ticketPreview') }}
</dt>
<dd>
<Badge variant="outline">{{
ticketKey
}}</Badge>
</dd>
</div>
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.setup') }}
</dt>
<dd class="break-words">
{{ selectedMode?.title }}
</dd>
</div>
<div
v-if="form.creation_mode === 'duplicate'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words">
{{
selectedProject?.name ||
t('projectCreation.selectProject')
}}
</dd>
<dd class="text-caption text-muted-foreground">
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div
v-else-if="form.creation_mode === 'template'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.templateLabel') }}
</dt>
<dd class="break-words">
{{
selectedTemplate?.name ||
t('projectCreation.selectTemplate')
}}
</dd>
</div>
</dl>
<p
class="text-caption text-muted-foreground border-t pt-4"
> >
No accessible source projects or saved templates are {{ t('projectCreation.cleanStart') }}
available yet. </p>
</div> </aside>
</div>
<div class="flex items-center gap-3"> <footer
<Button type="submit" :disabled="form.processing"> class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
Create project >
</Button> <Button
as-child
<Button as-child variant="outline"> variant="outline"
<Link :href="index()">Cancel</Link> :disabled="form.processing"
</Button> ><Link :href="index()">{{
</div> t('common.cancel')
</form> }}</Link></Button
</CardContent> >
</Card> <Button
type="submit"
:loading="form.processing"
:aria-label="
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
"
:loading-label="t('projectCreation.creating')"
><FolderPlus
v-if="!form.processing"
aria-hidden="true"
/>{{
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
}}</Button
>
</footer>
</form>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+158 -77
View File
@@ -1,103 +1,184 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { Folder, Plus } from 'lucide-vue-next'; import {
import Heading from '@/components/Heading.vue'; ArrowUpRight,
Columns3,
FolderGit2,
ListTodo,
Plus,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects/index'; import { create, index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types'; import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>(); defineProps<ProjectsIndexPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', ]);
href: index(),
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Projects" /> <Head :title="t('navigation.projects')" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<Heading <div class="gap-stack grid min-w-0">
title="Projects" <p class="text-label text-muted-foreground uppercase">
description="Create and browse project spaces that will later host boards, backlog, and settings." {{ t('projects.workspace') }}
/> </p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.projects') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.description') }}
</p>
</div>
<Button <Button
v-if="$page.props.auth.permissions.projectCreate" v-if="$page.props.auth.permissions.projectCreate"
as-child as-child
class="self-start"
> >
<Link :href="create()"> <Link :href="create()"
<Plus class="size-4" /> ><Plus aria-hidden="true" />{{
New project t('projects.create')
</Link> }}</Link
</Button>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<div
v-if="projects.length === 0"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
>
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
class="group block"
>
<Card
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
> >
<CardHeader class="gap-3"> </Button>
<div class="flex items-start justify-between gap-3"> </header>
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>
Project workspace entry point
</CardDescription>
</div>
<Badge variant="outline" class="px-2.5 py-1"> <FeedbackState
{{ project.key }} v-if="status"
</Badge> state="success"
</div> :title="t('projects.updated')"
</CardHeader> :description="status"
/>
<CardContent <FeedbackState
class="text-muted-foreground flex items-center gap-2 text-sm" v-if="projects.length === 0"
state="empty"
:title="t('projects.empty.title')"
:description="
$page.props.auth.permissions.projectCreate
? t('projects.empty.createDescription')
: t('projects.empty.accessDescription')
"
>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.empty.create')
}}</Link
>
</Button>
</FeedbackState>
<section
v-else
aria-labelledby="project-list-title"
class="gap-column grid"
>
<h2
id="project-list-title"
class="text-caption text-muted-foreground"
>
{{ t('projects.available', { count: projects.length }) }}
</h2>
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
<li
v-for="project in projects"
:key="project.id"
class="min-w-0"
>
<article
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
> >
<Folder class="size-4" /> <Link
Open board, backlog, and settings hub :href="show(project.id)"
</CardContent> :aria-label="
</Card> t('projects.open', { name: project.name })
</Link> "
</div> class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
>
<span
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
>
<FolderGit2
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid min-w-0 flex-1">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h3
class="text-title group-hover:text-primary break-words"
>
{{ project.name }}
</h3>
<span
class="text-caption text-muted-foreground"
>{{ t('projects.overview') }}</span
>
</div>
<ArrowUpRight
class="text-muted-foreground mt-1 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<div
class="gap-stack p-card grid grid-cols-2 border-t"
>
<Button as-child variant="secondary">
<Link
:href="boardIndex(project.id)"
:aria-label="
t('projects.openBoard', {
name: project.name,
})
"
>
<Columns3 aria-hidden="true" />{{
t('navigation.board')
}}
</Link>
</Button>
<Button as-child variant="ghost">
<Link
:href="backlogIndex(project.id)"
:aria-label="
t('projects.openBacklog', {
name: project.name,
})
"
>
<ListTodo aria-hidden="true" />{{
t('navigation.backlog')
}}
</Link>
</Button>
</div>
</article>
</li>
</ul>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+186 -165
View File
@@ -1,206 +1,227 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next'; import {
import Heading from '@/components/Heading.vue'; ArrowLeft,
ArrowRight,
Columns3,
Layers3,
ListTodo,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects/index'; import { projectSettingsNavigation } from '@/lib/navigation';
import { index as boardIndex } from '@/routes/projects/board'; import { index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { import { index as boardIndex } from '@/routes/projects/board';
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates'; import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>(); const props = defineProps<ProjectShowPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
]);
const projectAreas = computed(() => [
{ {
title: 'Projects', title: t('navigation.board'),
href: index(), description: t('projects.boardDescription'),
action: t('projects.boardAction'),
href: boardIndex(props.project.id),
icon: Columns3,
}, },
{ {
title: props.project.name, title: t('navigation.backlog'),
href: show(props.project.id), description: t('projects.backlogDescription'),
}, action: t('projects.backlogAction'),
]; href: backlogIndex(props.project.id),
const projectAreas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo, icon: ListTodo,
}, },
{ ]);
title: 'Board', const settingsItems = computed(() =>
description: projectSettingsNavigation(
'Track active work across the project-defined Kanban columns and update ticket status.', {
icon: LayoutGrid, id: props.project.id,
}, name: props.project.name,
{ key: props.project.key,
title: 'Project settings', canManage: props.canManageAccess,
description: canCreateTickets: false,
'Manage board columns, ticket metadata, and the project-specific workflow configuration.', },
icon: Settings2, t,
}, ),
{ );
title: 'Repositories',
description:
'Link project repositories so tickets can later discover related branches and pull requests.',
icon: FolderGit2,
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="project.name" /> <Head :title="project.name" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<div class="space-y-3"> <div class="gap-stack grid min-w-0">
<Badge variant="outline" class="px-3 py-1"> <Badge
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline" variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
> >
<Link :href="projectTemplateCreate(project.id)"> <h1
Save as new template class="text-headline-mobile md:text-headline break-words"
</Link> >
</Button> {{ project.name }}
</h1>
<Button as-child variant="outline"> <p class="text-muted-foreground max-w-2xl">
<Link :href="index()">Back to projects</Link> {{ t('projects.hubDescription') }}
</Button> </p>
</div> </div>
</div> <Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
class="self-start"
>
<Link :href="projectTemplateCreate(project.id)"
><Layers3 aria-hidden="true" />{{
t('projects.saveTemplate')
}}</Link
>
</Button>
</header>
<Card v-if="templateState?.inheritsFromTemplate"> <section
<CardHeader> :aria-label="t('projects.workAreas')"
<CardTitle> class="gap-column grid md:grid-cols-2"
Built from template {{ templateState.templateName }} >
</CardTitle> <Link
<CardDescription> v-for="area in projectAreas"
This project keeps its template link. Inherited areas :key="area.href.url"
follow the template baseline; overridden areas were :href="area.href"
customized locally. :aria-label="area.action"
</CardDescription> class="gap-column bg-card hover:border-primary/50 hover:shadow-card-hover group flex min-w-0 flex-col rounded-md border p-5 transition-[border-color,box-shadow]"
</CardHeader> >
<CardContent class="flex flex-wrap gap-2"> <span
class="bg-accent text-primary flex size-11 items-center justify-center rounded-md"
>
<component
:is="area.icon"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid">
<h2 class="text-title">{{ area.title }}</h2>
<p class="text-muted-foreground">
{{ area.description }}
</p>
</div>
<span
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
>
{{ area.action
}}<ArrowRight class="size-4" aria-hidden="true" />
</span>
</Link>
</section>
<section
v-if="settingsItems.length"
aria-labelledby="project-settings-title"
class="gap-column grid"
>
<div class="gap-stack grid">
<h2 id="project-settings-title" class="text-title">
{{ t('navigation.projectSettings') }}
</h2>
<p class="text-muted-foreground">
{{ t('projects.settingsDescription') }}
</p>
</div>
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
<li
v-for="item in settingsItems"
:key="item.title"
class="min-w-0"
>
<Link
:href="item.href"
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
>
<component
:is="item.icon"
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span
class="min-w-0 flex-1 break-words font-medium"
>{{ item.title }}</span
>
<ArrowRight
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
</ul>
</section>
<section
v-if="templateState?.inheritsFromTemplate"
aria-labelledby="project-template-title"
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
>
<div class="gap-card flex items-start">
<Layers3
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0">
<h2
id="project-template-title"
class="text-title break-words"
>
{{
t('projects.templateTitle', {
name: templateState.templateName,
})
}}
</h2>
<p class="text-muted-foreground">
{{ t('projects.templateDescription') }}
</p>
</div>
</div>
<div class="gap-stack flex flex-wrap">
<Badge <Badge
v-for="area in templateState.inheritedAreas" v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`" :key="`inherited-${area.value}`"
variant="outline" variant="outline"
class="max-w-full whitespace-normal break-words"
> >
{{ area.label }} · inherited {{ t('projects.inheritedArea', { name: area.label }) }}
</Badge> </Badge>
<Badge <Badge
v-for="area in templateState.overriddenAreas" v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`" :key="`overridden-${area.value}`"
variant="secondary"
class="max-w-full whitespace-normal break-words"
> >
{{ area.label }} · overridden {{ t('projects.overriddenArea', { name: area.label }) }}
</Badge> </Badge>
</CardContent> </div>
</Card> </section>
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
<Button as-child>
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="access(project.id)">Manage access</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card
v-for="area in projectAreas"
:key="area.title"
class="h-full"
>
<CardHeader>
<div
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent class="text-muted-foreground text-sm">
Use the project shortcuts above to open this area.
</CardContent>
</Card>
</div>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
File diff suppressed because it is too large Load Diff
+132 -306
View File
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { useSessionStorage } from '@vueuse/core';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -13,32 +16,30 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
buildAcceptanceCriteria, import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings'; import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>(); const props = defineProps<ProjectTicketCreatePageProps>();
const { t } = useLocalization();
const returnHref = const returnHref =
props.source === 'board' props.source === 'board'
? boardIndex(props.project.id) ? boardIndex(props.project.id)
: backlogIndex(props.project.id); : backlogIndex(props.project.id);
const returnLabel = const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog'; props.source === 'board'
? t('ticketForm.backToBoard')
: t('ticketForm.backToBacklog');
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Projects', title: t('navigation.projects'),
href: index(), href: index(),
}, },
{ {
@@ -46,28 +47,31 @@ const breadcrumbs: BreadcrumbItem[] = [
href: show(props.project.id), href: show(props.project.id),
}, },
{ {
title: props.source === 'board' ? 'Board' : 'Backlog', title:
props.source === 'board'
? t('navigation.board')
: t('navigation.backlog'),
href: returnHref, href: returnHref,
}, },
{ {
title: 'Create ticket', title: t('ticketForm.create'),
href: ProjectTicketController.create({ href: ProjectTicketController.create({
project: props.project.id, project: props.project.id,
}), }),
}, },
]; ];
const hasTicketMetadata = const hasTicketMetadata = computed(
props.ticketTypes.length > 0 && () =>
props.ticketPriorities.length > 0 && props.ticketTypes.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0,
);
const acceptanceCriteriaChecklist = ref(''); const draftKey = `projects:${props.project.id}:tickets:create`;
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
const form = useForm({ const form = useForm(`${draftKey}:${draftGeneration.value}`, {
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: [] as AcceptanceCriteriaModel,
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '', assignee_user_id: '',
description: '', description: '',
parent_ticket_id: '', parent_ticket_id: '',
@@ -80,9 +84,16 @@ const form = useForm({
title: '', title: '',
}); });
const acceptanceCriteriaPreviewItems = computed(() => const isDirty = computed(() => form.isDirty);
parseChecklistLines(acceptanceCriteriaChecklist.value), const protection = useFormProtection(form);
); const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
return;
}
form.reset();
form.clearErrors();
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
@@ -95,36 +106,35 @@ const acceptanceCriteriaError = computed(() => {
}); });
const submit = (): void => { const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria( if (!hasTicketMetadata.value || form.processing) {
acceptanceCriteriaChecklist.value, return;
}
protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true,
onSuccess: () => {
// The source page is unmounted before this callback, so its storage watcher has stopped.
window.sessionStorage.setItem(
`${draftKey}:generation`,
String(draftGeneration.value + 1),
);
draftGeneration.value++;
form.reset();
},
},
); );
form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true,
});
};
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
}; };
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" /> <Head :title="t('ticketForm.pageTitle', { project: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
>
<div <div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
@@ -132,10 +142,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Badge variant="outline" class="px-3 py-1"> <Badge variant="outline" class="px-3 py-1">
{{ project.key }} {{ project.key }}
</Badge> </Badge>
<Heading <h1 class="text-headline font-semibold tracking-tight">
title="Create ticket" {{ t('ticketForm.create') }}
:description="`Add a ticket to ${project.name}.`" </h1>
/> <p class="text-muted-foreground text-sm">
{{
t('ticketForm.createDescription', {
project: project.name,
})
}}
</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
@@ -143,7 +159,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Link :href="returnHref">{{ returnLabel }}</Link> <Link :href="returnHref">{{ returnLabel }}</Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link> <Link :href="show(project.id)">{{
t('ticketDetail.backToProject')
}}</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -157,261 +175,70 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-if="hasTicketMetadata"> <Card v-if="hasTicketMetadata">
<CardHeader> <CardHeader>
<CardTitle>Ticket details</CardTitle> <CardTitle>{{ t('ticketForm.details') }}</CardTitle>
<CardDescription> <CardDescription>
The ticket key is assigned after creation. {{ t('ticketForm.keyAssigned') }}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<div class="grid gap-2"> <FormFeedback
<Label for="title">Title</Label> :state="protection.state.value"
<Input @retry="submit"
id="title" />
v-model="form.title" <TicketFormFields
required v-model:title="form.title"
autofocus v-model:description="form.description"
:disabled="form.processing" v-model:type="form.ticket_type_id"
placeholder="Implement repository sync" v-model:priority="form.ticket_priority_id"
/> v-model:status="form.ticket_status_id"
<InputError :message="form.errors.title" /> v-model:assignee="form.assignee_user_id"
</div> v-model:parent="form.parent_ticket_id"
v-model:points="form.story_points"
<div :project="project"
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]" :ticket-types="ticketTypes"
:ticket-priorities="ticketPriorities"
:ticket-status-options="ticketStatusOptions"
:users="users"
:parent-ticket-options="parentTicketOptions"
:errors="form.errors"
:disabled="form.processing"
autofocus
> >
<div class="grid gap-6"> <template #content>
<div class="grid gap-2"> <AcceptanceCriteriaChecklist
<Label for="description">Description</Label> v-model="form.acceptance_criteria"
<textarea :disabled="form.processing"
id="description" :error="acceptanceCriteriaError"
v-model="form.description" />
rows="4" </template>
:disabled="form.processing" </TicketFormFields>
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" <div
placeholder="Describe the expected behavior and relevant context." class="flex flex-wrap items-center gap-3 border-t pt-5"
/> >
<InputError <Button type="submit" :disabled="form.processing">{{
:message="form.errors.description" form.processing
/> ? t('ticketForm.creating')
</div> : t('ticketForm.create')
}}</Button>
<div class="grid gap-4"> <Button
<div class="grid gap-2"> type="button"
<Label variant="outline"
for="acceptance_criteria_checklist" :disabled="form.processing || !isDirty"
> @click="reset"
Acceptance criteria checklist >{{ t('ticketForm.reset') }}</Button
</Label> >
<textarea <p
id="acceptance_criteria_checklist" role="status"
v-model=" class="text-muted-foreground text-sm"
acceptanceCriteriaChecklist >
" {{
rows="6" isDirty
:disabled="form.processing" ? t('ticketForm.unsaved')
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" : t('ticketForm.ready')
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials" }}
/> </p>
<p
class="text-muted-foreground text-xs"
>
Use <code>[ ]</code> /
<code>[x]</code> for checklist
items. Indent with two spaces for
nested steps.
</p>
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Checklist preview
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<span class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</div>
</div>
<div class="grid h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
v-model="form.ticket_type_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="String(ticketType.id)"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="String(ticketPriority.id)"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
v-model="form.ticket_status_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="String(user.id)"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="
String(parentTicketOption.id)
"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
v-model="form.story_points"
type="number"
step="1"
:disabled="form.processing"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
@@ -420,13 +247,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-else> <Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm"> <CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Ticket creation needs at least one type, one priority, {{ t('ticketForm.metadataRequired') }}
and one status.
</p> </p>
<div> <div>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="metadataSettings(project.id)"> <Link :href="metadataSettings(project.id)">
Open ticket settings {{ t('ticketForm.openSettings') }}
</Link> </Link>
</Button> </Button>
</div> </div>
+31
View File
@@ -0,0 +1,31 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
export type BacklogFilterState = {
assigneeId: BacklogAssigneeFilter;
priorityId: '' | number;
query: string;
statusId: '' | number;
typeId: '' | number;
};
export type BacklogTicketTreeNode = {
children: BacklogTicketTreeNode[];
matchesFilters: boolean;
ticket: TicketModel;
};
export type BacklogTicketTreeProps = {
canEditTickets: boolean;
collapsedTicketIds: Set<number>;
nodes: BacklogTicketTreeNode[];
pendingTicketId: number | null;
projectId: number;
};
export type BacklogTicketTreeEmits = {
moveFinish: [ticketId: number];
moveStart: [ticketId: number];
toggle: [ticketId: number];
};
+2
View File
@@ -1,4 +1,5 @@
import type { Auth } from '@/types/auth'; import type { Auth } from '@/types/auth';
import type { AppNavigation } from '@/types/navigation';
import type { LocalizationSettings } from '@/types/localization'; import type { LocalizationSettings } from '@/types/localization';
import type { Appearance } from '@/types/ui'; import type { Appearance } from '@/types/ui';
import type { Page, Router } from '@inertiajs/core'; import type { Page, Router } from '@inertiajs/core';
@@ -25,6 +26,7 @@ declare module '@inertiajs/core' {
appearance: Appearance; appearance: Appearance;
auth: Auth; auth: Auth;
sidebarOpen: boolean; sidebarOpen: boolean;
navigation: AppNavigation;
}; };
} }
} }
+2
View File
@@ -1,4 +1,6 @@
export * from './auth'; export * from './auth';
export * from './backlog';
export * from './navigation'; export * from './navigation';
export * from './page-props'; export * from './page-props';
export * from './ticket-tree';
export * from './ui'; export * from './ui';
+13
View File
@@ -11,4 +11,17 @@ export type NavItem = {
href: NonNullable<InertiaLinkProps['href']>; href: NonNullable<InertiaLinkProps['href']>;
icon?: LucideIcon; icon?: LucideIcon;
isActive?: boolean; isActive?: boolean;
match?: 'exact' | 'section';
};
export type NavigationProject = {
id: number;
name: string;
key: string;
canManage: boolean;
canCreateTickets: boolean;
};
export type AppNavigation = {
project: NavigationProject | null;
}; };
+9
View File
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
value: string; value: string;
}; };
export type AssignedTicketsProjectGroup = {
project: ProjectModel;
tickets: TicketModel[];
};
export type DashboardPageProps = {
assignedTicketsByProject: AssignedTicketsProjectGroup[];
};
export type ProjectsIndexPageProps = { export type ProjectsIndexPageProps = {
projects: ProjectModel[]; projects: ProjectModel[];
status?: string | null; status?: string | null;
+46
View File
@@ -0,0 +1,46 @@
import type { ProjectTicketPageProps } from '@/types/page-props';
export type TicketDetailHeaderProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailContentProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'canManageProject'
| 'parentTicketOptions'
| 'ticketFieldDefinitions'
| 'ticketPriorities'
| 'ticketTypes'
| 'users'
>;
export type TicketDetailMetadataProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
>;
export type TicketDetailGitProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'repositoryBranches'
| 'repositoryPullRequests'
| 'suggestedBranchName'
>;
export type TicketDetailRelationshipsProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'parentTicketOptions'
| 'childTicketTree'
| 'linkedTickets'
| 'ticketLinkTypes'
| 'ticketLinkOptions'
>;
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
export type TicketBranchFormData = {
branch_name: string;
repository_connection_id: string;
};
+13
View File
@@ -0,0 +1,13 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type TicketPresentationProps = {
layout?: 'card' | 'list';
projectId: number;
showDescription?: boolean;
showStatus?: boolean;
ticket: TicketModel;
};
export type TicketPresentationEmits = {
open: [ticketId: number];
};
+6
View File
@@ -0,0 +1,6 @@
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
export type TicketTreeListProps = {
nodes: TicketTreeNodeModel[];
projectId: number;
};

Some files were not shown because too many files have changed in this diff Show More