Compare commits

...
6 Commits
Author SHA1 Message Date
Twyco ba12e65e81 Merge pull request 'Fix KAN-64 project deletion on MariaDB' (#176) from fix/KAN-64-project-delete-response into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 10s
Reviewed-on: #176
2026-10-02 10:17:19 +00:00
Twyco 85b9409e3c Fix project deletion with linked ticket statuses on MariaDB 2026-10-02 10:17:19 +00:00
Twyco f862a6a226 Merge pull request 'KAN-12: Simplify links to action and ticket' (#175) from feature/KAN-12-simplify-link-actions into main
CI/CD / Publish immutable image (push) Successful in 14s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 8s
Reviewed-on: #175
2026-10-01 20:36:03 +00:00
Twyco c8f494fe7c feat: simplify ticket links to action and ticket (KAN-12)
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m2s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 22:32:02 +02:00
Twyco f7c3873c19 Merge pull request 'KAN-68: Add WYSIWYG ticket description editor' (#172) from feature/KAN-68-wysiwyg-description-editor into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m10s
CI/CD / test_and_check_app (push) Successful in 8s
CI/CD / Publish immutable image (push) Successful in 40s
Reviewed-on: #172
2026-10-01 19:22:22 +00:00
Twyco d8e1659f4b feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 19:21:22 +00:00
22 changed files with 1784 additions and 109 deletions
+1
View File
@@ -14,6 +14,7 @@ class DeleteProject
DB::transaction(function () use ($project): void { DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first(); $baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete(); $project->delete();
$baseTemplate?->delete(); $baseTemplate?->delete();
}); });
+5
View File
@@ -48,6 +48,11 @@
"dependencies": { "dependencies": {
"@inertiajs/core": "2.3.18", "@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7", "@inertiajs/vue3": "^2.3.7",
"@tiptap/core": "^3.31.3",
"@tiptap/markdown": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/vue-3": "^3.31.3",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+481
View File
@@ -221,6 +221,21 @@ importers:
'@inertiajs/vue3': '@inertiajs/vue3':
specifier: ^2.3.7 specifier: ^2.3.7
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3)) version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
'@tiptap/core':
specifier: ^3.31.3
version: 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/markdown':
specifier: ^3.31.3
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/pm':
specifier: ^3.31.3
version: 3.31.3
'@tiptap/starter-kit':
specifier: ^3.31.3
version: 3.31.3
'@tiptap/vue-3':
specifier: ^3.31.3
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))
'@vueuse/core': '@vueuse/core':
specifier: ^12.8.2 specifier: ^12.8.2
version: 12.8.2(typescript@5.9.3) version: 12.8.2(typescript@5.9.3)
@@ -733,6 +748,160 @@ packages:
peerDependencies: peerDependencies:
vue: ^2.7.0 || ^3.0.0 vue: ^2.7.0 || ^3.0.0
'@tiptap/core@3.31.3':
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
peerDependencies:
'@tiptap/pm': 3.31.3
'@tiptap/extension-blockquote@3.31.3':
resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-bold@3.31.3':
resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-bubble-menu@3.31.3':
resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-bullet-list@3.31.3':
resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-code-block@3.31.3':
resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-code@3.31.3':
resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-document@3.31.3':
resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-dropcursor@3.31.3':
resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
peerDependencies:
'@tiptap/extensions': 3.31.3
'@tiptap/extension-floating-menu@3.31.3':
resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-gapcursor@3.31.3':
resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
peerDependencies:
'@tiptap/extensions': 3.31.3
'@tiptap/extension-hard-break@3.31.3':
resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-heading@3.31.3':
resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-horizontal-rule@3.31.3':
resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-italic@3.31.3':
resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-link@3.31.3':
resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-list-item@3.31.3':
resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-list-keymap@3.31.3':
resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-list@3.31.3':
resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-ordered-list@3.31.3':
resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-paragraph@3.31.3':
resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-strike@3.31.3':
resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-text@3.31.3':
resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-underline@3.31.3':
resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extensions@3.31.3':
resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/markdown@3.31.3':
resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/pm@3.31.3':
resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
'@tiptap/starter-kit@3.31.3':
resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
'@tiptap/vue-3@3.31.3':
resolution: {integrity: sha512-PNt0+rm7BXzhe/U+xlNnXBeJ6t9x2mE3902VXeIeEBxu5CrYLCJ0a5yHrof1jU3KwJrLOe/BtlGWjbq4qnTTfQ==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
vue: ^3.0.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1922,6 +2091,9 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linkifyjs@4.3.3:
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1944,6 +2116,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.6:
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2031,6 +2208,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
own-keys@1.0.1: own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2158,6 +2338,45 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
prosemirror-changeset@2.4.3:
resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==}
prosemirror-commands@1.7.2:
resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
prosemirror-dropcursor@1.8.3:
resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-model@1.25.12:
resolution: {integrity: sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-transform@1.12.1:
resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
prosemirror-view@1.42.5:
resolution: {integrity: sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==}
proxy-from-env@2.1.0: proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2211,6 +2430,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2514,6 +2736,9 @@ packages:
typescript: typescript:
optional: true optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2896,6 +3121,178 @@ snapshots:
'@tanstack/virtual-core': 3.13.23 '@tanstack/virtual-core': 3.13.23
vue: 3.5.30(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/pm': 3.31.3
'@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
optional: true
'@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
optional: true
'@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
linkifyjs: 4.3.3
'@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
marked: 17.0.6
'@tiptap/pm@3.31.3':
dependencies:
prosemirror-changeset: 2.4.3
prosemirror-commands: 1.7.2
prosemirror-dropcursor: 1.8.3
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.12
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.5
'@tiptap/starter-kit@3.31.3':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
vue: 3.5.30(typescript@5.9.3)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -4179,6 +4576,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0
linkifyjs@4.3.3: {}
locate-path@6.0.0: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
@@ -4197,6 +4596,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.6: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
@@ -4284,6 +4685,8 @@ snapshots:
type-check: 0.4.0 type-check: 0.4.0
word-wrap: 1.2.5 word-wrap: 1.2.5
orderedmap@2.1.1: {}
own-keys@1.0.1: own-keys@1.0.1:
dependencies: dependencies:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
@@ -4341,6 +4744,80 @@ snapshots:
prettier@3.8.2: {} prettier@3.8.2: {}
prosemirror-changeset@2.4.3:
dependencies:
prosemirror-transform: 1.12.1
prosemirror-commands@1.7.2:
dependencies:
prosemirror-model: 1.25.12
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-dropcursor@1.8.3:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.5
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.12
prosemirror-state: 1.4.4
prosemirror-view: 1.42.5
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.5
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-model@1.25.12:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.12
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.12
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.5
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.12
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.5
prosemirror-transform@1.12.1:
dependencies:
prosemirror-model: 1.25.12
prosemirror-view@1.42.5:
dependencies:
prosemirror-model: 1.25.12
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
proxy-from-env@2.1.0: {} proxy-from-env@2.1.0: {}
punycode@2.3.1: {} punycode@2.3.1: {}
@@ -4423,6 +4900,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-arm64-msvc': 1.2.5
'@rolldown/binding-win32-x64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5
rope-sequence@1.3.4: {}
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -4768,6 +5247,8 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
w3c-keyname@2.2.8: {}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0
+1
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './theme.css'; @import './theme.css';
@import './ticket-description.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
+71
View File
@@ -0,0 +1,71 @@
.ticket-description {
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: var(--text-sm);
line-height: 1.75;
}
.ticket-description > * + * {
margin-top: 0.75rem;
}
.ticket-description h1 {
font-size: 1.5rem;
font-weight: 700;
}
.ticket-description h2 {
font-size: 1.25rem;
font-weight: 700;
}
.ticket-description h3,
.ticket-description h4,
.ticket-description h5,
.ticket-description h6 {
font-size: 1.125rem;
font-weight: 600;
}
.ticket-description ul {
list-style: disc;
padding-left: 1.5rem;
}
.ticket-description ol {
list-style: decimal;
padding-left: 1.5rem;
}
.ticket-description li > ul,
.ticket-description li > ol {
margin-top: 0.25rem;
}
.ticket-description blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.ticket-description a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 3px;
}
.ticket-description code {
border-radius: 0.25rem;
background: var(--color-muted);
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
.ticket-description pre {
max-width: 100%;
overflow-x: auto;
border-radius: 0.375rem;
background: var(--color-muted);
padding: 0.75rem;
white-space: pre;
}
.ticket-description pre code {
padding: 0;
background: none;
}
.ticket-description hr {
border-color: var(--color-border);
margin-block: 1rem;
}
.ticket-description:focus-visible {
outline: none;
}
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, defineComponent, h } from 'vue';
import {
parseDescription,
renderDescriptionNode,
supportsVisualDescription,
} from '@/lib/ticket-description';
const props = defineProps<{ description: string }>();
const document = computed(() =>
supportsVisualDescription(props.description)
? parseDescription(props.description)
: null,
);
const Content = defineComponent(
() => () =>
document.value
? h('div', { class: 'ticket-description' }, [
renderDescriptionNode(document.value),
])
: h(
'p',
{
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
},
props.description,
),
);
</script>
<template>
<Content />
</template>
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { EditorContent, useEditor } from '@tiptap/vue-3';
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
SquareCode,
Link,
Unlink,
Undo2,
Redo2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import {
descriptionExtensions,
parseDescription,
safeDescriptionLink,
supportsVisualDescription,
} from '@/lib/ticket-description';
import type {
DescriptionToolbarAction,
TicketDescriptionEditorProps,
} from '@/types/ticket-description';
const props = defineProps<TicketDescriptionEditorProps>();
const model = defineModel<string>({ required: true });
const { t } = useLocalization();
const sourceMode = ref(!supportsVisualDescription(model.value));
const supported = computed(() => supportsVisualDescription(model.value));
const visual = computed(() => !sourceMode.value && supported.value);
const linkOpen = ref(false);
const linkUrl = ref('');
const linkError = ref('');
const linkInput = ref<InstanceType<typeof Input> | null>(null);
let currentMarkdown = model.value;
const editor = useEditor({
extensions: descriptionExtensions(),
content: supported.value ? parseDescription(model.value) : '',
editable: !props.disabled,
editorProps: {
attributes: {
id: props.id,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': `${props.id}-label`,
'aria-describedby': `${props.id}-hint ${props.id}-error`,
'aria-invalid': String(Boolean(props.error)),
'aria-disabled': String(Boolean(props.disabled)),
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
},
},
onUpdate: ({ editor: instance }) => {
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
model.value = currentMarkdown;
},
});
watch(model, (value) => {
if (value === currentMarkdown) {
return;
}
currentMarkdown = value;
if (supportsVisualDescription(value)) {
editor.value?.commands.setContent(parseDescription(value), {
emitUpdate: false,
});
}
});
watch(
() => props.disabled,
(disabled) => {
editor.value?.setEditable(!disabled, false);
if (disabled) {
linkOpen.value = false;
}
},
);
watch([() => props.error, () => props.disabled], () => {
editor.value?.setOptions({
editorProps: {
attributes: {
...editor.value.options.editorProps.attributes,
'aria-invalid': String(Boolean(props.error)),
'aria-disabled': String(Boolean(props.disabled)),
},
},
});
});
const actions: DescriptionToolbarAction[] = [
{
label: 'descriptionEditor.bold',
icon: Bold,
active: 'bold',
run: (c) => c.toggleBold(),
},
{
label: 'descriptionEditor.italic',
icon: Italic,
active: 'italic',
run: (c) => c.toggleItalic(),
},
{
label: 'descriptionEditor.strike',
icon: Strikethrough,
active: 'strike',
run: (c) => c.toggleStrike(),
},
{
label: 'descriptionEditor.heading1',
icon: Heading1,
active: 'heading',
attributes: { level: 1 },
run: (c) => c.toggleHeading({ level: 1 }),
},
{
label: 'descriptionEditor.heading2',
icon: Heading2,
active: 'heading',
attributes: { level: 2 },
run: (c) => c.toggleHeading({ level: 2 }),
},
{
label: 'descriptionEditor.heading3',
icon: Heading3,
active: 'heading',
attributes: { level: 3 },
run: (c) => c.toggleHeading({ level: 3 }),
},
{
label: 'descriptionEditor.bulletList',
icon: List,
active: 'bulletList',
run: (c) => c.toggleBulletList(),
},
{
label: 'descriptionEditor.orderedList',
icon: ListOrdered,
active: 'orderedList',
run: (c) => c.toggleOrderedList(),
},
{
label: 'descriptionEditor.quote',
icon: Quote,
active: 'blockquote',
run: (c) => c.toggleBlockquote(),
},
{
label: 'descriptionEditor.code',
icon: Code,
active: 'code',
run: (c) => c.toggleCode(),
},
{
label: 'descriptionEditor.codeBlock',
icon: SquareCode,
active: 'codeBlock',
run: (c) => c.toggleCodeBlock(),
},
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
];
function runAction(action: DescriptionToolbarAction): void {
if (editor.value && !props.disabled) {
action.run(editor.value.chain().focus()).run();
}
}
function switchMode(): void {
linkOpen.value = false;
sourceMode.value = !sourceMode.value;
}
async function editLink(): Promise<void> {
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
linkError.value = '';
linkOpen.value = true;
await nextTick();
linkInput.value?.$el?.focus();
}
function saveLink(): void {
if (!editor.value || props.disabled) {
return;
}
const href = safeDescriptionLink(linkUrl.value.trim());
if (!href) {
linkError.value = t('descriptionEditor.invalidLink');
return;
}
const chain = editor.value.chain().focus().extendMarkRange('link');
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
chain
.insertContent({
type: 'text',
text: href,
marks: [{ type: 'link', attrs: { href } }],
})
.run();
} else {
chain.setLink({ href }).run();
}
linkOpen.value = false;
}
function closeLink(): void {
linkOpen.value = false;
editor.value?.commands.focus();
}
</script>
<template>
<div class="grid min-w-0 gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<Label
:id="`${id}-label`"
:for="id"
@click="visual && editor?.commands.focus()"
>{{ label }}</Label
>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="disabled || (!supported && !visual)"
:aria-pressed="sourceMode"
@click="switchMode"
>
{{
visual
? t('descriptionEditor.source')
: t('descriptionEditor.visual')
}}
</Button>
</div>
<div
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
>
<div
v-if="visual"
role="group"
:aria-label="t('descriptionEditor.formatting')"
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
>
<Button
v-for="action in actions"
:key="action.label"
type="button"
size="icon-sm"
:variant="
action.active &&
editor?.isActive(action.active, action.attributes)
? 'secondary'
: 'ghost'
"
:title="t(action.label)"
:aria-label="t(action.label)"
:aria-pressed="
action.active
? Boolean(
editor?.isActive(
action.active,
action.attributes,
),
)
: undefined
"
:disabled="
disabled ||
!editor ||
!action.run(editor.can().chain()).run()
"
@mousedown.prevent
@click="runAction(action)"
>
<component
:is="action.icon"
class="size-4"
aria-hidden="true"
/>
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:title="t('descriptionEditor.link')"
:aria-label="t('descriptionEditor.link')"
:aria-pressed="Boolean(editor?.isActive('link'))"
:disabled="
disabled ||
!editor ||
!editor.can().setLink({ href: 'https://example.com' })
"
@mousedown.prevent
@click="editLink"
><Link class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:title="t('descriptionEditor.unlink')"
:aria-label="t('descriptionEditor.unlink')"
:disabled="disabled || !editor?.isActive('link')"
@mousedown.prevent
@click="
editor
?.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
"
><Unlink class="size-4" aria-hidden="true"
/></Button>
</div>
<div
v-if="linkOpen && visual"
class="grid gap-2 border-b p-3"
@keydown.esc.prevent.stop="closeLink"
>
<Label :for="`${id}-link`">{{
t('descriptionEditor.linkUrl')
}}</Label>
<Input
:id="`${id}-link`"
ref="linkInput"
v-model="linkUrl"
type="text"
:disabled="disabled"
:invalid="Boolean(linkError)"
:aria-describedby="`${id}-link-error`"
placeholder="https://"
@keydown.enter.prevent.stop="saveLink"
/>
<InputError :id="`${id}-link-error`" :message="linkError" />
<div class="flex gap-2">
<Button
type="button"
size="sm"
:disabled="disabled"
@click="saveLink"
>{{ t('descriptionEditor.applyLink') }}</Button
>
<Button
type="button"
size="sm"
variant="ghost"
@click="closeLink"
>{{ t('common.cancel') }}</Button
>
</div>
</div>
<EditorContent v-if="visual && editor" :editor="editor" />
<textarea
v-else
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:placeholder="placeholder"
:aria-labelledby="`${id}-label`"
:aria-describedby="`${id}-hint ${id}-error`"
:aria-invalid="Boolean(error)"
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
/>
</div>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{
supported
? t('descriptionEditor.hint')
: t('descriptionEditor.unsupported')
}}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue'; import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets'; import { ticketStatusLabel } from '@/lib/tickets';
@@ -72,10 +73,9 @@ const statusOptions = computed(
:placeholder="t('ticketForm.titlePlaceholder')" :placeholder="t('ticketForm.titlePlaceholder')"
required required
/> />
<TicketFormField <TicketDescriptionEditor
id="description" id="description"
v-model="description" v-model="description"
kind="textarea"
:label="t('ticketDetail.description')" :label="t('ticketDetail.description')"
:error="errors.description" :error="errors.description"
:disabled="disabled" :disabled="disabled"
@@ -13,6 +13,7 @@ import {
watch, watch,
} from 'vue'; } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketDescription from '@/components/tickets/TicketDescription.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue'; import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
@@ -272,16 +273,12 @@ const submit = (): void => {
</h2></CardHeader </h2></CardHeader
> >
<CardContent> <CardContent>
<p <TicketDescription
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7" v-if="ticket.description"
:class="{ :description="ticket.description"
'text-muted-foreground': !ticket.description, />
}" <p v-else class="text-muted-foreground text-sm leading-7">
> {{ t('ticketDetail.noDescription') }}
{{
ticket.description ||
t('ticketDetail.noDescription')
}}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail'; import type {
TicketDetailRelationshipsProps,
TicketLinkFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>(); const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>(); defineEmits<{ editParent: [] }>();
@@ -33,7 +37,7 @@ const canAddLink = computed(
props.ticketLinkTypes.length > 0 && props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0, props.ticketLinkOptions.length > 0,
); );
const form = useForm({ const form = useForm<TicketLinkFormData>({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '', ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source', link_direction: 'source',
linked_ticket_id: '', linked_ticket_id: '',
@@ -41,10 +45,9 @@ const form = useForm({
const protection = useFormProtection(form, { const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty, isDirty: () => props.canEditTicket && form.isDirty,
}); });
const selectedType = computed(() => const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
props.ticketLinkTypes.find( () => props.ticketLinkTypes,
(type) => String(type.id) === String(form.ticket_link_type_id), form,
),
); );
const selectedTicket = computed(() => const selectedTicket = computed(() =>
props.ticketLinkOptions.find( props.ticketLinkOptions.find(
@@ -221,40 +224,18 @@ function submit(): void {
/> />
<fieldset <fieldset
:disabled="form.processing" :disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-3" class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
> >
<TicketFormField <TicketFormField
id="ticket_link_type_id" id="ticket_link_type_id"
v-model="form.ticket_link_type_id" v-model="selectedAction"
kind="select" kind="select"
:label="t('ticketDetail.linkType')" :label="t('ticketDetail.linkAction')"
:options=" :options="actions"
ticketLinkTypes.map((type) => ({ :error="
value: type.id, form.errors.ticket_link_type_id ||
label: `${type.label} / ${type.counterpart_label}`, form.errors.link_direction
}))
" "
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="link_direction"
v-model="form.link_direction"
kind="select"
:label="t('ticketRelationships.direction')"
:options="[
{
value: 'source',
label: selectedType?.label ?? '',
},
{
value: 'counterpart',
label:
selectedType?.counterpart_label ?? '',
},
]"
:error="form.errors.link_direction"
:disabled="form.processing" :disabled="form.processing"
required required
/> />
@@ -281,26 +262,19 @@ function submit(): void {
/> />
</fieldset> </fieldset>
<p <p
v-if="selectedType && selectedTicket" v-if="selectedActionLabel && selectedTicket"
aria-live="polite" aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm" class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
> >
<span class="font-mono text-xs">{{ <span class="font-mono text-xs">{{
ticket.ticket_key ticket.ticket_key
}}</span }}</span
><span>{{ ><span>{{ selectedActionLabel }}</span
form.link_direction === 'source'
? selectedType.label
: selectedType.counterpart_label
}}</span
><ArrowRight class="size-4" aria-hidden="true" /><span ><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs" class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span >{{ selectedTicket.ticket_key }}</span
> >
</p> </p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button <Button
type="submit" type="submit"
:disabled="form.processing || !selectedTicket" :disabled="form.processing || !selectedTicket"
@@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next'; import { Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
@@ -20,7 +18,6 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ removed: [] }>(); const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization(); const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({}); const form = useForm({});
const protection = useFormProtection(form); const protection = useFormProtection(form);
function remove(): void { function remove(): void {
@@ -47,27 +44,6 @@ function remove(): void {
:data-ticket-link="link.id" :data-ticket-link="link.id"
> >
<div class="flex min-w-0 flex-wrap items-center gap-2"> <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">{{ <span class="text-muted-foreground font-mono text-xs">{{
ticketKey ticketKey
}}</span> }}</span>
@@ -0,0 +1,57 @@
import { computed, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type {
TicketLinkActionOption,
TicketLinkFormData,
} from '@/types/ticket-detail';
export function useTicketLinkActions(
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
form: TicketLinkFormData,
) {
const actions = computed<TicketLinkActionOption[]>(() =>
toValue(ticketLinkTypes).flatMap((type) => {
const choices: TicketLinkActionOption[] = [
{
value: `${type.id}:source`,
label: type.label,
ticketLinkTypeId: type.id,
direction: 'source',
},
];
if (
type.counterpart_label &&
type.counterpart_label !== type.label
) {
choices.push({
value: `${type.id}:counterpart`,
label: type.counterpart_label,
ticketLinkTypeId: type.id,
direction: 'counterpart',
});
}
return choices;
}),
);
const selectedAction = computed({
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
set: (value: string | number | null) => {
const action = actions.value.find(
(option) => option.value === value,
);
if (action) {
form.ticket_link_type_id = action.ticketLinkTypeId;
form.link_direction = action.direction;
}
},
});
const selectedActionLabel = computed(
() =>
actions.value.find(
(action) => action.value === selectedAction.value,
)?.label,
);
return { actions, selectedAction, selectedActionLabel };
}
+129
View File
@@ -0,0 +1,129 @@
import type { JSONContent } from '@tiptap/core';
import { Markdown, MarkdownManager } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { h } from 'vue';
import type { VNodeChild } from 'vue';
export function safeDescriptionLink(value: unknown): string | null {
if (
typeof value !== 'string' ||
/[\s\\]/u.test(value) ||
[...value].some(
(character) =>
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
)
) {
return null;
}
if (/^(https?:\/\/|mailto:)/i.test(value)) {
try {
const url = new URL(value);
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
? value
: null;
} catch {
return null;
}
}
return /^(\/(?!\/)|#)/.test(value) ? value : null;
}
export function descriptionExtensions() {
return [
StarterKit.configure({
underline: false,
trailingNode: false,
link: {
openOnClick: false,
autolink: false,
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
},
}),
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
];
}
const markdown = new MarkdownManager({
extensions: descriptionExtensions(),
markedOptions: { gfm: true, breaks: true },
});
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
export function supportsVisualDescription(value: string): boolean {
let supported = true;
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
if (
['html', 'table', 'image'].includes(token.type) ||
(token.type === 'list_item' && token.task) ||
(token.type === 'link' && !safeDescriptionLink(token.href))
) {
supported = false;
}
});
return supported;
}
export function parseDescription(value: string): JSONContent {
return markdown.parse(value);
}
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
export function renderDescriptionNode(node: JSONContent): VNodeChild {
if (node.type === 'text') {
let content: VNodeChild = node.text ?? '';
for (const mark of node.marks ?? []) {
const tag = {
bold: 'strong',
italic: 'em',
strike: 's',
code: 'code',
}[mark.type];
if (tag) {
content = h(tag, {}, [content]);
} else if (mark.type === 'link') {
const href = safeDescriptionLink(mark.attrs?.href);
if (href) {
content = h(
'a',
{ href, target: '_blank', rel: 'noopener noreferrer' },
[content],
);
}
}
}
return content;
}
const children = (node.content ?? []).map(renderDescriptionNode);
switch (node.type) {
case 'doc':
return children;
case 'paragraph':
return h('p', {}, children);
case 'heading':
return h(
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
{},
children,
);
case 'bulletList':
return h('ul', {}, children);
case 'orderedList':
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
case 'listItem':
return h('li', {}, children);
case 'blockquote':
return h('blockquote', {}, children);
case 'codeBlock':
return h('pre', {}, [h('code', {}, children)]);
case 'hardBreak':
return h('br');
case 'horizontalRule':
return h('hr');
default:
return children;
}
}
+28 -6
View File
@@ -1,4 +1,31 @@
export const en = { export const en = {
'descriptionEditor.bold': 'Bold',
'descriptionEditor.italic': 'Italic',
'descriptionEditor.strike': 'Strikethrough',
'descriptionEditor.heading1': 'Heading 1',
'descriptionEditor.heading2': 'Heading 2',
'descriptionEditor.heading3': 'Heading 3',
'descriptionEditor.bulletList': 'Bullet list',
'descriptionEditor.orderedList': 'Numbered list',
'descriptionEditor.quote': 'Quote',
'descriptionEditor.code': 'Inline code',
'descriptionEditor.codeBlock': 'Code block',
'descriptionEditor.undo': 'Undo',
'descriptionEditor.redo': 'Redo',
'descriptionEditor.link': 'Add or edit link',
'descriptionEditor.unlink': 'Remove link',
'descriptionEditor.linkUrl': 'Link address',
'descriptionEditor.applyLink': 'Apply link',
'descriptionEditor.invalidLink':
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
'descriptionEditor.formatting': 'Description formatting',
'descriptionEditor.source': 'Markdown',
'descriptionEditor.visual': 'Visual editor',
'descriptionEditor.hint':
'Format your description using the toolbar or edit its Markdown source.',
'descriptionEditor.unsupported':
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
'nativeApp.title': 'App', 'nativeApp.title': 'App',
'nativeApp.description': 'Manage the KanbanGit app on this device.', 'nativeApp.description': 'Manage the KanbanGit app on this device.',
'nativeApp.workspace': 'Workspace', 'nativeApp.workspace': 'Workspace',
@@ -1186,7 +1213,7 @@ export const en = {
'ticketDetail.linkedTickets': 'Linked tickets', 'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription': 'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.', 'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkType': 'Link type', 'ticketDetail.linkAction': 'Action',
'ticketDetail.linkedTicket': 'Linked ticket', 'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link', 'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks': 'ticketDetail.noLinks':
@@ -1200,11 +1227,6 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.', 'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link', 'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket', 'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.direction': 'This ticket',
'ticketRelationships.directionHelp':
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}', 'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes': 'ticketRelationships.noTypes':
'No link types are configured for this project.', 'No link types are configured for this project.',
+19
View File
@@ -0,0 +1,19 @@
import type { ChainedCommands } from '@tiptap/core';
import type { Component } from 'vue';
import type { TranslationKey } from '@/types/localization';
export type TicketDescriptionEditorProps = {
id: string;
label: string;
error?: string;
disabled?: boolean;
placeholder?: string;
};
export type DescriptionToolbarAction = {
label: TranslationKey;
icon: Component;
active?: string;
attributes?: Record<string, unknown>;
run: (chain: ChainedCommands) => ChainedCommands;
};
+11
View File
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
branch_name: string; branch_name: string;
repository_connection_id: string; repository_connection_id: string;
}; };
export type TicketLinkFormData = {
ticket_link_type_id: number | string;
link_direction: 'source' | 'counterpart';
linked_ticket_id: number | string;
};
export type TicketLinkActionOption = {
value: string;
label: string;
ticketLinkTypeId: number;
direction: TicketLinkFormData['link_direction'];
};
@@ -805,6 +805,9 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseMissing('board_columns', [ $this->assertDatabaseMissing('board_columns', [
'id' => $boardColumn->id, 'id' => $boardColumn->id,
]); ]);
$this->assertDatabaseMissing('ticket_statuses', [
'id' => $ticketStatus->id,
]);
$this->assertDatabaseMissing('tickets', [ $this->assertDatabaseMissing('tickets', [
'id' => $ticket->id, 'id' => $ticket->id,
]); ]);
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketDescriptionTest extends TestCase
{
use RefreshDatabase;
public function test_description_formatting_and_safe_rendering(): void
{
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Ticket description checks passed.', $process->getOutput());
}
public function test_markdown_descriptions_can_be_created_read_updated_and_cleared(): void
{
$this->withoutVite();
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $user, ['view', 'write']);
$description = "## Plan\n\n**Bold** and *italic*\n\n- First\n- [Second](https://example.test)\n\n```php\necho '<tag>';\n```";
$payload = [
'title' => 'Formatted description',
'description' => $description,
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail()->id,
];
$this->actingAs($user)->post(route('projects.tickets.store', $project), $payload)
->assertSessionHasNoErrors()->assertRedirect();
$ticket = $project->tickets()->sole();
$this->assertSame($description, $ticket->description);
$this->get(route('projects.tickets.show', [$project, $ticket]))
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description));
$updated = "> Updated quote\n\n1. One\n2. Two\n\n`inline`";
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
...$payload, 'description' => $updated, 'is_blocked' => false,
])->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame($updated, $ticket->fresh()->description);
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
...$payload, 'description' => ['invalid'], 'is_blocked' => false,
])->assertSessionHasErrors('description');
$this->assertSame($updated, $ticket->fresh()->description);
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
...$payload, 'description' => '', 'is_blocked' => false,
])->assertSessionHasNoErrors()->assertRedirect();
$this->assertNull($ticket->fresh()->description);
}
}
@@ -0,0 +1,208 @@
import { createApp, h, nextTick, reactive, ref } from 'vue';
import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue';
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
import { createLocalization } from '../../resources/js/lib/localization';
import '../../resources/css/app.css';
const original = 'First line\nSecond line\n\n**Existing bold**';
const state = reactive({
value: original,
disabled: false,
error: '',
result: 'Running browser checks…',
updates: 0,
});
const component = ref(null);
const app = createApp({
render: () =>
h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [
h(
'h1',
{ class: 'text-xl font-semibold' },
'Ticket description editor',
),
h('p', { id: 'test-result', role: 'status' }, state.result),
h(TicketDescriptionEditor, {
ref: component,
id: 'description',
label: 'Description',
modelValue: state.value,
disabled: state.disabled,
error: state.error,
'onUpdate:modelValue': (value) => {
state.value = value;
state.updates++;
},
}),
h('h2', { class: 'font-semibold' }, 'Saved description preview'),
h(TicketDescription, { description: state.value }),
]),
}).use(createLocalization());
app.mount('#app');
const tick = async () => {
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
};
const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};
const button = (name) =>
[...document.querySelectorAll('button')].find(
(element) =>
element.getAttribute('aria-label') === name ||
element.textContent.trim() === name,
);
const click = async (name) => {
const element = button(name);
assert(element && !element.disabled, `Button available: ${name}`);
element.click();
await tick();
};
try {
await tick();
const editor = component.value.$.setupState.editor;
assert(editor, 'Editor mounted');
assert(
state.value === original && state.updates === 0,
'Mount preserves exact source',
);
assert(
document
.querySelector('#description')
.getAttribute('contenteditable') === 'true',
'Visual textbox is editable',
);
assert(
editor.getText().includes('First line\nSecond line'),
'Legacy newlines retained',
);
state.value = 'Format this';
await tick();
editor.commands.selectAll();
for (const name of ['Bold', 'Italic', 'Strikethrough']) {
await click(name);
assert(
button(name).getAttribute('aria-pressed') === 'true',
`${name} active`,
);
}
assert(
state.value.includes('**') && state.value.includes('~~'),
'Formatting emits Markdown',
);
await click('Undo');
assert(!editor.isActive('strike'), 'Undo restores prior state');
await click('Redo');
assert(editor.isActive('strike'), 'Redo restores formatting');
await click('Markdown');
const source = document.querySelector('textarea#description');
assert(source.value === state.value, 'Source matches the editor output');
source.value = '## From source\n\n- One\n- Two';
source.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
await click('Visual editor');
assert(
document.querySelector('#description h2')?.textContent ===
'From source',
'Source updates visual editor',
);
assert(
document.querySelectorAll('#description li').length === 2,
'Lists render after source switch',
);
for (const [name, selector] of [
['Bullet list', 'ul li'],
['Numbered list', 'ol li'],
['Quote', 'blockquote'],
['Inline code', 'code'],
['Code block', 'pre code'],
['Heading 2', 'h2'],
['Heading 3', 'h3'],
]) {
state.value = `Check ${name}`;
await tick();
editor.commands.selectAll();
await click(name);
assert(
document.querySelector(`#description ${selector}`),
`${name} renders`,
);
}
state.value = 'Link text';
await tick();
editor.commands.selectAll();
await click('Add or edit link');
assert(
document.activeElement.id === 'description-link',
'Link field receives focus',
);
const input = document.querySelector('#description-link');
input.value = 'javascript:alert(1)';
input.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
await click('Apply link');
assert(
document
.querySelector('#description-link-error')
.textContent.includes('Enter a valid'),
'Unsafe link rejected',
);
input.value = 'https://example.test/docs';
input.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
await click('Apply link');
assert(
state.value === '[Link text](https://example.test/docs)',
'Selection becomes a Markdown link',
);
await click('Remove link');
assert(state.value === 'Link text', 'Link can be removed');
state.disabled = true;
state.error = 'Description rejected.';
await tick();
assert(
document
.querySelector('#description')
.getAttribute('contenteditable') === 'false',
'Processing disables editing',
);
assert(
document.querySelector('#description').getAttribute('aria-invalid') ===
'true',
'Server error reflected on editor',
);
assert(
button('Bold').disabled && button('Markdown').disabled,
'Processing disables controls',
);
assert(state.value === 'Link text', 'Validation retains draft');
state.disabled = false;
state.error = '';
state.value = 'Reset draft';
await tick();
assert(editor.getText() === 'Reset draft', 'External reset syncs editor');
editor.commands.selectAll();
await click('Heading 1');
assert(state.value.startsWith('# '), 'Heading command saves Markdown');
editor.commands.clearContent();
await tick();
assert(state.value === '', 'Clearing editor saves an empty description');
state.value = '| A | B |\n| - | - |\n| a | b |';
await tick();
assert(
document.querySelector('textarea#description').value === state.value,
'Unsupported content retained as source',
);
assert(button('Visual editor').disabled, 'Lossy conversion is unavailable');
state.value =
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
await tick();
state.result =
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
} catch (error) {
state.result = `FAILED: ${error.message}`;
console.error(error);
}
window.addEventListener('pagehide', () => app.unmount(), { once: true });
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
if (serve) {
server.middlewares.use(async (request, response) => {
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Description fixture ready on port 4186');
} else {
try {
const {
safeDescriptionLink,
supportsVisualDescription,
parseDescription,
renderDescriptionNode,
} = await server.ssrLoadModule(
'/resources/js/lib/ticket-description.ts',
);
const { default: Description } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketDescription.vue',
);
const { default: Editor } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketDescriptionEditor.vue',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const render = (description) =>
renderToString(
createSSRApp({ render: () => h(Description, { description }) }),
);
const markdown =
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
assert.equal(supportsVisualDescription(markdown), true);
const rendered = await render(markdown);
for (const snippet of [
'<h1>Title</h1>',
'<strong>Bold</strong>',
'<em>italic</em>',
'<s>strike</s>',
'<ul>',
'Nested',
'<ol start="3">',
'<blockquote>',
'<code>inline</code>',
'&lt;tag&gt;',
'rel="noopener noreferrer"',
'<hr>',
]) {
assert.ok(
rendered.includes(snippet),
`Missing formatted output: ${snippet}`,
);
}
assert.match(await render('First\nSecond'), /First<br>Second/);
assert.match(
await render('München 🚀 & details'),
/München 🚀 &amp; details/,
);
assert.match(
await render('&lt;img src=x onerror=alert(1)&gt;'),
/&lt;img/,
);
for (const value of [
'https://example.test/a?b=1&c=2',
'http://example.test',
'mailto:test@example.test',
'/projects/1',
'#section',
]) {
assert.equal(safeDescriptionLink(value), value);
}
for (const value of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'data:text/html,test',
'vbscript:test',
'//evil.test',
'/\\evil.test',
'\u0000https://example.test',
'https://',
'https://exa mple.test',
'java\nscript:alert(1)',
null,
]) {
assert.equal(safeDescriptionLink(value), null);
}
for (const value of [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'| A | B |\n| - | - |\n| a | b |',
'![Image](https://example.test/image.png)',
'- [x] Done',
'[unsafe](javascript:alert%281%29)',
]) {
assert.equal(supportsVisualDescription(value), false, value);
const html = await render(value);
assert.doesNotMatch(html, /<script|<img|href="javascript:/);
assert.ok(
html.includes(
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;'),
),
'Original source is retained',
);
}
const maliciousNode = {
type: 'paragraph',
content: [
{
type: 'text',
text: '<script>bad</script>',
marks: [
{ type: 'link', attrs: { href: 'javascript:bad()' } },
],
},
],
};
const safe = await renderToString(
createSSRApp({
render: () =>
h('div', {}, [renderDescriptionNode(maliciousNode)]),
}),
);
assert.doesNotMatch(safe, /<script|<a/);
assert.match(safe, /&lt;script&gt;/);
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
let updates = 0;
const initial = await renderToString(
createSSRApp({
render: () =>
h(Editor, {
id: 'description',
label: 'Description',
modelValue: markdown,
error: 'Invalid description.',
disabled: true,
'onUpdate:modelValue': () => updates++,
}),
}).use(createLocalization()),
);
assert.match(initial, /id="description"/);
assert.match(initial, /aria-invalid="true"/);
assert.match(initial, /id="description-error"/);
assert.match(initial, /disabled/);
assert.equal(
updates,
0,
'Mounting must not rewrite existing descriptions',
);
console.log('Ticket description checks passed.');
} finally {
await server.close();
}
}
+54 -4
View File
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite'; import { createServer } from 'vite';
import { createSSRApp, h } from 'vue'; import { createSSRApp, h, reactive, ref } from 'vue';
import { renderToString } from 'vue/server-renderer'; import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve'); const serve = process.argv.includes('--serve');
@@ -463,6 +463,40 @@ if (serve) {
); );
} else { } else {
try { try {
const { useTicketLinkActions } = await server.ssrLoadModule(
'/resources/js/composables/useTicketLinkActions.ts',
);
const linkTypes = ref([
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
]);
const linkForm = reactive({
ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41,
});
const { actions, selectedAction, selectedActionLabel } =
useTicketLinkActions(linkTypes, linkForm);
assert.deepEqual(
actions.value.map((action) => action.label),
['Blocks', 'Blocked by', 'Relates to'],
);
assert.equal(selectedActionLabel.value, 'Blocks');
selectedAction.value = '1:counterpart';
assert.equal(selectedActionLabel.value, 'Blocked by');
assert.deepEqual(
{ ...linkForm },
{
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
},
);
selectedAction.value = '2:source';
assert.equal(linkForm.ticket_link_type_id, 2);
assert.equal(linkForm.link_direction, 'source');
selectedAction.value = 'unknown';
assert.equal(selectedActionLabel.value, 'Relates to');
const { createLocalization } = await server.ssrLoadModule( const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts', '/resources/js/lib/localization.ts',
); );
@@ -491,8 +525,24 @@ if (serve) {
return result.body.split('id="main-content"')[1]; return result.body.split('id="main-content"')[1];
}; };
const html = await render(); const html = await render();
assert.ok(html.includes('id="link_direction"')); assert.ok(!html.includes('id="link_direction"'));
assert.ok(html.includes('Blocked by')); assert.ok(html.includes('Blocked by'));
const linkFields = [
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
].find(([fieldset]) =>
fieldset.includes('id="ticket_link_type_id"'),
)?.[0];
assert.ok(linkFields);
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
assert.match(
linkFields,
/<option value="1:source"[^>]*>Blocks<\/option>/,
);
assert.match(
linkFields,
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
);
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
for (const text of [ for (const text of [
ticket.title, ticket.title,
'Keep every existing ticket action available.', 'Keep every existing ticket action available.',
@@ -570,9 +620,9 @@ if (serve) {
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/, /<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
); );
assert.match(html, /Save link/); assert.match(html, /Save link/);
assert.match(html, /Outgoing/); assert.doesNotMatch(html, /Outgoing/);
const incoming = await render('?incoming'); const incoming = await render('?incoming');
assert.match(incoming, /Incoming/); assert.doesNotMatch(incoming, /Incoming/);
assert.match(incoming, /Blocked by/); assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/); assert.match(await render('?noTypes'), /No link types are configured/);
assert.match( assert.match(
+19 -18
View File
@@ -41,8 +41,6 @@ try {
'Platform delivery', 'Platform delivery',
'Child tickets', 'Child tickets',
'Nested child with a long title that must wrap on mobile', 'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) { ]) {
assert.equal( assert.equal(
await section() await section()
@@ -52,6 +50,13 @@ try {
text, text,
); );
} }
assert.equal(
await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.isVisible(),
true,
);
const collapse = section().getByRole('button', { const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41', name: 'Collapse children of ENG-41',
}); });
@@ -68,16 +73,7 @@ try {
); );
await page.keyboard.press('Space'); await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true'); assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus(); await control('Action').focus();
await page.keyboard.press('Tab');
assert.equal(
await control('This ticket').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
assert.equal( assert.equal(
await control('Linked ticket').evaluate( await control('Linked ticket').evaluate(
@@ -130,7 +126,10 @@ try {
link_direction: 'source', link_direction: 'source',
linked_ticket_id: 41, linked_ticket_id: 41,
}); });
await section().getByText('Outgoing', { exact: true }).waitFor(); await section()
.locator('[data-ticket-link]')
.getByText('Blocks', { exact: true })
.waitFor();
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '', () => document.querySelector('#linked_ticket_id')?.value === '',
); );
@@ -152,11 +151,10 @@ try {
); );
await visit('?noLinks'); await visit('?noLinks');
await control('This ticket').selectOption('counterpart'); await control('Action').selectOption('1:counterpart');
await control('Linked ticket').selectOption('41'); await control('Linked ticket').selectOption('41');
await section() await section()
.locator('form') .locator('p[aria-live="polite"]')
.last()
.getByText('Blocked by', { exact: true }) .getByText('Blocked by', { exact: true })
.waitFor(); .waitFor();
request = page.waitForRequest( request = page.waitForRequest(
@@ -168,7 +166,10 @@ try {
link_direction: 'counterpart', link_direction: 'counterpart',
linked_ticket_id: 41, linked_ticket_id: 41,
}); });
await section().getByText('Incoming', { exact: true }).waitFor(); await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.waitFor();
await visit('?rejectLink'); await visit('?rejectLink');
await control('Linked ticket').selectOption('41'); await control('Linked ticket').selectOption('41');
@@ -177,7 +178,7 @@ try {
.getByText('Choose another ticket.', { exact: true }) .getByText('Choose another ticket.', { exact: true })
.waitFor(); .waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41'); assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Link type', 'Linked ticket']) { for (const label of ['Action', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true'); assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
} }
await page.route('**/tickets/42/links', (route) => route.abort('failed')); await page.route('**/tickets/42/links', (route) => route.abort('failed'));