Compare commits

...
71 Commits
Author SHA1 Message Date
Twyco 0a2807a28c Merge pull request 'KAN-50: UI Rework 35: Öffentliche Einstiegsseite' (#144) from feature/KAN-50-public-entry-page into main
CI/CD / test_and_check_app (push) Successful in 5m45s
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Successful in 33s
Reviewed-on: #144
2026-09-16 07:46:21 +00:00
Eric 8a9113a7af feat: add public entry page for KAN-50 2026-09-16 07:46:21 +00:00
Twyco 0a974cfd49 Merge pull request 'KAN-45: Rebuild project notification settings' (#140) from feature/KAN-45-project-notification-settings into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 1m34s
Reviewed-on: #140
2026-09-16 07:32:21 +00:00
Twyco 538b17b9b8 feat(KAN-45): rebuild project notification settings
CI/CD / preconditions (pull_request) 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-16 09:32:05 +02:00
Twyco b2789b4670 Merge pull request 'KAN-44: UI Rework 29: Repository-Einstellungen des Projekts' (#143) from feature/KAN-44-project-repository-settings 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: #143
2026-09-16 07:26:40 +00:00
Eric 7c6981dc0a feat(KAN-44): rebuild project repository settings 2026-09-16 07:26:40 +00:00
Twyco b8409a702c Merge pull request 'KAN-49: Rebuild user administration' (#142) from feature/KAN-49-user-administration into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #142
2026-09-16 07:01:11 +00:00
Twyco 2acbdb4760 feat(KAN-49): rebuild user administration 2026-09-16 07:01:11 +00:00
Twyco 9faf02e2c4 Merge pull request 'KAN-46: Rebuild reusable project template management' (#139) from feature/KAN-46-reusable-project-templates into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 5m40s
Reviewed-on: #139
2026-09-16 07:01:03 +00:00
Twyco 0941745883 feat(KAN-46): rebuild reusable project template management 2026-09-16 07:01:03 +00:00
Twyco 5a10c85e61 Merge pull request 'KAN-43: UI Rework 28: Benutzerdefinierte Felder und Linktypen' (#138) from feature/KAN-43-custom-fields-link-types into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 5m49s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #138
2026-09-16 06:54:15 +00:00
Eric fb4faa6070 feat(KAN-43): rebuild custom fields and link types
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-15 17:50:10 +02:00
Twyco b226ee8d48 Merge pull request 'KAN-42: Rebuild ticket types, priorities and display settings' (#137) from feature/KAN-42-ticket-types-priorities-display into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 22m13s
CI/CD / Publish immutable image (push) Successful in 43s
Reviewed-on: #137
2026-09-15 14:05:51 +00:00
Twyco ac553f0087 feat(KAN-42): rebuild ticket types priorities and display settings
CI/CD / preconditions (pull_request) 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-15 16:05:30 +02:00
Twyco 28d20882ac Merge pull request 'KAN-41: Erlaubte Statusübergänge bearbeiten' (#136) from feature/KAN-41-allowed-status-transitions 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: #136
2026-09-15 14:05:14 +00:00
Twyco 208989d5fc feat(KAN-41): edit allowed status transitions
CI/CD / preconditions (pull_request) 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-15 16:05:06 +02:00
Twyco d2f659de8f Merge pull request 'KAN-40: Workflow-Einstellungen für Spalten und Status' (#135) from feature/KAN-40-workflow-columns-and-statuses 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: #135
2026-09-15 14:04:52 +00:00
Twyco f461439ce6 feat(KAN-40): rebuild workflow column and status settings
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 36s
2026-09-15 16:04:05 +02:00
Twyco 3eea473336 Merge pull request 'KAN-39: Allgemeine Projekteinstellungen und Vererbung' (#134) from feature/KAN-39-project-settings-inheritance into main
CI/CD / preconditions (push) Successful in 1m4s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #134
2026-09-15 14:03:25 +00:00
Twyco bfec4f464e feat(KAN-39): rebuild general project settings and inheritance
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
2026-09-15 16:02:41 +02:00
Twyco 2edee702d4 Merge pull request 'KAN-38: Persönliche Git-Verbindungen' (#133) from feature/KAN-38-personal-git-connections 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: #133
2026-09-15 14:01:31 +00:00
Twyco 9b90c24224 feat(KAN-38): rebuild personal Git connection settings 2026-09-15 14:01:31 +00:00
Twyco 3869e5b8a6 Merge pull request 'KAN-37: Sicherheit und Anwendungsschlüssel' (#132) from feature/KAN-37-security-application-keys 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: #132
2026-09-15 14:01:23 +00:00
Twyco 196623bb67 feat(KAN-37): rebuild security and application key settings 2026-09-15 14:01:23 +00:00
Twyco bc6a113acf Merge pull request 'KAN-36: Profil und Erscheinungsbild' (#131) from feature/KAN-36-profile-and-appearance into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 38s
Reviewed-on: #131
2026-09-15 14:01:17 +00:00
Twyco 1377ee8510 feat(KAN-36): rebuild profile and appearance settings
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Failing after 3m17s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 17:36:41 +02:00
Twyco 2409c3b18f Merge pull request 'KAN-35 ticket changes' (#129) from feature/KAN-35-ticket-git-area-and-branch-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1m1s
Reviewed-on: #129
2026-09-14 14:52:29 +00:00
Twyco f61298fda4 test(KAN-35): isolate branch error browser scenario
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 5s
2026-09-14 14:52:19 +00:00
Eric 7a4c6f3865 KAN-35 ticket changes 2026-09-14 14:52:19 +00:00
Twyco 79c78b3434 Merge pull request 'Feature/kan 34 blocker ticket history' (#130) from feature/KAN-34-blocker-ticket-history into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Canceled after 5s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #130
2026-09-14 14:52:12 +00:00
Eric 38438b4700 feat(KAN-34): rework blocker and ticket history
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Failing after 3m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 16:20:09 +02:00
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
230 changed files with 35539 additions and 17646 deletions
-3
View File
@@ -33,9 +33,6 @@
vendor
**/.DS_Store
.editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production
LICENSE
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:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
permissions:
contents: read
@@ -11,9 +17,19 @@ env:
REGISTRY: git.twyco.de
jobs:
test_and_check_app:
name: test_and_check_app
preconditions:
if: >
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
runs-on: ubuntu-latest
steps:
- name: Conditions met
run: |
echo "Conditions for CI/CD are met. Proceeding with the workflow."
test_and_check_app:
runs-on: ubuntu-latest
needs:
- preconditions
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -27,6 +43,7 @@ jobs:
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
needs:
- preconditions
- test_and_check_app
runs-on: ubuntu-latest
steps:
+5 -3
View File
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \
&& npm run build
&& corepack enable pnpm \
&& pnpm install --frozen-lockfile \
&& pnpm run build
FROM build AS test
RUN touch .env \
RUN cp .env.example .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
'tickets' => TicketResource::collection(
Ticket::query()
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
'ticketStatuses',
'tickets' => fn ($query) => $query
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
]),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
];
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
'acceptanceCriteria',
'assignee',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation),
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
],
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
'label' => $this->label,
'position' => $this->position,
'project_id' => $this->project_id,
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
'counterpart_label' => $this->counterpart_label ?: $this->label,
'position' => $this->position,
'project_id' => $this->project_id,
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->dontFlash(['api_token']);
})->create();
+6 -6
View File
@@ -47,15 +47,15 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
"pnpm install",
"pnpm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan dev"
],
"dev:ssr": [
"npm run build:ssr",
"pnpm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
@@ -67,9 +67,9 @@
],
"ci:check": [
"Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high",
"npm run lint:check",
"npm run types:check",
"pnpm audit --audit-level=high",
"pnpm run lint:check",
"pnpm run types:check",
"@test"
],
"test": [
+1
View File
@@ -25,6 +25,7 @@ const prettierOptions = {
const eslintConfig = [
{
ignores: [
'bootstrap/ssr/**',
'**/dist/**',
'node_modules/**',
'.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",
"private": true,
"type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
"onFail": "error"
}
},
"scripts": {
"build": "vite build",
"build:ssr": "vite build && vite build --ssr",
@@ -39,6 +46,7 @@
"vue-tsc": "^2.2.4"
},
"dependencies": {
"@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1",
-1
View File
@@ -19,7 +19,6 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
+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
+69 -28
View File
@@ -1,39 +1,80 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [
{ value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const;
const tabs = computed(() => [
{
value: 'light' as const,
Icon: Sun,
label: t('appearance.light'),
description: t('appearance.lightDescription'),
},
{
value: 'dark' as const,
Icon: Moon,
label: t('appearance.dark'),
description: t('appearance.darkDescription'),
},
{
value: 'system' as const,
Icon: Monitor,
label: t('appearance.system'),
description: t('appearance.systemDescription'),
},
]);
</script>
<template>
<div
role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)"
:class="[
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
appearance === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]"
>
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span>{{ t(label) }}</span>
</button>
</div>
<fieldset class="min-w-0">
<legend class="sr-only">{{ t('appearance.label') }}</legend>
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
<label
v-for="{ value, Icon, label, description } in tabs"
:key="value"
class="relative min-w-0"
>
<input
type="radio"
name="appearance"
:value="value"
:checked="appearance === value"
:aria-describedby="`appearance-${value}-description`"
class="peer sr-only"
@change="updateAppearance(value)"
/>
<span
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
>
<span class="flex items-center justify-between gap-3">
<span
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
>
<component
:is="Icon"
class="size-5"
aria-hidden="true"
/>
</span>
<Check
v-if="appearance === value"
class="text-primary size-5"
aria-hidden="true"
/>
</span>
<span class="font-semibold">{{ label }}</span>
<span
:id="`appearance-${value}-description`"
class="text-caption text-muted-foreground"
>
{{ description }}
</span>
</span>
</label>
</div>
</fieldset>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
import { login } from '@/routes/index';
const props = defineProps<{
state: FormFeedbackState;
responseStatus?: number | null;
loadingTitle?: string;
successTitle?: string;
}>();
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const expired = computed(
() => props.state === 'server' && props.responseStatus === 419,
);
const limited = computed(
() => props.state === 'server' && props.responseStatus === 429,
);
const title = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredTitle');
}
if (limited.value) {
return t('auth.feedback.limitedTitle');
}
if (props.state === 'loading') {
return props.loadingTitle ?? t('auth.feedback.pending');
}
if (props.state === 'success') {
return props.successTitle ?? t('auth.feedback.completed');
}
if (props.state === 'validation') {
return t('auth.feedback.validationTitle');
}
return undefined;
});
const description = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredDescription');
}
if (limited.value) {
return t('auth.feedback.limitedDescription');
}
if (props.state === 'validation') {
return t('auth.feedback.validationDescription');
}
if (props.state === 'network') {
return t('auth.feedback.networkDescription');
}
if (props.state === 'server') {
return t('auth.feedback.serverDescription');
}
if (props.state === 'cancelled') {
return t('auth.feedback.cancelledDescription');
}
return undefined;
});
</script>
<template>
<div class="gap-card grid" v-show="state !== 'idle'">
<FormFeedback
:state="state"
:title="title"
:description="description"
:retryable="!expired"
@retry="$emit('retry')"
/>
<Button v-if="expired" as-child variant="outline">
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
</Button>
</div>
</template>
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type {
BacklogTicketTreeEmits,
BacklogTicketTreeProps,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
const props = defineProps<BacklogTicketTreeProps>();
const emit = defineEmits<BacklogTicketTreeEmits>();
const { t } = useLocalization();
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
</script>
<template>
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
:aria-controls="`backlog-children-${node.ticket.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.ticket.id)
? 'backlog.tree.expand'
: 'backlog.tree.collapse',
{ key: node.ticket.ticket_key },
)
"
@click="emit('toggle', node.ticket.id)"
>
<ChevronRight
v-if="collapsedTicketIds.has(node.ticket.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="grid min-w-0 gap-2">
<Badge
v-if="!node.matchesFilters"
variant="secondary"
class="w-fit"
>
{{ t('backlog.filters.parentContext') }}
</Badge>
<TicketCard
:project-id="projectId"
:ticket="node.ticket"
layout="list"
:class="{ 'opacity-80': !node.matchesFilters }"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
move.form({
project: projectId,
ticket: node.ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`backlog-ticket-${node.ticket.id}`"
:options="{ preserveScroll: true }"
:on-before="() => pendingTicketId === null"
class="grid min-w-0 gap-2"
@start="emit('moveStart', node.ticket.id)"
@finish="emit('moveFinish', node.ticket.id)"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${node.ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>
{{
t('backlog.moveTicket', {
key: node.ticket.ticket_key,
})
}}
</label>
<select
:id="`backlog-ticket-status-${node.ticket.id}`"
name="ticket_status_id"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
:aria-invalid="
!!errors.ticket_status_id || undefined
"
:aria-describedby="
errors.ticket_status_id
? `backlog-ticket-status-error-${node.ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
node.ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<InputError
:id="`backlog-ticket-status-error-${node.ticket.id}`"
:message="errors.ticket_status_id"
/>
<Button
type="submit"
variant="outline"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
>
{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}
</Button>
<p
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
{{ t('board.noMovesDescription') }}
</p>
</Form>
</template>
</TicketCard>
</div>
</div>
<div
v-if="
node.children.length > 0 &&
!collapsedTicketIds.has(node.ticket.id)
"
:id="`backlog-children-${node.ticket.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<BacklogTicketTree
:nodes="node.children"
:project-id="projectId"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="emit('toggle', $event)"
@move-start="emit('moveStart', $event)"
@move-finish="emit('moveFinish', $event)"
/>
</div>
</li>
</ul>
</template>
+91 -84
View File
@@ -2,10 +2,10 @@
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogClose,
@@ -17,97 +17,104 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script>
<template>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription>
</DialogHeader>
<Card
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
<p class="text-muted-foreground">
{{ t('profile.delete.description') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<div
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
>
<div class="gap-stack grid">
<p class="text-destructive font-semibold">
{{ t('profile.delete.warning') }}
</p>
<p class="text-body text-muted-foreground">
{{ t('profile.delete.warningDescription') }}
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button
variant="destructive"
class="justify-self-start"
data-test="delete-user-button"
>{{ t('profile.delete.action') }}</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('profile.delete.confirmTitle')
}}</DialogTitle>
<DialogDescription>
{{ t('profile.delete.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only"
>Password</Label
>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password" class="sr-only">{{
t('profile.delete.password')
}}</Label>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
:placeholder="t('profile.delete.password')"
/>
<InputError :message="errors.password" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
{{ t('common.cancel') }}
</Button>
</DialogClose>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Cancel
{{ t('profile.delete.confirmAction') }}
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</template>
+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>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
<div v-show="message" role="alert">
<p class="text-body text-destructive">
{{ message }}
</p>
</div>
+369
View File
@@ -0,0 +1,369 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { KeyRound, UserRound } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import {
save,
store,
} from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { UserModel } from '@/types/models/user-model';
const props = withDefaults(
defineProps<{
mode: 'create' | 'edit';
user?: UserModel | null;
}>(),
{ user: null },
);
const emit = defineEmits<{ cancel: [] }>();
const { t } = useLocalization();
const isEdit = computed(() => props.mode === 'edit');
const confirmationOpen = ref(false);
const form = useForm(
'ManagedUser:' + props.mode + ':' + (props.user?.id ?? 'new'),
{
name: props.user?.name ?? '',
email: props.user?.email ?? '',
password: '',
password_confirmation: '',
must_change_password_on_next_login:
props.user?.must_change_password_on_next_login ?? true,
},
);
const protection = useFormProtection(form);
function performSubmit(): void {
confirmationOpen.value = false;
protection.submit(
isEdit.value && props.user ? save(props.user.id) : store(),
{ preserveScroll: true },
);
}
function requestSubmit(): void {
if (isEdit.value) {
confirmationOpen.value = true;
return;
}
performSubmit();
}
function updateConfirmation(open: boolean): void {
if (!form.processing) {
confirmationOpen.value = open;
}
}
</script>
<template>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<div class="flex items-start gap-3">
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<UserRound class="size-5" aria-hidden="true" />
</span>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('adminUsers.form.detailsTitle') }}
</h2>
<p class="text-muted-foreground">
{{
t(
isEdit
? 'adminUsers.form.editDescription'
: 'adminUsers.form.createDescription',
)
}}
</p>
</div>
</div>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="requestSubmit"
>
<FormFeedback
:state="protection.state.value"
@retry="performSubmit"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
>
<legend class="sr-only">
{{ t('adminUsers.form.detailsTitle') }}
</legend>
<div class="grid gap-5 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="managed-user-name">{{
t('adminUsers.form.name')
}}</Label>
<Input
id="managed-user-name"
v-model="form.name"
name="name"
required
autofocus
autocomplete="name"
:placeholder="
t('adminUsers.form.namePlaceholder')
"
:aria-invalid="!!form.errors.name"
aria-describedby="managed-user-name-error"
/>
<InputError
id="managed-user-name-error"
:message="form.errors.name"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="managed-user-email">{{
t('adminUsers.form.email')
}}</Label>
<Input
id="managed-user-email"
v-model="form.email"
name="email"
type="email"
required
autocomplete="email"
:placeholder="
t('adminUsers.form.emailPlaceholder')
"
:aria-invalid="!!form.errors.email"
aria-describedby="managed-user-email-error"
/>
<InputError
id="managed-user-email-error"
:message="form.errors.email"
/>
</div>
</div>
<section
aria-labelledby="managed-user-password-title"
class="gap-column grid min-w-0 border-t pt-6"
>
<div class="flex items-start gap-3">
<KeyRound
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0">
<h3
id="managed-user-password-title"
class="font-semibold"
>
{{ t('adminUsers.form.passwordTitle') }}
</h3>
<p class="text-caption text-muted-foreground">
{{
t(
isEdit
? 'adminUsers.form.passwordEditHelp'
: 'adminUsers.form.passwordCreateHelp',
)
}}
</p>
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="managed-user-password">{{
t(
isEdit
? 'adminUsers.form.newPassword'
: 'adminUsers.form.password',
)
}}</Label>
<PasswordInput
id="managed-user-password"
v-model="form.password"
name="password"
:required="!isEdit"
autocomplete="new-password"
:placeholder="
t(
isEdit
? 'adminUsers.form.passwordEditPlaceholder'
: 'adminUsers.form.passwordPlaceholder',
)
"
:aria-invalid="!!form.errors.password"
aria-describedby="managed-user-password-error"
/>
<InputError
id="managed-user-password-error"
:message="form.errors.password"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label
for="managed-user-password-confirmation"
>{{
t('adminUsers.form.confirmPassword')
}}</Label
>
<PasswordInput
id="managed-user-password-confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
:required="!isEdit"
autocomplete="new-password"
:placeholder="
t(
'adminUsers.form.confirmPasswordPlaceholder',
)
"
:aria-invalid="
!!form.errors.password_confirmation
"
aria-describedby="managed-user-password-confirmation-error"
/>
<InputError
id="managed-user-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
</div>
</section>
<div class="grid gap-2 border-t pt-6">
<Label
for="managed-user-force-password-change"
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
id="managed-user-force-password-change"
v-model="
form.must_change_password_on_next_login
"
name="must_change_password_on_next_login"
:invalid="
!!form.errors
.must_change_password_on_next_login
"
aria-describedby="managed-user-force-password-change-help managed-user-force-password-change-error"
/>
<span class="grid gap-1">
<span>{{
t('adminUsers.form.forcePasswordChange')
}}</span>
<span
id="managed-user-force-password-change-help"
class="text-caption text-muted-foreground font-normal"
>
{{
t(
'adminUsers.form.forcePasswordChangeHelp',
)
}}
</span>
</span>
</Label>
<InputError
id="managed-user-force-password-change-error"
:message="
form.errors.must_change_password_on_next_login
"
/>
</div>
</fieldset>
<div
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<Button
type="button"
variant="ghost"
@click="emit('cancel')"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:loading="form.processing"
class="sm:min-w-36"
data-test="managed-user-submit"
>
{{
t(
isEdit
? 'adminUsers.form.reviewChanges'
: 'adminUsers.form.createAction',
)
}}
</Button>
</div>
</form>
</CardContent>
</Card>
<Dialog :open="confirmationOpen" @update:open="updateConfirmation">
<DialogContent>
<DialogHeader>
<DialogTitle>{{
t('adminUsers.form.confirmTitle', {
name: form.name || props.user?.name || '',
})
}}</DialogTitle>
<DialogDescription>
{{ t('adminUsers.form.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
>
{{ t('adminUsers.form.confirmWarning') }}
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="form.processing"
@click="confirmationOpen = false"
>
{{ t('common.cancel') }}
</Button>
<Button
type="button"
:loading="form.processing"
data-test="confirm-managed-user-update"
@click="performSubmit"
>
{{ t('adminUsers.form.confirmAction') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+11 -4
View File
@@ -2,6 +2,7 @@
import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
const props = defineProps<{
class?: HTMLAttributes['class'];
disabled?: boolean;
}>();
const model = defineModel<string | number | null>();
const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef');
const { t } = useLocalization();
defineExpose({
$el: inputRef,
@@ -28,7 +31,8 @@ defineExpose({
ref="inputRef"
v-model="model"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)"
:class="cn('pr-12', props.class)"
:disabled="disabled"
v-bind="$attrs"
/>
<button
@@ -36,11 +40,14 @@ defineExpose({
@click="showPassword = !showPassword"
:class="
cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
)
"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
:aria-label="
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
"
:aria-pressed="showPassword"
:disabled="disabled"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
+75 -27
View File
@@ -1,45 +1,93 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
import type { TicketTreeListProps } from '@/types/ticket-tree';
defineProps<{
nodes: TicketTreeNodeModel[];
projectId: number;
}>();
defineProps<TicketTreeListProps>();
const { t } = useLocalization();
const collapsedTicketIds = reactive(new Set<number>());
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script>
<template>
<ul class="space-y-3">
<li v-for="node in nodes" :key="node.id">
<div class="border-border rounded-xl border px-4 py-4">
<div class="flex flex-wrap items-center gap-2">
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.id)"
:aria-controls="`ticket-tree-children-${node.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
"
class="text-foreground hover:text-primary font-medium transition"
@click="toggleTicket(node.id)"
>
{{ node.ticket_key }}
</Link>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
<ChevronRight
v-if="collapsedTicketIds.has(node.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="text-foreground mt-2 text-sm">
{{ node.title }}
</div>
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div>
<div
v-if="node.children.length > 0"
class="border-border mt-3 border-l pl-4"
v-if="
node.children.length > 0 && !collapsedTicketIds.has(node.id)
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<TicketTreeList
:nodes="node.children"
+150 -104
View File
@@ -1,123 +1,169 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { ref } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
const { t } = useLocalization();
const {
recoveryCodesList,
fetchRecoveryCodes,
clearRecoveryCodes,
errors,
loading,
} = useTwoFactorAuth();
const visible = ref(false);
const showRegenerate = ref(false);
const form = useForm({});
const protection = useFormProtection(form);
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
function hide(): void {
visible.value = false;
clearRecoveryCodes();
}
function toggle(): void {
if (visible.value) {
hide();
} else {
visible.value = true;
void fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
function closeRegenerate(open: boolean): void {
if (!form.processing) {
showRegenerate.value = open;
protection.clearFeedback();
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
}
function regenerate(): void {
protection.submit(regenerateRecoveryCodes(), {
preserveScroll: true,
onBefore: hide,
onSuccess: () => {
showRegenerate.value = false;
visible.value = true;
return fetchRecoveryCodes();
},
});
}
</script>
<template>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
<section
class="gap-column grid min-w-0 rounded-lg border p-4"
aria-labelledby="recovery-title"
>
<div class="gap-stack grid">
<h3
id="recovery-title"
class="flex items-center gap-2 font-semibold"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
codes
</Button>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
<LockKeyhole class="text-primary size-4" />{{
t('security.recoveryTitle')
}}
</h3>
<p class="text-body text-muted-foreground">
{{ t('security.recoveryDescription') }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<Button
variant="outline"
:aria-expanded="visible"
aria-controls="recovery-codes"
:disabled="form.processing"
@click="toggle"
><component :is="visible ? EyeOff : Eye" />{{
t(
visible
? 'security.hideRecovery'
: 'security.viewRecovery',
)
}}</Button
>
<Button
v-if="visible && recoveryCodesList.length"
variant="secondary"
@click="showRegenerate = true"
><RefreshCw />{{ t('security.regenerate') }}</Button
>
</div>
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
<p
v-if="loading"
role="status"
class="bg-muted animate-pulse rounded-lg p-4"
>
{{ t('security.loadingRecovery') }}
</p>
<template v-else-if="errors.length">
<AlertError :errors="errors" />
<Button
class="justify-self-start"
variant="outline"
@click="fetchRecoveryCodes"
>{{ t('common.retry') }}</Button
>
<Button
variant="secondary"
type="submit"
:disabled="processing"
>
<RefreshCw /> Regenerate codes
</Button>
</Form>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible
? 'h-auto opacity-100'
: 'h-0 opacity-0',
]"
</template>
<template v-else>
<ul
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
:aria-label="t('security.recoveryTitle')"
>
<li v-for="code in recoveryCodesList" :key="code">
{{ code }}
</li>
</ul>
<p class="text-caption text-muted-foreground">
{{ t('security.recoveryOnce') }}
</p>
</template>
</div>
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<div v-if="errors?.length" class="mt-6">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
<DialogHeader>
<DialogTitle>{{
t('security.regenerateTitle')
}}</DialogTitle>
<DialogDescription>{{
t('security.regenerateDescription')
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="regenerate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="closeRegenerate(false)"
>{{ t('common.cancel') }}</Button
>
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div
v-for="n in 8"
:key="n"
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
></div>
</div>
<div
v-else
v-for="(code, index) in recoveryCodesList"
:key="index"
>
{{ code }}
</div>
</div>
<p class="text-muted-foreground select-none text-xs">
Each recovery code can be used once to access your
account and will be removed after use. If you need more,
click
<span class="font-bold">Regenerate codes</span> above.
</p>
</div>
</div>
</CardContent>
</Card>
<Button :loading="form.processing" @click="regenerate">{{
t('security.regenerate')
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</template>
+220 -256
View File
@@ -1,297 +1,261 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { useForm } from '@inertiajs/vue3';
import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
const props = defineProps<{
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
};
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
}>();
const isOpen = defineModel<boolean>('isOpen');
const { t } = useLocalization();
const {
qrCodeSvg,
manualSetupKey,
hasSetupData,
clearSetupData,
fetchSetupData,
errors,
loading,
} = useTwoFactorAuth();
const form = useForm({ code: '' }).dontRemember('code');
const protection = useFormProtection(form, { isDirty: () => false });
const verification = ref(false);
const copied = ref(false);
const copyError = ref(false);
const codeInput = useTemplateRef('codeInput');
const title = computed(() =>
t(
props.twoFactorEnabled
? 'security.setupEnabled'
: verification.value
? 'security.verifyTitle'
: 'security.setupTitle',
),
);
const description = computed(() =>
t(
props.twoFactorEnabled
? 'security.setupEnabledDescription'
: verification.value
? 'security.verifyDescription'
: 'security.setupDescription',
),
);
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<string>('');
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
const modalConfig = computed<TwoFactorConfigContent>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
function close(open: boolean): void {
if (!form.processing) {
isOpen.value = open;
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable two-factor authentication',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
}
async function next(): Promise<void> {
if (props.twoFactorEnabled || !props.requiresConfirmation) {
close(false);
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
verification.value = true;
await nextTick();
codeInput.value?.$el?.focus();
}
function submit(): void {
protection.submit(confirm(), {
preserveScroll: true,
errorBag: 'confirmTwoFactorAuthentication',
onSuccess: () => {
isOpen.value = false;
},
onFinish: () => {
form.reset();
},
});
}
async function copyKey(): Promise<void> {
const key = manualSetupKey.value;
if (!key) {
return;
}
showVerificationStep.value = false;
code.value = '';
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
} catch {
if (isOpen.value) {
copyError.value = true;
}
},
);
}
}
watch(isOpen, (open) => {
verification.value = false;
copied.value = false;
copyError.value = false;
form.resetAndClearErrors();
protection.clearFeedback();
clearSetupData();
if (open) {
void fetchSetupData();
}
});
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
<Dialog :open="isOpen" @update:open="close">
<DialogContent
class="sm:max-w-md"
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<ScanLine class="text-primary size-8" />
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<template v-if="!verification">
<p
v-if="loading"
role="status"
class="bg-muted animate-pulse rounded-lg p-6 text-center"
>
{{ t('security.loadingSetup') }}
</p>
<div v-else-if="errors.length" class="gap-stack grid">
<AlertError :errors="errors" />
<Button variant="outline" @click="fetchSetupData">{{
t('common.retry')
}}</Button>
</div>
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
<div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
>
<div
class="absolute inset-0 grid grid-cols-5 opacity-50"
>
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-border border-r last:border-r-0"
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
v-html="qrCodeSvg"
/>
<div class="grid min-w-0 gap-2">
<Label for="setup-key">{{
t('security.manualKey')
}}</Label>
<div class="flex min-w-0 gap-2">
<Input
id="setup-key"
:model-value="manualSetupKey"
readonly
class="min-w-0 font-mono text-xs"
/>
<Button
variant="outline"
:aria-label="
t(
copied
? 'security.copied'
: 'security.copyKey',
)
"
@click="copyKey"
><Check v-if="copied" /><Copy v-else
/></Button>
</div>
<div
class="absolute inset-0 grid grid-rows-5 opacity-50"
<p
v-if="copied"
role="status"
class="text-caption text-success-foreground"
>
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-border border-b last:border-b-0"
/>
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
{{ t('security.copied') }}
</p>
<InputError
v-if="copyError"
:message="t('security.copyError')"
/>
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
>
<div
v-if="!qrCodeSvg"
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
>
<Spinner class="size-6" />
</div>
<div
v-else
class="relative z-10 overflow-hidden border p-5"
>
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div
class="relative flex w-full items-center justify-center"
>
<div
class="bg-border absolute inset-0 top-1/2 h-px w-full"
/>
<span class="bg-card relative px-2 py-1"
>or, enter the code manually</span
>
</div>
<div
class="flex w-full items-center justify-center space-x-2"
>
<div
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
>
<div
v-if="!manualSetupKey"
class="bg-muted flex h-full w-full items-center justify-center p-3"
>
<Spinner />
</div>
<template v-else>
<input
type="text"
readonly
:value="manualSetupKey"
class="bg-background text-foreground h-full w-full p-3"
/>
<button
@click="copy(manualSetupKey || '')"
class="border-border hover:bg-muted relative block h-auto border-l px-3"
>
<Check
v-if="copied"
class="w-4 text-green-500"
/>
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
</template>
<template v-else>
<Form
v-bind="confirm.form()"
error-bag="confirmTwoFactorAuthentication"
reset-on-error
@finish="code = ''"
@success="isOpen = false"
v-slot="{ errors, processing }"
<p
v-if="requiresConfirmation && !twoFactorEnabled"
class="text-caption text-muted-foreground"
>
{{ t('security.setupPendingDescription') }}
</p>
<DialogFooter>
<Button variant="outline" @click="close(false)">{{
t('common.close')
}}</Button>
<Button v-if="hasSetupData" @click="next">{{
t(
twoFactorEnabled || !requiresConfirmation
? 'common.done'
: 'security.continue',
)
}}</Button>
</DialogFooter>
</template>
<form v-else class="gap-column grid" @submit.prevent="submit">
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="authentication-code">{{
t('security.authenticationCode')
}}</Label>
<Input
id="authentication-code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
required
:disabled="form.processing"
:aria-invalid="!!form.errors.code"
aria-describedby="authentication-code-error"
class="font-mono text-lg tracking-widest"
/>
<InputError
id="authentication-code-error"
:message="form.errors.code"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="
verification = false;
form.resetAndClearErrors();
protection.clearFeedback();
"
>{{ t('common.back') }}</Button
>
<input type="hidden" name="code" :value="code" />
<div
ref="pinInputContainerRef"
class="relative w-full space-y-3"
>
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
<InputError :message="errors?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button
type="submit"
class="w-auto flex-1"
:disabled="processing || code.length < 6"
>
Confirm
</Button>
</div>
</div>
</Form>
</template>
</div>
<Button
type="submit"
:disabled="form.code.length !== 6"
:loading="form.processing"
>{{ t('security.confirm') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import {
destroyPriority,
destroyType,
storePriority,
storeType,
updatePriority,
updateType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { MetadataEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: MetadataEditorTarget | null;
types: TicketTypeModel[];
priorities: TicketPriorityModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
position: 1,
is_parent: false,
replacement_ticket_type_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deletionError = ref<string | null>(null);
const deleting = computed(() => props.target?.action === 'delete');
const entries = computed(() =>
props.target?.kind === 'type' ? props.types : props.priorities,
);
const replacements = computed(() =>
props.types.filter((type) => type.id !== props.target?.entry?.id),
);
const needsReplacement = computed(
() =>
deleting.value &&
props.target?.kind === 'type' &&
(props.target.entry?.tickets_count ?? 0) > 0,
);
const title = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.action === 'create') {
return t(
target.kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
);
}
return t(
target.action === 'edit'
? 'metadata.editEntry'
: 'metadata.deleteEntry',
{ name: target.entry.name },
);
});
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name: target.entry?.name ?? '',
position: target.entry?.position ?? 1,
is_parent: target.entry?.is_parent ?? false,
replacement_ticket_type_id: '',
});
form.resetAndClearErrors();
protection.clearFeedback();
deletionError.value = null;
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(deleting.value && entries.value.length <= 1)
) {
return;
}
const route =
target.kind === 'type'
? target.action === 'create'
? storeType(props.projectId)
: target.action === 'edit'
? updateType([props.projectId, target.entry.id])
: destroyType([props.projectId, target.entry.id])
: target.action === 'create'
? storePriority(props.projectId)
: target.action === 'edit'
? updatePriority([props.projectId, target.entry.id])
: destroyPriority([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? target.kind === 'type'
? {
replacement_ticket_type_id:
data.replacement_ticket_type_id || null,
}
: {}
: target.action === 'edit'
? { name: data.name, position: data.position }
: target.kind === 'type'
? { name: data.name, is_parent: data.is_parent }
: { name: data.name },
);
deletionError.value = null;
protection.submit(route, {
preserveScroll: true,
onSuccess: (page) => {
if (page.props.error) {
deletionError.value =
typeof page.props.status === 'string'
? page.props.status
: t('common.error');
return;
}
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>{{
t(
deleting
? target?.kind === 'type'
? 'metadata.deleteTypeHelp'
: 'metadata.deletePriorityHelp'
: 'metadata.editHelp',
)
}}</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback
:state="deletionError ? 'idle' : protection.state.value"
@retry="submit"
/>
<p
v-if="deletionError"
role="alert"
class="text-destructive text-sm"
>
{{ deletionError }}
</p>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<template v-if="!deleting">
<div class="grid gap-2">
<Label for="metadata-name">{{
t('metadata.name')
}}</Label>
<Input
id="metadata-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="metadata-name-error"
/>
<InputError
id="metadata-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="target?.action === 'edit'"
class="grid gap-2"
>
<Label for="metadata-position">{{
t('workflow.position')
}}</Label>
<Input
id="metadata-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="entries.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="metadata-position-help metadata-position-error"
/>
<p
id="metadata-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: entries.length,
})
}}
</p>
<InputError
id="metadata-position-error"
:message="form.errors.position"
/>
</div>
<div v-if="target?.kind === 'type'" class="grid gap-2">
<Label
v-if="target.action === 'create'"
for="metadata-parent"
class="flex items-center gap-3"
>
<Checkbox
id="metadata-parent"
v-model="form.is_parent"
name="is_parent"
:invalid="!!form.errors.is_parent"
aria-describedby="metadata-parent-help metadata-parent-error"
/>{{ t('metadata.allowChildren') }}
</Label>
<p v-else class="text-sm font-medium">
{{
t(
form.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}
</p>
<p
id="metadata-parent-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentFixed') }}
</p>
<InputError
id="metadata-parent-error"
:message="form.errors.is_parent"
/>
</div>
</template>
<div v-if="needsReplacement" class="grid gap-2">
<p class="text-sm">
{{
t('metadata.replacementHelp', {
count: target?.entry?.tickets_count ?? 0,
})
}}
</p>
<Label for="metadata-replacement">{{
t('metadata.replacement')
}}</Label>
<select
id="metadata-replacement"
v-model="form.replacement_ticket_type_id"
name="replacement_ticket_type_id"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="
!!form.errors.replacement_ticket_type_id
"
aria-describedby="metadata-replacement-help metadata-replacement-error"
>
<option value="">
{{ t('metadata.chooseType') }}
</option>
<option
v-for="entry in replacements"
:key="entry.id"
:value="entry.id"
>
{{ entry.name
}}{{
entry.is_parent
? ` (${t('metadata.parentType')})`
: ''
}}
</option>
</select>
<p
id="metadata-replacement-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentReplacement') }}
</p>
</div>
<InputError
id="metadata-replacement-error"
:message="form.errors.replacement_ticket_type_id"
/>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
:disabled="deleting && entries.length <= 1"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type {
MetadataEditorTarget,
MetadataEntry,
MetadataKind,
} from '@/types/project-metadata';
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
:aria-labelledby="`metadata-${kind}`"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<h3 :id="`metadata-${kind}`" class="font-semibold">
{{
t(
kind === 'type'
? 'metadata.types'
: 'metadata.priorities',
)
}}
</h3>
<Badge variant="secondary">{{ entries.length }}</Badge>
</div>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="$emit('edit', { kind, action: 'create' })"
>
<Plus class="size-4" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
)
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{
t(
kind === 'type'
? 'metadata.typesDescription'
: 'metadata.prioritiesDescription',
)
}}
</p>
<ol
class="divide-y"
:aria-label="
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
"
>
<li
v-for="entry in entries"
:key="entry.id"
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
:data-metadata-entry="`${kind}-${entry.id}`"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: entry.position,
})
"
>{{ entry.position }}</span
>
<div class="grid min-w-0 flex-1 basis-28 gap-1">
<p class="break-words font-medium">{{ entry.name }}</p>
<div
v-if="kind === 'type'"
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
t(
entry.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}</Badge>
<span>{{
t('metadata.assignedTickets', {
count: entry.tickets_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editEntry', { name: entry.name })
"
@click="$emit('edit', { kind, action: 'edit', entry })"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="entries.length <= 1"
:aria-label="
t('metadata.deleteEntry', { name: entry.name })
"
:aria-describedby="
entries.length <= 1
? `metadata-${kind}-protection`
: undefined
"
@click="
$emit('edit', { kind, action: 'delete', entry })
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ol>
<p
v-if="entries.length <= 1"
:id="`metadata-${kind}-protection`"
class="text-caption text-muted-foreground flex items-start gap-2"
>
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.lastType'
: 'metadata.lastPriority',
)
}}
</p>
</section>
</template>
@@ -0,0 +1,632 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
destroy,
store,
update,
} from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { notificationLabel } from '@/lib/notification-labels';
import { useLocalization } from '@/composables/useLocalization';
import {
serializeNotificationFilters,
supportsFilterValue,
} from '@/lib/notification-filters';
import type { ProjectNotificationSettingsPageProps } from '@/types';
import type {
NotificationEditorTarget,
NotificationFilterFormState,
} from '@/types/project-notifications';
const props = defineProps<{
target: NotificationEditorTarget | null;
settings: ProjectNotificationSettingsPageProps;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
event_type: '',
channel: '',
enabled: true,
delivery_url: '',
message_template: '',
allowed_mentions: [] as string[],
filters: [] as NotificationFilterFormState[],
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() =>
t(
deleting.value
? 'notifications.delete'
: props.target?.action === 'edit'
? 'notifications.edit'
: 'notifications.create',
),
);
const inputClass =
'border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2';
let nextFilterId = 0;
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
const entry = target.entry;
const mentions = entry?.channel_configuration.allowed_mentions;
form.defaults({
name: entry?.name ?? '',
event_type:
entry?.event_type ??
props.settings.eventTypeOptions[0]?.value ??
'',
channel:
entry?.channel ??
props.settings.channelOptions.find(
(option) => option.value === 'webhook',
)?.value ??
props.settings.channelOptions[0]?.value ??
'',
enabled: entry?.enabled ?? true,
delivery_url: '',
message_template: entry?.message_template ?? '',
allowed_mentions: Array.isArray(mentions)
? mentions.filter(
(value): value is string => typeof value === 'string',
)
: [],
filters: (entry?.filters ?? []).map((filter) => ({
id: nextFilterId++,
attribute: filter.attribute,
operator: filter.operator,
value: Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? ''),
})),
});
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function addFilter(): void {
form.filters.push({
id: nextFilterId++,
attribute: props.settings.filterAttributeOptions[0]?.value ?? '',
operator: 'equals',
value: '',
});
}
function removeFilter(index: number): void {
form.filters.splice(index, 1);
form.clearErrors(
...(Object.keys(form.errors).filter((key) =>
key.startsWith('filters'),
) as (keyof typeof form.errors)[]),
);
}
function errorFor(prefix: string): string | undefined {
return Object.entries(form.errors).find(
([key]) => key === prefix || key.startsWith(`${prefix}.`),
)?.[1];
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const project = props.settings.project.id;
const route =
target.action === 'create'
? store(project)
: target.action === 'edit'
? update([project, target.entry.id])
: destroy([project, target.entry.id]);
form.transform((data) =>
deleting.value
? {}
: {
name: data.name,
event_type: data.event_type,
channel: data.channel,
enabled: data.enabled,
delivery_url: data.delivery_url || null,
channel_configuration:
data.channel === 'discord'
? { allowed_mentions: data.allowed_mentions }
: {},
message_template:
data.channel === 'discord' ? data.message_template : null,
filters: serializeNotificationFilters(data.filters),
},
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
class="sm:max-w-2xl"
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{
deleting
? t('notifications.deleteHelp', {
name: target?.entry?.name ?? '',
})
: t('notifications.editorHelp')
}}</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-5"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-5"
>
<div class="grid gap-2">
<Label for="notification-name">{{
t('notifications.name')
}}</Label>
<Input
id="notification-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="notification-name-error"
/>
<InputError
id="notification-name-error"
:message="form.errors.name"
/>
</div>
<div class="grid min-w-0 gap-4 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="notification-event">{{
t('notifications.event')
}}</Label>
<select
id="notification-event"
v-model="form.event_type"
name="event_type"
:class="inputClass"
:aria-invalid="!!form.errors.event_type"
aria-describedby="notification-event-error"
>
<option
v-for="option in settings.eventTypeOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'event',
option.value,
option.label,
)
}}
</option>
</select>
<InputError
id="notification-event-error"
:message="form.errors.event_type"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="notification-channel">{{
t('notifications.channel')
}}</Label>
<select
id="notification-channel"
v-model="form.channel"
name="channel"
:class="inputClass"
:aria-invalid="!!form.errors.channel"
aria-describedby="notification-channel-error"
>
<option
v-for="option in settings.channelOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'channel',
option.value,
option.label,
)
}}
</option>
</select>
<InputError
id="notification-channel-error"
:message="form.errors.channel"
/>
</div>
</div>
<section
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-4"
aria-labelledby="notification-delivery-title"
>
<h3
id="notification-delivery-title"
class="font-semibold"
>
{{ t('notifications.delivery') }}
</h3>
<p v-if="target?.action === 'edit'" class="text-sm">
{{
t(
target.entry.has_delivery_url
? 'notifications.keepUrl'
: 'notifications.urlMissing',
)
}}
</p>
<Label for="notification-url">{{
t(
target?.action === 'edit'
? 'notifications.replaceUrl'
: 'notifications.url',
)
}}</Label>
<Input
id="notification-url"
v-model="form.delivery_url"
name="delivery_url"
type="password"
autocomplete="new-password"
:spellcheck="false"
:required="target?.action === 'create'"
maxlength="2048"
:aria-invalid="!!form.errors.delivery_url"
aria-describedby="notification-url-help notification-url-error"
/>
<p
id="notification-url-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.secretHelp') }}
</p>
<InputError
id="notification-url-error"
:message="form.errors.delivery_url"
/>
</section>
<section
v-if="form.channel === 'discord'"
class="grid min-w-0 gap-4"
aria-labelledby="notification-message-title"
>
<div class="grid gap-2">
<Label
id="notification-message-title"
for="notification-message"
>{{ t('notifications.message') }}</Label
>
<textarea
id="notification-message"
v-model="form.message_template"
name="message_template"
rows="4"
required
maxlength="2000"
:class="inputClass"
:aria-invalid="!!form.errors.message_template"
aria-describedby="notification-message-help notification-message-error"
/>
<p
id="notification-message-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.messageHelp') }}
</p>
<InputError
id="notification-message-error"
:message="form.errors.message_template"
/>
</div>
<details class="rounded-lg border p-3">
<summary
class="focus-visible:ring-ring cursor-pointer rounded font-medium focus-visible:ring-2"
>
{{ t('notifications.variables') }}
</summary>
<dl class="mt-3 grid min-w-0 gap-3 text-sm">
<div
v-for="variable in settings.templateVariables"
:key="variable.value"
class="grid min-w-0 gap-1"
>
<dt class="break-all font-mono">
{{ variable.value }}
</dt>
<dd class="text-muted-foreground">
{{
notificationLabel(
t,
'variable',
variable.value.slice(1),
variable.description,
)
}}
</dd>
</div>
</dl>
</details>
<fieldset
class="grid gap-2"
aria-describedby="notification-mentions-help notification-mentions-error"
>
<legend class="mb-2 text-sm font-medium">
{{ t('notifications.mentions') }}
</legend>
<label
v-for="option in settings.discordAllowedMentionOptions"
:key="option.value"
class="flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
>
<input
v-model="form.allowed_mentions"
type="checkbox"
:value="option.value"
:aria-invalid="
!!errorFor('channel_configuration')
"
class="accent-primary size-4"
/>{{
notificationLabel(
t,
'mention',
option.value,
option.label,
)
}}
</label>
<p
id="notification-mentions-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.mentionsHelp') }}
</p>
<InputError
id="notification-mentions-error"
:message="errorFor('channel_configuration')"
/>
</fieldset>
</section>
<section
class="grid min-w-0 gap-3 border-t pt-4"
aria-labelledby="notification-filters-title"
>
<h3
id="notification-filters-title"
class="font-semibold"
>
{{ t('notifications.filters') }}
</h3>
<p class="text-caption text-muted-foreground">
{{ t('notifications.filtersHelp') }}
</p>
<p v-if="!form.filters.length" class="text-sm">
{{ t('notifications.noFilters') }}
</p>
<fieldset
v-for="(filter, index) in form.filters"
:key="filter.id"
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-3"
>
<legend class="px-1 text-sm font-medium">
{{
t('notifications.filterNumber', {
number: index + 1,
})
}}
</legend>
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label
:for="`filter-attribute-${filter.id}`"
>{{
t('notifications.attribute')
}}</Label
>
<select
:id="`filter-attribute-${filter.id}`"
v-model="filter.attribute"
:class="inputClass"
:aria-invalid="
!!errorFor(`filters.${index}`)
"
:aria-describedby="`filter-error-${filter.id}`"
>
<option
v-for="option in settings.filterAttributeOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'attribute',
option.value,
option.label,
)
}}
</option>
</select>
</div>
<div class="grid min-w-0 gap-2">
<Label
:for="`filter-operator-${filter.id}`"
>{{
t('notifications.operator')
}}</Label
>
<select
:id="`filter-operator-${filter.id}`"
v-model="filter.operator"
:class="inputClass"
:aria-invalid="
!!errorFor(`filters.${index}`)
"
:aria-describedby="`filter-error-${filter.id}`"
>
<option
v-for="option in settings.filterOperatorOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'operator',
option.value,
option.label,
)
}}
</option>
</select>
</div>
</div>
<div
v-if="supportsFilterValue(filter.operator)"
class="grid gap-2"
>
<Label :for="`filter-value-${filter.id}`">{{
t(
filter.operator === 'in'
? 'notifications.values'
: 'notifications.value',
)
}}</Label>
<textarea
:id="`filter-value-${filter.id}`"
v-model="filter.value"
rows="2"
:class="inputClass"
:aria-invalid="
!!errorFor(`filters.${index}`)
"
:aria-describedby="`filter-error-${filter.id}`"
/>
</div>
<InputError
:id="`filter-error-${filter.id}`"
:message="errorFor(`filters.${index}`)"
/>
<Button
type="button"
variant="outline"
class="justify-self-end"
:aria-label="
t('notifications.removeFilterNumber', {
number: index + 1,
})
"
@click="removeFilter(index)"
>{{ t('notifications.removeFilter') }}</Button
>
</fieldset>
<InputError :message="form.errors.filters" />
<Button
type="button"
variant="outline"
class="justify-self-start"
@click="addFilter"
>{{ t('notifications.addFilter') }}</Button
>
</section>
<div class="grid gap-2 border-t pt-4">
<Label
for="notification-enabled"
class="flex min-h-11 items-center gap-3"
><Checkbox
id="notification-enabled"
v-model="form.enabled"
:invalid="!!form.errors.enabled"
aria-describedby="notification-enabled-help notification-enabled-error"
/>{{ t('notifications.enabled') }}</Label
>
<p
id="notification-enabled-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.enabledHelp') }}
</p>
<InputError
id="notification-enabled-error"
:message="form.errors.enabled"
/>
</div>
</fieldset>
<InputError v-else :message="errorFor('notificationAction')" />
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('notifications.confirmDelete')
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,347 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates';
import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types';
import type { ProjectModel } from '@/types/models/project-model';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel;
template?: ProjectTemplateModel;
}>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm(
`ProjectTemplate:${props.template ? `edit:${props.template.id}` : `create:${props.project?.id}`}`,
{
name:
props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }),
slug:
props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`,
copy_areas: props.template
? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false,
},
);
const protection = useFormProtection(form);
const copyAreasError = computed(
() =>
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1],
);
const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id),
);
function submit(): void {
if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return;
}
form.transform((data) =>
props.template
? {
name: data.name,
copy_areas: data.copy_areas,
...(linkedCount.value > 0
? {
confirm_linked_projects_update:
data.confirm_linked_projects_update,
}
: {}),
}
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
);
protection.submit(
props.template ? update(props.template.id) : store(props.project!.id),
{ preserveScroll: true },
);
}
</script>
<template>
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
>
<div class="gap-page grid min-w-0">
<section
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-details-title"
>
<h2 id="template-details-title" class="text-title">
{{ t('templates.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{ t('templates.name') }}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing || !source"
:placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="slug"
><LockKeyhole
v-if="template"
class="size-4"
aria-hidden="true"
/>{{ t('templates.slug') }}</Label
>
<Input
id="slug"
v-model="form.slug"
name="slug"
required
maxlength="255"
:disabled="!!template || form.processing || !source"
:placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error"
:spellcheck="false"
autocapitalize="none"
/>
<p
id="slug-help"
class="text-caption text-muted-foreground"
>
{{
t(
template
? 'templates.slugLocked'
: 'templates.slugHelp',
)
}}
</p>
<InputError
id="slug-error"
:message="form.errors.slug"
/>
</div>
</section>
<fieldset
class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source"
aria-describedby="areas-help copy-areas-error"
>
<legend class="text-title px-1">
{{ t('templates.groups') }}
</legend>
<p
id="areas-help"
class="text-caption text-muted-foreground mb-4"
>
{{
t(
template
? 'templates.editGroupsHelp'
: 'templates.createGroupsHelp',
)
}}
</p>
<div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="area.value"
:aria-labelledby="`area-${area.value}`"
:aria-describedby="`area-${area.value}-help copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span class="gap-stack grid min-w-0">
<span
:id="`area-${area.value}`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
area.label,
)
}}</span
>
<span
:id="`area-${area.value}-help`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
area.description,
)
}}</span
>
</span>
</label>
</div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-impact-title"
>
<h2
id="template-impact-title"
class="text-title flex items-center gap-2"
>
<Layers3 class="text-primary size-5" aria-hidden="true" />{{
t('templates.impact')
}}
</h2>
<dl class="gap-column grid">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words font-semibold">
{{
source?.name ?? t('templates.sourceUnavailable')
}}
</dd>
<dd v-if="source">
<Badge variant="outline">{{ source.key }}</Badge>
</dd>
</div>
<div class="gap-stack grid" aria-live="polite">
<dt class="text-caption text-muted-foreground">
{{ t('templates.groups') }}
</dt>
<dd>
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div v-if="template" class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('templates.inheritance') }}
</dt>
<dd>
{{
t('templates.linkedCount', {
count: linkedCount,
})
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground border-t pt-4">
{{
t(
template
? 'templates.refreshHelp'
: 'templates.cleanStart',
)
}}
</p>
</aside>
</div>
<section
v-if="linkedCount > 0"
class="border-warning/50 bg-warning/10 gap-column grid rounded-md border p-5"
aria-labelledby="linked-title"
>
<h2 id="linked-title" class="text-title">
{{ t('templates.linkedWarning') }}
</h2>
<p id="linked-help" class="text-body">
{{ t('templates.linkedImpact', { count: linkedCount }) }}
</p>
<label class="flex min-h-11 cursor-pointer items-start gap-3">
<input
id="confirm-linked"
v-model="form.confirm_linked_projects_update"
type="checkbox"
:disabled="form.processing || !source"
:aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span>{{ t('templates.acknowledge') }}</span>
</label>
<InputError
id="linked-error"
:message="form.errors.confirm_linked_projects_update"
/>
</section>
<footer
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:justify-end"
>
<Button as-child variant="outline" :disabled="form.processing"
><Link :href="cancelTarget">{{
t('common.cancel')
}}</Link></Button
>
<Button
type="submit"
:loading="form.processing"
:loading-label="t('forms.saving')"
:disabled="
!source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
"
>{{
t(template ? 'templates.saveChanges' : 'templates.saveNew')
}}</Button
>
</footer>
</form>
</template>
@@ -1,26 +1,32 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import { Layers3, RotateCcw } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{
areas: string[];
projectId: number;
templateState?: ProjectTemplateState | null;
}>();
const { t } = useLocalization();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
@@ -28,51 +34,82 @@ const form = useForm<{
areas: [],
status_replacements: {},
});
const activeArea = computed(() => form.areas[0]);
const protection = useFormProtection(form);
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
const inheritedAreas = computed(
() =>
props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const overriddenAreas = computed(
() =>
props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const statusRequirement = computed(
() => props.templateState?.resetRequirements?.ticket_statuses,
const statusRequirement = computed(() =>
pendingArea.value?.value === 'ticket_statuses'
? props.templateState?.resetRequirements?.ticket_statuses
: undefined,
);
const canReset = (area: ProjectTemplateStateArea): boolean => {
if (area.value !== 'ticket_statuses') {
return true;
}
return (
statusRequirement.value?.removedStatuses.every(
(status) =>
form.status_replacements[String(status.id)] !== undefined,
) ?? true
);
};
const resetArea = (area: ProjectTemplateStateArea): void => {
if (!canReset(area)) {
const canReset = computed(
() =>
statusRequirement.value?.removedStatuses.every((status) =>
statusRequirement.value?.replacementOptions.some(
(option) =>
option.id === form.status_replacements[String(status.id)],
),
) ?? true,
);
const areaKeys = {
project_settings: 'inheritance.area.projectSettings',
board_columns: 'inheritance.area.boardColumns',
ticket_statuses: 'inheritance.area.ticketStatuses',
status_transitions: 'inheritance.area.statusTransitions',
ticket_types: 'inheritance.area.ticketTypes',
ticket_priorities: 'inheritance.area.ticketPriorities',
ticket_fields: 'inheritance.area.ticketFields',
ticket_link_types: 'inheritance.area.ticketLinkTypes',
repository_connections: 'inheritance.area.repositoryConnections',
notification_actions: 'inheritance.area.notificationActions',
} as const;
function areaLabel(area: ProjectTemplateStateArea): string {
const key = areaKeys[area.value as keyof typeof areaKeys];
return key ? t(key) : area.label;
}
function openReset(area: ProjectTemplateStateArea): void {
form.resetAndClearErrors();
protection.clearFeedback();
pendingArea.value = area;
}
function closeReset(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.areas = [area.value];
form.post(ProjectController.resetTemplate.url(props.projectId), {
pendingArea.value = null;
form.resetAndClearErrors();
protection.clearFeedback();
}
function resetArea(): void {
if (
!pendingArea.value ||
!canReset.value ||
!overriddenAreas.value.some(
(area) => area.value === pendingArea.value?.value,
)
) {
return;
}
form.areas = [pendingArea.value.value];
protection.submit(resetTemplate(props.projectId), {
preserveScroll: true,
onSuccess: () => form.reset(),
onSuccess: () => {
form.resetAndClearErrors();
pendingArea.value = null;
},
});
};
}
</script>
<template>
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
"
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Every setting area inherits independently. Local changes stay
unchanged when the template is updated until that area is reset.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
</div>
<p class="text-muted-foreground break-words">
{{
t('inheritance.description', {
template: templateState.templateName,
})
}}
</p>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="area in inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<p class="text-caption text-muted-foreground">
{{ t('inheritance.help') }}
</p>
<ul
class="grid min-w-0 divide-y"
:aria-label="t('inheritance.areas')"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Badge>{{ area.label }} · template overridden</Badge>
<Button
type="button"
size="sm"
variant="outline"
:disabled="form.processing || !canReset(area)"
@click="resetArea(area)"
>
{{
activeArea === area.value && form.processing
? 'Resetting…'
: 'Reset to template'
}}
</Button>
</div>
<div
v-if="area.value === 'ticket_statuses' && statusRequirement"
class="space-y-3"
<li
v-for="area in inheritedAreas"
:key="area.value"
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
>
<p class="text-muted-foreground text-sm">
Choose where tickets in local statuses should move
before those statuses are removed.
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2 sm:grid-cols-2 sm:items-center"
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge
variant="outline"
class="border-primary/25 text-primary"
>{{ t('projectSettings.inherited') }}</Badge
>
<Label :for="`replacement-${status.id}`">
{{ status.name }} ({{ status.ticketCount }} tickets)
</Label>
<div class="grid gap-1">
<select
:id="`replacement-${status.id}`"
v-model="
form.status_replacements[String(status.id)]
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select replacement status
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</li>
<li
v-for="area in overriddenAreas"
:key="area.value"
class="gap-stack grid min-w-0 py-3"
>
<div
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
>
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge variant="secondary">{{
t('projectSettings.override')
}}</Badge>
</div>
</div>
<InputError :message="form.errors.areas" />
</div>
<div
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.overrideHelp') }}
</p>
<Button
type="button"
size="sm"
variant="outline"
class="shrink-0"
:disabled="form.processing"
:aria-label="
t('inheritance.resetArea', {
area: areaLabel(area),
})
"
@click="openReset(area)"
>
<RotateCcw aria-hidden="true" />{{
t('inheritance.reset')
}}
</Button>
</div>
</li>
</ul>
</CardContent>
<Dialog :open="pendingArea !== null" @update:open="closeReset">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{
t('inheritance.confirmTitle')
}}</DialogTitle>
<DialogDescription>{{
t('inheritance.confirmDescription', {
area: pendingArea ? areaLabel(pendingArea) : '',
template: templateState.templateName,
})
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="resetArea"
>
<FormFeedback
:state="protection.state.value"
:title="
form.processing
? t('inheritance.resetting')
: undefined
"
@retry="resetArea"
/>
<InputError
:message="form.errors.areas ?? form.errors['areas.0']"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
>
<legend class="sr-only">
{{ t('inheritance.confirmTitle') }}
</legend>
<div
v-if="statusRequirement"
class="gap-column grid min-w-0"
>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }}
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2"
>
<Label :for="'replacement-' + status.id">{{
t('inheritance.statusLabel', {
status: status.name,
count: status.ticketCount,
})
}}</Label>
<select
:id="'replacement-' + status.id"
v-model="
form.status_replacements[
String(status.id)
]
"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
:aria-invalid="
!!form.errors[
`status_replacements.${status.id}`
]
"
:aria-describedby="
'replacement-error-' + status.id
"
>
<option :value="undefined" disabled>
{{ t('inheritance.selectStatus') }}
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:id="'replacement-error-' + status.id"
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</div>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
>
{{ t('inheritance.resetWarning') }}
</p>
<DialogFooter>
<Button
type="button"
variant="secondary"
@click="closeReset(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit" :disabled="!canReset">{{
t(
form.processing
? 'inheritance.resetting'
: 'inheritance.reset',
)
}}</Button>
</DialogFooter>
</fieldset>
</form>
</DialogContent>
</Dialog>
</Card>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectModel } from '@/types/models/project-model';
const props = defineProps<{ project: ProjectModel }>();
const { t } = useLocalization();
const form = useForm({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
const protection = useFormProtection(form);
watch(
() => [props.project.show_author, props.project.show_story_points],
() => {
form.defaults({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
form.resetAndClearErrors();
},
);
function submit(): void {
protection.submit(update(props.project.id), {
preserveScroll: true,
onSuccess: () => form.defaults(),
});
}
</script>
<template>
<form
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
<p class="text-muted-foreground text-sm">
{{ t('metadata.displayDescription') }}
</p>
<FormFeedback :state="protection.state.value" @retry="submit" />
<fieldset :disabled="form.processing" class="grid gap-4">
<div
v-for="option in ['show_author', 'show_story_points'] as const"
:key="option"
class="grid gap-2"
>
<Label
:for="option"
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
:id="option"
v-model="form[option]"
:name="option"
:invalid="!!form.errors[option]"
:aria-describedby="`${option}-help ${option}-error`"
/>
<span class="grid gap-1">
<span>{{
t(
option === 'show_author'
? 'metadata.author'
: 'metadata.storyPoints',
)
}}</span>
<span
:id="`${option}-help`"
class="text-muted-foreground text-sm font-normal"
>{{
t(
option === 'show_author'
? 'metadata.authorHelp'
: 'metadata.storyPointsHelp',
)
}}</span
>
</span>
</Label>
<InputError
:id="`${option}-error`"
:message="form.errors[option]"
/>
</div>
</fieldset>
<Button
type="submit"
class="w-full sm:w-fit"
:loading="form.processing"
>{{ t('common.save') }}</Button
>
</form>
</template>
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyFieldDefinition,
storeFieldDefinition,
updateFieldDefinition,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
target: TicketFieldDefinitionEditorTarget | null;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
label: '',
field_type: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addField');
}
if (props.target?.action === 'edit') {
return t('metadata.editField', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteField', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
label: target.entry.label,
field_type: target.entry.field_type,
position: target.entry.position,
}
: {
label: '',
field_type: props.fieldTypeOptions[0]?.value ?? '',
position: props.definitions.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const route =
target.action === 'create'
? storeFieldDefinition(props.projectId)
: target.action === 'edit'
? updateFieldDefinition([props.projectId, target.entry.id])
: destroyFieldDefinition([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? { label: data.label, field_type: data.field_type }
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteFieldHelp'
: 'metadata.fieldEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.fieldDeletionImpact', {
count:
target.entry.ticket_field_values_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="field-definition-label">{{
t('metadata.fieldLabel')
}}</Label>
<Input
id="field-definition-label"
v-model="form.label"
name="label"
required
maxlength="255"
:aria-invalid="!!form.errors.label"
aria-describedby="field-definition-label-error"
/>
<InputError
id="field-definition-label-error"
:message="form.errors.label"
/>
</div>
<div class="grid gap-2">
<Label for="field-definition-type">{{
t('metadata.fieldType')
}}</Label>
<select
id="field-definition-type"
v-model="form.field_type"
name="field_type"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="!!form.errors.field_type"
aria-describedby="field-definition-type-help field-definition-type-error"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<p
id="field-definition-type-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.fieldTypeHelp') }}
</p>
<InputError
id="field-definition-type-error"
:message="form.errors.field_type"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="field-definition-position">{{
t('workflow.position')
}}</Label>
<Input
id="field-definition-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="definitions.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="field-definition-position-help field-definition-position-error"
/>
<p
id="field-definition-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: definitions.length,
})
}}
</p>
<InputError
id="field-definition-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Braces, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketFieldDefinitionEditorDialog from '@/components/projects/TicketFieldDefinitionEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const { t } = useLocalization();
const target = ref<TicketFieldDefinitionEditorTarget | null>(null);
const fieldTypeLabels = computed(
() =>
new Map(
props.fieldTypeOptions.map((option) => [
option.value,
option.label,
]),
),
);
function fieldTypeLabel(fieldType: string): string {
return fieldTypeLabels.value.get(fieldType) ?? fieldType;
}
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
aria-labelledby="metadata-custom-fields"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Braces
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-custom-fields" class="font-semibold">
{{ t('metadata.customFields') }}
</h3>
<Badge variant="secondary">{{ definitions.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addField')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.customFieldsDescription') }}
</p>
<FeedbackState
v-if="definitions.length === 0"
state="empty"
:title="t('metadata.noCustomFields')"
:description="t('metadata.noCustomFieldsDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.customFields')">
<li
v-for="definition in definitions"
:key="definition.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-field-definition="definition.id"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: definition.position,
})
"
>{{ definition.position }}</span
>
<div class="grid min-w-0 gap-1">
<p class="break-words font-medium">
{{ definition.label }}
</p>
<div
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
fieldTypeLabel(definition.field_type)
}}</Badge>
<span>{{
t('metadata.storedValues', {
count:
definition.ticket_field_values_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editField', { name: definition.label })
"
@click="target = { action: 'edit', entry: definition }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteField', {
name: definition.label,
})
"
@click="
target = { action: 'delete', entry: definition }
"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketFieldDefinitionEditorDialog
:definitions="definitions"
:field-type-options="fieldTypeOptions"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRight, CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyLinkType,
storeLinkType,
updateLinkType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: TicketLinkTypeEditorTarget | null;
linkTypes: TicketLinkTypeModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
counterpart_name: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addLinkType');
}
if (props.target?.action === 'edit') {
return t('metadata.editLinkType', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteLinkType', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
name: target.entry.label,
counterpart_name: target.entry.counterpart_label,
position: target.entry.position,
}
: {
name: '',
counterpart_name: '',
position: props.linkTypes.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const route =
target.action === 'create'
? storeLinkType(props.projectId)
: target.action === 'edit'
? updateLinkType([props.projectId, target.entry.id])
: destroyLinkType([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? {
name: data.name,
counterpart_name: data.counterpart_name,
}
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteLinkTypeHelp'
: 'metadata.linkTypeEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.linkTypeDeletionImpact', {
count: target.entry.ticket_links_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="link-type-source-label">{{
t('metadata.sourceLabel')
}}</Label>
<Input
id="link-type-source-label"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="link-type-source-label-help link-type-source-label-error"
/>
<p
id="link-type-source-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.sourceLabelHelp') }}
</p>
<InputError
id="link-type-source-label-error"
:message="form.errors.name"
/>
</div>
<ArrowRight
class="text-primary mx-auto size-5 rotate-90 sm:rotate-0"
aria-hidden="true"
/>
<div class="grid gap-2">
<Label for="link-type-counterpart-label">{{
t('metadata.counterpartLabel')
}}</Label>
<Input
id="link-type-counterpart-label"
v-model="form.counterpart_name"
name="counterpart_name"
required
maxlength="255"
:aria-invalid="!!form.errors.counterpart_name"
aria-describedby="link-type-counterpart-label-help link-type-counterpart-label-error"
/>
<p
id="link-type-counterpart-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.counterpartLabelHelp') }}
</p>
<InputError
id="link-type-counterpart-label-error"
:message="form.errors.counterpart_name"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="link-type-position">{{
t('workflow.position')
}}</Label>
<Input
id="link-type-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="linkTypes.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="link-type-position-help link-type-position-error"
/>
<p
id="link-type-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: linkTypes.length,
})
}}
</p>
<InputError
id="link-type-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ArrowRight, Link2, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketLinkTypeEditorDialog from '@/components/projects/TicketLinkTypeEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
defineProps<{ projectId: number; linkTypes: TicketLinkTypeModel[] }>();
const { t } = useLocalization();
const target = ref<TicketLinkTypeEditorTarget | null>(null);
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
aria-labelledby="metadata-link-types"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Link2
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-link-types" class="font-semibold">
{{ t('metadata.linkTypes') }}
</h3>
<Badge variant="secondary">{{ linkTypes.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addLinkType')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.linkTypesDescription') }}
</p>
<FeedbackState
v-if="linkTypes.length === 0"
state="empty"
:title="t('metadata.noLinkTypes')"
:description="t('metadata.noLinkTypesDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.linkTypes')">
<li
v-for="linkType in linkTypes"
:key="linkType.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-link-type="linkType.id"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: linkType.position,
})
"
>{{ linkType.position }}</span
>
<div class="grid min-w-0 gap-2">
<div
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
:aria-label="
t('metadata.linkDirection', {
source: linkType.label,
counterpart: linkType.counterpart_label,
})
"
>
<span class="break-words font-medium">{{
linkType.label
}}</span>
<ArrowRight
class="text-primary size-4 shrink-0"
aria-hidden="true"
/>
<span class="break-words font-medium">{{
linkType.counterpart_label
}}</span>
</div>
<p class="text-caption text-muted-foreground">
{{
t('metadata.ticketRelationships', {
count: linkType.ticket_links_count ?? 0,
})
}}
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'edit', entry: linkType }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'delete', entry: linkType }"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketLinkTypeEditorDialog
:link-types="linkTypes"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
destroy,
destroyStatus,
store,
storeStatus,
update,
updateStatus,
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<{
projectId: number;
columnCount: number;
target: WorkflowEditorTarget | null;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({ name: '', position: 1 });
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.kind === 'column') {
return t(
props.target.action === 'create'
? 'workflow.addColumn'
: props.target.action === 'edit'
? 'workflow.editColumn'
: 'workflow.deleteColumn',
);
}
return t(
props.target?.action === 'create'
? 'workflow.addStatus'
: props.target?.action === 'edit'
? 'workflow.editStatus'
: 'workflow.deleteStatus',
);
});
const description = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.kind === 'column') {
return t(
target.action === 'delete'
? 'workflow.deleteColumnDescription'
: target.action === 'create'
? 'workflow.addColumnDescription'
: 'workflow.editColumnDescription',
{ name: target.column?.name ?? '' },
);
}
return t(
target.action === 'delete'
? 'workflow.deleteStatusDescription'
: 'workflow.statusContext',
{
name: target.status?.name ?? '',
context: target.column?.name ?? t('workflow.backlog'),
replacement: target.replacement?.name ?? '',
},
);
});
const otherErrors = computed(() =>
Object.entries(form.errors)
.filter(([key]) => !['name', 'position'].includes(key))
.map(([, message]) => message),
);
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name:
target.kind === 'column'
? (target.column?.name ?? '')
: (target.status?.name ?? ''),
position: target.column?.position ?? 1,
});
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(target.kind === 'status' &&
target.action === 'delete' &&
!target.replacement)
) {
return;
}
const route =
target.kind === 'column'
? target.action === 'create'
? store(props.projectId)
: target.action === 'edit'
? update([props.projectId, target.column.id])
: destroy([props.projectId, target.column.id])
: target.action === 'create'
? storeStatus(props.projectId)
: target.action === 'edit'
? updateStatus([props.projectId, target.status.id])
: destroyStatus([props.projectId, target.status.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.kind === 'column' && target.action === 'edit'
? data
: target.kind === 'status' && target.action === 'create'
? {
name: data.name,
board_column_id: target.column?.id ?? null,
}
: { name: data.name },
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="break-words">{{
description
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<InputError
v-for="message in otherErrors"
:key="message"
:message="message"
/>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="workflow-name">{{
t(
target?.kind === 'column'
? 'workflow.columnName'
: 'workflow.statusName',
)
}}</Label>
<Input
id="workflow-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
:aria-describedby="
form.errors.name
? 'workflow-name-error'
: undefined
"
/>
<InputError
id="workflow-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="
target?.kind === 'column' &&
target.action === 'edit'
"
class="grid gap-2"
>
<Label for="workflow-position">{{
t('workflow.position')
}}</Label>
<Input
id="workflow-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="columnCount"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="workflow-position-help workflow-position-error"
/>
<p
id="workflow-position-help"
class="text-caption text-muted-foreground"
>
{{
t('workflow.positionHelp', {
count: columnCount,
})
}}
</p>
<InputError
id="workflow-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
defineProps<{
column: BoardColumnModel | null;
statuses: TicketStatusModel[];
}>();
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<div class="grid min-w-0 gap-3">
<ul
class="divide-y"
:aria-label="
t('workflow.statusesIn', {
context: column?.name ?? t('workflow.backlog'),
})
"
>
<li
v-for="status in statuses"
:key="status.id"
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
>
<span
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
>{{ status.name }}</span
>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('workflow.renameStatus', { name: status.name })
"
@click="
$emit('edit', {
kind: 'status',
action: 'edit',
column,
status,
})
"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="statuses.length <= 1"
:aria-label="
t('workflow.removeStatus', { name: status.name })
"
:aria-describedby="
statuses.length <= 1
? `status-protection-${column?.id ?? 'backlog'}`
: undefined
"
@click="
$emit('edit', {
kind: 'status',
action: 'delete',
column,
status,
replacement: statuses.find(
(item) => item.id !== status.id,
),
})
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ul>
<p
v-if="statuses.length <= 1"
:id="`status-protection-${column?.id ?? 'backlog'}`"
class="text-caption text-muted-foreground"
>
{{ t('workflow.lastStatus') }}
</p>
<Button
type="button"
variant="outline"
class="w-full sm:w-fit"
:aria-label="
t('workflow.addStatusTo', {
context: column?.name ?? t('workflow.backlog'),
})
"
@click="$emit('edit', { kind: 'status', action: 'create', column })"
><Plus class="size-4" aria-hidden="true" />{{
t('workflow.addStatus')
}}</Button
>
</div>
</template>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRightLeft } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<{
projectId: number;
statuses: TicketStatusModel[];
}>();
const { t } = useLocalization();
const sourceId = ref(props.statuses[0]?.id ?? null);
const source = computed(() =>
props.statuses.find((status) => status.id === sourceId.value),
);
const targets = computed(() =>
props.statuses.filter((status) => status.id !== sourceId.value),
);
const savedTargets = computed(
() =>
source.value?.allowed_target_statuses
?.map((status) => status.id)
.sort((a, b) => a - b) ?? [],
);
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
const protection = useFormProtection(form);
const saved = ref(false);
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
function reset(): void {
saved.value = false;
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
form.resetAndClearErrors();
protection.clearFeedback();
}
watch(
() => props.statuses,
() => {
if (!source.value) {
sourceId.value = props.statuses[0]?.id ?? null;
reset();
} else if (!form.isDirty && !form.processing) {
reset();
}
},
);
function selectSource(event: Event): void {
const select = event.target as HTMLSelectElement;
if (form.processing || !protection.confirmLeave()) {
select.value = String(sourceId.value);
return;
}
sourceId.value = Number(select.value);
reset();
}
function toggleTarget(id: number, event: Event): void {
saved.value = false;
const checked = (event.target as HTMLInputElement).checked;
form.allowed_ticket_status_ids = checked
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
: form.allowed_ticket_status_ids.filter((target) => target !== id);
form.clearErrors();
protection.clearFeedback();
}
function submit(): void {
if (!source.value || !form.isDirty || form.processing) {
return;
}
protection.submit(
updateStatusTransitions([props.projectId, source.value.id]),
{
preserveScroll: true,
onSuccess: () => {
saved.value = true;
form.defaults({
allowed_ticket_status_ids: [...savedTargets.value],
});
form.resetAndClearErrors();
},
},
);
}
</script>
<template>
<section
class="bg-card min-w-0 overflow-hidden rounded-xl border"
aria-labelledby="workflow-transitions"
>
<header class="grid gap-3 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<ArrowRightLeft
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="workflow-transitions" class="font-semibold">
{{ t('workflow.transitions') }}
</h3>
</div>
<p class="text-muted-foreground text-sm">
{{ t('workflow.transitionsDescription') }}
</p>
</header>
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
<p v-if="!source" class="text-muted-foreground text-sm">
{{ t('workflow.noStatuses') }}
</p>
<template v-else>
<div class="grid min-w-0 gap-2">
<Label for="workflow-source">{{
t('workflow.sourceStatus')
}}</Label>
<select
id="workflow-source"
:value="sourceId"
:disabled="form.processing"
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
@change="selectSource"
>
<option
v-for="status in statuses"
:key="status.id"
:value="status.id"
>
{{ ticketStatusLabel(status, '') }}
</option>
</select>
<p class="text-muted-foreground text-sm">
{{ t('workflow.directionHelp') }}
</p>
</div>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-3"
aria-describedby="workflow-target-help workflow-transition-errors"
:aria-invalid="hasErrors"
>
<legend class="mb-3 break-words font-medium">
{{
t('workflow.targetsFrom', { name: source.name })
}}
</legend>
<p
id="workflow-target-help"
class="text-muted-foreground text-sm"
>
{{ t('workflow.targetHelp') }}
</p>
<p
v-if="targets.length === 0"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noTargets') }}
</p>
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<label
v-for="target in targets"
:key="target.id"
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
:class="
form.allowed_ticket_status_ids.includes(
target.id,
)
? 'border-primary/40 bg-accent/40'
: 'border-input'
"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="target.id"
:checked="
form.allowed_ticket_status_ids.includes(
target.id,
)
"
class="accent-primary size-4 shrink-0"
@change="toggleTarget(target.id, $event)"
/>
<span class="grid min-w-0 gap-1">
<span
class="break-words text-sm font-medium"
>{{
ticketStatusLabel(target, '')
}}</span
>
<span
class="text-muted-foreground text-xs"
>{{
t(
form.allowed_ticket_status_ids.includes(
target.id,
)
? 'workflow.allowed'
: 'workflow.notAllowed',
)
}}</span
>
</span>
</label>
</div>
</fieldset>
<p
v-if="
targets.length > 0 &&
form.allowed_ticket_status_ids.length === 0
"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noMovesHelp') }}
</p>
<div id="workflow-transition-errors" role="alert">
<InputError
v-for="(message, key) in form.errors"
:key="key"
:message="message"
/>
</div>
<FormFeedback
:state="
!saved && protection.state.value === 'success'
? 'idle'
: protection.state.value
"
@retry="submit"
/>
<div
class="flex flex-wrap items-center gap-3 border-t pt-4"
>
<Badge
:variant="form.isDirty ? 'warning' : 'secondary'"
role="status"
>{{
t(
form.isDirty
? 'workflow.unsavedMoves'
: 'workflow.savedMoves',
{ count: savedTargets.length },
)
}}</Badge
>
<div
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
>
<Button
type="button"
variant="outline"
:disabled="!form.isDirty || form.processing"
@click="reset"
>{{ t('workflow.resetMoves') }}</Button
>
<Button
type="submit"
:disabled="!form.isDirty"
:loading="form.processing"
>{{ t('workflow.saveMoves') }}</Button
>
</div>
</div>
</form>
</template>
</div>
</section>
</template>
@@ -0,0 +1,320 @@
<script setup lang="ts">
import {
ClipboardCheck,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, useId, watch } from 'vue';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
canIndentAcceptanceCriterion,
indentAcceptanceCriterion,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(
defineProps<{
modelValue: AcceptanceCriteriaModel;
readonly?: boolean;
disabled?: boolean;
persistToggles?: boolean;
allowEditing?: boolean;
saving?: boolean;
dirty?: boolean;
error?: string;
}>(),
{
readonly: false,
disabled: false,
persistToggles: false,
allowEditing: true,
saving: undefined,
dirty: false,
error: '',
},
);
const emit = defineEmits<{
'update:modelValue': [items: AcceptanceCriteriaModel];
toggle: [index: number, checked: boolean];
}>();
const { t } = useLocalization();
const id = useId();
const root = ref<HTMLElement>();
const editing = ref(!props.persistToggles);
const completed = computed(
() => props.modelValue.filter((item) => item.checked).length,
);
const locked = computed(() => props.readonly || props.disabled);
const update = (items: AcceptanceCriteriaModel): void => {
if (!locked.value) {
emit('update:modelValue', items);
}
};
const focusRow = async (index: number): Promise<void> => {
await nextTick();
const inputs =
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
if (inputs?.[index]) {
inputs[index].focus();
} else {
root.value
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
?.focus();
}
};
const add = (): void => {
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
void focusRow(props.modelValue.length);
};
const remove = (index: number): void => {
update(removeAcceptanceCriterion(props.modelValue, index));
void focusRow(Math.max(0, index - 1));
};
watch(
() => props.modelValue,
async () => {
await nextTick();
root.value
?.querySelectorAll<HTMLTextAreaElement>('textarea')
.forEach((input) => {
input.setCustomValidity(
input.value.trim() === '' ? t('criteria.required') : '',
);
});
},
);
const changeText = (index: number, event: Event): void => {
const text = (event.target as HTMLTextAreaElement).value;
update(
props.modelValue.map((item, position) =>
position === index ? { ...item, text } : item,
),
);
};
const toggle = (index: number, event: Event): void => {
const target = event.target as HTMLInputElement;
const checked = target.checked;
target.checked = props.modelValue[index].checked;
if (locked.value) {
return;
}
if (props.persistToggles && !editing.value && !props.dirty) {
emit('toggle', index, checked);
} else {
update(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
checked,
),
);
}
};
const onKeydown = (index: number, event: KeyboardEvent): void => {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
event.preventDefault();
update(
indentAcceptanceCriterion(
props.modelValue,
index,
event.key === 'ArrowRight' ? 1 : -1,
),
);
};
</script>
<template>
<section
ref="root"
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
:aria-labelledby="`${id}-title`"
:aria-busy="disabled"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3
:id="`${id}-title`"
class="flex items-center gap-2 font-semibold"
>
<ClipboardCheck
class="text-primary size-5"
aria-hidden="true"
/>
{{ t('criteria.title') }}
</h3>
<span class="text-muted-foreground text-sm" aria-live="polite">{{
t('criteria.progress', { completed, total: modelValue.length })
}}</span>
<Button
v-if="persistToggles && allowEditing && !readonly"
type="button"
variant="outline"
:disabled="
disabled ||
(editing &&
modelValue.some((item) => item.text.trim() === ''))
"
:aria-pressed="editing"
@click="editing = !editing"
>
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
</Button>
</div>
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
{{ t('criteria.empty') }}
</p>
<ol v-else class="space-y-3">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `new-${index}`"
:data-indent="item.indent"
class="border-border min-w-0"
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
>
<div class="flex min-w-0 items-start gap-2">
<label
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
:class="{ 'cursor-not-allowed opacity-60': locked }"
>
<input
:checked="item.checked"
type="checkbox"
:disabled="locked"
:aria-label="
t('criteria.complete', {
number: index + 1,
text: item.text,
})
"
class="border-input accent-primary size-5 rounded"
@change="toggle(index, $event)"
/>
</label>
<div class="min-w-0 flex-1">
<textarea
v-if="editing && !readonly"
:value="item.text"
:aria-label="
t('criteria.item', { number: index + 1 })
"
:aria-describedby="`${id}-hint ${id}-error`"
:disabled="disabled"
:aria-invalid="Boolean(error)"
required
rows="2"
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
@input="changeText(index, $event)"
@keydown="onKeydown(index, $event)"
/>
<p
v-else
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
:class="{
'text-muted-foreground line-through':
item.checked,
}"
>
{{ item.text }}
</p>
<div
v-if="editing && !readonly"
class="flex flex-wrap gap-1"
>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled || item.indent === 0"
:aria-label="
t('criteria.outdent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
-1,
),
)
"
><IndentDecrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
disabled ||
!canIndentAcceptanceCriterion(
modelValue,
index,
)
"
:aria-label="
t('criteria.indent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
1,
),
)
"
><IndentIncrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled"
:aria-label="
t('criteria.remove', { number: index + 1 })
"
@click="remove(index)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</div>
</div>
</li>
</ol>
<template v-if="editing && !readonly">
<Button
data-add-criterion
type="button"
variant="outline"
:disabled="disabled"
@click="add"
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{ t('criteria.hint') }}
</p>
</template>
<p
v-if="persistToggles && dirty"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.unsaved') }}
</p>
<p
v-if="saving ?? disabled"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.saving') }}
</p>
<InputError :id="`${id}-error`" :message="error" role="alert" />
</section>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<article
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div
:class="
layout === 'list'
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
: ''
"
>
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
:class="[
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
:class="[
'border-t p-4',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div>
</div>
</article>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { ProjectTicketCreatePageProps } from '@/types';
const props = defineProps<
Pick<
ProjectTicketCreatePageProps,
| 'project'
| 'ticketTypes'
| 'ticketPriorities'
| 'users'
| 'parentTicketOptions'
> & {
errors: Record<string, string>;
disabled?: boolean;
autofocus?: boolean;
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
}
>();
const title = defineModel<string>('title', { required: true });
const description = defineModel<string>('description', { required: true });
const type = defineModel<string | number>('type', { required: true });
const priority = defineModel<string | number>('priority', { required: true });
const assignee = defineModel<string | number>('assignee', { required: true });
const parent = defineModel<string | number>('parent', { required: true });
const points = defineModel<string | number | null>('points', {
required: true,
});
const status = defineModel<string | number>('status');
const { t } = useLocalization();
const typeOptions = computed(() =>
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
);
const priorityOptions = computed(() =>
props.ticketPriorities.map((item) => ({
value: item.id,
label: item.name,
})),
);
const userOptions = computed(() => [
{ value: '', label: t('tickets.unassigned') },
...props.users.map((item) => ({ value: item.id, label: item.name })),
]);
const parentOptions = computed(() => [
{ value: '', label: t('ticketDetail.noParent') },
...props.parentTicketOptions.map((item) => ({
value: item.id,
label: `${item.ticket_key} - ${item.title}`,
})),
]);
const statusOptions = computed(
() =>
props.ticketStatusOptions?.map((item) => ({
value: item.id,
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
})) ?? [],
);
</script>
<template>
<div class="grid min-w-0 gap-6">
<TicketFormField
id="title"
v-model="title"
:label="t('ticketDetail.title')"
:error="errors.title"
:disabled="disabled"
:autofocus="autofocus"
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
:placeholder="t('ticketForm.descriptionPlaceholder')"
/>
<slot name="content" />
<fieldset
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
:disabled="disabled"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.metadata') }}
</legend>
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
<TicketFormField
id="ticket_type_id"
v-model="type"
kind="select"
:label="t('ticketDetail.type')"
:options="typeOptions"
:error="errors.ticket_type_id"
:disabled="disabled"
required
/>
<TicketFormField
id="ticket_priority_id"
v-model="priority"
kind="select"
:label="t('ticketDetail.priority')"
:options="priorityOptions"
:error="errors.ticket_priority_id"
:disabled="disabled"
required
/>
<TicketFormField
v-if="ticketStatusOptions && status !== undefined"
id="ticket_status_id"
v-model="status"
kind="select"
:label="t('ticketDetail.status')"
:options="statusOptions"
:error="errors.ticket_status_id"
:disabled="disabled"
required
/>
<TicketFormField
id="assignee_user_id"
v-model="assignee"
kind="select"
:label="t('ticketDetail.assignee')"
:options="userOptions"
:error="errors.assignee_user_id"
:disabled="disabled"
/>
<TicketFormField
id="parent_ticket_id"
v-model="parent"
kind="select"
:label="t('ticketDetail.parent')"
:options="parentOptions"
:error="errors.parent_ticket_id"
:disabled="disabled"
/>
<TicketFormField
v-if="project.show_story_points"
id="story_points"
v-model="points"
kind="number"
:label="t('ticketDetail.storyPoints')"
:error="errors.story_points"
:disabled="disabled"
:min="0"
step="1"
/>
<slot name="metadata" />
</div>
</fieldset>
</div>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<li
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
>
{{ ticket.title }}
</h3>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
>
<slot name="actions" />
</div>
</div>
</li>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketModel } from '@/types/models/ticket-model';
const props = withDefaults(
defineProps<{
showStatus?: boolean;
ticket: TicketModel;
}>(),
{
showStatus: true,
},
);
const { t } = useLocalization();
const statusLabel = computed(() =>
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
);
</script>
<template>
<div class="gap-stack flex min-w-0 flex-wrap items-center">
<Badge
v-if="ticket.is_blocked"
variant="warning"
:aria-label="t('tickets.blocked')"
>
<OctagonAlert aria-hidden="true" />
{{ t('tickets.blocked') }}
</Badge>
<Badge
variant="secondary"
:aria-label="
t('tickets.typeLabel', { name: ticket.ticket_type.name })
"
:data-ticket-type-id="ticket.ticket_type.id"
>
{{ ticket.ticket_type.name }}
</Badge>
<Badge
variant="outline"
:aria-label="
t('tickets.priorityLabel', {
name: ticket.ticket_priority.name,
})
"
:data-ticket-priority-id="ticket.ticket_priority.id"
>
{{ ticket.ticket_priority.name }}
</Badge>
<Badge
v-if="showStatus"
variant="outline"
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
:data-ticket-status-id="ticket.ticket_status?.id"
>
{{ statusLabel }}
</Badge>
<span
v-if="ticket.assignee"
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
:aria-label="
t('tickets.assignedTo', { name: ticket.assignee.name })
"
>
<UserAvatar
:user="ticket.assignee"
size="sm"
:label="ticket.assignee.name"
/>
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
</span>
<span
v-else
class="text-caption text-muted-foreground flex items-center gap-1.5"
>
<CircleUserRound class="size-4" aria-hidden="true" />
{{ t('tickets.unassigned') }}
</span>
</div>
</template>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { Link, useForm, usePage } from '@inertiajs/vue3';
import {
CheckCircle2,
CircleAlert,
GitBranchPlus,
LoaderCircle,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import {
store,
validate as validateBranch,
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type {
TicketBranchCreateDialogProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketBranchCreateDialogProps>();
const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
const isOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const defaultRepositoryConnectionId = computed(() =>
String(
props.repositoryConnections.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
props.repositoryConnections[0]?.id ??
'',
),
);
const form = useForm<TicketBranchFormData>(draftKey, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultRepositoryConnectionId.value,
});
const protection = useFormProtection(form, {
isDirty: () => isOpen.value && form.isDirty,
});
const selectedRepositoryConnection = computed(
() =>
props.repositoryConnections.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
form.repository_connection_id,
) ?? null,
);
const selectedRepositoryHasPersonalAccess = computed(
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
);
const validationStateClasses = computed(() => {
if (isCheckingBranchName.value) {
return 'border-primary/30 bg-primary/5 text-foreground';
}
if (branchNameValidation.value?.is_valid) {
return 'border-success/40 bg-success/10 text-success-foreground';
}
if (branchNameValidation.value !== null) {
return 'border-warning/40 bg-warning/10 text-warning-foreground';
}
return 'border-border bg-muted/30 text-muted-foreground';
});
function updateOpen(open: boolean): void {
if (!open && !protection.confirmLeave()) {
return;
}
isOpen.value = open;
}
function initializeForm(): void {
if (form.isDirty) {
return;
}
form.clearErrors();
protection.clearFeedback();
form.branch_name = props.suggestedBranchName;
form.repository_connection_id = defaultRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
}
watch(isOpen, (open) => {
if (!open) {
form.clearErrors();
protection.clearFeedback();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeForm();
});
watch(
[isOpen, () => form.repository_connection_id, () => form.branch_name],
(
[open, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!open) {
return;
}
const repositoryConnection = selectedRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
branchNameValidation.value = null;
isCheckingBranchName.value = true;
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
try {
const response = await fetch(
validateBranch.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
function submit(): void {
if (
!selectedRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
protection.submit(
store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isOpen.value = false;
},
preserveScroll: true,
},
);
}
</script>
<template>
<Dialog :open="isOpen" @update:open="updateOpen">
<Button
type="button"
variant="outline"
class="w-full gap-2 sm:w-auto"
@click="isOpen = true"
>
<GitBranchPlus class="size-4" aria-hidden="true" />
{{ t('ticketDetail.createBranch') }}
</Button>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form class="grid gap-4" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
:title="
protection.state.value === 'loading'
? t('ticketDetail.creatingBranch')
: undefined
"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid min-w-0 gap-2">
<Label for="repository_connection_id">{{
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="form.repository_connection_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in repositoryConnections"
:key="repositoryConnection.id"
:value="String(repositoryConnection.id)"
>
{{ repositoryConnection.repository_full_name
}}{{
repositoryConnection.has_personal_access
? ''
: ` — ${t('ticketDetail.personalAccessRequired')}`
}}
</option>
</select>
<InputError
:message="form.errors.repository_connection_id"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="form.branch_name"
:disabled="!selectedRepositoryHasPersonalAccess"
autocomplete="off"
/>
<InputError :message="form.errors.branch_name" />
</div>
</fieldset>
<div
role="status"
aria-live="polite"
:aria-busy="isCheckingBranchName || undefined"
class="rounded-lg border px-4 py-3 transition-colors"
:class="validationStateClasses"
>
<div class="flex items-start gap-3">
<LoaderCircle
v-if="isCheckingBranchName"
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<CheckCircle2
v-else-if="branchNameValidation?.is_valid"
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<CircleAlert
v-else-if="branchNameValidation !== null"
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t('ticketDetail.checkingBranch')
: (branchNameValidation?.message ??
t('ticketDetail.selectRepository'))
}}
</p>
</div>
</div>
<div
v-if="
!selectedRepositoryHasPersonalAccess &&
selectedRepositoryConnection !== null
"
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
>
<div class="grid gap-1">
<p class="font-semibold">
{{ t('ticketDetail.gitAccessRequired') }}
</p>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
</div>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
:disabled="form.processing"
@click="updateOpen(false)"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:disabled="
isCheckingBranchName ||
branchNameValidation?.is_valid !== true
"
:loading="form.processing"
:loading-label="t('ticketDetail.creatingBranch')"
>
{{ t('ticketDetail.createNow') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailBlockerProps>();
const { t } = useLocalization();
const savedFormData = () => ({
acceptance_criteria: props.ticket.acceptance_criteria,
assignee_user_id: props.ticket.assignee_user_id,
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description,
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
value_number: fieldValue.value_number,
value_user_id: fieldValue.value_user_id,
})),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id,
story_points: props.ticket.story_points,
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
});
const form = useForm(
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
savedFormData(),
);
const protection = useFormProtection(form);
const isBlocked = computed(() => Boolean(form.is_blocked));
const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
return;
}
form.reset();
form.clearErrors();
protection.clearFeedback();
};
const submit = (): void => {
if (!props.canEditTicket || form.processing) {
return;
}
protection.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
onSuccess: () => {
Object.assign(form, savedFormData());
form.defaults();
},
},
);
};
</script>
<template>
<section
id="ticket-blocker"
aria-labelledby="ticket-blocker-title"
class="min-w-0 scroll-mt-6"
>
<Card :class="{ 'border-warning/40': isBlocked }">
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
{{ t('ticketDetail.blocker') }}
</h2>
<Badge :variant="isBlocked ? 'warning' : 'outline'">
{{
isBlocked
? t('ticketDetail.blockerActive')
: t('ticketDetail.blockerInactive')
}}
</Badge>
</div>
<CardDescription>
{{ t('ticketDetail.blockerDescription') }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTicket">
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
>
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:disabled="form.processing"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="ticket-blocker-state-description is_blocked-error"
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
/>
<div class="grid min-w-0 gap-1">
<Label for="is_blocked">
{{ t('ticketDetail.blocked') }}
</Label>
<p
id="ticket-blocker-state-description"
class="text-muted-foreground text-sm"
>
{{
isBlocked
? t(
'ticketDetail.blockerActiveDescription',
)
: t(
'ticketDetail.blockerInactiveDescription',
)
}}
</p>
</div>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
v-if="isBlocked"
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:hint="t('ticketDetail.blockerCommentHelp')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:disabled="form.processing"
required
/>
<div
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
>
<Button
type="button"
variant="outline"
:disabled="!form.isDirty || form.processing"
@click="reset"
>
{{ t('ticketDetail.resetBlocker') }}
</Button>
<Button
type="submit"
:loading="form.processing"
:loading-label="t('ticketDetail.savingBlocker')"
:disabled="!form.isDirty"
>
{{
isBlocked
? t('ticketDetail.saveBlocker')
: t('ticketDetail.unblockTicket')
}}
</Button>
</div>
</form>
</CardContent>
<CardContent v-else>
<p
class="whitespace-pre-wrap text-sm leading-6"
:class="{ 'text-muted-foreground': !isBlocked }"
>
{{
isBlocked
? ticket.blocked_comment
: t('ticketDetail.blockerInactiveDescription')
}}
</p>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,497 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
import {
computed,
ref,
toRef,
unref,
useTemplateRef,
nextTick,
watch,
} from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import {
normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailContentProps>();
const { t } = useLocalization();
const editor = useTemplateRef<HTMLDetailsElement>('editor');
async function editParent(): Promise<void> {
if (!props.canEditTicket || !editor.value) {
return;
}
editor.value.open = true;
await nextTick();
editor.value
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
?.focus();
}
defineExpose({ editParent });
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
`${draftKey}:criteria`,
);
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({
acceptance_criteria: normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
const currentFieldValue = props.ticket.field_values?.find(
(fieldValue) =>
fieldValue.ticket_field_definition_id ===
ticketFieldDefinition.id,
);
return {
ticket_field_definition_id: ticketFieldDefinition.id,
value_number: currentFieldValue?.value_number ?? '',
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
story_points: props.ticket.story_points,
});
const form = useForm(draftKey, savedFormData());
watch(
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
([isBlocked, blockedComment]) => {
form.is_blocked = isBlocked;
form.blocked_comment = blockedComment ?? '';
form.defaults('is_blocked', isBlocked);
form.defaults('blocked_comment', blockedComment ?? '');
},
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
JSON.stringify(acceptanceCriteriaItems.value) !==
JSON.stringify(savedAcceptanceCriteria.value),
);
const isDirty = computed(
() =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
);
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const reset = (): void => {
if (
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
!protection.confirmLeave()
) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.'),
)?.[1] ??
''
);
});
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
acceptance_criteria: acceptanceCriteria,
assignee_user_id: props.ticket.assignee_user_id,
author_user_id: props.ticket.author_user_id,
blocked_comment: props.ticket.blocked_comment,
description: props.ticket.description,
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
value_number: fieldValue.value_number,
value_user_id: fieldValue.value_user_id,
})),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id,
story_points: props.ticket.story_points,
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
});
const persistAcceptanceCriteriaToggle = async (
index: number,
checked: boolean,
): Promise<void> => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value
) {
return;
}
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
savedAcceptanceCriteria.value,
index,
checked,
);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
isPersistingAcceptanceCriteriaToggle.value = true;
acceptanceCriteriaToggleError.value = '';
try {
const response = await fetch(
ProjectTicketController.update.url({
project: props.project.id,
ticket: props.ticket.id,
}),
{
method: 'PATCH',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken ?? '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(
buildSavedTicketPayload(nextAcceptanceCriteria),
),
},
);
if (!response.ok) {
throw new Error('Checklist toggle update failed.');
}
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
nextAcceptanceCriteria,
);
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError');
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const submit = (): void => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value
) {
return;
}
form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
onSuccess: () => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
form.defaults();
},
},
);
};
</script>
<template>
<div class="flex min-w-0 flex-col gap-6">
<section
id="ticket-description"
aria-labelledby="ticket-description-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-description-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.description') }}
</h2></CardHeader
>
<CardContent>
<p
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
:class="{
'text-muted-foreground': !ticket.description,
}"
>
{{
ticket.description ||
t('ticketDetail.noDescription')
}}
</p>
</CardContent>
</Card>
</section>
<div id="ticket-checklist" class="scroll-mt-6">
<AcceptanceCriteriaChecklist
:model-value="savedAcceptanceCriteria"
:readonly="!canEditTicket"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
:saving="
form.processing || isPersistingAcceptanceCriteriaToggle
"
:allow-editing="false"
persist-toggles
:dirty="hasUnsavedAcceptanceCriteriaDraft"
:error="acceptanceCriteriaToggleError"
@toggle="persistAcceptanceCriteriaToggle"
/>
</div>
<details
v-if="canEditTicket"
id="ticket-edit"
ref="editor"
class="bg-card group rounded-xl border shadow-sm"
>
<summary
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
>
{{ t('ticketDetail.edit') }}
</summary>
<div class="border-t p-4 sm:p-6">
<p class="text-muted-foreground mb-6 text-sm">
{{ t('ticketDetail.editDescription') }}
</p>
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<TicketFormFields
v-model:title="form.title"
v-model:description="form.description"
v-model:type="form.ticket_type_id"
v-model:priority="form.ticket_priority_id"
v-model:assignee="form.assignee_user_id"
v-model:parent="form.parent_ticket_id"
v-model:points="form.story_points"
:project="project"
:ticket-types="ticketTypes"
:ticket-priorities="ticketPriorities"
:users="users"
:parent-ticket-options="parentTicketOptions"
:errors="form.errors"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<template #content>
<AcceptanceCriteriaChecklist
v-model="acceptanceCriteriaItems"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
:error="acceptanceCriteriaError"
/>
</template>
<template #metadata>
<TicketFormField
v-if="canViewAuthor"
id="author_user_id"
v-model="form.author_user_id"
kind="select"
:label="t('ticketDetail.author')"
:hint="
!canEditAuthor
? t('ticketForm.authorRestricted')
: undefined
"
:options="[
{
value: '',
label: t('ticketDetail.noAuthor'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="form.errors.author_user_id"
:disabled="!canEditAuthor || form.processing"
/>
</template>
</TicketFormFields>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.customFields') }}
</legend>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.customFieldsDescription') }}
</p>
<InputError :message="form.errors.field_values" />
<p
v-if="!ticketFieldDefinitions.length"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.noCustomFields') }}
</p>
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
<template
v-for="(
definition, index
) in ticketFieldDefinitions"
:key="definition.id"
>
<TicketFormField
v-if="definition.field_type === 'user'"
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_user_id
"
kind="select"
:label="definition.label"
:options="[
{
value: '',
label: t('tickets.unassigned'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="
form.errors[
`field_values.${index}.value_user_id`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
<TicketFormField
v-else
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_number
"
kind="number"
step="0.1"
:label="definition.label"
:error="
form.errors[
`field_values.${index}.value_number`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
</template>
</div>
</fieldset>
<div
class="flex flex-wrap items-center gap-3 border-t pt-5"
>
<Button
type="submit"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>{{
form.processing
? t('ticketForm.saving')
: t('ticketDetail.saveChanges')
}}</Button
>
<Button
type="button"
variant="outline"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
!isDirty
"
@click="reset"
>{{ t('ticketForm.reset') }}</Button
>
<p role="status" class="text-muted-foreground text-sm">
{{
isDirty
? t('ticketForm.unsaved')
: t('ticketForm.saved')
}}
</p>
</div>
</form>
</div>
</details>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketDetailGitProps } from '@/types/ticket-detail';
defineProps<TicketDetailGitProps>();
const { t } = useLocalization();
</script>
<template>
<section
id="ticket-git"
aria-labelledby="ticket-git-title"
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
>
<h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }}
</h2>
<div class="grid min-w-0 gap-6">
<TicketGitBranches
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
:repository-branches="repositoryBranches"
:suggested-branch-name="suggestedBranchName"
/>
<TicketGitPullRequests
:repository-pull-requests="repositoryPullRequests"
/>
</div>
</section>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { show } from '@/routes/projects';
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
defineProps<TicketDetailHeaderProps>();
const { t } = useLocalization();
const sections = [
'description',
'checklist',
'metadata',
'blocker',
'relationships',
'history',
'git',
] as const;
</script>
<template>
<header class="flex min-w-0 flex-col gap-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<Button as-child variant="ghost" class="max-w-full"
><Link :href="show(project.id)"
><ArrowLeft aria-hidden="true" />{{
t('ticketDetail.backToProject')
}}</Link
></Button
>
<div class="flex flex-wrap gap-2">
<Button as-child variant="outline"
><Link :href="backlogIndex(project.id)"
><ListTree aria-hidden="true" />{{
t('ticketDetail.openBacklog')
}}</Link
></Button
>
<Button as-child variant="outline"
><Link :href="boardIndex(project.id)"
><Columns3 aria-hidden="true" />{{
t('ticketDetail.openBoard')
}}</Link
></Button
>
</div>
</div>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-primary font-mono text-sm font-semibold">{{
ticket.ticket_key
}}</span
><Badge v-if="!canEditTicket" variant="outline">{{
t('ticketDetail.readOnly')
}}</Badge>
</div>
<h1
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
>
{{ ticket.title }}
</h1>
<TicketMetadata :ticket="ticket" />
</div>
<nav
:aria-label="t('ticketDetail.sections')"
class="flex flex-wrap gap-1 border-b pb-3"
>
<a
v-for="section in sections"
:key="section"
:href="`#ticket-${section}`"
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
>{{ t(`ticketDetail.${section}`) }}</a
>
</nav>
</header>
</template>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
import type { TranslationKey } from '@/types/localization';
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization();
const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
created: 'ticketDetail.historyType.created',
link_removed: 'ticketDetail.historyType.linkRemoved',
link_updated: 'ticketDetail.historyType.linkUpdated',
status_changed: 'ticketDetail.historyType.statusChanged',
updated: 'ticketDetail.historyType.updated',
};
</script>
<template>
<section
id="ticket-history"
aria-labelledby="ticket-history-title"
class="wrap-anywhere min-w-0 scroll-mt-6"
>
<Card class="h-fit">
<CardHeader>
<h2 id="ticket-history-title" class="text-lg font-semibold">
{{ t('ticketDetail.history') }}
</h2>
<CardDescription>{{
t('ticketDetail.historyDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
{{ t('ticketDetail.noHistory') }}
</div>
<ol v-else class="relative grid gap-0" role="list">
<li
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
>
<div
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
>
<UserAvatar
v-if="worklogEntry.actor"
:user="worklogEntry.actor"
size="lg"
/>
<span
v-else
aria-hidden="true"
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
>
S
</span>
</div>
<article
class="bg-muted/20 min-w-0 rounded-xl border p-4"
>
<div
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
>
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<span
class="text-foreground text-sm font-semibold"
>
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
</span>
<Badge variant="outline">
{{
t(
entryTypeLabels[
worklogEntry.entry_type
],
)
}}
</Badge>
</div>
<time
:datetime="worklogEntry.created_at"
class="text-muted-foreground shrink-0 text-xs"
>
{{
formatDateTime(worklogEntry.created_at)
}}
</time>
</div>
<p class="text-foreground mt-3 text-sm leading-6">
{{ worklogEntry.summary }}
</p>
</article>
</li>
</ol>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailMetadataProps>();
const { t, date, number } = useLocalization();
const fields = computed(() =>
props.ticketFieldDefinitions.map((definition) => {
const value = props.ticket.field_values?.find(
(field) => field.ticket_field_definition_id === definition.id,
);
return {
id: definition.id,
label: definition.label,
value:
definition.field_type === 'user'
? (value?.user_value?.name ?? t('tickets.unassigned'))
: value?.value_number != null
? number(value.value_number)
: t('ticketDetail.notSet'),
};
}),
);
</script>
<template>
<section
id="ticket-metadata"
aria-labelledby="ticket-metadata-title"
class="min-w-0 scroll-mt-6"
>
<Card class="bg-muted/30">
<CardHeader
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
{{ t('ticketDetail.metadata') }}
</h2></CardHeader
>
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
<dl
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.status') }}
</dt>
<dd>
{{
ticketStatusLabel(
ticket.ticket_status,
t('tickets.noStatus'),
)
}}
</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.type') }}
</dt>
<dd>{{ ticket.ticket_type.name }}</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.priority') }}
</dt>
<dd>
<Badge variant="outline">{{
ticket.ticket_priority.name
}}</Badge>
</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.assignee') }}
</dt>
<dd class="flex items-center gap-2">
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
size="sm"
/>{{
ticket.assignee?.name ?? t('tickets.unassigned')
}}
</dd>
</div>
<div
v-if="project.show_author || canManageProject"
class="space-y-2"
>
<dt class="text-muted-foreground">
{{ t('ticketDetail.author') }}
</dt>
<dd class="flex items-center gap-2">
<UserAvatar
v-if="ticket.author"
:user="ticket.author"
size="sm"
/>{{
ticket.author?.name ??
t('ticketDetail.noAuthor')
}}
</dd>
</div>
<div v-if="project.show_story_points" class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.storyPoints') }}
</dt>
<dd>
{{
ticket.story_points != null
? number(ticket.story_points)
: t('ticketDetail.notSet')
}}
</dd>
</div>
<div
v-for="field in fields"
:key="field.id"
class="space-y-2"
>
<dt class="text-muted-foreground">{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</div>
</dl>
<div
v-if="ticket.is_blocked"
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
>
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
<p class="whitespace-pre-wrap">
{{ ticket.blocked_comment }}
</p>
</div>
<dl
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
>
<div class="space-y-1">
<dt>{{ t('ticketDetail.created') }}</dt>
<dd>
<time :datetime="ticket.created_at">{{
date(new Date(ticket.created_at), {
dateStyle: 'medium',
timeStyle: 'short',
})
}}</time>
</dd>
</div>
<div class="space-y-1">
<dt>{{ t('ticketDetail.updated') }}</dt>
<dd>
<time :datetime="ticket.updated_at">{{
date(new Date(ticket.updated_at), {
dateStyle: 'medium',
timeStyle: 'short',
})
}}</time>
</dd>
</div>
</dl>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
import { computed, nextTick, useTemplateRef } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>();
const { t } = useLocalization();
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
const currentParentTicket = computed(
() =>
props.ticket.parent_ticket ??
props.parentTicketOptions.find(
(parent) => parent.id === props.ticket.parent_ticket_id,
) ??
null,
);
const canAddLink = computed(
() =>
props.canEditTicket &&
props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0,
);
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
linked_ticket_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty,
});
const selectedType = computed(() =>
props.ticketLinkTypes.find(
(type) => String(type.id) === String(form.ticket_link_type_id),
),
);
const selectedTicket = computed(() =>
props.ticketLinkOptions.find(
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
),
);
async function focusLinks(): Promise<void> {
await nextTick();
linksHeading.value?.focus();
}
function submit(): void {
if (!canAddLink.value) {
return;
}
protection.submit(
store({ project: props.project.id, ticket: props.ticket.id }),
{
preserveScroll: true,
onSuccess: () => {
form.linked_ticket_id = '';
form.clearErrors();
void focusLinks();
},
},
);
}
</script>
<template>
<section
id="ticket-relationships"
aria-labelledby="ticket-relationships-title"
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
>
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
{{ t('ticketDetail.relationships') }}
</h2>
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader
class="flex flex-wrap items-center justify-between gap-3"
>
<h3 class="flex items-center gap-2 font-semibold">
<GitFork class="text-primary size-5" aria-hidden="true" />{{
t('ticketRelationships.hierarchy')
}}
</h3>
<Button
v-if="canEditTicket"
type="button"
variant="outline"
size="sm"
@click="$emit('editParent')"
>{{ t('ticketRelationships.editParent') }}</Button
>
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<div class="grid min-w-0 gap-2">
<h4
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
>
{{ t('ticketDetail.parent') }}
</h4>
<Link
v-if="currentParentTicket"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
>
<span
class="text-primary font-mono text-xs font-semibold"
>{{ currentParentTicket.ticket_key }}</span
>
<span
class="group-hover:text-primary text-sm font-medium"
>{{ currentParentTicket.title }}</span
>
</Link>
<p
v-else
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noParent') }}
</p>
<p
v-if="canEditTicket"
class="text-muted-foreground text-xs"
>
{{ t('ticketRelationships.parentHelp') }}
</p>
</div>
<div
v-if="ticket.ticket_type?.is_parent"
class="grid min-w-0 gap-3 border-t pt-5"
>
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold">
{{ t('ticketDetail.children') }}
</h4>
<Badge variant="secondary">{{
childTicketTree.length
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.childrenHelp') }}
</p>
<p
v-if="childTicketTree.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noChildren') }}
</p>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</div>
</CardContent>
</Card>
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader>
<h3
ref="linksHeading"
tabindex="-1"
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
>
<Link2 class="text-primary size-5" aria-hidden="true" />{{
t('ticketDetail.linkedTickets')
}}<Badge variant="secondary">{{
linkedTickets.length
}}</Badge>
</h3>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.linkedTicketsDescription') }}
</p>
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<p
v-if="linkedTickets.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noLinks') }}
</p>
<ul v-else class="grid min-w-0 gap-3">
<TicketRelationshipLink
v-for="link in linkedTickets"
:key="link.id"
:link="link"
:project-id="project.id"
:ticket-id="ticket.id"
:ticket-key="ticket.ticket_key"
:can-edit="canEditTicket"
@removed="focusLinks"
/>
</ul>
<form
v-if="canAddLink"
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
@submit.prevent="submit"
>
<h4 class="flex items-center gap-2 text-sm font-semibold">
<Plus class="size-4" aria-hidden="true" />{{
t('ticketRelationships.addLink')
}}
</h4>
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
>
<TicketFormField
id="ticket_link_type_id"
v-model="form.ticket_link_type_id"
kind="select"
:label="t('ticketDetail.linkType')"
:options="
ticketLinkTypes.map((type) => ({
value: type.id,
label: type.label,
}))
"
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="linked_ticket_id"
v-model="form.linked_ticket_id"
kind="select"
:label="t('ticketDetail.linkedTicket')"
:options="[
{
value: '',
label: t(
'ticketRelationships.selectTicket',
),
},
...ticketLinkOptions.map((ticket) => ({
value: ticket.id,
label: `${ticket.ticket_key} — ${ticket.title}`,
})),
]"
:error="form.errors.linked_ticket_id"
:disabled="form.processing"
required
/>
</fieldset>
<p
v-if="selectedType && selectedTicket"
aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-mono text-xs">{{
ticket.ticket_key
}}</span
><span>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
>
</p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button
type="submit"
:disabled="form.processing || !selectedTicket"
class="sm:justify-self-start"
>{{
form.processing
? t('forms.saving')
: t('ticketDetail.saveLink')
}}</Button
>
</form>
<p
v-else-if="canEditTicket"
class="text-muted-foreground text-sm"
>
{{
t(
ticketLinkTypes.length === 0
? 'ticketRelationships.noTypes'
: 'ticketRelationships.noTargets',
)
}}
</p>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ExternalLink, GitBranch } from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
const props = defineProps<TicketGitBranchesProps>();
const { t } = useLocalization();
const visibleRepositories = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranch = computed(
() => props.canEditTicket && repositoryConnections.value.length > 0,
);
</script>
<template>
<Card class="min-w-0 gap-0 overflow-hidden">
<CardHeader
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-1.5">
<CardTitle class="flex items-center gap-2">
<GitBranch
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
{{ t('ticketDetail.branches') }}
</CardTitle>
<CardDescription
>{ t('ticketDetail.branchesDescription') }}</CardDescription
>
</div>
<TicketBranchCreateDialog
v-if="canCreateBranch"
:ticket="ticket"
:project="project"
:repository-connections="repositoryConnections"
:suggested-branch-name="suggestedBranchName"
/>
</CardHeader>
<CardContent class="grid gap-4 pt-6">
<FeedbackState
v-if="repositoryBranches.length === 0"
state="empty"
:title="t('ticketDetail.noLinkedRepositories')"
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
/>
<FeedbackState
v-else-if="visibleRepositories.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<article
v-for="repositoryBranch in visibleRepositories"
v-else
:key="repositoryBranch.repository_connection.id"
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
>
<header class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<a
:href="
repositoryBranch.repository_connection
.repository_url
"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openRepositoryExternal', {
repository:
repositoryBranch.repository_connection
.repository_full_name,
provider:
repositoryBranch.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
repositoryBranch.repository_connection
.repository_full_name
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{ repositoryBranch.repository_connection.base_url }}
</p>
</header>
<FeedbackState
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
state="empty"
:title="t('ticketDetail.gitAccessRequired')"
:description="t('ticketDetail.inspectBranches')"
>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</FeedbackState>
<FeedbackState
v-else-if="repositoryBranch.error"
state="error"
:title="t('ticketDetail.branchesUnavailable')"
:description="repositoryBranch.error"
/>
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<li
v-for="branch in repositoryBranch.branches"
:key="branch.name"
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
>
<GitBranch
class="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="wrap-anywhere font-mono text-xs">{{
branch.name
}}</span>
</li>
</ul>
</article>
</CardContent>
</Card>
</template>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
const props = defineProps<TicketGitPullRequestsProps>();
const { t } = useLocalization();
const visibleRepositories = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
</script>
<template>
<Card class="min-w-0 gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle class="flex items-center gap-2">
<GitPullRequest
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
{{ t('ticketDetail.pullRequests') }}
</CardTitle>
<CardDescription
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
>
</CardHeader>
<CardContent class="grid gap-4 pt-6">
<FeedbackState
v-if="repositoryPullRequests.length === 0"
state="empty"
:title="t('ticketDetail.noLinkedRepositories')"
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
/>
<FeedbackState
v-else-if="visibleRepositories.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<article
v-for="repositoryPullRequest in visibleRepositories"
v-else
:key="repositoryPullRequest.repository_connection.id"
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
>
<header class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest.repository_connection
.provider_label
}}
</Badge>
<a
:href="
repositoryPullRequest.repository_connection
.repository_url
"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openRepositoryExternal', {
repository:
repositoryPullRequest
.repository_connection
.repository_full_name,
provider:
repositoryPullRequest
.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
repositoryPullRequest.repository_connection
.repository_full_name
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{
repositoryPullRequest.repository_connection.base_url
}}
</p>
</header>
<FeedbackState
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
state="empty"
:title="t('ticketDetail.gitAccessRequired')"
:description="t('ticketDetail.inspectPullRequests')"
>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</FeedbackState>
<FeedbackState
v-else-if="repositoryPullRequest.error"
state="error"
:title="t('ticketDetail.pullRequestsUnavailable')"
:description="repositoryPullRequest.error"
/>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openPullRequestExternal', {
number: pullRequest.number,
provider:
repositoryPullRequest
.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
pullRequest.title
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</li>
</ul>
</article>
</CardContent>
</Card>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show } from '@/routes/projects/tickets';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
const props = defineProps<{
link: TicketLinkModel;
projectId: number;
ticketId: number;
ticketKey: string;
canEdit: boolean;
}>();
const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({});
const protection = useFormProtection(form);
function remove(): void {
if (!props.canEdit) {
return;
}
protection.submit(
destroy({
project: props.projectId,
ticket: props.ticketId,
ticketLink: props.link.id,
}),
{
preserveScroll: true,
onSuccess: () => emit('removed'),
},
);
}
</script>
<template>
<li
class="grid min-w-0 gap-3 rounded-xl border p-4"
:data-ticket-link="link.id"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{
ticketKey
}}</span>
<span class="text-sm font-medium">{{
link.ticket_link_type.label
}}</span>
</div>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<Link
:href="
show({ project: projectId, ticket: link.linked_ticket.id })
"
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
>
<span class="text-primary font-mono text-xs font-semibold">{{
link.linked_ticket.ticket_key
}}</span>
<span class="wrap-anywhere text-sm font-medium">{{
link.linked_ticket.title
}}</span>
</Link>
<form v-if="canEdit" @submit.prevent="remove">
<Button
type="submit"
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-destructive"
:aria-label="
t('ticketRelationships.removeLink', {
key: link.linked_ticket.ticket_key,
})
"
:disabled="form.processing"
><Unlink class="size-4" aria-hidden="true" />{{
t('ticketDetail.removeLink')
}}</Button
>
</form>
</div>
<FormFeedback
v-if="canEdit"
:state="protection.state.value"
@retry="remove"
/>
</li>
</template>
+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">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true,
},
);
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
:class="
cn(
'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
props.class,
)
"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -1,17 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
>
<slot />
</div>
</template>
@@ -1,59 +1,71 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const props = defineProps<
DialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
<DialogContent
:class="
cn(
'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<X class="w-4 h-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -1,39 +1,48 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import type {
DropdownMenuCheckboxItemEmits,
DropdownMenuCheckboxItemProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const props = defineProps<
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class=" cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -1,39 +1,49 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import type {
DropdownMenuContentEmits,
DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
defineProps<
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
>(),
{
sideOffset: 4,
},
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -1,31 +1,41 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const props = withDefaults(
defineProps<
DropdownMenuItemProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
variant?: "default" | "destructive";
}
>(),
{
variant: "default",
},
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps)
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot />
</DropdownMenuItem>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</DropdownMenuItem>
</template>
@@ -1,40 +1,49 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import type {
DropdownMenuRadioItemEmits,
DropdownMenuRadioItemProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -1,27 +1,34 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import type {
DropdownMenuSubContentEmits,
DropdownMenuSubContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const props = defineProps<
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
>
<slot />
</DropdownMenuSubContent>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
props.class,
)
"
>
<slot />
</DropdownMenuSubContent>
</template>
@@ -1,30 +1,34 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import {
DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next";
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)
"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>
@@ -1,62 +1,70 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import SheetOverlay from "./SheetOverlay.vue"
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left";
}
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
})
const emits = defineEmits<DialogContentEmits>()
side: "right",
});
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side")
const delegatedProps = reactiveOmit(props, "class", "side");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom'
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class)"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogClose
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
+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 };
}
@@ -0,0 +1,34 @@
import { injectDialogRootContext } from 'reka-ui';
import { watch } from 'vue';
/** Reka restores registered triggers; also support dialogs opened programmatically. */
export function useDialogFocus() {
const context = injectDialogRootContext();
let previousFocus: HTMLElement | null = null;
watch(
context.open,
(open) => {
if (open && typeof document !== 'undefined') {
previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
},
{ flush: 'sync', immediate: true },
);
function restoreFocus(event: Event): void {
if (
!event.defaultPrevented &&
!context.triggerElement.value &&
previousFocus?.isConnected
) {
event.preventDefault();
previousFocus.focus({ preventScroll: true });
}
}
return { restoreFocus };
}
@@ -0,0 +1,122 @@
import type {
PendingVisit,
UrlMethodPair,
UseFormSubmitOptions,
} from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import type { InertiaForm } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
import {
formRequestHeader,
isFormNetworkFailure,
matchesFormRequest,
} from '@/lib/form-feedback';
import type { FormFeedbackState } from '@/lib/form-feedback';
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
export function useFormProtection<T extends object>(
form: InertiaForm<T>,
options: { isDirty?: () => boolean } = {},
) {
const failure = ref<FormFeedbackState | null>(null);
const responseStatus = ref<number | null>(null);
let ownVisit: PendingVisit | null = null;
let requestId: string | null = null;
const removeListeners: Array<() => void> = [];
const { confirmLeave } = useUnsavedChanges(
() => form.processing || (options.isDirty?.() ?? form.isDirty),
(visit) => visit === ownVisit,
);
const state = computed<FormFeedbackState>(() => {
if (form.processing) {
return 'loading';
}
if (failure.value) {
return failure.value;
}
if (Object.keys(form.errors).length > 0) {
return 'validation';
}
return form.recentlySuccessful ? 'success' : 'idle';
});
onMounted(() => {
removeListeners.push(
router.on('exception', (event) => {
if (isFormNetworkFailure(event.detail.exception, requestId)) {
event.preventDefault();
failure.value = 'network';
}
}),
router.on('invalid', (event) => {
if (
matchesFormRequest(event.detail.response.config, requestId)
) {
event.preventDefault();
failure.value = 'server';
responseStatus.value = event.detail.response.status;
}
}),
);
});
onUnmounted(() => removeListeners.forEach((remove) => remove()));
function submit(
target: UrlMethodPair,
visitOptions: UseFormSubmitOptions = {},
): void {
if (form.processing) {
return;
}
const nextRequestId = crypto.randomUUID();
form.submit(target, {
...visitOptions,
preserveState: true,
headers: {
...visitOptions.headers,
[formRequestHeader]: nextRequestId,
},
onBefore: (visit) => {
if (visitOptions.onBefore?.(visit) === false) {
return false;
}
ownVisit = visit;
},
onStart: (visit) => {
requestId = nextRequestId;
failure.value = null;
responseStatus.value = null;
visitOptions.onStart?.(visit);
},
onError: (errors) => {
failure.value = 'validation';
return visitOptions.onError?.(errors);
},
onSuccess: (page) => {
failure.value = null;
return visitOptions.onSuccess?.(page);
},
onCancel: () => {
failure.value = 'cancelled';
visitOptions.onCancel?.();
},
onFinish: (visit) => {
requestId = null;
ownVisit = null;
visitOptions.onFinish?.(visit);
},
});
}
function clearFeedback(): void {
failure.value = null;
responseStatus.value = null;
}
return { state, responseStatus, submit, confirmLeave, clearFeedback };
}
@@ -0,0 +1,27 @@
import { useLocalization } from '@/composables/useLocalization';
const areaKeys = {
project_settings: 'projectCreation.areas.projectSettings',
board_columns: 'projectCreation.areas.boardColumns',
ticket_statuses: 'projectCreation.areas.ticketStatuses',
status_transitions: 'projectCreation.areas.statusTransitions',
ticket_types: 'projectCreation.areas.ticketTypes',
ticket_priorities: 'projectCreation.areas.ticketPriorities',
ticket_fields: 'projectCreation.areas.ticketFields',
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
repository_connections: 'projectCreation.areas.repositories',
notification_actions: 'projectCreation.areas.notifications',
} as const;
export function useProjectTemplateAreas() {
const { t } = useLocalization();
function areaText(
value: string,
part: 'label' | 'description',
fallback = value,
): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key ? t(`${key}.${part}`) : fallback;
}
return { areaText };
}
+87 -100
View File
@@ -1,120 +1,107 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { computed, onScopeDispose, ref } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
/** Keep secrets within the consuming component and discard late responses after hiding them. */
export function useTwoFactorAuth() {
const { t } = useLocalization();
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const loading = ref(false);
let pending: AbortController | null = null;
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal,
});
if (!response.ok || response.redirected) {
throw new Error(String(response.status));
}
return response.json();
}
return response.json();
};
function clearErrors(): void {
errors.value = [];
}
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const hasSetupData = computed<boolean>(
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
);
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const fetchQrCode = async (): Promise<void> => {
try {
const { svg } = await fetchJson<{ svg: string; url: string }>(
qrCode.url(),
);
qrCodeSvg.value = svg;
} catch {
errors.value.push('Failed to fetch QR code');
qrCodeSvg.value = null;
}
};
const fetchSetupKey = async (): Promise<void> => {
try {
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
secretKey.url(),
);
manualSetupKey.value = key;
} catch {
errors.value.push('Failed to fetch a setup key');
manualSetupKey.value = null;
}
};
const clearSetupData = (): void => {
function clearTwoFactorAuthData(): void {
pending?.abort();
pending = null;
loading.value = false;
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = [];
};
clearErrors();
}
const fetchRecoveryCodes = async (): Promise<void> => {
async function load(kind: 'setup' | 'recovery'): Promise<void> {
clearTwoFactorAuthData();
const request = new AbortController();
pending = request;
loading.value = true;
try {
clearErrors();
recoveryCodesList.value = await fetchJson<string[]>(
recoveryCodes.url(),
);
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
if (kind === 'setup') {
const [qr, key] = await Promise.all([
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
fetchJson<{ secretKey: string }>(
secretKey.url(),
request.signal,
),
]);
if (pending !== request) {
return;
}
qrCodeSvg.value = qr.svg;
manualSetupKey.value = key.secretKey;
} else {
const codes = await fetchJson<string[]>(
recoveryCodes.url(),
request.signal,
);
if (pending === request) {
recoveryCodesList.value = codes;
}
}
} catch (error) {
if (pending === request && !request.signal.aborted) {
const status = error instanceof Error ? error.message : '';
errors.value = [
t(
status === '423' || status === '401' || status === '419'
? 'security.confirmAgain'
: kind === 'setup'
? 'security.setupError'
: 'security.recoveryError',
),
];
}
} finally {
if (pending === request) {
pending = null;
loading.value = false;
}
}
};
const fetchSetupData = async (): Promise<void> => {
try {
clearErrors();
await Promise.all([fetchQrCode(), fetchSetupKey()]);
} catch {
qrCodeSvg.value = null;
manualSetupKey.value = null;
}
};
}
onScopeDispose(clearTwoFactorAuthData);
return {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
loading,
hasSetupData: computed(() =>
Boolean(qrCodeSvg.value && manualSetupKey.value),
),
clearSetupData: clearTwoFactorAuthData,
clearRecoveryCodes: clearTwoFactorAuthData,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
clearErrors,
fetchSetupData: () => load('setup'),
fetchRecoveryCodes: () => load('recovery'),
};
};
}
@@ -0,0 +1,42 @@
import type { PendingVisit } from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import { onMounted, onUnmounted, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
export function useUnsavedChanges(
dirty: MaybeRefOrGetter<boolean>,
allowVisit: (visit: PendingVisit) => boolean = () => false,
) {
const { t } = useLocalization();
let removeBeforeListener: (() => void) | undefined;
const confirmLeave = (): boolean =>
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
const beforeUnload = (event: BeforeUnloadEvent): void => {
if (toValue(dirty)) {
event.preventDefault();
event.returnValue = '';
}
};
onMounted(() => {
removeBeforeListener = router.on('before', (event) => {
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
return;
}
// Keep the original visit and its callbacks; never replay a mutation.
return confirmLeave();
});
window.addEventListener('beforeunload', beforeUnload);
});
onUnmounted(() => {
removeBeforeListener?.();
window.removeEventListener('beforeunload', beforeUnload);
});
return { confirmLeave };
}
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import type { Component } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import { useLocalization } from '@/composables/useLocalization';
import { home } from '@/routes/index';
defineProps<{ title: string; description: string; icon?: Component }>();
const { t } = useLocalization();
</script>
<template>
<div class="bg-background text-foreground flex min-h-dvh flex-col">
<header
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
>
<Link
:href="home()"
:aria-label="t('auth.home')"
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
><AppLogo
/></Link>
</header>
<main
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
>
<section
aria-labelledby="auth-title"
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
>
<div class="gap-column grid">
<span
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
>
<component
:is="icon ?? ShieldCheck"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid">
<p
class="text-label text-muted-foreground uppercase tracking-wide"
>
{{ t('auth.accountAccess') }}
</p>
<h1
id="auth-title"
class="text-headline-mobile sm:text-headline"
>
{{ title }}
</h1>
<p class="text-body text-muted-foreground">
{{ description }}
</p>
</div>
</div>
<slot />
</section>
<div
v-if="$slots.footer"
class="px-card text-body text-muted-foreground text-center"
>
<slot name="footer" />
</div>
</main>
<footer class="pb-page flex justify-center px-4">
<AppearanceTabs />
</footer>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Settings2 } from 'lucide-vue-next';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
defineProps<{ projectName: string; projectKey: string }>();
const { projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
</script>
<template>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
>
<header class="gap-stack grid min-w-0">
<div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground break-all uppercase">
{{ projectKey }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.projectSettings') }}
</h1>
<p class="text-muted-foreground break-words">
{{ t('projectSettings.description', { project: projectName }) }}
</p>
</header>
<div
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.projectSettings')"
>
<Link
v-for="item in projectSettingsItems"
:key="toUrl(item.href)"
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="[
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
isCurrentUrl(item.href)
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
<slot />
</section>
</div>
</div>
</div>
</template>
+55 -32
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{
title: t('navigation.profile'),
href: editProfile(),
icon: UserRound,
},
{
title: t('navigation.security'),
href: editSecurity(),
icon: ShieldCheck,
},
{
title: t('navigation.gitConnections'),
href: editGitConnections(),
icon: GitBranch,
},
{
title: t('navigation.applications'),
href: editApplications(),
icon: KeyRound,
},
{
title: t('navigation.appearance'),
href: editAppearance(),
icon: Palette,
},
]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script>
<template>
<div class="px-4 py-6">
<Heading
:title="t('navigation.settings')"
:description="t('navigation.settingsDescription')"
/>
<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 min-w-0">
<div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground uppercase">
{{ t('settings.eyebrow') }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.settings') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('navigation.settingsDescription') }}
</p>
</header>
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<div
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-1 overflow-x-auto lg:flex-col"
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.settings')"
>
<Button
<Link
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href) ? 'page' : undefined
"
:class="[
'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
isCurrentOrParentUrl(item.href)
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
]"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<Separator class="lg:hidden" />
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
<slot />
</section>
</div>
+70
View File
@@ -5,6 +5,76 @@ import type {
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 = {
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)
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
import type { NotificationFilterFormState } from '@/types/project-notifications';
export const supportsFilterValue = (operator: string): boolean =>
!['exists', 'not_exists'].includes(operator);
export function serializeNotificationFilters(
filters: NotificationFilterFormState[],
): NotificationActionFilterModel[] {
return filters.map(({ attribute, operator, value }) => ({
attribute,
operator,
value:
operator === 'in'
? value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean)
: supportsFilterValue(operator)
? value.trim()
: null,
}));
}
+13
View File
@@ -0,0 +1,13 @@
import type { Localization } from '@/lib/localization';
import { en } from '@/locales/en';
import type { TranslationKey } from '@/types/localization';
export function notificationLabel(
t: Localization['t'],
group: string,
value: string,
fallback: string,
): string {
const key = `notifications.${group}.${value}`;
return Object.hasOwn(en, key) ? t(key as TranslationKey) : fallback;
}
+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}`;
}
File diff suppressed because it is too large Load Diff
+197 -33
View File
@@ -1,47 +1,211 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types';
import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
{
title: 'Dashboard',
href: dashboard(),
label: t('dashboard.summary.assignedTickets'),
value: assignedTickets.value.length,
icon: ListTodo,
},
];
{
label: t('dashboard.summary.projects'),
value: props.assignedTicketsByProject.length,
icon: FolderGit2,
},
{
label: t('dashboard.summary.blockedTickets'),
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
.length,
icon: OctagonAlert,
},
]);
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
<Head :title="t('navigation.dashboard')" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('dashboard.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline break-words">
{{
t('dashboard.greeting', {
name: $page.props.auth.user.name,
})
}}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.description') }}
</p>
</header>
<section :aria-label="t('dashboard.summary')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
>
<PlaceholderPattern />
</div>
<header class="gap-stack grid min-w-0">
<h2 id="assigned-work-title" class="text-title">
{{ t('dashboard.assignedWork') }}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.assignedWorkDescription') }}
</p>
</header>
<FeedbackState
v-if="assignedTicketsByProject.length === 0"
state="empty"
:title="t('dashboard.empty.title')"
:description="t('dashboard.empty.description')"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
</div>
</section>
</div>
</AppLayout>
</template>
+221 -412
View File
@@ -1,436 +1,245 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { dashboard, login } from '@/routes/index';
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard, home, login } from '@/routes/index';
const { t } = useLocalization();
const features = computed(() => [
{
icon: Columns3,
title: t('welcome.features.workflows.title'),
description: t('welcome.features.workflows.description'),
},
{
icon: ListChecks,
title: t('welcome.features.tickets.title'),
description: t('welcome.features.tickets.description'),
},
{
icon: GitBranch,
title: t('welcome.features.git.title'),
description: t('welcome.features.git.description'),
},
]);
const previewColumns = computed(() => [
{
title: t('welcome.preview.backlog'),
tickets: [
t('welcome.preview.ticketOnboarding'),
t('welcome.preview.ticketRelease'),
],
},
{
title: t('welcome.preview.inProgress'),
tickets: [t('welcome.preview.ticketRepository')],
},
{
title: t('welcome.preview.review'),
tickets: [t('welcome.preview.ticketPullRequest')],
},
]);
</script>
<template>
<Head title="Welcome">
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</Head>
<div
class="flex min-h-screen flex-col items-center bg-[#FDFDFC] p-6 text-[#1b1b18] lg:justify-center lg:p-8 dark:bg-[#0a0a0a]"
class="bg-background text-foreground relative isolate min-h-dvh overflow-x-hidden"
>
<Head :title="t('welcome.metaTitle')" />
<div
aria-hidden="true"
class="bg-brand/10 mask-[linear-gradient(to_bottom,black,transparent)] absolute inset-x-0 top-0 -z-10 h-[34rem]"
/>
<div
aria-hidden="true"
class="bg-primary/15 absolute left-1/2 top-12 -z-10 size-80 -translate-x-1/2 rounded-full blur-3xl sm:size-[32rem]"
/>
<header
class="not-has-[nav]:hidden mb-6 w-full max-w-[335px] text-sm lg:max-w-4xl"
class="mx-auto flex w-full min-w-0 max-w-6xl items-center justify-between gap-3 px-4 py-4 sm:gap-4 sm:px-6 lg:px-8"
>
<nav class="flex items-center justify-end gap-4">
<Link
v-if="$page.props.auth.user"
:href="dashboard()"
class="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
>
Dashboard
</Link>
<template v-else>
<Link
:href="login()"
class="inline-block rounded-sm border border-transparent px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#19140035] dark:text-[#EDEDEC] dark:hover:border-[#3E3E3A]"
>
Log in
<Link
:href="home()"
:aria-label="t('welcome.homeLabel')"
class="focus-visible:ring-ring/35 min-w-0 rounded-md outline-none focus-visible:ring-2"
>
<AppLogo />
</Link>
<nav :aria-label="t('welcome.navigation')" class="shrink-0">
<Button as-child variant="outline" size="sm">
<Link v-if="$page.props.auth.user" :href="dashboard()">
{{ t('welcome.action.dashboardShort') }}
<ArrowRight aria-hidden="true" />
</Link>
</template>
<Link v-else :href="login()">
{{ t('welcome.action.signInShort') }}
<ArrowRight aria-hidden="true" />
</Link>
</Button>
</nav>
</header>
<div
class="duration-750 starting:opacity-0 flex w-full items-center justify-center opacity-100 transition-opacity lg:grow"
>
<main
class="flex w-full max-w-[335px] flex-col-reverse overflow-hidden rounded-lg lg:max-w-4xl lg:flex-row"
<main class="min-w-0">
<section
aria-labelledby="welcome-title"
class="mx-auto grid w-full min-w-0 max-w-6xl gap-10 px-4 pb-16 pt-12 sm:px-6 sm:pt-20 lg:px-8 lg:pt-24"
>
<div
class="flex-1 rounded-bl-lg rounded-br-lg bg-white p-6 pb-12 text-[13px] leading-[20px] shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-br-none lg:rounded-tl-lg lg:p-20 dark:bg-[#161615] dark:text-[#EDEDEC] dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
class="mx-auto grid w-full min-w-0 max-w-3xl justify-items-center gap-6 text-center"
>
<h1 class="mb-1 font-medium">Let's get started</h1>
<p class="mb-2 text-[#706f6c] dark:text-[#A1A09A]">
Laravel has an incredibly rich ecosystem. <br />We
suggest starting with the following.
<p
class="border-primary/20 bg-card/80 text-primary text-label shadow-xs rounded-full border px-3 py-1.5 uppercase backdrop-blur"
>
{{ t('welcome.eyebrow') }}
</p>
<ul class="mb-4 flex flex-col lg:mb-6">
<li
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-0 before:left-[0.4rem] before:top-1/2 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
<div class="grid gap-4">
<h1
id="welcome-title"
class="text-balance text-4xl font-semibold leading-tight tracking-[-0.04em] sm:text-5xl lg:text-6xl"
>
<span
class="relative bg-white py-1 dark:bg-[#161615]"
>
<span
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
>
<span
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
/>
</span>
</span>
<span>
Read the
<a
href="https://laravel.com/docs"
target="_blank"
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
>
<span>Documentation</span>
<svg
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="h-2.5 w-2.5"
>
<path
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
</a>
</span>
</li>
<li
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-1/2 before:left-[0.4rem] before:top-0 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
{{ t('welcome.title') }}
</h1>
<p
class="text-muted-foreground mx-auto max-w-2xl text-pretty text-base leading-7 sm:text-lg"
>
<span
class="relative bg-white py-1 dark:bg-[#161615]"
>
<span
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
>
<span
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
/>
</span>
</span>
<span>
Watch video tutorials at
<a
href="https://laracasts.com"
target="_blank"
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
>
<span>Laracasts</span>
<svg
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="h-2.5 w-2.5"
>
<path
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
</a>
</span>
</li>
</ul>
<ul class="flex gap-3 text-sm leading-normal">
<li>
<a
href="https://cloud.laravel.com"
target="_blank"
class="inline-block rounded-sm border border-black bg-[#1b1b18] px-5 py-1.5 text-sm leading-normal text-white hover:border-black hover:bg-black dark:border-[#eeeeec] dark:bg-[#eeeeec] dark:text-[#1C1C1A] dark:hover:border-white dark:hover:bg-white"
>
Deploy now
</a>
</li>
</ul>
{{ t('welcome.description') }}
</p>
</div>
<Button as-child size="lg">
<Link v-if="$page.props.auth.user" :href="dashboard()">
{{ t('welcome.action.dashboard') }}
<ArrowRight aria-hidden="true" />
</Link>
<Link v-else :href="login()">
{{ t('welcome.action.signIn') }}
<ArrowRight aria-hidden="true" />
</Link>
</Button>
<p class="text-caption text-muted-foreground">
{{ t('welcome.actionHint') }}
</p>
</div>
<div
class="relative -mb-px aspect-[335/364] w-full shrink-0 overflow-hidden rounded-t-lg bg-[#fff2f2] lg:-ml-px lg:mb-0 lg:aspect-auto lg:w-[438px] lg:rounded-r-lg lg:rounded-t-none dark:bg-[#1D0002]"
<section
:aria-label="t('welcome.preview.label')"
class="bg-card/90 min-w-0 rounded-xl border p-3 shadow-[0_24px_80px_-32px_rgba(0,110,47,0.35)] backdrop-blur sm:p-5 dark:shadow-[0_24px_80px_-32px_rgba(74,225,118,0.22)]"
>
<!-- Laravel Logo -->
<svg
class="duration-750 starting:opacity-0 motion-safe:starting:translate-y-6 w-full max-w-none translate-y-0 text-[#F53003] opacity-100 transition-all dark:text-[#F61500]"
viewBox="0 0 438 104"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.2036 -3H0V102.197H49.5189V86.7187H17.2036V-3Z"
fill="currentColor"
/>
<path
d="M110.256 41.6337C108.061 38.1275 104.945 35.3731 100.905 33.3681C96.8667 31.3647 92.8016 30.3618 88.7131 30.3618C83.4247 30.3618 78.5885 31.3389 74.201 33.2923C69.8111 35.2456 66.0474 37.928 62.9059 41.3333C59.7643 44.7401 57.3198 48.6726 55.5754 53.1293C53.8287 57.589 52.9572 62.274 52.9572 67.1813C52.9572 72.1925 53.8287 76.8995 55.5754 81.3069C57.3191 85.7173 59.7636 89.6241 62.9059 93.0293C66.0474 96.4361 69.8119 99.1155 74.201 101.069C78.5885 103.022 83.4247 103.999 88.7131 103.999C92.8016 103.999 96.8667 102.997 100.905 100.994C104.945 98.9911 108.061 96.2359 110.256 92.7282V102.195H126.563V32.1642H110.256V41.6337ZM108.76 75.7472C107.762 78.4531 106.366 80.8078 104.572 82.8112C102.776 84.8161 100.606 86.4183 98.0637 87.6206C95.5202 88.823 92.7004 89.4238 89.6103 89.4238C86.5178 89.4238 83.7252 88.823 81.2324 87.6206C78.7388 86.4183 76.5949 84.8161 74.7998 82.8112C73.004 80.8078 71.6319 78.4531 70.6856 75.7472C69.7356 73.0421 69.2644 70.1868 69.2644 67.1821C69.2644 64.1758 69.7356 61.3205 70.6856 58.6154C71.6319 55.9102 73.004 53.5571 74.7998 51.5522C76.5949 49.5495 78.738 47.9451 81.2324 46.7427C83.7252 45.5404 86.5178 44.9396 89.6103 44.9396C92.7012 44.9396 95.5202 45.5404 98.0637 46.7427C100.606 47.9451 102.776 49.5487 104.572 51.5522C106.367 53.5571 107.762 55.9102 108.76 58.6154C109.756 61.3205 110.256 64.1758 110.256 67.1821C110.256 70.1868 109.756 73.0421 108.76 75.7472Z"
fill="currentColor"
/>
<path
d="M242.805 41.6337C240.611 38.1275 237.494 35.3731 233.455 33.3681C229.416 31.3647 225.351 30.3618 221.262 30.3618C215.974 30.3618 211.138 31.3389 206.75 33.2923C202.36 35.2456 198.597 37.928 195.455 41.3333C192.314 44.7401 189.869 48.6726 188.125 53.1293C186.378 57.589 185.507 62.274 185.507 67.1813C185.507 72.1925 186.378 76.8995 188.125 81.3069C189.868 85.7173 192.313 89.6241 195.455 93.0293C198.597 96.4361 202.361 99.1155 206.75 101.069C211.138 103.022 215.974 103.999 221.262 103.999C225.351 103.999 229.416 102.997 233.455 100.994C237.494 98.9911 240.611 96.2359 242.805 92.7282V102.195H259.112V32.1642H242.805V41.6337ZM241.31 75.7472C240.312 78.4531 238.916 80.8078 237.122 82.8112C235.326 84.8161 233.156 86.4183 230.614 87.6206C228.07 88.823 225.251 89.4238 222.16 89.4238C219.068 89.4238 216.275 88.823 213.782 87.6206C211.289 86.4183 209.145 84.8161 207.35 82.8112C205.554 80.8078 204.182 78.4531 203.236 75.7472C202.286 73.0421 201.814 70.1868 201.814 67.1821C201.814 64.1758 202.286 61.3205 203.236 58.6154C204.182 55.9102 205.554 53.5571 207.35 51.5522C209.145 49.5495 211.288 47.9451 213.782 46.7427C216.275 45.5404 219.068 44.9396 222.16 44.9396C225.251 44.9396 228.07 45.5404 230.614 46.7427C233.156 47.9451 235.326 49.5487 237.122 51.5522C238.917 53.5571 240.312 55.9102 241.31 58.6154C242.306 61.3205 242.806 64.1758 242.806 67.1821C242.805 70.1868 242.305 73.0421 241.31 75.7472Z"
fill="currentColor"
/>
<path
d="M438 -3H421.694V102.197H438V-3Z"
fill="currentColor"
/>
<path
d="M139.43 102.197H155.735V48.2834H183.712V32.1665H139.43V102.197Z"
fill="currentColor"
/>
<path
d="M324.49 32.1665L303.995 85.794L283.498 32.1665H266.983L293.748 102.197H314.242L341.006 32.1665H324.49Z"
fill="currentColor"
/>
<path
d="M376.571 30.3656C356.603 30.3656 340.797 46.8497 340.797 67.1828C340.797 89.6597 356.094 104 378.661 104C391.29 104 399.354 99.1488 409.206 88.5848L398.189 80.0226C398.183 80.031 389.874 90.9895 377.468 90.9895C363.048 90.9895 356.977 79.3111 356.977 73.269H411.075C413.917 50.1328 398.775 30.3656 376.571 30.3656ZM357.02 61.0967C357.145 59.7487 359.023 43.3761 376.442 43.3761C393.861 43.3761 395.978 59.7464 396.099 61.0967H357.02Z"
fill="currentColor"
/>
</svg>
<!-- 13 -->
<svg
class="relative -ml-8 -mt-[6.6rem] w-[438px] max-w-none [--stroke-color:#1B1B18] lg:ml-0 dark:[--stroke-color:#FF750F]"
viewBox="0 0 440 392"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g
class="duration-750 starting:opacity-0 text-[#1B1B18] opacity-100 mix-blend-darken transition-all delay-300 dark:text-black dark:mix-blend-normal"
>
<mask
id="path-1-mask"
maskUnits="userSpaceOnUse"
x="-0.328613"
y="103"
width="338"
height="299"
fill="black"
>
<rect
fill="white"
x="-0.328613"
y="103"
width="338"
height="299"
/>
<path
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
/>
<path
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
/>
</mask>
<path
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
fill="currentColor"
/>
<path
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
fill="currentColor"
/>
<path
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-1-mask)"
/>
<path
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-1-mask)"
/>
</g>
<g
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[26px] text-[#F3BEC7] opacity-100 transition-all dark:text-[#4B0600]"
>
<mask
id="path-2-mask"
maskUnits="userSpaceOnUse"
x="25.3357"
y="103"
width="338"
height="299"
fill="black"
>
<rect
fill="white"
x="25.3357"
y="103"
width="338"
height="299"
/>
<path
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
/>
<path
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
/>
</mask>
<path
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
fill="currentColor"
/>
<path
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
fill="currentColor"
/>
<path
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-2-mask)"
/>
<path
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-2-mask)"
/>
</g>
<g
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[51px] text-[#F8B803] opacity-100 mix-blend-color transition-all dark:text-[#391800] dark:mix-blend-hard-light"
>
<mask
id="path-3-mask"
maskUnits="userSpaceOnUse"
x="51"
y="103"
width="338"
height="299"
fill="black"
>
<rect
fill="white"
x="51"
y="103"
width="338"
height="299"
/>
<path
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
/>
<path
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
/>
</mask>
<path
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
fill="currentColor"
/>
<path
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
fill="currentColor"
/>
<path
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-3-mask)"
/>
<path
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-3-mask)"
/>
</g>
<g
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[78px] text-[#F3BEC7] opacity-100 mix-blend-multiply transition-all dark:text-[#733000] dark:mix-blend-normal"
>
<mask
id="path-4-mask"
maskUnits="userSpaceOnUse"
x="76.6643"
y="103"
width="338"
height="299"
fill="black"
>
<rect
fill="white"
x="76.6643"
y="103"
width="338"
height="299"
/>
<path
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
/>
<path
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
/>
</mask>
<path
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
fill="currentColor"
/>
<path
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
fill="currentColor"
/>
<path
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-4-mask)"
/>
<path
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-4-mask)"
/>
</g>
<g
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[102px] text-[#F3BEC7] opacity-100 mix-blend-hard-light transition-all dark:text-[#4B0600]"
>
<mask
id="path-5-mask"
maskUnits="userSpaceOnUse"
x="102.329"
y="103"
width="338"
height="299"
fill="black"
>
<rect
fill="white"
x="102.329"
y="103"
width="338"
height="299"
/>
<path
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
/>
<path
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
/>
</mask>
<path
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
fill="currentColor"
/>
<path
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
fill="currentColor"
/>
<path
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-5-mask)"
/>
<path
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
stroke="var(--stroke-color)"
stroke-width="2.4"
mask="url(#path-5-mask)"
/>
</g>
</svg>
<div
class="absolute inset-0 rounded-t-lg shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-r-lg lg:rounded-t-none dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
></div>
class="border-border/80 mb-3 flex min-w-0 items-center justify-between gap-3 border-b px-1 pb-3 sm:mb-5 sm:gap-4 sm:px-2 sm:pb-4"
>
<div class="flex shrink-0 gap-1.5" aria-hidden="true">
<span class="bg-border size-2 rounded-full" />
<span class="bg-border size-2 rounded-full" />
<span class="bg-brand size-2 rounded-full" />
</div>
<p
class="text-caption text-muted-foreground min-w-0 truncate"
>
{{ t('welcome.preview.project') }}
</p>
<span
class="bg-success text-success-foreground text-label shrink-0 rounded-full px-2 py-1 uppercase"
>
{{ t('welcome.preview.connected') }}
</span>
</div>
<div class="grid min-w-0 gap-3 md:grid-cols-3">
<section
v-for="column in previewColumns"
:key="column.title"
class="bg-muted/70 grid min-w-0 content-start gap-3 rounded-lg border p-3 sm:p-4"
>
<h2 class="text-caption font-semibold">
{{ column.title }}
</h2>
<article
v-for="ticket in column.tickets"
:key="ticket"
class="bg-card text-card-foreground shadow-xs grid min-w-0 gap-3 rounded-md border p-3"
>
<p class="text-body break-words font-medium">
{{ ticket }}
</p>
<div
class="flex items-center justify-between gap-3"
aria-hidden="true"
>
<span
class="bg-accent h-1.5 w-16 rounded-full"
/>
<span
class="bg-brand size-5 rounded-full"
/>
</div>
</article>
</section>
</div>
</section>
</section>
<section
aria-labelledby="welcome-features-title"
class="border-border/80 bg-card/60 border-y"
>
<div
class="mx-auto grid w-full max-w-6xl gap-8 px-4 py-14 sm:px-6 lg:px-8"
>
<div class="mx-auto grid max-w-2xl gap-3 text-center">
<p class="text-label text-primary uppercase">
{{ t('welcome.features.eyebrow') }}
</p>
<h2
id="welcome-features-title"
class="text-headline-mobile sm:text-headline"
>
{{ t('welcome.features.title') }}
</h2>
</div>
<ul class="grid gap-4 md:grid-cols-3">
<li
v-for="feature in features"
:key="feature.title"
class="bg-card shadow-xs grid gap-4 rounded-lg border p-5"
>
<span
class="bg-accent text-primary flex size-10 items-center justify-center rounded-md"
>
<component
:is="feature.icon"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="grid gap-2">
<h3 class="text-title">
{{ feature.title }}
</h3>
<p
class="text-muted-foreground text-pretty leading-6"
>
{{ feature.description }}
</p>
</div>
</li>
</ul>
</div>
</main>
</div>
<div class="h-14.5 hidden lg:block"></div>
</section>
</main>
<footer
class="text-caption text-muted-foreground mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-8 text-center sm:px-6 lg:px-8"
>
<p class="text-foreground font-semibold">KanbanGit</p>
<p>{{ t('welcome.footer') }}</p>
</footer>
</div>
</template>
+25 -151
View File
@@ -1,165 +1,39 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Head, router } from '@inertiajs/vue3';
import { computed } from 'vue';
import ManagedUserForm from '@/components/ManagedUserForm.vue';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/admin/users';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'User management',
href: index(),
},
{
title: 'Create user',
href: create(),
},
];
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('adminUsers.title'), href: index() },
{ title: t('adminUsers.create.title'), href: create() },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Create user" />
<Head :title="t('adminUsers.create.title')" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<Heading
title="Create user"
description="Create a user account and optionally force a password change on the next login."
/>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
>
<header class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('adminUsers.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('adminUsers.create.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('adminUsers.create.description') }}
</p>
</header>
<Card>
<CardHeader>
<CardTitle>User details</CardTitle>
<CardDescription>
Set an initial password that the user can replace after
their first sign-in.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="UserManagementController.store.form()"
:transform="
(data) => ({
...data,
must_change_password_on_next_login:
data.must_change_password_on_next_login ===
true ||
data.must_change_password_on_next_login ===
'on',
})
"
class="space-y-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
name="name"
required
autofocus
autocomplete="name"
placeholder="Jane Admin"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
autocomplete="email"
placeholder="jane@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
name="password"
required
autocomplete="new-password"
placeholder="Temporary password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm password</Label
>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="grid gap-2">
<Label
for="must_change_password_on_next_login"
class="flex items-start gap-3 rounded-lg border px-4 py-3"
>
<input
id="must_change_password_on_next_login"
name="must_change_password_on_next_login"
type="checkbox"
checked
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
/>
<span class="space-y-1">
<span class="block font-medium">
Must change password on next login
</span>
<span
class="text-muted-foreground block text-sm"
>
Redirect the user to the security
settings page until they save a new
password.
</span>
</span>
</Label>
<InputError
:message="
errors.must_change_password_on_next_login
"
/>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create user
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
<ManagedUserForm mode="create" @cancel="router.visit(index())" />
</div>
</AppLayout>
</template>
+43 -153
View File
@@ -1,170 +1,60 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Head, router } from '@inertiajs/vue3';
import { computed } from 'vue';
import ManagedUserForm from '@/components/ManagedUserForm.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit, index, save } from '@/routes/admin/users';
import { edit, index } from '@/routes/admin/users';
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
const props = defineProps<AdminUserEditPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('adminUsers.title'), href: index() },
{
title: 'User management',
href: index(),
},
{
title: 'Edit user',
title: t('adminUsers.edit.breadcrumb', { name: props.user.name }),
href: edit(props.user.id),
},
];
const form = useForm({
name: props.user.name,
email: props.user.email,
password: '',
password_confirmation: '',
must_change_password_on_next_login:
props.user.must_change_password_on_next_login,
});
const submit = (): void => {
form.patch(save.url(props.user.id), {
preserveScroll: true,
});
};
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Edit ${user.name}`" />
<Head :title="t('adminUsers.edit.pageTitle', { name: user.name })" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<Heading
:title="`Edit ${user.name}`"
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
>
<header
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
>
<UserAvatar
:user="user"
size="xl"
rounded
class="ring-border shrink-0 ring-1"
/>
<div class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('adminUsers.eyebrow') }}
</p>
<h1
class="text-headline-mobile md:text-headline break-words"
>
{{ t('adminUsers.edit.title', { name: user.name }) }}
</h1>
<p class="text-muted-foreground break-all">
{{ user.email }}
</p>
</div>
</header>
<ManagedUserForm
mode="edit"
:user="user"
@cancel="router.visit(index())"
/>
<Card>
<CardHeader>
<CardTitle>User details</CardTitle>
<CardDescription>
Leave the password fields empty if the current password
should stay unchanged.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
required
autofocus
autocomplete="name"
placeholder="Jane Admin"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="form.email"
type="email"
required
autocomplete="email"
placeholder="jane@example.com"
/>
<InputError :message="form.errors.email" />
</div>
<div class="grid gap-2">
<Label for="password">New password</Label>
<PasswordInput
id="password"
v-model="form.password"
autocomplete="new-password"
placeholder="Leave blank to keep current password"
/>
<InputError :message="form.errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm new password</Label
>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError
:message="form.errors.password_confirmation"
/>
</div>
<div class="grid gap-2">
<Label
for="must_change_password_on_next_login"
class="flex items-start gap-3 rounded-lg border px-4 py-3"
>
<input
id="must_change_password_on_next_login"
v-model="
form.must_change_password_on_next_login
"
type="checkbox"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
/>
<span class="space-y-1">
<span class="block font-medium">
Must change password on next login
</span>
<span
class="text-muted-foreground block text-sm"
>
Keep the user locked to the security
settings page until they save a new
password.
</span>
</span>
</Label>
<InputError
:message="
form.errors
.must_change_password_on_next_login
"
/>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save user
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
+428 -227
View File
@@ -1,267 +1,468 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { Head, Link, useForm } from '@inertiajs/vue3';
import {
FolderPlus,
KeyRound,
Pencil,
Shield,
ShieldCheck,
UserPlus,
Users,
UsersRound,
} from 'lucide-vue-next';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import Heading from '@/components/Heading.vue';
import { computed, ref } from 'vue';
import { update } from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import {
create as createAdminUser,
edit as editAdminUser,
index as adminUsers,
} from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type {
AdminUsersPageProps,
BreadcrumbItem,
PermissionOption,
} from '@/types';
import type { UserModel } from '@/types/models/user-model';
const props = defineProps<AdminUsersPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
const { t } = useLocalization();
const permissionsOpen = ref(false);
const selectedUser = ref<UserModel | null>(null);
const permissionForm = useForm('AdminUserGlobalPermissions', {
permissions: [] as string[],
});
const permissionProtection = useFormProtection(permissionForm, {
isDirty: () => permissionsOpen.value && permissionForm.isDirty,
});
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('adminUsers.title'), href: adminUsers() },
]);
const summaryItems = computed(() => [
{
title: 'User management',
href: adminUsers(),
label: t('adminUsers.summary.total'),
value: props.users.length,
icon: UsersRound,
},
];
{
label: t('adminUsers.summary.admins'),
value: props.users.filter((user) =>
hasPermission(user.id, 'global:admin'),
).length,
icon: ShieldCheck,
},
{
label: t('adminUsers.summary.passwordChanges'),
value: props.users.filter(
(user) => user.must_change_password_on_next_login,
).length,
icon: KeyRound,
},
]);
const hasPermission = (actorId: number, permission: string) =>
(props.directPermissions[actorId] ?? []).includes(permission);
function permissionsFor(userId: number): string[] {
return props.directPermissions[userId] ?? [];
}
function hasPermission(userId: number, permission: string): boolean {
return permissionsFor(userId).includes(permission);
}
function permissionLabel(option: PermissionOption): string {
if (option.value === 'global:admin') {
return t('adminUsers.permissions.globalAdmin');
}
if (option.value === 'project:create') {
return t('adminUsers.permissions.projectCreate');
}
return option.label;
}
function openPermissions(user: UserModel): void {
selectedUser.value = user;
const permissions = [...permissionsFor(user.id)];
permissionForm.defaults({ permissions });
permissionForm.resetAndClearErrors();
permissionProtection.clearFeedback();
permissionsOpen.value = true;
}
function updatePermission(value: string, checked: boolean): void {
permissionForm.permissions = checked
? [...new Set([...permissionForm.permissions, value])]
: permissionForm.permissions.filter(
(permission) => permission !== value,
);
permissionForm.clearErrors('permissions');
permissionProtection.clearFeedback();
}
function closePermissions(): void {
permissionsOpen.value = false;
selectedUser.value = null;
permissionForm.defaults({ permissions: [] });
permissionForm.resetAndClearErrors();
permissionProtection.clearFeedback();
}
function updatePermissionsOpen(open: boolean): void {
if (open) {
permissionsOpen.value = true;
return;
}
if (!permissionForm.processing && permissionProtection.confirmLeave()) {
closePermissions();
}
}
function submitPermissions(): void {
if (!selectedUser.value) {
return;
}
permissionProtection.submit(update(selectedUser.value.id), {
preserveScroll: true,
onSuccess: closePermissions,
});
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="User management" />
<Head :title="t('adminUsers.title')" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="flex items-start justify-between gap-4">
<Heading
title="User management"
description="Assign the global permissions that unlock platform-wide administration and project creation."
/>
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('adminUsers.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('adminUsers.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('adminUsers.description') }}
</p>
</div>
<Button as-child class="self-start">
<Link :href="createAdminUser()">
<UserPlus aria-hidden="true" />
{{ t('adminUsers.createAction') }}
</Link>
</Button>
</header>
<div class="flex items-center gap-3">
<Button as-child>
<FeedbackState
v-if="status"
state="success"
:title="t('adminUsers.updated')"
:description="status"
/>
<section :aria-label="t('adminUsers.summary.label')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="managed-users-title"
class="gap-column grid min-w-0"
>
<div class="gap-stack grid">
<h2 id="managed-users-title" class="text-title">
{{
t('adminUsers.listTitle', {
count: users.length,
})
}}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('adminUsers.listDescription') }}
</p>
</div>
<FeedbackState
v-if="users.length === 0"
state="empty"
:title="t('adminUsers.empty.title')"
:description="t('adminUsers.empty.description')"
>
<Button as-child variant="outline">
<Link :href="createAdminUser()">
<UserPlus class="size-4" />
User create
<UserPlus aria-hidden="true" />
{{ t('adminUsers.createAction') }}
</Link>
</Button>
</FeedbackState>
<Badge variant="outline" class="gap-1.5 px-3 py-1">
<Users class="size-3.5" />
Admin-only area
</Badge>
</div>
</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>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Users</CardTitle>
<CardDescription>
Global permissions are assigned directly to users and
apply across the entire application.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div
v-if="users.length === 0"
class="text-muted-foreground p-6 text-sm"
<ul
v-else
class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="user in users"
:key="user.id"
class="min-w-0"
data-managed-user
>
No users exist yet.
</div>
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
<article
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">
Current access
</th>
<th
class="px-4 py-3 text-right font-medium"
<div class="flex min-w-0 items-start gap-4 p-5">
<UserAvatar
:user="user"
size="lg"
rounded
class="ring-border shrink-0 ring-1"
/>
<div class="min-w-0 flex-1">
<h3
class="text-body break-words font-semibold"
>
{{ user.name }}
</h3>
<p
class="text-caption text-muted-foreground break-all"
>
Action
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="user in users"
:key="user.id"
class="bg-background align-top"
>
<td class="px-4 py-4">
<div
class="text-foreground items-medium flex items-center"
>
<UserAvatar
:user="user"
class="mr-2 size-8"
rounded
/>
{{ user.name }}
</div>
</td>
<td class="text-muted-foreground px-4 py-4">
{{ user.email }}
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-2">
<Badge
v-if="
user.must_change_password_on_next_login
"
variant="outline"
class="gap-1.5"
>
<KeyRound class="size-3.5" />
Must change password
</Badge>
<Badge
v-if="
hasPermission(
user.id,
'global:admin',
)
"
class="gap-1.5"
>
<Shield class="size-3.5" />
Global admin
</Badge>
<Badge
v-if="
hasPermission(
user.id,
'project:create',
)
"
variant="secondary"
class="gap-1.5"
>
<FolderPlus class="size-3.5" />
Project create
</Badge>
<span
v-if="
(
directPermissions[
user.id
] ?? []
).length === 0
"
class="text-muted-foreground"
>
No direct global permissions
</span>
</div>
</td>
<td class="px-4 py-4">
<div class="flex justify-end">
<div
class="flex flex-wrap justify-end gap-3"
>
<Button
as-child
size="sm"
variant="outline"
>
<Link
:href="
editAdminUser(
user.id,
)
"
>
<Pencil
class="size-4"
/>
Edit
</Link>
</Button>
</p>
</div>
<Badge variant="outline" class="shrink-0">
{{ t('adminUsers.managedAccount') }}
</Badge>
</div>
<Form
v-bind="
UserManagementController.update.form(
user.id,
)
"
class="flex flex-wrap justify-end gap-3"
v-slot="{ processing }"
>
<label
v-for="option in permissionOptions"
:key="`${user.id}-${option.value}`"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="permissions[]"
:value="
option.value
"
:checked="
hasPermission(
user.id,
option.value,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
option.label
}}</span>
</label>
<div
class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
>
<Badge
v-if="
user.must_change_password_on_next_login
"
variant="outline"
class="gap-1.5 whitespace-normal"
>
<KeyRound
class="size-3.5"
aria-hidden="true"
/>
{{ t('adminUsers.passwordChangePending') }}
</Badge>
<Badge
v-for="option in permissionOptions"
v-show="
hasPermission(user.id, option.value)
"
:key="option.value"
variant="secondary"
class="gap-1.5 whitespace-normal"
>
<ShieldCheck
v-if="option.value === 'global:admin'"
class="size-3.5"
aria-hidden="true"
/>
<FolderPlus
v-else
class="size-3.5"
aria-hidden="true"
/>
{{ permissionLabel(option) }}
</Badge>
<span
v-if="permissionsFor(user.id).length === 0"
class="text-caption text-muted-foreground"
>
{{ t('adminUsers.permissions.none') }}
</span>
</div>
<div
class="w-full sm:hidden"
/>
<Button
type="submit"
size="sm"
variant="outline"
:disabled="processing"
>
Save
</Button>
</Form>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
<div
class="grid gap-2 p-4 sm:grid-cols-2"
data-user-actions
>
<Button
variant="outline"
@click="openPermissions(user)"
>
<ShieldCheck aria-hidden="true" />
{{ t('adminUsers.permissions.action') }}
</Button>
<Button as-child variant="ghost">
<Link
:href="editAdminUser(user.id)"
:aria-label="
t('adminUsers.edit.action', {
name: user.name,
})
"
>
<Pencil aria-hidden="true" />
{{ t('adminUsers.edit.shortAction') }}
</Link>
</Button>
</div>
</article>
</li>
</ul>
</section>
</div>
<Dialog :open="permissionsOpen" @update:open="updatePermissionsOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{
t('adminUsers.permissions.title', {
name: selectedUser?.name ?? '',
})
}}
</DialogTitle>
<DialogDescription>
{{ t('adminUsers.permissions.description') }}
</DialogDescription>
</DialogHeader>
<form
class="gap-column grid"
:aria-busy="permissionForm.processing"
@submit.prevent="submitPermissions"
>
<FormFeedback
:state="permissionProtection.state.value"
@retry="submitPermissions"
/>
<fieldset
class="grid gap-3"
:disabled="permissionForm.processing"
>
<legend class="sr-only">
{{ t('adminUsers.permissions.available') }}
</legend>
<Label
v-for="option in permissionOptions"
:key="option.value"
:for="'permission-' + option.value"
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
:id="'permission-' + option.value"
:checked="
permissionForm.permissions.includes(
option.value,
)
"
:value="option.value"
:invalid="!!permissionForm.errors.permissions"
:aria-describedby="
'permission-help-' + option.value
"
@update:checked="
(checked: boolean | 'indeterminate') =>
updatePermission(
option.value,
checked === true,
)
"
/>
<span class="grid gap-1">
<span>{{ permissionLabel(option) }}</span>
<span
:id="'permission-help-' + option.value"
class="text-caption text-muted-foreground font-normal"
>
{{
t(
option.value === 'global:admin'
? 'adminUsers.permissions.globalAdminHelp'
: 'adminUsers.permissions.projectCreateHelp',
)
}}
</span>
</span>
</Label>
<InputError
:message="permissionForm.errors.permissions"
/>
</fieldset>
<div
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
>
{{ t('adminUsers.permissions.warning') }}
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="permissionForm.processing"
@click="updatePermissionsOpen(false)"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:loading="permissionForm.processing"
data-test="confirm-user-permissions"
>
{{ t('adminUsers.permissions.confirm') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</AppLayout>
</template>
+64 -39
View File
@@ -1,52 +1,77 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/password/confirm';
const { t } = useLocalization();
const form = useForm({ password: '' }).dontRemember('password');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), { onSuccess: () => form.reset() });
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
<AuthFlowLayout
:title="t('auth.confirm.title')"
:description="t('auth.confirm.description')"
:icon="ShieldCheck"
>
<Head title="Confirm password" />
<Form
v-bind="store.form()"
reset-on-success
v-slot="{ errors, processing }"
<Head :title="t('auth.confirm.title')" />
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="confirm-password-button"
>
<Spinner v-if="processing" />
Confirm password
</Button>
</div>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.confirm.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.confirm.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="current-password"
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.confirm.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
</Form>
</AuthLayout>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.confirm.pending')"
data-test="confirm-password-button"
>{{ t('auth.confirm.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.confirm.footer') }}</template>
</AuthFlowLayout>
</template>
+155 -100
View File
@@ -1,115 +1,170 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { LogOut } from 'lucide-vue-next';
import { Head, useForm } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { logout } from '@/routes/index';
withDefaults(
defineProps<{
status?: string | null;
}>(),
{
status: null,
},
);
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
const { t } = useLocalization();
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
}).dontRemember('current_password', 'password', 'password_confirmation');
const logoutForm = useForm({});
const protection = useFormProtection(form, { isDirty: () => false });
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => form.processing || logoutForm.processing);
const submit = (): void =>
protection.submit(SecurityController.update(), {
onSuccess: () => form.reset(),
});
const signOut = (): void => {
if (busy.value) {
return;
}
form.reset();
logoutProtection.submit(logout());
};
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Change your password"
description="You must update your password before you can continue using the application."
<AuthFlowLayout
:title="t('auth.forceChange.title')"
:description="t('auth.forceChange.description')"
:icon="KeyRound"
>
<Head title="Change your password" />
<div class="mb-6 flex justify-end">
<Button as-child variant="ghost" size="sm">
<Link :href="logout()" as="button">
<LogOut class="size-4" />
Log out
</Link>
</Button>
</div>
<div
<Head :title="t('auth.forceChange.title')" />
<FeedbackState
v-if="status"
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
state="success"
:title="t('auth.forceChange.statusTitle')"
:description="status"
/>
<form
:action="SecurityController.update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
{{ status }}
</div>
<Form
v-bind="SecurityController.update.form()"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
v-slot="{ errors, processing }"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<PasswordInput
id="current_password"
name="current_password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm new password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="force-password-change-button"
>
<Spinner v-if="processing" />
Save new password
</Button>
</div>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forceChange.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="current_password">{{
t('auth.forceChange.currentPassword')
}}</Label>
<PasswordInput
id="current_password"
v-model="form.current_password"
name="current_password"
required
autocomplete="current-password"
autofocus
:disabled="busy"
:invalid="Boolean(form.errors.current_password)"
aria-describedby="current-password-error"
/>
<InputError
id="current-password-error"
:message="form.errors.current_password"
/>
</div>
</Form>
</AuthLayout>
<div class="gap-stack grid">
<Label for="password">{{
t('auth.forceChange.password')
}}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password)"
aria-describedby="new-password-hint new-password-error"
/>
<p
id="new-password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.forceChange.passwordHint') }}
</p>
<InputError
id="new-password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.forceChange.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="new-password-confirmation-error"
/>
<InputError
id="new-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:disabled="logoutForm.processing"
:loading="form.processing"
:loading-label="t('auth.forceChange.pending')"
data-test="force-password-change-button"
>{{ t('auth.forceChange.submit') }}</Button
>
</form>
<div class="gap-stack pt-column grid border-t">
<p class="text-body text-muted-foreground">
{{ t('auth.forceChange.wrongAccount') }}
</p>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="outline"
class="w-full"
:disabled="form.processing"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
</div>
<template #footer>{{ t('auth.forceChange.footer') }}</template>
</AuthFlowLayout>
</template>
+72 -48
View File
@@ -1,65 +1,89 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { Mail } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index';
import { email } from '@/routes/password';
defineProps<{
status?: string;
}>();
defineProps<{ status?: string }>();
const { t } = useLocalization();
const form = useForm({ email: '' }).dontRemember('email');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void => protection.submit(email());
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Forgot password"
description="Enter your email to receive a password reset link"
<AuthFlowLayout
:title="t('auth.forgot.title')"
:description="t('auth.forgot.description')"
:icon="Mail"
>
<Head title="Forgot password" />
<div
<Head :title="t('auth.forgot.title')" />
<FeedbackState
v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600"
state="success"
:title="t('auth.forgot.sentTitle')"
:description="status"
/>
<form
:action="email.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
{{ status }}
</div>
<div class="space-y-6">
<Form v-bind="email.form()" v-slot="{ errors, processing }">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="off"
autofocus
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="my-6 flex items-center justify-start">
<Button
class="w-full"
:disabled="processing"
data-test="email-password-reset-link-button"
>
<Spinner v-if="processing" />
Email password reset link
</Button>
</div>
</Form>
<div class="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forgot.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="email">{{ t('auth.forgot.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
inputmode="email"
autocomplete="email"
autocapitalize="none"
:spellcheck="false"
required
autofocus
:disabled="form.processing"
:placeholder="t('auth.forgot.emailPlaceholder')"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-hint email-error"
/>
<p id="email-hint" class="text-caption text-muted-foreground">
{{ t('auth.forgot.emailHint') }}
</p>
<InputError id="email-error" :message="form.errors.email" />
</div>
</div>
</AuthLayout>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.forgot.pending')"
data-test="email-password-reset-link-button"
>{{ t('auth.forgot.submit') }}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
</template>
+105 -81
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { LogIn } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import TextLink from '@/components/TextLink.vue';
@@ -7,94 +11,114 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
defineProps<{
status?: string;
canResetPassword: boolean;
}>();
defineProps<{ status?: string; canResetPassword: boolean }>();
const { t } = useLocalization();
// Credentials stay in memory only; no remembered form key or browser storage.
const form = useForm({ email: '', password: '', remember: false }).dontRemember(
'password',
);
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), {
onSuccess: () => form.reset('password'),
});
onUnmounted(() => form.reset('password'));
</script>
<template>
<AuthBase
title="Log in to your account"
description="Enter your email and password below to log in"
<AuthFlowLayout
:title="t('auth.login.title')"
:description="t('auth.login.description')"
:icon="LogIn"
>
<Head title="Log in" />
<div
<Head :title="t('auth.login.submit')" />
<FeedbackState
v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600"
state="success"
:title="t('auth.login.statusTitle')"
:description="status"
/>
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
{{ status }}
</div>
<Form
v-bind="store.form()"
:reset-on-success="['password']"
v-slot="{ errors, processing }"
class="flex flex-col gap-6"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
required
autofocus
:tabindex="1"
autocomplete="email"
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="text-sm"
:tabindex="5"
>
Forgot password?
</TextLink>
</div>
<PasswordInput
id="password"
name="password"
required
:tabindex="2"
autocomplete="current-password"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3">
<Checkbox id="remember" name="remember" :tabindex="3" />
<span>Remember me</span>
</Label>
</div>
<Button
type="submit"
class="mt-4 w-full"
:tabindex="4"
:disabled="processing"
data-test="login-button"
>
<Spinner v-if="processing" />
Log in
</Button>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.login.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="email">{{ t('auth.login.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
inputmode="email"
autocomplete="username"
autocapitalize="none"
:spellcheck="false"
required
autofocus
:disabled="form.processing"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-error"
/>
<InputError id="email-error" :message="form.errors.email" />
</div>
</Form>
</AuthBase>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.login.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="current-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-error"
/>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-card flex flex-wrap items-center justify-between">
<Label for="remember" class="min-h-control gap-stack">
<Checkbox
id="remember"
v-model="form.remember"
name="remember"
:disabled="form.processing"
/>
{{ t('auth.login.remember') }}
</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="min-h-control text-body inline-flex items-center"
>{{ t('auth.login.forgot') }}</TextLink
>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.login.pending')"
data-test="login-button"
>{{ t('auth.login.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.login.help') }}</template>
</AuthFlowLayout>
</template>
+122 -74
View File
@@ -1,88 +1,136 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { update } from '@/routes/password';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { request, update } from '@/routes/password';
const props = defineProps<{
token: string;
email: string;
}>();
const inputEmail = ref(props.email);
const props = defineProps<{ token: string; email: string }>();
const { t } = useLocalization();
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
}).dontRemember('token', 'email', 'password', 'password_confirmation');
const protection = useFormProtection(form, { isDirty: () => false });
const invalidLink = computed(() => Boolean(form.errors.email));
const feedbackState = computed(() =>
invalidLink.value ? 'idle' : protection.state.value,
);
const submit = (): void =>
protection.submit(update(), {
onSuccess: () => form.reset('password', 'password_confirmation'),
});
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Reset password"
description="Please enter your new password below"
<AuthFlowLayout
:title="t('auth.reset.title')"
:description="t('auth.reset.description')"
:icon="KeyRound"
>
<Head title="Reset password" />
<Form
v-bind="update.form()"
:transform="(data) => ({ ...data, token, email })"
:reset-on-success="['password', 'password_confirmation']"
v-slot="{ errors, processing }"
<Head :title="t('auth.reset.title')" />
<FeedbackState
v-if="invalidLink"
state="error"
:title="t('auth.reset.invalidTitle')"
:description="form.errors.email"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
v-model="inputEmail"
class="mt-1 block w-full"
readonly
/>
<InputError :message="errors.email" class="mt-2" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
name="password"
autocomplete="new-password"
class="mt-1 block w-full"
autofocus
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autocomplete="new-password"
class="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-4 w-full"
:disabled="processing"
data-test="reset-password-button"
>
<Spinner v-if="processing" />
Reset password
</Button>
<Button as-child variant="outline" class="w-full">
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
</Button>
</FeedbackState>
<form
:action="update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<AuthFormFeedback
:state="feedbackState"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.reset.pending')"
@retry="submit"
/>
<input type="hidden" name="token" :value="form.token" />
<div class="gap-stack grid">
<Label for="email">{{ t('auth.reset.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
autocomplete="username"
readonly
:disabled="form.processing"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-error"
/>
<InputError id="email-error" :message="form.errors.email" />
</div>
</Form>
</AuthLayout>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.reset.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="new-password"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.reset.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.reset.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
autocomplete="new-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.reset.pending')"
data-test="reset-password-button"
>{{ t('auth.reset.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.reset.footer') }}</template>
</AuthFlowLayout>
</template>
+159 -119
View File
@@ -1,133 +1,173 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import { Head, useForm } from '@inertiajs/vue3';
import { KeyRound, ShieldCheck } from 'lucide-vue-next';
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index';
import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {
return {
title: 'Recovery code',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
buttonText: 'login using an authentication code',
};
const { t } = useLocalization();
const recovery = ref(false);
const form = useForm({ code: '', recovery_code: '' }).dontRemember(
'code',
'recovery_code',
);
const protection = useFormProtection(form, { isDirty: () => false });
const codeInput = useTemplateRef('codeInput');
const recoveryInput = useTemplateRef('recoveryInput');
const fieldError = computed(() =>
recovery.value
? (form.errors.recovery_code ?? form.errors.code)
: form.errors.code,
);
const toggleMode = async (): Promise<void> => {
if (form.processing) {
return;
}
return {
title: 'Authentication code',
description:
'Enter the authentication code provided by your authenticator application.',
buttonText: 'login using a recovery code',
};
});
const showRecoveryInput = ref<boolean>(false);
const toggleRecoveryMode = (clearErrors: () => void): void => {
showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
recovery.value = !recovery.value;
form.resetAndClearErrors();
protection.clearFeedback();
await nextTick();
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
};
const code = ref<string>('');
const pasteCode = (event: ClipboardEvent): void => {
const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
if (/^[0-9]{6}$/.test(code)) {
event.preventDefault();
form.code = code;
}
};
const submit = (): void => {
form.transform((data) =>
recovery.value
? { recovery_code: data.recovery_code.trim() }
: { code: data.code },
);
protection.submit(store(), { onSuccess: () => form.reset() });
};
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
:title="authConfigContent.title"
:description="authConfigContent.description"
<AuthFlowLayout
:title="
t(
recovery
? 'auth.twoFactor.recoveryTitle'
: 'auth.twoFactor.title',
)
"
:description="
t(
recovery
? 'auth.twoFactor.recoveryDescription'
: 'auth.twoFactor.description',
)
"
:icon="recovery ? KeyRound : ShieldCheck"
>
<Head title="Two-factor authentication" />
<div class="space-y-6">
<template v-if="!showRecoveryInput">
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
@error="code = ''"
#default="{ errors, processing, clearErrors }"
<Head :title="t('auth.twoFactor.title')" />
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.twoFactor.pending')"
@retry="submit"
/>
<div v-if="!recovery" class="gap-stack grid">
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
<Input
id="code"
key="code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
minlength="6"
maxlength="6"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="code-hint code-error"
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
@paste="pasteCode"
/>
<p id="code-hint" class="text-caption text-muted-foreground">
{{ t('auth.twoFactor.codeHint') }}
</p>
<InputError id="code-error" :message="fieldError" />
</div>
<div v-else class="gap-stack grid">
<Label for="recovery_code">{{
t('auth.twoFactor.recoveryCode')
}}</Label>
<Input
id="recovery_code"
key="recovery"
ref="recoveryInput"
v-model="form.recovery_code"
name="recovery_code"
type="text"
autocomplete="one-time-code"
autocapitalize="none"
:spellcheck="false"
required
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="recovery-hint recovery-error"
class="font-mono"
/>
<p
id="recovery-hint"
class="text-caption text-muted-foreground"
>
<input type="hidden" name="code" :value="code" />
<div
class="flex flex-col items-center justify-center space-y-3 text-center"
>
<div class="flex w-full items-center justify-center">
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
autofocus
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
</div>
<InputError :message="errors.code" />
</div>
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-muted-foreground text-center text-sm">
<span>or you can </span>
<button
type="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
:autofocus="showRecoveryInput"
required
/>
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-muted-foreground text-center text-sm">
<span>or you can </span>
<button
type="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
</div>
</AuthLayout>
{{ t('auth.twoFactor.recoveryHint') }}
</p>
<InputError id="recovery-error" :message="fieldError" />
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.twoFactor.pending')"
>{{ t('auth.twoFactor.submit') }}</Button
>
<Button
type="button"
variant="outline"
class="w-full whitespace-normal"
:disabled="form.processing"
@click="toggleMode"
>{{
t(
recovery
? 'auth.twoFactor.useCode'
: 'auth.twoFactor.useRecovery',
)
}}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
</template>

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