Compare commits

...
51 Commits
Author SHA1 Message Date
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
152 changed files with 27081 additions and 15652 deletions
-3
View File
@@ -33,9 +33,6 @@
vendor vendor
**/.DS_Store **/.DS_Store
.editorconfig .editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production .env.production
LICENSE LICENSE
README.md README.md
-4
View File
@@ -1,4 +0,0 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+19 -2
View File
@@ -2,7 +2,13 @@ name: CI/CD
on: on:
push: push:
branches:
- main
tags:
- "v*"
pull_request: pull_request:
branches:
- main
permissions: permissions:
contents: read contents: read
@@ -11,9 +17,19 @@ env:
REGISTRY: git.twyco.de REGISTRY: git.twyco.de
jobs: jobs:
test_and_check_app: preconditions:
name: test_and_check_app if: >
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- name: Conditions met
run: |
echo "Conditions for CI/CD are met. Proceeding with the workflow."
test_and_check_app:
runs-on: ubuntu-latest
needs:
- preconditions
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -27,6 +43,7 @@ jobs:
github.event_name == 'push' && github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
needs: needs:
- preconditions
- test_and_check_app - test_and_check_app
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+5 -3
View File
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \ RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \ && corepack enable pnpm \
&& npm run build && pnpm install --frozen-lockfile \
&& pnpm run build
FROM build AS test FROM build AS test
RUN touch .env \ RUN cp .env.example .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \ && composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check && COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
DuplicateProjectConfiguration::COPY_TICKET_FIELDS, DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
]), ]),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(), 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
]; ];
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
'copy_areas' => ['required', 'array', 'min:1'], 'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [ 'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation),
Rule::requiredIf($requiresImpactConfirmation), Rule::requiredIf($requiresImpactConfirmation),
'accepted', 'accepted',
], ],
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
'label' => $this->label, 'label' => $this->label,
'position' => $this->position, 'position' => $this->position,
'project_id' => $this->project_id, '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(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),
]; ];
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
'counterpart_label' => $this->counterpart_label ?: $this->label, 'counterpart_label' => $this->counterpart_label ?: $this->label,
'position' => $this->position, 'position' => $this->position,
'project_id' => $this->project_id, '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(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_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 { ->withExceptions(function (Exceptions $exceptions): void {
// $exceptions->dontFlash(['api_token']);
})->create(); })->create();
+6 -6
View File
@@ -47,15 +47,15 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate", "@php artisan key:generate",
"@php artisan migrate --force", "@php artisan migrate --force",
"npm install", "pnpm install",
"npm run build" "pnpm run build"
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@php artisan dev" "@php artisan dev"
], ],
"dev:ssr": [ "dev:ssr": [
"npm run build:ssr", "pnpm run build:ssr",
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
], ],
@@ -67,9 +67,9 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high", "pnpm audit --audit-level=high",
"npm run lint:check", "pnpm run lint:check",
"npm run types:check", "pnpm run types:check",
"@test" "@test"
], ],
"test": [ "test": [
+1
View File
@@ -25,6 +25,7 @@ const prettierOptions = {
const eslintConfig = [ const eslintConfig = [
{ {
ignores: [ ignores: [
'bootstrap/ssr/**',
'**/dist/**', '**/dist/**',
'node_modules/**', 'node_modules/**',
'.github/**', '.github/**',
-7269
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -2,6 +2,13 @@
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
"onFail": "error"
}
},
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:ssr": "vite build && vite build --ssr", "build:ssr": "vite build && vite build --ssr",
@@ -39,6 +46,7 @@
"vue-tsc": "^2.2.4" "vue-tsc": "^2.2.4"
}, },
"dependencies": { "dependencies": {
"@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7", "@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
-1
View File
@@ -19,7 +19,6 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="AVATAR_FILESYSTEM_DISK" value="public"/> <env name="AVATAR_FILESYSTEM_DISK" value="public"/>
<env name="AVATAR_URLS_TEMPORARY" value="false"/> <env name="AVATAR_URLS_TEMPORARY" value="false"/>
+4832
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
minimumReleaseAge: 10080
allowBuilds:
unrs-resolver: true
vue-demi: true
+69 -28
View File
@@ -1,39 +1,80 @@
<script setup lang="ts"> <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 { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization(); const { t } = useLocalization();
const tabs = [ const tabs = computed(() => [
{ value: 'light', Icon: Sun, label: 'appearance.light' }, {
{ value: 'dark', Icon: Moon, label: 'appearance.dark' }, value: 'light' as const,
{ value: 'system', Icon: Monitor, label: 'appearance.system' }, Icon: Sun,
] as const; 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> </script>
<template> <template>
<div <fieldset class="min-w-0">
role="group" <legend class="sr-only">{{ t('appearance.label') }}</legend>
:aria-label="t('appearance.label')" <div class="grid min-w-0 gap-3 sm:grid-cols-3">
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1" <label
> v-for="{ value, Icon, label, description } in tabs"
<button :key="value"
v-for="{ value, Icon, label } in tabs" class="relative min-w-0"
:key="value" >
type="button" <input
:aria-pressed="appearance === value" type="radio"
@click="updateAppearance(value)" name="appearance"
:class="[ :value="value"
'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', :checked="appearance === value"
appearance === value :aria-describedby="`appearance-${value}-description`"
? 'bg-primary text-primary-foreground' class="peer sr-only"
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground', @change="updateAppearance(value)"
]" />
> <span
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" /> 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>{{ t(label) }}</span> >
</button> <span class="flex items-center justify-between gap-3">
</div> <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> </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 { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue'; import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -17,97 +17,104 @@ import {
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput'); const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script> </script>
<template> <template>
<div class="space-y-6"> <Card
<Heading class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
variant="small" >
title="Delete account" <CardHeader class="gap-stack border-b p-5 sm:p-6">
description="Delete your account and all of its resources" <h2 class="text-title">{{ t('profile.delete.title') }}</h2>
/> <p class="text-muted-foreground">
<div {{ t('profile.delete.description') }}
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" </p>
> </CardHeader>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100"> <CardContent class="p-5 sm:p-6">
<p class="font-medium">Warning</p> <div
<p class="text-sm"> class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
Please proceed with caution, this cannot be undone. >
</p> <div class="gap-stack grid">
</div> <p class="text-destructive font-semibold">
<Dialog> {{ t('profile.delete.warning') }}
<DialogTrigger as-child> </p>
<Button variant="destructive" data-test="delete-user-button" <p class="text-body text-muted-foreground">
>Delete account</Button {{ t('profile.delete.warningDescription') }}
> </p>
</DialogTrigger> </div>
<DialogContent> <Dialog>
<Form <DialogTrigger as-child>
v-bind="ProfileController.destroy.form()" <Button
reset-on-success variant="destructive"
@error="() => passwordInput?.focus()" class="justify-self-start"
:options="{ data-test="delete-user-button"
preserveScroll: true, >{{ t('profile.delete.action') }}</Button
}" >
class="space-y-6" </DialogTrigger>
v-slot="{ errors, processing, reset, clearErrors }" <DialogContent>
> <Form
<DialogHeader class="space-y-3"> v-bind="ProfileController.destroy.form()"
<DialogTitle reset-on-success
>Are you sure you want to delete your @error="() => passwordInput?.focus()"
account?</DialogTitle :options="{
> preserveScroll: true,
<DialogDescription> }"
Once your account is deleted, all of its class="space-y-6"
resources and data will also be permanently v-slot="{ errors, processing, reset, clearErrors }"
deleted. Please enter your password to confirm >
you would like to permanently delete your <DialogHeader class="space-y-3">
account. <DialogTitle>{{
</DialogDescription> t('profile.delete.confirmTitle')
</DialogHeader> }}</DialogTitle>
<DialogDescription>
{{ t('profile.delete.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password" class="sr-only" <Label for="password" class="sr-only">{{
>Password</Label t('profile.delete.password')
> }}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
name="password" name="password"
ref="passwordInput" ref="passwordInput"
placeholder="Password" :placeholder="t('profile.delete.password')"
/> />
<InputError :message="errors.password" /> <InputError :message="errors.password" />
</div> </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 <Button
variant="secondary" type="submit"
@click=" variant="destructive"
() => { :disabled="processing"
clearErrors(); data-test="confirm-delete-user-button"
reset();
}
"
> >
Cancel {{ t('profile.delete.confirmAction') }}
</Button> </Button>
</DialogClose> </DialogFooter>
</Form>
<Button </DialogContent>
type="submit" </Dialog>
variant="destructive" </div>
:disabled="processing" </CardContent>
data-test="confirm-delete-user-button" </Card>
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</template> </template>
+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>
+75 -27
View File
@@ -1,45 +1,93 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model'; import type { TicketTreeListProps } from '@/types/ticket-tree';
defineProps<{ defineProps<TicketTreeListProps>();
nodes: TicketTreeNodeModel[];
projectId: number; const { t } = useLocalization();
}>(); const collapsedTicketIds = reactive(new Set<number>());
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script> </script>
<template> <template>
<ul class="space-y-3"> <ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.id"> <li v-for="node in nodes" :key="node.id" class="min-w-0">
<div class="border-border rounded-xl border px-4 py-4"> <div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex justify-center pt-1">
<Link <button
:href=" v-if="node.children.length > 0"
showTicket({ type="button"
project: projectId, class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
ticket: node.id, :aria-expanded="!collapsedTicketIds.has(node.id)"
}) :aria-controls="`ticket-tree-children-${node.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
" "
class="text-foreground hover:text-primary font-medium transition" @click="toggleTicket(node.id)"
> >
{{ node.ticket_key }} <ChevronRight
</Link> v-if="collapsedTicketIds.has(node.id)"
class="size-4"
<Badge v-if="node.ticket_type_name" variant="secondary"> aria-hidden="true"
{{ node.ticket_type_name }} />
</Badge> <ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div> </div>
<div class="text-foreground mt-2 text-sm"> <Link
{{ node.title }} :href="
</div> showTicket({
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div> </div>
<div <div
v-if="node.children.length > 0" v-if="
class="border-border mt-3 border-l pl-4" node.children.length > 0 && !collapsedTicketIds.has(node.id)
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
> >
<TicketTreeList <TicketTreeList
:nodes="node.children" :nodes="node.children"
+150 -104
View File
@@ -1,123 +1,169 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next'; 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 AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth(); const { t } = useLocalization();
const isRecoveryCodesVisible = ref<boolean>(false); const {
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef'); recoveryCodesList,
fetchRecoveryCodes,
clearRecoveryCodes,
errors,
loading,
} = useTwoFactorAuth();
const visible = ref(false);
const showRegenerate = ref(false);
const form = useForm({});
const protection = useFormProtection(form);
const toggleRecoveryCodesVisibility = async () => { function hide(): void {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) { visible.value = false;
await fetchRecoveryCodes(); clearRecoveryCodes();
}
function toggle(): void {
if (visible.value) {
hide();
} else {
visible.value = true;
void fetchRecoveryCodes();
} }
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value; function closeRegenerate(open: boolean): void {
if (!form.processing) {
if (isRecoveryCodesVisible.value) { showRegenerate.value = open;
await nextTick(); protection.clearFeedback();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
} }
}; }
function regenerate(): void {
onMounted(async () => { protection.submit(regenerateRecoveryCodes(), {
if (!recoveryCodesList.value.length) { preserveScroll: true,
await fetchRecoveryCodes(); onBefore: hide,
} onSuccess: () => {
}); showRegenerate.value = false;
visible.value = true;
return fetchRecoveryCodes();
},
});
}
</script> </script>
<template> <template>
<Card class="w-full"> <section
<CardHeader> class="gap-column grid min-w-0 rounded-lg border p-4"
<CardTitle class="flex gap-3"> aria-labelledby="recovery-title"
<LockKeyhole class="size-4" />2FA recovery codes >
</CardTitle> <div class="gap-stack grid">
<CardDescription> <h3
Recovery codes let you regain access if you lose your 2FA id="recovery-title"
device. Store them in a secure password manager. class="flex items-center gap-2 font-semibold"
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
> >
<Button @click="toggleRecoveryCodesVisibility" class="w-fit"> <LockKeyhole class="text-primary size-4" />{{
<component t('security.recoveryTitle')
:is="isRecoveryCodesVisible ? EyeOff : Eye" }}
class="size-4" </h3>
/> <p class="text-body text-muted-foreground">
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery {{ t('security.recoveryDescription') }}
codes </p>
</Button> </div>
<div class="flex flex-wrap gap-3">
<Form <Button
v-if="isRecoveryCodesVisible && recoveryCodesList.length" variant="outline"
v-bind="regenerateRecoveryCodes.form()" :aria-expanded="visible"
method="post" aria-controls="recovery-codes"
:options="{ preserveScroll: true }" :disabled="form.processing"
@success="fetchRecoveryCodes" @click="toggle"
#default="{ processing }" ><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 </template>
variant="secondary" <template v-else>
type="submit" <ul
:disabled="processing" class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
> :aria-label="t('security.recoveryTitle')"
<RefreshCw /> Regenerate codes >
</Button> <li v-for="code in recoveryCodesList" :key="code">
</Form> {{ code }}
</div> </li>
<div </ul>
:class="[ <p class="text-caption text-muted-foreground">
'relative overflow-hidden transition-all duration-300', {{ t('security.recoveryOnce') }}
isRecoveryCodesVisible </p>
? 'h-auto opacity-100' </template>
: 'h-0 opacity-0', </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"> <DialogHeader>
<AlertError :errors="errors" /> <DialogTitle>{{
</div> t('security.regenerateTitle')
<div v-else class="mt-3 space-y-3"> }}</DialogTitle>
<div <DialogDescription>{{
ref="recoveryCodeSectionRef" t('security.regenerateDescription')
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm" }}</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"> <Button :loading="form.processing" @click="regenerate">{{
<div t('security.regenerate')
v-for="n in 8" }}</Button>
:key="n" </DialogFooter>
class="bg-muted-foreground/20 h-4 animate-pulse rounded" </DialogContent>
></div> </Dialog>
</div> </section>
<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>
</template> </template>
+220 -256
View File
@@ -1,297 +1,261 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { Check, Copy, ScanLine } from 'lucide-vue-next'; import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'; import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue'; import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import { Input } from '@/components/ui/input';
InputOTP, import { Label } from '@/components/ui/label';
InputOTPGroup, import { useFormProtection } from '@/composables/useFormProtection';
InputOTPSlot, import { useLocalization } from '@/composables/useLocalization';
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor'; import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = { const props = defineProps<{
requiresConfirmation: boolean; requiresConfirmation: boolean;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
}; }>();
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
const isOpen = defineModel<boolean>('isOpen'); 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(); function close(open: boolean): void {
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } = if (!form.processing) {
useTwoFactorAuth(); isOpen.value = open;
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',
};
} }
}
if (showVerificationStep.value) { async function next(): Promise<void> {
return { if (props.twoFactorEnabled || !props.requiresConfirmation) {
title: 'Verify authentication code', close(false);
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();
});
return; return;
} }
verification.value = true;
clearSetupData(); await nextTick();
isOpen.value = false; codeInput.value?.$el?.focus();
}; }
function submit(): void {
const resetModalState = () => { protection.submit(confirm(), {
if (props.twoFactorEnabled) { preserveScroll: true,
clearSetupData(); errorBag: 'confirmTwoFactorAuthentication',
onSuccess: () => {
isOpen.value = false;
},
onFinish: () => {
form.reset();
},
});
}
async function copyKey(): Promise<void> {
const key = manualSetupKey.value;
if (!key) {
return;
} }
copyError.value = false;
showVerificationStep.value = false; try {
code.value = ''; await navigator.clipboard.writeText(key);
}; if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
} }
} catch {
if (!qrCodeSvg.value) { if (isOpen.value) {
await fetchSetupData(); 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> </script>
<template> <template>
<Dialog :open="isOpen" @update:open="isOpen = $event"> <Dialog :open="isOpen" @update:open="close">
<DialogContent class="sm:max-w-md"> <DialogContent
<DialogHeader class="flex items-center justify-center"> class="sm:max-w-md"
<div :show-close-button="!form.processing"
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm" @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 <div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5" class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
> v-html="qrCodeSvg"
<div />
class="absolute inset-0 grid grid-cols-5 opacity-50" <div class="grid min-w-0 gap-2">
> <Label for="setup-key">{{
<div t('security.manualKey')
v-for="i in 5" }}</Label>
:key="`col-${i}`" <div class="flex min-w-0 gap-2">
class="border-border border-r last:border-r-0" <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>
<div <p
class="absolute inset-0 grid grid-rows-5 opacity-50" v-if="copied"
role="status"
class="text-caption text-success-foreground"
> >
<div {{ t('security.copied') }}
v-for="i in 5" </p>
:key="`row-${i}`" <InputError
class="border-border border-b last:border-b-0" v-if="copyError"
/> :message="t('security.copyError')"
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
/> />
</div> </div>
</div> </div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle> <p
<DialogDescription class="text-center"> v-if="requiresConfirmation && !twoFactorEnabled"
{{ modalConfig.description }} class="text-caption text-muted-foreground"
</DialogDescription> >
</DialogHeader> {{ t('security.setupPendingDescription') }}
</p>
<div <DialogFooter>
class="relative flex w-auto flex-col items-center justify-center space-y-5" <Button variant="outline" @click="close(false)">{{
> t('common.close')
<template v-if="!showVerificationStep"> }}</Button>
<AlertError v-if="errors?.length" :errors="errors" /> <Button v-if="hasSetupData" @click="next">{{
<template v-else> t(
<div twoFactorEnabled || !requiresConfirmation
class="relative mx-auto flex max-w-md items-center overflow-hidden" ? 'common.done'
> : 'security.continue',
<div )
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border" }}</Button>
> </DialogFooter>
<div </template>
v-if="!qrCodeSvg" <form v-else class="gap-column grid" @submit.prevent="submit">
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center" <AuthFormFeedback
> :state="protection.state.value"
<Spinner class="size-6" /> :response-status="protection.responseStatus.value"
</div> @retry="submit"
<div />
v-else <div class="grid gap-2">
class="relative z-10 overflow-hidden border p-5" <Label for="authentication-code">{{
> t('security.authenticationCode')
<div }}</Label>
v-html="qrCodeSvg" <Input
class="flex aspect-square size-full items-center justify-center" id="authentication-code"
:style="{ ref="codeInput"
filter: v-model="form.code"
resolvedAppearance === 'dark' name="code"
? 'invert(1) brightness(1.5)' type="text"
: undefined, inputmode="numeric"
}" autocomplete="one-time-code"
/> pattern="[0-9]{6}"
</div> maxlength="6"
</div> required
</div> :disabled="form.processing"
:aria-invalid="!!form.errors.code"
<div class="flex w-full items-center space-x-5"> aria-describedby="authentication-code-error"
<Button class="w-full" @click="handleModalNextStep"> class="font-mono text-lg tracking-widest"
{{ modalConfig.buttonText }} />
</Button> <InputError
</div> id="authentication-code-error"
:message="form.errors.code"
<div />
class="relative flex w-full items-center justify-center" </div>
> <DialogFooter>
<div <Button
class="bg-border absolute inset-0 top-1/2 h-px w-full" type="button"
/> variant="outline"
<span class="bg-card relative px-2 py-1" :disabled="form.processing"
>or, enter the code manually</span @click="
> verification = false;
</div> form.resetAndClearErrors();
protection.clearFeedback();
<div "
class="flex w-full items-center justify-center space-x-2" >{{ t('common.back') }}</Button
>
<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 }"
> >
<input type="hidden" name="code" :value="code" /> <Button
<div type="submit"
ref="pinInputContainerRef" :disabled="form.code.length !== 6"
class="relative w-full space-y-3" :loading="form.processing"
> >{{ t('security.confirm') }}</Button
<div >
class="flex w-full flex-col items-center justify-center space-y-3 py-2" </DialogFooter>
> </form>
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </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"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; 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 InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types'; import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
areas: string[]; areas: string[];
projectId: number; projectId: number;
templateState?: ProjectTemplateState | null; templateState?: ProjectTemplateState | null;
}>(); }>();
const { t } = useLocalization();
const form = useForm<{ const form = useForm<{
areas: string[]; areas: string[];
status_replacements: Record<string, number | undefined>; status_replacements: Record<string, number | undefined>;
@@ -28,51 +34,82 @@ const form = useForm<{
areas: [], areas: [],
status_replacements: {}, status_replacements: {},
}); });
const protection = useFormProtection(form);
const activeArea = computed(() => form.areas[0]); const pendingArea = ref<ProjectTemplateStateArea | null>(null);
const inheritedAreas = computed( const inheritedAreas = computed(
() => () =>
props.templateState?.inheritedAreas.filter((area) => props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value), props.areas.includes(area.value),
) ?? [], ) ?? [],
); );
const overriddenAreas = computed( const overriddenAreas = computed(
() => () =>
props.templateState?.overriddenAreas.filter((area) => props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value), props.areas.includes(area.value),
) ?? [], ) ?? [],
); );
const statusRequirement = computed(() =>
const statusRequirement = computed( pendingArea.value?.value === 'ticket_statuses'
() => props.templateState?.resetRequirements?.ticket_statuses, ? props.templateState?.resetRequirements?.ticket_statuses
: undefined,
); );
const canReset = computed(
const canReset = (area: ProjectTemplateStateArea): boolean => { () =>
if (area.value !== 'ticket_statuses') { statusRequirement.value?.removedStatuses.every((status) =>
return true; statusRequirement.value?.replacementOptions.some(
} (option) =>
option.id === form.status_replacements[String(status.id)],
return ( ),
statusRequirement.value?.removedStatuses.every( ) ?? true,
(status) => );
form.status_replacements[String(status.id)] !== undefined, const areaKeys = {
) ?? true project_settings: 'inheritance.area.projectSettings',
); board_columns: 'inheritance.area.boardColumns',
}; ticket_statuses: 'inheritance.area.ticketStatuses',
status_transitions: 'inheritance.area.statusTransitions',
const resetArea = (area: ProjectTemplateStateArea): void => { ticket_types: 'inheritance.area.ticketTypes',
if (!canReset(area)) { 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; return;
} }
pendingArea.value = null;
form.areas = [area.value]; form.resetAndClearErrors();
form.post(ProjectController.resetTemplate.url(props.projectId), { 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, preserveScroll: true,
onSuccess: () => form.reset(), onSuccess: () => {
form.resetAndClearErrors();
pendingArea.value = null;
},
}); });
}; }
</script> </script>
<template> <template>
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
templateState?.inheritsFromTemplate && templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0) (inheritedAreas.length > 0 || overriddenAreas.length > 0)
" "
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
> >
<CardHeader> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<CardTitle> <div class="flex items-center gap-2">
Template inheritance: {{ templateState.templateName }} <Layers3
</CardTitle> class="text-primary size-5 shrink-0"
<CardDescription> aria-hidden="true"
Every setting area inherits independently. Local changes stay />
unchanged when the template is updated until that area is reset. <h2 class="text-title">{{ t('inheritance.title') }}</h2>
</CardDescription> </div>
<p class="text-muted-foreground break-words">
{{
t('inheritance.description', {
template: templateState.templateName,
})
}}
</p>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap gap-2"> <p class="text-caption text-muted-foreground">
<Badge {{ t('inheritance.help') }}
v-for="area in inheritedAreas" </p>
:key="`inherited-${area.value}`" <ul
variant="outline" class="grid min-w-0 divide-y"
> :aria-label="t('inheritance.areas')"
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
> >
<div class="flex flex-wrap items-center justify-between gap-3"> <li
<Badge>{{ area.label }} · template overridden</Badge> v-for="area in inheritedAreas"
<Button :key="area.value"
type="button" class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
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"
> >
<p class="text-muted-foreground text-sm"> <span class="min-w-0 break-words font-medium">{{
Choose where tickets in local statuses should move areaLabel(area)
before those statuses are removed. }}</span>
</p> <Badge
<div variant="outline"
v-for="status in statusRequirement.removedStatuses" class="border-primary/25 text-primary"
:key="status.id" >{{ t('projectSettings.inherited') }}</Badge
class="grid gap-2 sm:grid-cols-2 sm:items-center"
> >
<Label :for="`replacement-${status.id}`"> </li>
{{ status.name }} ({{ status.ticketCount }} tickets) <li
</Label> v-for="area in overriddenAreas"
<div class="grid gap-1"> :key="area.value"
<select class="gap-stack grid min-w-0 py-3"
:id="`replacement-${status.id}`" >
v-model=" <div
form.status_replacements[String(status.id)] class="flex min-w-0 flex-wrap items-center justify-between gap-3"
" >
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]" <span class="min-w-0 break-words font-medium">{{
> areaLabel(area)
<option :value="undefined" disabled> }}</span>
Select replacement status <Badge variant="secondary">{{
</option> t('projectSettings.override')
<option }}</Badge>
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>
</div> </div>
</div> <div
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
<InputError :message="form.errors.areas" /> >
</div> <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> </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> </Card>
</template> </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>
+73 -50
View File
@@ -11,6 +11,7 @@ import type {
} from '@/types/ticket-presentation'; } from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), { withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true, showDescription: true,
showStatus: true, showStatus: true,
}); });
@@ -22,63 +23,85 @@ const { t } = useLocalization();
<template> <template>
<article <article
:class="[ :class="[
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm', 'bg-card text-card-foreground min-w-0 rounded-md border',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border', ticket.is_blocked ? 'border-warning' : 'border-border',
]" ]"
> >
<Link <div
:href="showTicket({ project: projectId, ticket: ticket.id })" :class="
:aria-label=" layout === 'list'
t('tickets.open', { ? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
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-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
@click="emit('open', ticket.id)"
> >
<div class="flex min-w-0 items-start justify-between gap-3"> <Link
<Badge :href="showTicket({ project: projectId, ticket: ticket.id })"
variant="outline" :aria-label="
class="wrap-anywhere max-w-full whitespace-normal" t('tickets.open', {
> key: ticket.ticket_key,
{{ ticket.ticket_key }} title: ticket.title,
</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> :class="[
</Link> '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>
<div v-if="$slots.actions" class="border-t p-4"> <h3
<slot name="actions" /> 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> </div>
</article> </article>
</template> </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,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>
+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,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, onScopeDispose, ref } from 'vue';
import { computed, ref } from 'vue'; import { useLocalization } from '@/composables/useLocalization';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = { /** Keep secrets within the consuming component and discard late responses after hiding them. */
qrCodeSvg: Ref<string | null>; export function useTwoFactorAuth() {
manualSetupKey: Ref<string | null>; const { t } = useLocalization();
recoveryCodesList: Ref<string[]>; const errors = ref<string[]>([]);
errors: Ref<string[]>; const manualSetupKey = ref<string | null>(null);
hasSetupData: ComputedRef<boolean>; const qrCodeSvg = ref<string | null>(null);
clearSetupData: () => void; const recoveryCodesList = ref<string[]>([]);
clearErrors: () => void; const loading = ref(false);
clearTwoFactorAuthData: () => void; let pending: AbortController | null = null;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
const fetchJson = async <T>(url: string): Promise<T> => { async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); credentials: 'same-origin',
signal,
if (!response.ok) { });
throw new Error(`Failed to fetch: ${response.status}`); 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[]>([]); function clearTwoFactorAuthData(): void {
const manualSetupKey = ref<string | null>(null); pending?.abort();
const qrCodeSvg = ref<string | null>(null); pending = null;
const recoveryCodesList = ref<string[]>([]); loading.value = false;
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 => {
manualSetupKey.value = null; manualSetupKey.value = null;
qrCodeSvg.value = null; qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = []; 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 { try {
clearErrors(); if (kind === 'setup') {
recoveryCodesList.value = await fetchJson<string[]>( const [qr, key] = await Promise.all([
recoveryCodes.url(), fetchJson<{ svg: string }>(qrCode.url(), request.signal),
); fetchJson<{ secretKey: string }>(
} catch { secretKey.url(),
errors.value.push('Failed to fetch recovery codes'); request.signal,
recoveryCodesList.value = []; ),
]);
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 { return {
qrCodeSvg, qrCodeSvg,
manualSetupKey, manualSetupKey,
recoveryCodesList, recoveryCodesList,
errors, errors,
hasSetupData, loading,
clearSetupData, hasSetupData: computed(() =>
clearErrors, Boolean(qrCodeSvg.value && manualSetupKey.value),
),
clearSetupData: clearTwoFactorAuthData,
clearRecoveryCodes: clearTwoFactorAuthData,
clearTwoFactorAuthData, clearTwoFactorAuthData,
fetchQrCode, clearErrors,
fetchSetupKey, fetchSetupData: () => load('setup'),
fetchSetupData, fetchRecoveryCodes: () => load('recovery'),
fetchRecoveryCodes,
}; };
}; }
@@ -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"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
import { computed } from 'vue'; 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 { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: t('navigation.profile'), title: t('navigation.profile'),
href: editProfile(), href: editProfile(),
icon: UserRound,
}, },
{ {
title: t('navigation.security'), title: t('navigation.security'),
href: editSecurity(), href: editSecurity(),
icon: ShieldCheck,
}, },
{ {
title: t('navigation.gitConnections'), title: t('navigation.gitConnections'),
href: editGitConnections(), href: editGitConnections(),
icon: GitBranch,
}, },
{ {
title: t('navigation.applications'), title: t('navigation.applications'),
href: editApplications(), href: editApplications(),
icon: KeyRound,
}, },
{ {
title: t('navigation.appearance'), title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
icon: Palette,
}, },
]); ]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<div class="px-4 py-6"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
:title="t('navigation.settings')" >
:description="t('navigation.settingsDescription')" <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"> <div
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0"> 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 <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')" :aria-label="t('navigation.settings')"
> >
<Button <Link
v-for="item in sidebarNavItems" v-for="item in sidebarNavItems"
:key="toUrl(item.href)" :key="toUrl(item.href)"
variant="ghost" :href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href) ? 'page' : undefined
"
:class="[ :class="[
'shrink-0 justify-start lg:w-full', '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',
{ 'bg-muted': isCurrentOrParentUrl(item.href) }, 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 <component
:href="item.href" :is="item.icon"
:aria-current=" class="size-4 shrink-0"
isCurrentOrParentUrl(item.href) aria-hidden="true"
? 'page' />
: undefined <span class="whitespace-nowrap">{{ item.title }}</span>
" </Link>
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
</nav> </nav>
</aside> </aside>
<Separator class="lg:hidden" /> <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">
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<slot /> <slot />
</section> </section>
</div> </div>
+70
View File
@@ -5,6 +5,76 @@ import type {
const MAX_INDENT_LEVEL = 8; const MAX_INDENT_LEVEL = 8;
export const canIndentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): boolean => {
const item = items[index];
const previous = items[index - 1];
if (!item || !previous || item.indent > previous.indent) {
return false;
}
return items
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
};
const acceptanceCriteriaSubtreeEnd = (
items: AcceptanceCriteriaModel,
index: number,
): number => {
let end = index + 1;
while (end < items.length && items[end].indent > items[index].indent) {
end++;
}
return end;
};
export const indentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
direction: 1 | -1,
): AcceptanceCriteriaModel => {
if (
!items[index] ||
(direction === 1
? !canIndentAcceptanceCriterion(items, index)
: items[index].indent === 0)
) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.map((item, position) =>
position >= index && position < end
? { ...item, indent: item.indent + direction }
: item,
);
};
/** Remove only the selected criterion, promoting its children without losing them. */
export const removeAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): AcceptanceCriteriaModel => {
if (!items[index]) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.flatMap((item, position) =>
position === index
? []
: [
{
...item,
indent:
position > index && position < end
? item.indent - 1
: item.indent,
},
],
);
};
export type AcceptanceCriteriaEditorState = { export type AcceptanceCriteriaEditorState = {
checklist: string; checklist: string;
}; };
+114
View File
@@ -0,0 +1,114 @@
import type {
BacklogFilterState,
BacklogTicketTreeNode,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
export function buildBacklogTicketTree(
tickets: TicketModel[],
): BacklogTicketTreeNode[] {
const nodesByTicketId = new Map<number, BacklogTicketTreeNode>();
for (const ticket of tickets) {
nodesByTicketId.set(ticket.id, {
children: [],
matchesFilters: true,
ticket,
});
}
const roots: BacklogTicketTreeNode[] = [];
for (const ticket of tickets) {
const node = nodesByTicketId.get(ticket.id);
if (!node) {
continue;
}
const parentNode =
ticket.parent_ticket_id === null
? undefined
: nodesByTicketId.get(ticket.parent_ticket_id);
if (parentNode) {
parentNode.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export function filterBacklogTicketTree(
nodes: BacklogTicketTreeNode[],
filters: BacklogFilterState,
): BacklogTicketTreeNode[] {
return nodes.flatMap((node) => {
const children = filterBacklogTicketTree(node.children, filters);
const matchesFilters = ticketMatchesFilters(node.ticket, filters);
if (!matchesFilters && children.length === 0) {
return [];
}
return [
{
...node,
children,
matchesFilters,
},
];
});
}
export function countBacklogTreeTickets(
nodes: BacklogTicketTreeNode[],
): number {
return nodes.reduce(
(count, node) => count + 1 + countBacklogTreeTickets(node.children),
0,
);
}
export function expandableBacklogTicketIds(
nodes: BacklogTicketTreeNode[],
): number[] {
return nodes.flatMap((node) => [
...(node.children.length > 0 ? [node.ticket.id] : []),
...expandableBacklogTicketIds(node.children),
]);
}
function ticketMatchesFilters(
ticket: TicketModel,
filters: BacklogFilterState,
): boolean {
const query = filters.query.trim().toLocaleLowerCase();
const searchableText = [
ticket.ticket_key,
ticket.title,
ticket.description,
ticket.ticket_type.name,
ticket.ticket_priority.name,
ticket.ticket_status?.name,
ticket.assignee?.name,
]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLocaleLowerCase();
return (
(query === '' || searchableText.includes(query)) &&
(filters.typeId === '' || ticket.ticket_type_id === filters.typeId) &&
(filters.priorityId === '' ||
ticket.ticket_priority_id === filters.priorityId) &&
(filters.statusId === '' ||
ticket.ticket_status_id === filters.statusId) &&
(filters.assigneeId === '' ||
(filters.assigneeId === 'unassigned'
? ticket.assignee_user_id === null
: ticket.assignee_user_id === filters.assigneeId))
);
}
+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;
}
File diff suppressed because it is too large Load Diff
+25 -151
View File
@@ -1,165 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import ManagedUserForm from '@/components/ManagedUserForm.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
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 AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/admin/users'; import { create, index } from '@/routes/admin/users';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [ const { t } = useLocalization();
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'User management', { title: t('adminUsers.title'), href: index() },
href: index(), { title: t('adminUsers.create.title'), href: create() },
}, ]);
{
title: 'Create user',
href: create(),
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <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"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
title="Create user" >
description="Create a user account and optionally force a password change on the next login." <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> <ManagedUserForm mode="create" @cancel="router.visit(index())" />
<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>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+43 -153
View File
@@ -1,170 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue'; import { computed } from 'vue';
import InputError from '@/components/InputError.vue'; import ManagedUserForm from '@/components/ManagedUserForm.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button'; import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { edit, index, save } from '@/routes/admin/users'; import { edit, index } from '@/routes/admin/users';
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types'; import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
const props = defineProps<AdminUserEditPageProps>(); const props = defineProps<AdminUserEditPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('adminUsers.title'), href: index() },
{ {
title: 'User management', title: t('adminUsers.edit.breadcrumb', { name: props.user.name }),
href: index(),
},
{
title: 'Edit user',
href: edit(props.user.id), 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> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <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"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
:title="`Edit ${user.name}`" >
description="Update user details, reset the password if needed, and control whether the user must change it on next login." <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> </div>
</AppLayout> </AppLayout>
</template> </template>
+428 -227
View File
@@ -1,267 +1,468 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { import {
FolderPlus, FolderPlus,
KeyRound, KeyRound,
Pencil, Pencil,
Shield, ShieldCheck,
UserPlus, UserPlus,
Users, UsersRound,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController'; import { computed, ref } from 'vue';
import Heading from '@/components/Heading.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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; 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 AppLayout from '@/layouts/AppLayout.vue';
import { import {
create as createAdminUser, create as createAdminUser,
edit as editAdminUser, edit as editAdminUser,
index as adminUsers, index as adminUsers,
} from '@/routes/admin/users'; } from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types'; import type {
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; AdminUsersPageProps,
BreadcrumbItem,
PermissionOption,
} from '@/types';
import type { UserModel } from '@/types/models/user-model';
const props = defineProps<AdminUsersPageProps>(); const props = defineProps<AdminUsersPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ 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', label: t('adminUsers.summary.total'),
href: adminUsers(), 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) => function permissionsFor(userId: number): string[] {
(props.directPermissions[actorId] ?? []).includes(permission); 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> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <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="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div class="flex items-start justify-between gap-4"> <header
<Heading class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
title="User management" >
description="Assign the global permissions that unlock platform-wide administration and project creation." <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"> <FeedbackState
<Button as-child> 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()"> <Link :href="createAdminUser()">
<UserPlus class="size-4" /> <UserPlus aria-hidden="true" />
User create {{ t('adminUsers.createAction') }}
</Link> </Link>
</Button> </Button>
</FeedbackState>
<Badge variant="outline" class="gap-1.5 px-3 py-1"> <ul
<Users class="size-3.5" /> v-else
Admin-only area class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
</Badge> >
</div> <li
</div> v-for="user in users"
:key="user.id"
<div class="min-w-0"
v-if="status" data-managed-user
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"
> >
No users exist yet. <article
</div> class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
> >
<thead <div class="flex min-w-0 items-start gap-4 p-5">
class="bg-muted/40 text-muted-foreground text-left" <UserAvatar
> :user="user"
<tr> size="lg"
<th class="px-4 py-3 font-medium">User</th> rounded
<th class="px-4 py-3 font-medium">Email</th> class="ring-border shrink-0 ring-1"
<th class="px-4 py-3 font-medium"> />
Current access <div class="min-w-0 flex-1">
</th> <h3
<th class="text-body break-words font-semibold"
class="px-4 py-3 text-right font-medium" >
{{ 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 }} {{ user.email }}
</td> </p>
<td class="px-4 py-4"> </div>
<div class="flex flex-wrap gap-2"> <Badge variant="outline" class="shrink-0">
<Badge {{ t('adminUsers.managedAccount') }}
v-if=" </Badge>
user.must_change_password_on_next_login </div>
"
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>
<Form <div
v-bind=" class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
UserManagementController.update.form( >
user.id, <Badge
) v-if="
" user.must_change_password_on_next_login
class="flex flex-wrap justify-end gap-3" "
v-slot="{ processing }" variant="outline"
> class="gap-1.5 whitespace-normal"
<label >
v-for="option in permissionOptions" <KeyRound
:key="`${user.id}-${option.value}`" class="size-3.5"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" aria-hidden="true"
> />
<input {{ t('adminUsers.passwordChangePending') }}
type="checkbox" </Badge>
name="permissions[]" <Badge
:value=" v-for="option in permissionOptions"
option.value v-show="
" hasPermission(user.id, option.value)
:checked=" "
hasPermission( :key="option.value"
user.id, variant="secondary"
option.value, class="gap-1.5 whitespace-normal"
) >
" <ShieldCheck
class="border-input text-primary focus:ring-ring size-4 rounded" v-if="option.value === 'global:admin'"
/> class="size-3.5"
<span>{{ aria-hidden="true"
option.label />
}}</span> <FolderPlus
</label> 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 <div
class="w-full sm:hidden" class="grid gap-2 p-4 sm:grid-cols-2"
/> data-user-actions
>
<Button <Button
type="submit" variant="outline"
size="sm" @click="openPermissions(user)"
variant="outline" >
:disabled="processing" <ShieldCheck aria-hidden="true" />
> {{ t('adminUsers.permissions.action') }}
Save </Button>
</Button> <Button as-child variant="ghost">
</Form> <Link
</div> :href="editAdminUser(user.id)"
</div> :aria-label="
</td> t('adminUsers.edit.action', {
</tr> name: user.name,
</tbody> })
</table> "
</div> >
</CardContent> <Pencil aria-hidden="true" />
</Card> {{ t('adminUsers.edit.shortAction') }}
</Link>
</Button>
</div>
</article>
</li>
</ul>
</section>
</div> </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> </AppLayout>
</template> </template>
+26 -162
View File
@@ -1,174 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import { Badge } from '@/components/ui/badge';
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 AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import { index, show } from '@/routes/projects';
index as projectsIndex, import { create } from '@/routes/projects/templates';
show as projectShow,
} from '@/routes/projects/index';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
} from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>(); const props = defineProps<ProjectTemplatesCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', { title: props.project.name, href: show(props.project.id) },
href: projectsIndex(), { title: t('projects.saveTemplate'), href: create(props.project.id) },
}, ]);
{
title: props.project.name,
href: projectShow(props.project.id),
},
{
title: 'Save as new template',
href: projectTemplateCreate(props.project.id),
},
];
const form = useForm({
copy_areas: props.copyAreaOptions.map(
(copyAreaOption) => copyAreaOption.value,
),
name: `${props.project.name} template`,
slug: `${props.project.key.toLowerCase()}-template`,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const submit = (): void => {
form.post(projectTemplateStore.url(props.project.id), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Save ${project.name} as new template`" /> <Head :title="t('templates.createTitle', { project: project.name })" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <header class="gap-stack grid">
<div class="space-y-3"> <h1 class="text-headline-mobile md:text-headline break-words">
<Badge variant="outline" class="px-3 py-1">{{ {{ t('templates.createTitle', { project: project.name }) }}
project.key </h1>
}}</Badge> <p class="text-muted-foreground">
<Heading {{ t('templates.createDescription') }}
:title="`Save ${project.name} as new template`" </p>
description="Capture reusable project configuration without copying operational ticket data." </header>
/> <ProjectTemplateForm
</div> :key="project.id"
:project="project"
<Card> :copy-area-options="copyAreaOptions"
<CardHeader> />
<CardTitle>Template details</CardTitle>
<CardDescription>
Pick the configuration groups that should be reusable in
future projects.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input
id="slug"
v-model="form.slug"
required
placeholder="foundation-template"
/>
<p class="text-muted-foreground text-sm">
Slugs are normalized automatically and used to
identify reusable templates.
</p>
<InputError :message="form.errors.slug" />
</div>
<div class="grid gap-3">
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Only the selected groups will be stored in
the template.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save new template
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)"
>Cancel</Link
>
</Button>
</div>
</form>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+24 -211
View File
@@ -1,223 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import { Badge } from '@/components/ui/badge';
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 AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import { index, edit } from '@/routes/project-templates';
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
update as projectTemplatesUpdate,
} from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
const props = defineProps<ProjectTemplatesEditPageProps>(); const props = defineProps<ProjectTemplatesEditPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.templates'), href: index() },
title: 'Project templates', { title: props.template.name, href: edit(props.template.id) },
href: projectTemplatesIndex(), ]);
},
{
title: props.template.name,
href: projectTemplatesEdit(props.template.id),
},
];
const form = useForm({
confirm_linked_projects_update: false,
copy_areas: [...props.template.copy_areas],
name: props.template.name,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const linkedProjectCount = computed(
() => props.template.linked_project_count ?? 0,
);
const submit = (): void => {
form.patch(projectTemplatesUpdate.url(props.template.id), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Edit ${template.name}`" /> <Head :title="t('templates.editTitle', { name: template.name })" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <header class="gap-stack grid">
<div class="space-y-3"> <h1 class="text-headline-mobile md:text-headline break-words">
<div class="flex flex-wrap items-center gap-3"> {{ t('templates.editTitle', { name: template.name }) }}
<Badge variant="outline" class="px-3 py-1">{{ </h1>
template.slug <p class="text-muted-foreground">
}}</Badge> {{ t('templates.editDescription') }}
<Badge v-if="linkedProjectCount > 0" variant="secondary"> </p>
{{ linkedProjectCount }} linked </header>
{{ linkedProjectCount === 1 ? 'project' : 'projects' }} <ProjectTemplateForm
</Badge> :key="template.id"
</div> :template="template"
<Heading :copy-area-options="copyAreaOptions"
:title="`Edit ${template.name}`" />
description="Refresh the template baseline from its source project while keeping immutable identifiers protected."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
<template v-if="template.source_project">
This template baseline is rebuilt from
{{ template.source_project.name }} ({{
template.source_project.key
}}) when you save.
</template>
<template v-else>
This template baseline is rebuilt from its source
project when you save.
</template>
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input
id="slug"
:model-value="template.slug"
disabled
/>
<p class="text-muted-foreground text-sm">
The slug stays locked so existing template
references remain stable.
</p>
</div>
<div class="grid gap-3">
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Saving rebuilds the selected groups from the
current source-project configuration.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div
v-if="linkedProjectCount > 0"
class="space-y-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
<div class="space-y-1">
<div class="font-medium">Warning</div>
<p class="text-sm">
Saving this template updates the baseline
for {{ linkedProjectCount }} linked
{{
linkedProjectCount === 1
? 'project'
: 'projects'
}}. Inherited areas will follow the new
template version, while existing overrides
stay untouched.
</p>
</div>
<Label
class="flex cursor-pointer items-start gap-3"
>
<input
v-model="
form.confirm_linked_projects_update
"
type="checkbox"
class="mt-1"
/>
<span class="text-sm">
I understand that linked projects inheriting
from this template will be affected.
</span>
</Label>
<InputError
:message="
form.errors.confirm_linked_projects_update
"
/>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save template changes
</Button>
<Button as-child variant="outline">
<Link :href="projectTemplatesIndex()"
>Cancel</Link
>
</Button>
</div>
</form>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+227 -127
View File
@@ -1,158 +1,258 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue'; import { ArrowRight, LayoutTemplate, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates';
import { import {
destroy as projectTemplatesDestroy, create as createProject,
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
} from '@/routes/project-templates';
import {
create as projectsCreate,
index as projectsIndex, index as projectsIndex,
} from '@/routes/projects/index'; } from '@/routes/projects';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<ProjectTemplatesIndexPageProps>(); const props = defineProps<ProjectTemplatesIndexPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const { areaText } = useProjectTemplateAreas();
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'Project templates', { title: t('navigation.templates'), href: index() },
href: projectTemplatesIndex(), ]);
}, const templates = computed(() =>
]; props.templates.filter((template) => template.is_published),
);
const removeTemplate = (templateId: number): void => { const pending = ref<ProjectTemplateModel | null>(null);
router.delete(projectTemplatesDestroy.url(templateId), { const form = useForm({});
const protection = useFormProtection(form);
function requestDelete(template: ProjectTemplateModel): void {
form.resetAndClearErrors();
protection.clearFeedback();
pending.value = template;
}
function close(open: boolean): void {
if (!open && !form.processing) {
pending.value = null;
form.clearErrors();
protection.clearFeedback();
}
}
function removeTemplate(): void {
if (!pending.value) {
return;
}
protection.submit(destroy(pending.value.id), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
pending.value = null;
},
}); });
}; }
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Project templates" /> <Head :title="t('navigation.templates')" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
<div class="flex flex-1 flex-col gap-6 p-4"> <header
<div class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<Heading <div class="gap-stack grid min-w-0">
title="Project templates" <h1 class="text-headline-mobile md:text-headline">
description="Reusable published project templates. Internal project base templates stay hidden here by default." {{ t('navigation.templates') }}
/> </h1>
<p class="text-muted-foreground max-w-2xl">
<div class="flex flex-wrap gap-3"> {{ t('templates.description') }}
<Button as-child variant="outline"> </p>
<Link :href="projectsIndex()">Back to projects</Link>
</Button>
<Button as-child>
<Link :href="projectsCreate()">Create project</Link>
</Button>
</div> </div>
</div> <Button as-child class="shrink-0"
><Link :href="createProject()"
<div ><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
></Button
>
</header>
<FeedbackState
v-if="status" 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" state="success"
:title="t('templates.updated')"
/>
<section
class="bg-card gap-column flex min-w-0 flex-col rounded-md border p-5 sm:flex-row sm:items-center"
aria-labelledby="save-template-help"
> >
{{ status }} <LayoutTemplate
</div> class="text-primary size-6 shrink-0"
aria-hidden="true"
<div />
<div class="gap-stack grid min-w-0 flex-1">
<h2 id="save-template-help" class="text-title">
{{ t('templates.fromProject') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('templates.fromProjectHelp') }}
</p>
</div>
<Button as-child variant="outline" class="shrink-0"
><Link :href="projectsIndex()"
>{{ t('templates.browseProjects')
}}<ArrowRight aria-hidden="true" /></Link
></Button>
</section>
<FeedbackState
v-if="templates.length === 0" v-if="templates.length === 0"
class="text-muted-foreground text-sm" state="empty"
:title="t('templates.empty')"
:description="t('templates.emptyHelp')"
/>
<ul
v-else
class="gap-column grid min-w-0 md:grid-cols-2 xl:grid-cols-3"
:aria-label="t('templates.published')"
> >
No published project templates have been saved yet. <li
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="template in templates" v-for="template in templates"
:key="template.id" :key="template.id"
class="h-full" class="bg-card flex min-w-0 flex-col rounded-md border"
:data-template="template.id"
> >
<CardHeader> <div class="gap-column grid min-w-0 p-5">
<div class="flex flex-wrap items-center gap-2"> <div class="gap-stack grid min-w-0">
<CardTitle>{{ template.name }}</CardTitle> <h2 class="text-title break-words">
<Badge variant="secondary">{{ {{ template.name }}
template.slug </h2>
}}</Badge> <p
<Badge class="text-caption text-muted-foreground break-all"
v-if="template.linked_project_count"
variant="outline"
> >
{{ template.linked_project_count }} linked {{ template.slug }}
{{ </p>
template.linked_project_count === 1
? 'project'
: 'projects'
}}
</Badge>
</div> </div>
<CardDescription> <Badge
<template v-if="template.source_project"> variant="secondary"
Saved from class="w-fit whitespace-normal"
{{ template.source_project.name }} ({{ >{{
template.source_project.key t('templates.linkedCount', {
}}). count: template.linked_project_count ?? 0,
</template> })
<template v-else> }}</Badge
Saved template configuration. >
</template> <p
</CardDescription> class="text-caption text-muted-foreground break-words"
</CardHeader> >
<CardContent class="space-y-4"> {{
<div class="flex flex-wrap gap-2"> template.source_project
<Badge ? t('templates.savedFrom', {
v-for="copyArea in template.copy_areas" project: template.source_project.name,
:key="copyArea" key: template.source_project.key,
variant="outline" })
> : t('templates.sourceUnavailable')
{{ copyArea }} }}
</Badge> </p>
</div> <ul
class="flex flex-wrap gap-2"
<div class="flex flex-wrap gap-3"> :aria-label="t('templates.groups')"
<Button as-child variant="secondary"> >
<Link :href="projectTemplatesEdit(template.id)"> <li v-for="area in template.copy_areas" :key="area">
Edit <Badge
</Link> variant="outline"
</Button> class="whitespace-normal"
>{{ areaText(area, 'label') }}</Badge
<Button as-child variant="outline">
<Link
:href="
projectsCreate({
query: { template: template.id },
})
"
> >
Use template </li>
</Link> </ul>
</Button> </div>
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
<Button <Button as-child variant="secondary"
type="button" ><Link
variant="destructive" :href="
@click="removeTemplate(template.id)" createProject({
> query: { template: template.id },
Delete })
</Button> "
</div> >{{ t('templates.use') }}</Link
</CardContent> ></Button
</Card> >
</div> <Button as-child variant="outline"
><Link
:href="edit(template.id)"
:aria-label="
t('templates.editTitle', {
name: template.name,
})
"
>{{ t('templates.edit') }}</Link
></Button
>
<Button
variant="ghost"
size="icon"
class="text-destructive ml-auto"
:aria-label="
t('templates.deleteTitle', {
name: template.name,
})
"
@click="requestDelete(template)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</li>
</ul>
</div> </div>
<Dialog :open="pending !== null" @update:open="close">
<DialogContent :show-close-button="!form.processing">
<DialogHeader>
<DialogTitle>{{
t('templates.deleteTitle', {
name: pending?.name ?? '',
})
}}</DialogTitle>
<DialogDescription>{{
t('templates.deleteHelp')
}}</DialogDescription>
</DialogHeader>
<p v-if="pending?.linked_project_count" class="text-body">
{{
t('templates.deleteImpact', {
count: pending.linked_project_count,
})
}}
</p>
<FormFeedback
:state="protection.state.value"
@retry="removeTemplate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="form.processing"
:loading-label="t('templates.deleting')"
@click="removeTemplate"
>{{ t('templates.delete') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout> </AppLayout>
</template> </template>
+399 -166
View File
@@ -1,70 +1,155 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import {
import Heading from '@/components/Heading.vue'; ArrowLeft,
import { TicketListItem } from '@/components/tickets'; ChevronsDownUp,
ChevronsUpDown,
Columns3,
Plus,
RotateCcw,
Search,
} from 'lucide-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets'; import {
buildBacklogTicketTree,
countBacklogTreeTickets,
expandableBacklogTicketIds,
filterBacklogTicketTree,
} from '@/lib/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index'; import { index as projectIndex, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets'; import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; import type {
import type { TicketModel } from '@/types/models/ticket-model'; BacklogFilterState,
import type { TicketStatusModel } from '@/types/models/ticket-status-model'; BreadcrumbItem,
ProjectBacklogPageProps,
} from '@/types';
const props = defineProps<ProjectBacklogPageProps>(); const props = defineProps<ProjectBacklogPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
]);
const breadcrumbs: BreadcrumbItem[] = [ const filters = reactive<BacklogFilterState>({
{ assigneeId: '',
title: 'Projects', priorityId: '',
href: index(), query: '',
}, statusId: '',
{ typeId: '',
title: props.project.name, });
href: show(props.project.id), const collapsedTicketIds = reactive(new Set<number>());
}, const pendingTicketId = ref<number | null>(null);
{ const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
title: 'Backlog', const filteredTicketTree = computed(() =>
href: backlogIndex(props.project.id), filterBacklogTicketTree(ticketTree.value, filters),
}, );
]; const visibleTicketCount = computed(() =>
countBacklogTreeTickets(filteredTicketTree.value),
);
const expandableTicketIds = computed(() =>
expandableBacklogTicketIds(filteredTicketTree.value),
);
const hasActiveFilters = computed(
() =>
filters.query.trim() !== '' ||
filters.typeId !== '' ||
filters.priorityId !== '' ||
filters.statusId !== '' ||
filters.assigneeId !== '',
);
const allVisibleBranchesCollapsed = computed(
() =>
expandableTicketIds.value.length > 0 &&
expandableTicketIds.value.every((ticketId) =>
collapsedTicketIds.has(ticketId),
),
);
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { function clearFilters(): void {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter( filters.query = '';
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id, filters.typeId = '';
); filters.priorityId = '';
}; filters.statusId = '';
filters.assigneeId = '';
}
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
function toggleAllBranches(): void {
if (allVisibleBranchesCollapsed.value) {
collapsedTicketIds.clear();
return;
}
for (const ticketId of expandableTicketIds.value) {
collapsedTicketIds.add(ticketId);
}
}
function finishMove(ticketId: number): void {
if (pendingTicketId.value === ticketId) {
pendingTicketId.value = null;
}
}
watch(
() => props.project.id,
() => {
clearFilters();
collapsedTicketIds.clear();
pendingTicketId.value = null;
},
);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} backlog`" /> <Head :title="t('backlog.title', { name: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
> >
<div class="space-y-3"> <div class="grid min-w-0 gap-2">
<Badge variant="outline" class="px-3 py-1"> <div class="flex min-w-0 flex-wrap items-center gap-3">
{{ project.key }} <Badge
</Badge> variant="outline"
<Heading class="wrap-anywhere max-w-full whitespace-normal"
:title="`${project.name} backlog`" >
description="All newly created tickets start here before they move onto the board." {{ project.key }}
/> </Badge>
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: tickets.length }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('backlog.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('backlog.description') }}
</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-2">
<Button v-if="canCreateTickets" as-child> <Button v-if="canCreateTickets" as-child>
<Link <Link
:href=" :href="
@@ -74,138 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
) )
" "
> >
Create ticket <Plus aria-hidden="true" />
{{ t('navigation.createTicket') }}
</Link> </Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link> <Link :href="boardIndex(project.id)">
<Columns3 aria-hidden="true" />
{{ t('backlog.openBoard') }}
</Link>
</Button> </Button>
<Button as-child variant="ghost">
<Button as-child variant="outline"> <Link :href="show(project.id)">
<Link :href="show(project.id)">Back to project</Link> <ArrowLeft aria-hidden="true" />
{{ t('backlog.backToProject') }}
</Link>
</Button> </Button>
</div> </div>
</header>
<FeedbackState v-if="status" state="success" :title="status" />
<section
aria-labelledby="backlog-filter-title"
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
>
<div
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-filter-title" class="text-title">
{{ t('backlog.filters.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('backlog.filters.description') }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="!hasActiveFilters"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<div
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
>
<div
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
>
<label
for="backlog-query"
class="text-caption font-semibold"
>
{{ t('backlog.filters.search') }}
</label>
<div class="relative min-w-0">
<Search
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="backlog-query"
v-model="filters.query"
type="search"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
:placeholder="
t('backlog.filters.searchPlaceholder')
"
/>
</div>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-type"
class="text-caption font-semibold"
>
{{ t('backlog.filters.type') }}
</label>
<select
id="backlog-type"
v-model="filters.typeId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allTypes') }}
</option>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-priority"
class="text-caption font-semibold"
>
{{ t('backlog.filters.priority') }}
</label>
<select
id="backlog-priority"
v-model="filters.priorityId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allPriorities') }}
</option>
<option
v-for="priority in ticketPriorities"
:key="priority.id"
:value="priority.id"
>
{{ priority.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-status"
class="text-caption font-semibold"
>
{{ t('backlog.filters.status') }}
</label>
<select
id="backlog-status"
v-model="filters.statusId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allStatuses') }}
</option>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-assignee"
class="text-caption font-semibold"
>
{{ t('backlog.filters.assignee') }}
</label>
<select
id="backlog-assignee"
v-model="filters.assigneeId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option value="">
{{ t('backlog.filters.allAssignees') }}
</option>
<option value="unassigned">
{{ t('tickets.unassigned') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</div>
</section>
<div
v-if="tickets.length === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.empty.title')"
:description="t('backlog.empty.description')"
/>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
<Plus aria-hidden="true" />
{{ t('backlog.empty.action') }}
</Link>
</Button>
</div> </div>
<div <div
v-if="status" v-else-if="visibleTicketCount === 0"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" class="grid justify-items-start gap-3"
> >
{{ status }} <FeedbackState
state="empty"
:title="t('backlog.filters.empty.title')"
:description="t('backlog.filters.empty.description')"
/>
<Button
type="button"
variant="outline"
size="sm"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div> </div>
<Card class="gap-0 overflow-hidden"> <section
<CardHeader class="border-b"> v-else
<CardTitle>Backlog tickets</CardTitle> aria-labelledby="backlog-results-title"
<CardDescription> class="grid min-w-0 gap-3"
Tickets remain here while their status belongs to >
backlog. <div
</CardDescription> class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
</CardHeader> >
<div class="grid min-w-0 gap-1">
<CardContent class="p-0"> <h2 id="backlog-results-title" class="text-title">
<div {{ t('backlog.results.title') }}
v-if="tickets.length === 0" </h2>
class="text-muted-foreground p-6 text-sm" <p class="text-caption text-muted-foreground">
> {{
No backlog tickets exist yet. t('backlog.results.count', {
count: visibleTicketCount,
total: tickets.length,
})
}}
</p>
</div> </div>
<Button
v-if="expandableTicketIds.length > 0"
type="button"
variant="outline"
size="sm"
@click="toggleAllBranches"
>
<ChevronsUpDown
v-if="allVisibleBranchesCollapsed"
aria-hidden="true"
/>
<ChevronsDownUp v-else aria-hidden="true" />
{{
t(
allVisibleBranchesCollapsed
? 'backlog.tree.expandAll'
: 'backlog.tree.collapseAll',
)
}}
</Button>
</div>
<ul v-else class="gap-column grid p-4"> <BacklogTicketTree
<TicketListItem :nodes="filteredTicketTree"
v-for="ticket in tickets" :project-id="project.id"
:key="ticket.id" :can-edit-tickets="canEditTickets"
:project-id="project.id" :collapsed-ticket-ids="collapsedTicketIds"
:ticket="ticket" :pending-ticket-id="pendingTicketId"
> @toggle="toggleTicket"
<template v-if="canEditTickets" #actions> @move-start="pendingTicketId = $event"
<Form @move-finish="finishMove"
v-bind=" />
ProjectBoardController.move.form({ </section>
project: project.id,
ticket: ticket.id,
})
"
class="flex w-full flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${ticket.id}`"
class="text-label text-muted-foreground uppercase"
>
Move to status
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`backlog-ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(ticket)
.length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
'Unknown status',
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(ticket)
.length === 0
"
>
Move
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</template>
</TicketListItem>
</ul>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+295 -3
View File
@@ -1,13 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next'; import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController'; import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import FeedbackState from '@/components/FeedbackState.vue'; import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets'; import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useBoardDrag } from '@/composables/useBoardDrag';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets'; import { ticketStatusLabel } from '@/lib/tickets';
@@ -67,6 +78,140 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter( (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id, (status) => status.id !== ticket.ticket_status_id,
); );
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
const feedback = useFormProtection(moveForm, { isDirty: () => false });
const menuPending = ref<number | null>(null);
const busy = computed(() => moveForm.processing || menuPending.value !== null);
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
const findTicket = (id: number) =>
props.boardColumns
.flatMap((column) => column.tickets ?? [])
.find((ticket) => ticket.id === id);
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
const statusIds =
props.boardColumns
.find((column) => column.id === columnId)
?.ticket_statuses?.map((status) => status.id) ?? [];
return allowedTargetStatuses(ticket).filter((status) =>
statusIds.includes(status.id),
);
};
const choiceTicket = computed(() =>
choice.value ? findTicket(choice.value.ticketId) : undefined,
);
const choiceTargets = computed(() =>
choice.value && choiceTicket.value
? targetsForColumn(choiceTicket.value, choice.value.columnId)
: [],
);
const choiceOpen = computed({
get: () => choice.value !== null,
set: (open: boolean) => {
if (!open) {
choice.value = null;
}
},
});
function submitMove(ticketId: number, statusId: number): void {
const ticket = findTicket(ticketId);
if (
busy.value ||
!props.canEditTickets ||
!ticket ||
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
) {
return;
}
lastMove.value = { ticketId, statusId };
moveForm.ticket_status_id = statusId;
moveForm.clearErrors();
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
errorBag: `board-ticket-${ticketId}`,
preserveScroll: true,
onSuccess: async () => {
const destination = props.boardColumns.find((column) =>
column.ticket_statuses?.some(
(status) => status.id === statusId,
),
);
if (destination) {
selectedColumnId.value = destination.id;
}
await nextTick();
document
.getElementById(`ticket-status-${ticketId}`)
?.focus({ preventScroll: true });
},
});
}
function dropTicket(ticket: TicketModel, columnId: number): void {
const current = findTicket(ticket.id);
if (!current || busy.value || !props.canEditTickets) {
return;
}
const targets = targetsForColumn(current, columnId);
if (targets.length === 1) {
submitMove(current.id, targets[0].id);
} else if (targets.length > 1) {
moveForm.ticket_status_id = targets[0].id;
choice.value = { ticketId: current.id, columnId };
}
}
const {
draggedTicket,
columnId: dropColumnId,
x: dragX,
y: dragY,
start: startDrag,
cancel: cancelDrag,
} = useBoardDrag({
canStart: (ticket) =>
props.canEditTickets &&
!busy.value &&
!choice.value &&
allowedTargetStatuses(ticket).length > 0,
drop: dropTicket,
});
const canDrop = (columnId: number) =>
!!draggedTicket.value &&
targetsForColumn(draggedTicket.value, columnId).length > 0;
const dropClasses = (columnId: number) =>
!draggedTicket.value
? {}
: {
'border-primary border-dashed': canDrop(columnId),
'ring-2 ring-primary bg-primary/10':
canDrop(columnId) && dropColumnId.value === columnId,
'opacity-40': !canDrop(columnId),
};
function confirmMove(): void {
if (
!choice.value ||
!choiceTargets.value.some(
(status) => status.id === moveForm.ticket_status_id,
)
) {
return;
}
const ticketId = choice.value.ticketId;
choice.value = null;
submitMove(ticketId, moveForm.ticket_status_id);
}
function focusStatus(ticketId: number): void {
document.getElementById(`ticket-status-${ticketId}`)?.focus();
}
watch(
() => [props.project.id, props.boardColumns, props.canEditTickets],
() => {
cancelDrag();
choice.value = null;
},
);
</script> </script>
<template> <template>
@@ -130,6 +275,13 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
</header> </header>
<FeedbackState v-if="status" state="success" :title="status" /> <FeedbackState v-if="status" state="success" :title="status" />
<FormFeedback
:state="feedback.state.value"
:description="moveForm.errors.ticket_status_id"
@retry="
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
"
/>
<FeedbackState <FeedbackState
v-if="columns.length === 0" v-if="columns.length === 0"
state="empty" state="empty"
@@ -168,10 +320,18 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
> >
{{ t('board.scrollHint') }} {{ t('board.scrollHint') }}
</p> </p>
<p
v-if="canEditTickets"
id="board-drag-hint"
class="text-caption text-muted-foreground"
>
{{ t('board.dragHint') }}
</p>
<section <section
:aria-label="t('board.columns')" :aria-label="t('board.columns')"
aria-describedby="board-scroll-hint" aria-describedby="board-scroll-hint"
tabindex="0" tabindex="0"
data-board-drag-scroll
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain" class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
> >
<section <section
@@ -179,9 +339,11 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
:id="`board-column-${column.id}`" :id="`board-column-${column.id}`"
:key="`${project.id}-${column.id}`" :key="`${project.id}-${column.id}`"
:aria-labelledby="`board-column-title-${column.id}`" :aria-labelledby="`board-column-title-${column.id}`"
:data-board-drop-column="column.id"
:class="[ :class="[
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80', 'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
selectedColumnId === column.id ? 'flex' : 'hidden', selectedColumnId === column.id ? 'flex' : 'hidden',
dropClasses(column.id),
]" ]"
> >
<header <header
@@ -250,6 +412,10 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
v-for="ticket in group.tickets" v-for="ticket in group.tickets"
:key="ticket.id" :key="ticket.id"
class="min-w-0" class="min-w-0"
:class="{
'opacity-50':
draggedTicket?.id === ticket.id,
}"
> >
<TicketCard <TicketCard
:project-id="project.id" :project-id="project.id"
@@ -260,6 +426,36 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
v-if="canEditTickets" v-if="canEditTickets"
#actions #actions
> >
<Button
type="button"
variant="ghost"
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
:disabled="
busy ||
allowedTargetStatuses(
ticket,
).length === 0
"
:aria-label="
t('board.dragTicket', {
key: ticket.ticket_key,
})
"
aria-describedby="board-drag-hint"
@pointerdown="
startDrag($event, ticket)
"
@click.prevent
@keydown.enter.prevent="
focusStatus(ticket.id)
"
@keydown.space.prevent="
focusStatus(ticket.id)
"
><GripVertical
aria-hidden="true"
/>{{ t('board.drag') }}</Button
>
<Form <Form
v-bind=" v-bind="
move.form({ move.form({
@@ -272,6 +468,12 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
:options="{ :options="{
preserveScroll: true, preserveScroll: true,
}" }"
:on-before="
() =>
!busy && !draggedTicket
"
@start="menuPending = ticket.id"
@finish="menuPending = null"
class="grid min-w-0 gap-2" class="grid min-w-0 gap-2"
> >
<input <input
@@ -292,6 +494,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
:id="`ticket-status-${ticket.id}`" :id="`ticket-status-${ticket.id}`"
name="ticket_status_id" name="ticket_status_id"
:disabled=" :disabled="
busy ||
processing || processing ||
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
@@ -345,6 +548,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
type="submit" type="submit"
variant="outline" variant="outline"
:disabled=" :disabled="
busy ||
processing || processing ||
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
@@ -381,6 +585,94 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
</section> </section>
</section> </section>
</template> </template>
<div
v-if="draggedTicket"
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
data-board-mobile-targets
>
<p class="text-caption mb-2 font-semibold">
{{ t('board.dropHint') }}
</p>
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
<div
v-for="column in columns"
:key="column.id"
:data-board-drop-column="column.id"
:class="dropClasses(column.id)"
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
>
{{ column.name }}
</div>
</div>
</div>
<div
v-if="draggedTicket"
aria-hidden="true"
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
:style="{
left: `${Math.max(0, dragX - 80)}px`,
top: `${Math.max(0, dragY - 56)}px`,
}"
>
{{ draggedTicket.ticket_key }}
</div>
<p class="sr-only" aria-live="polite">
{{
draggedTicket
? t('board.dragging', { key: draggedTicket.ticket_key })
: ''
}}
</p>
<Dialog v-model:open="choiceOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
<DialogDescription>{{
t('board.chooseStatusDescription', {
key: choiceTicket?.ticket_key ?? '',
})
}}</DialogDescription>
</DialogHeader>
<form class="grid gap-4" @submit.prevent="confirmMove">
<label
for="board-drop-status"
class="text-body font-semibold"
>{{ t('board.targetStatus') }}</label
>
<select
id="board-drop-status"
v-model="moveForm.ticket_status_id"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option
v-for="target in choiceTargets"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="choiceOpen = false"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="busy || choiceTargets.length === 0"
>{{ t('board.updateStatus') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
File diff suppressed because it is too large Load Diff
+120 -320
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue'; import { useSessionStorage } from '@vueuse/core';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -15,32 +16,30 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
buildAcceptanceCriteria, import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings'; import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>(); const props = defineProps<ProjectTicketCreatePageProps>();
const { t } = useLocalization();
const returnHref = const returnHref =
props.source === 'board' props.source === 'board'
? boardIndex(props.project.id) ? boardIndex(props.project.id)
: backlogIndex(props.project.id); : backlogIndex(props.project.id);
const returnLabel = const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog'; props.source === 'board'
? t('ticketForm.backToBoard')
: t('ticketForm.backToBacklog');
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Projects', title: t('navigation.projects'),
href: index(), href: index(),
}, },
{ {
@@ -48,36 +47,31 @@ const breadcrumbs: BreadcrumbItem[] = [
href: show(props.project.id), href: show(props.project.id),
}, },
{ {
title: props.source === 'board' ? 'Board' : 'Backlog', title:
props.source === 'board'
? t('navigation.board')
: t('navigation.backlog'),
href: returnHref, href: returnHref,
}, },
{ {
title: 'Create ticket', title: t('ticketForm.create'),
href: ProjectTicketController.create({ href: ProjectTicketController.create({
project: props.project.id, project: props.project.id,
}), }),
}, },
]; ];
const hasTicketMetadata = const hasTicketMetadata = computed(
props.ticketTypes.length > 0 && () =>
props.ticketPriorities.length > 0 && props.ticketTypes.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0,
);
const draftKey = `projects:${props.project.id}:tickets:create`; const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember( const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
{ checklist: '' }, const form = useForm(`${draftKey}:${draftGeneration.value}`, {
`${draftKey}:checklist`, acceptance_criteria: [] as AcceptanceCriteriaModel,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '', assignee_user_id: '',
description: '', description: '',
parent_ticket_id: '', parent_ticket_id: '',
@@ -90,13 +84,16 @@ const form = useForm(draftKey, {
title: '', title: '',
}); });
const protection = useFormProtection(form, { const isDirty = computed(() => form.isDirty);
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '', const protection = useFormProtection(form);
}); const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
const acceptanceCriteriaPreviewItems = computed(() => return;
parseChecklistLines(acceptanceCriteriaChecklist.value), }
); form.reset();
form.clearErrors();
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
@@ -109,39 +106,35 @@ const acceptanceCriteriaError = computed(() => {
}); });
const submit = (): void => { const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria( if (!hasTicketMetadata.value || form.processing) {
acceptanceCriteriaChecklist.value, return;
); }
protection.submit( protection.submit(
ProjectTicketController.store({ project: props.project.id }), ProjectTicketController.store({ project: props.project.id }),
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
// The source page is unmounted before this callback, so its storage watcher has stopped.
window.sessionStorage.setItem(
`${draftKey}:generation`,
String(draftGeneration.value + 1),
);
draftGeneration.value++;
form.reset();
},
}, },
); );
}; };
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" /> <Head :title="t('ticketForm.pageTitle', { project: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
>
<div <div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
@@ -149,10 +142,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Badge variant="outline" class="px-3 py-1"> <Badge variant="outline" class="px-3 py-1">
{{ project.key }} {{ project.key }}
</Badge> </Badge>
<Heading <h1 class="text-headline font-semibold tracking-tight">
title="Create ticket" {{ t('ticketForm.create') }}
:description="`Add a ticket to ${project.name}.`" </h1>
/> <p class="text-muted-foreground text-sm">
{{
t('ticketForm.createDescription', {
project: project.name,
})
}}
</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
@@ -160,7 +159,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Link :href="returnHref">{{ returnLabel }}</Link> <Link :href="returnHref">{{ returnLabel }}</Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link> <Link :href="show(project.id)">{{
t('ticketDetail.backToProject')
}}</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -174,9 +175,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-if="hasTicketMetadata"> <Card v-if="hasTicketMetadata">
<CardHeader> <CardHeader>
<CardTitle>Ticket details</CardTitle> <CardTitle>{{ t('ticketForm.details') }}</CardTitle>
<CardDescription> <CardDescription>
The ticket key is assigned after creation. {{ t('ticketForm.keyAssigned') }}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -186,258 +187,58 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:state="protection.state.value" :state="protection.state.value"
@retry="submit" @retry="submit"
/> />
<div class="grid gap-2"> <TicketFormFields
<Label for="title">Title</Label> v-model:title="form.title"
<Input v-model:description="form.description"
id="title" v-model:type="form.ticket_type_id"
v-model="form.title" v-model:priority="form.ticket_priority_id"
:invalid="Boolean(form.errors.title)" v-model:status="form.ticket_status_id"
aria-describedby="title-error" v-model:assignee="form.assignee_user_id"
required v-model:parent="form.parent_ticket_id"
autofocus v-model:points="form.story_points"
:disabled="form.processing" :project="project"
placeholder="Implement repository sync" :ticket-types="ticketTypes"
/> :ticket-priorities="ticketPriorities"
<InputError :ticket-status-options="ticketStatusOptions"
id="title-error" :users="users"
:message="form.errors.title" :parent-ticket-options="parentTicketOptions"
/> :errors="form.errors"
</div> :disabled="form.processing"
autofocus
<div
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
> >
<div class="grid gap-6"> <template #content>
<div class="grid gap-2"> <AcceptanceCriteriaChecklist
<Label for="description">Description</Label> v-model="form.acceptance_criteria"
<textarea :disabled="form.processing"
id="description" :error="acceptanceCriteriaError"
v-model="form.description" />
rows="4" </template>
:disabled="form.processing" </TicketFormFields>
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" <div
placeholder="Describe the expected behavior and relevant context." class="flex flex-wrap items-center gap-3 border-t pt-5"
/> >
<InputError <Button type="submit" :disabled="form.processing">{{
:message="form.errors.description" form.processing
/> ? t('ticketForm.creating')
</div> : t('ticketForm.create')
}}</Button>
<div class="grid gap-4"> <Button
<div class="grid gap-2"> type="button"
<Label variant="outline"
for="acceptance_criteria_checklist" :disabled="form.processing || !isDirty"
> @click="reset"
Acceptance criteria checklist >{{ t('ticketForm.reset') }}</Button
</Label> >
<textarea <p
id="acceptance_criteria_checklist" role="status"
v-model=" class="text-muted-foreground text-sm"
acceptanceCriteriaChecklist >
" {{
rows="6" isDirty
:disabled="form.processing" ? t('ticketForm.unsaved')
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" : t('ticketForm.ready')
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials" }}
/> </p>
<p
class="text-muted-foreground text-xs"
>
Use <code>[ ]</code> /
<code>[x]</code> for checklist
items. Indent with two spaces for
nested steps.
</p>
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Checklist preview
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<span class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</div>
</div>
<div class="grid h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
v-model="form.ticket_type_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="String(ticketType.id)"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="String(ticketPriority.id)"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
v-model="form.ticket_status_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="String(user.id)"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="
String(parentTicketOption.id)
"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
v-model="form.story_points"
type="number"
step="1"
:disabled="form.processing"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
@@ -446,13 +247,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-else> <Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm"> <CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Ticket creation needs at least one type, one priority, {{ t('ticketForm.metadataRequired') }}
and one status.
</p> </p>
<div> <div>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="metadataSettings(project.id)"> <Link :href="metadataSettings(project.id)">
Open ticket settings {{ t('ticketForm.openSettings') }}
</Link> </Link>
</Button> </Button>
</div> </div>
+175 -640
View File
@@ -1,674 +1,209 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController'; import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue'; import FeedbackState from '@/components/FeedbackState.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardHeader } from '@/components/ui/card';
Card, import { useLocalization } from '@/composables/useLocalization';
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index'; import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { import { index, show } from '@/routes/projects';
board as boardSettings, import { board } from '@/routes/projects/settings';
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<ProjectBoardSettingsPageProps>(); const props = defineProps<ProjectBoardSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const target = ref<WorkflowEditorTarget | null>(null);
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'Projects', { title: t('navigation.projects'), href: index() },
href: index(), { title: props.project.name, href: show(props.project.id) },
}, { title: t('workflow.title'), href: board(props.project.id) },
{ ]);
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board settings',
href: boardSettings(props.project.id),
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const transitionTargetOptions = (
sourceStatus: TicketStatusModel,
): TicketStatusModel[] => {
return props.ticketStatuses.filter(
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
);
};
const statusAllowsTarget = (
sourceStatus: TicketStatusModel,
targetStatus: TicketStatusModel,
): boolean => {
return (
sourceStatus.allowed_target_statuses?.some(
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
) ?? false
);
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board settings`" /> <Head :title="t('workflow.pageTitle', { project: project.name })" />
<SettingsLayout :project-name="project.name" :project-key="project.key">
<div class="flex flex-1 flex-col gap-6 p-4"> <header class="grid gap-3">
<div <h2 class="text-title">{{ t('workflow.title') }}</h2>
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" <p class="text-muted-foreground">
> {{ t('workflow.description') }}
<div class="space-y-3"> </p>
<Badge variant="outline" class="px-3 py-1"> </header>
{{ project.key }} <FeedbackState
</Badge>
<Heading
:title="`${project.name} board settings`"
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status" 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" state="success"
> :title="t('workflow.updated')"
{{ status }} />
</div>
<TemplateInheritancePanel <TemplateInheritancePanel
:project-id="project.id"
:template-state="templateState"
:areas="[ :areas="[
'board_columns', 'board_columns',
'ticket_statuses', 'ticket_statuses',
'status_transitions', 'status_transitions',
]" ]"
:project-id="project.id"
:template-state="templateState"
/> />
<Card
<Card> class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
<CardHeader> >
<CardTitle>Workflow model</CardTitle> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<CardDescription> <div class="flex flex-wrap items-center gap-2">
Backlog is always the intake queue; board columns group <Inbox
the statuses that appear on the board. class="text-primary size-5 shrink-0"
</CardDescription> aria-hidden="true"
</CardHeader> />
<h3 class="font-semibold">
<CardContent class="flex flex-wrap gap-3"> {{ t('workflow.backlog') }}
<Badge class="px-3 py-1">Backlog</Badge> </h3>
<Badge <Badge variant="secondary">{{
v-for="ticketStatus in backlogTicketStatuses" t('workflow.offBoard')
:key="ticketStatus.id" }}</Badge>
variant="outline" </div>
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
Tickets move between statuses; backlog statuses are not {{ t('workflow.backlogDescription') }}
included in a board column.
</p> </p>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Status moves</CardTitle>
<CardDescription>
Choose exactly where each status is allowed to move
next.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="p-5 sm:p-6"
<CardContent class="p-0"> ><WorkflowStatusList
<div class="overflow-x-auto"> :column="null"
<table :statuses="backlogTicketStatuses"
class="divide-border min-w-full divide-y text-sm" @edit="target = $event"
> /></CardContent>
<thead </Card>
class="bg-muted/40 text-muted-foreground text-left" <section
> class="grid min-w-0 gap-4"
<tr> aria-labelledby="workflow-columns"
<th class="px-4 py-3 font-medium"> >
Source status <header
</th> class="flex flex-wrap items-center justify-between gap-3"
<th class="px-4 py-3 font-medium"> >
Allowed move targets <div class="flex items-center gap-2">
</th> <Columns3
<th class="px-4 py-3 font-medium">Save</th> class="text-primary size-5"
</tr> aria-hidden="true"
</thead> />
<tbody class="divide-border divide-y"> <h3 id="workflow-columns" class="font-semibold">
<tr {{ t('workflow.columns') }}
v-for="sourceStatus in ticketStatuses" </h3>
:key="sourceStatus.id" <Badge variant="secondary">{{
class="bg-background align-top" boardColumns.length
> }}</Badge>
<td class="px-4 py-4">
<Badge variant="outline">
{{
ticketStatusLabel(sourceStatus)
}}
</Badge>
</td>
<td class="px-4 py-4">
<Form
:id="`status-transitions-${sourceStatus.id}`"
v-bind="
ProjectBoardSettingsController.updateStatusTransitions.form(
[
project.id,
sourceStatus.id,
],
)
"
class="grid gap-3"
v-slot="{ errors }"
>
<div
v-if="
transitionTargetOptions(
sourceStatus,
).length === 0
"
class="text-muted-foreground"
>
No other statuses exist yet.
</div>
<div
v-else
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
>
<label
v-for="targetStatus in transitionTargetOptions(
sourceStatus,
)"
:key="targetStatus.id"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="targetStatus.id"
:checked="
statusAllowsTarget(
sourceStatus,
targetStatus,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
ticketStatusLabel(
targetStatus,
)
}}</span>
</label>
</div>
<InputError
:message="
errors.allowed_ticket_status_ids
"
/>
</Form>
</td>
<td class="px-4 py-4">
<Button
type="submit"
variant="outline"
:form="`status-transitions-${sourceStatus.id}`"
>
Save
</Button>
</td>
</tr>
</tbody>
</table>
</div> </div>
</CardContent> <Button
</Card> type="button"
class="w-full sm:w-auto"
<Card> @click="target = { kind: 'column', action: 'create' }"
<CardHeader> ><Plus class="size-4" aria-hidden="true" />{{
<CardTitle>Backlog statuses</CardTitle> t('workflow.addColumn')
<CardDescription> }}</Button
New backlog tickets choose one of these statuses.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
class="border-border rounded-lg border p-4"
> >
<Form </header>
v-bind=" <p class="text-muted-foreground text-sm">
ProjectBoardSettingsController.updateStatus.form( {{ t('workflow.columnsDescription') }}
[project.id, ticketStatus.id], </p>
) <FeedbackState
" v-if="boardColumns.length === 0"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" state="empty"
v-slot="{ errors, processing }" :title="t('workflow.emptyColumns')"
:description="t('workflow.emptyColumnsDescription')"
/>
<ol
v-else
class="grid min-w-0 gap-4"
:aria-label="t('workflow.columns')"
>
<li
v-for="column in boardColumns"
:key="column.id"
class="bg-card min-w-0 overflow-hidden rounded-xl border"
:data-column-id="column.id"
>
<header
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
> >
<div class="grid gap-2"> <span
<Label class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:for="`backlog-status-${ticketStatus.id}`" :aria-label="
> t('workflow.positionValue', {
Status name position: column.position,
</Label> })
<Input
:id="`backlog-status-${ticketStatus.id}`"
name="name"
:default-value="ticketStatus.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[project.id, ticketStatus.id],
)
" "
v-slot="{ errors, processing }" >{{ column.position }}</span
> >
<InputError <h4
:message="errors.ticket_status_id" class="min-w-0 flex-1 basis-24 break-words font-semibold"
class="mb-2" >
/> {{ column.name }}
</h4>
<div class="flex gap-1">
<Button <Button
type="submit" type="button"
variant="destructive" variant="ghost"
:disabled=" size="icon"
processing || :aria-label="
backlogTicketStatuses.length <= 1 t('workflow.changeColumn', {
name: column.name,
})
" "
> @click="
Delete target = {
</Button> kind: 'column',
</Form> action: 'edit',
</div> column,
</div> }
"
<Form ><Pencil class="size-4" aria-hidden="true"
v-bind=" /></Button>
ProjectBoardSettingsController.storeStatus.form( <Button
project.id, type="button"
) variant="ghost"
" size="icon"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" class="text-destructive"
v-slot="{ errors, processing }" :aria-label="
> t('workflow.removeColumn', {
<div class="grid gap-2"> name: column.name,
<Label for="backlog-status-name"> })
New backlog status "
</Label> @click="
<Input target = {
id="backlog-status-name" kind: 'column',
name="name" action: 'delete',
placeholder="Ready" column,
required }
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</header>
<div class="p-4 sm:p-5">
<WorkflowStatusList
:column="column"
:statuses="column.ticket_statuses ?? []"
@edit="target = $event"
/> />
<InputError :message="errors.name" />
</div> </div>
</li>
<div class="flex items-end"> </ol>
<Button type="submit" :disabled="processing"> </section>
Add status <WorkflowTransitionMatrix
</Button> :project-id="project.id"
</div> :statuses="ticketStatuses"
</Form> />
</CardContent> <WorkflowEditorDialog
</Card> :project-id="project.id"
:column-count="boardColumns.length"
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]"> :target="target"
<Card> @close="target = null"
<CardHeader> />
<CardTitle>Add column</CardTitle> </SettingsLayout>
<CardDescription>
New columns extend the project-specific workflow and
are inserted at the end unless you specify a
position later.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="
ProjectBoardSettingsController.store.form(
project.id,
)
"
class="space-y-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Column name</Label>
<Input
id="name"
name="name"
required
autofocus
placeholder="In Review"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add column
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Board columns</CardTitle>
<CardDescription>
Rename columns or change the numeric position to
reorder the board from left to right.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="boardColumns.length === 0"
class="text-muted-foreground text-sm"
>
No board columns exist yet.
</div>
<div
v-for="column in boardColumns"
:key="column.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectBoardSettingsController.update.form([
project.id,
column.id,
])
"
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label :for="`column-name-${column.id}`">
Name
</Label>
<Input
:id="`column-name-${column.id}`"
name="name"
:default-value="column.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`column-position-${column.id}`"
>
Position
</Label>
<Input
:id="`column-position-${column.id}`"
name="position"
type="number"
min="1"
:max="boardColumns.length"
:default-value="column.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroy.form(
[project.id, column.id],
)
"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Delete
</Button>
</Form>
</div>
<div class="border-border mt-4 border-t pt-4">
<div class="mb-3">
<p class="text-sm font-medium">
Ticket statuses
</p>
<p class="text-muted-foreground text-sm">
Moving into this column requires a
specific status.
</p>
</div>
<div class="space-y-3">
<div
v-for="ticketStatus in column.ticket_statuses ??
[]"
:key="ticketStatus.id"
class="border-border rounded-lg border p-3"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`column-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`column-status-${ticketStatus.id}`"
name="name"
:default-value="
ticketStatus.name
"
required
/>
<InputError
:message="errors.name"
/>
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="
errors.ticket_status_id
"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
(column.ticket_statuses
?.length ?? 0) <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<input
type="hidden"
name="board_column_id"
:value="column.id"
/>
<div class="grid gap-2">
<Label
:for="`new-column-status-${column.id}`"
>
New status
</Label>
<Input
:id="`new-column-status-${column.id}`"
name="name"
placeholder="Ready for QA"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
:disabled="processing"
>
Add status
</Button>
</div>
</Form>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout> </AppLayout>
</template> </template>
+393 -311
View File
@@ -1,281 +1,316 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController'; import { Layers3, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed } from 'vue';
import {
destroy,
switchTemplate,
} from '@/actions/App/Http/Controllers/ProjectController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardHeader } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { import { general } from '@/routes/projects/settings';
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types'; import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps<ProjectGeneralSettingsPageProps>(); const props = defineProps<ProjectGeneralSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', { title: props.project.name, href: show(props.project.id) },
href: index(), { title: t('navigation.general'), href: general(props.project.id) },
}, ]);
{ const switchTemplateForm = useForm<{
title: props.project.name, confirmation_key: string;
href: show(props.project.id), source_template_id: number | undefined;
}, }>({
{
title: 'General settings',
href: generalSettings(props.project.id),
},
];
const switchTemplateForm = useForm({
confirmation_key: '', confirmation_key: '',
source_template_id: props.sourceTemplates.find( source_template_id: undefined,
(template) => template.id !== props.project.project_template_id,
)?.id,
}); });
const deleteForm = useForm({ confirmation_key: '' });
const deleteForm = useForm({ const switchProtection = useFormProtection(switchTemplateForm);
confirmation_key: '', const deleteProtection = useFormProtection(deleteForm);
}); const processing = computed(
() => switchTemplateForm.processing || deleteForm.processing,
);
const availableSwitchTemplates = computed(() => const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter( props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id, (template) => template.id !== props.project.project_template_id,
), ),
); );
const currentTemplate = computed(() => props.project.project_template ?? null); const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed( const canSwitchTemplate = computed(
() => () =>
isSwitchConfirmationValid.value && switchTemplateForm.confirmation_key === props.project.key &&
switchTemplateForm.source_template_id !== undefined, availableSwitchTemplates.value.some(
(template) => template.id === switchTemplateForm.source_template_id,
),
); );
const isDeleteConfirmationValid = computed( const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key, () => deleteForm.confirmation_key === props.project.key,
); );
const inheritanceAreas = computed(() =>
[
...(props.templateState?.inheritedAreas ?? []),
...(props.templateState?.overriddenAreas ?? []),
].map((area) => area.value),
);
const settingsSource = computed(() => {
if (!props.templateState?.inheritsFromTemplate) {
return 'projectSettings.local';
}
if (
props.templateState.inheritedAreas.some(
(area) => area.value === 'project_settings',
)
) {
return 'projectSettings.inherited';
}
return props.templateState.overriddenAreas.some(
(area) => area.value === 'project_settings',
)
? 'projectSettings.override'
: 'projectSettings.local';
});
const submitSwitchTemplate = (): void => { function submitSwitchTemplate(): void {
if (!canSwitchTemplate.value) { if (!canSwitchTemplate.value || processing.value) {
return; return;
} }
switchProtection.submit(switchTemplate(props.project.id), {
preserveScroll: true,
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
});
}
switchTemplateForm.post( function submitDelete(): void {
ProjectController.switchTemplate.url(props.project.id), if (!isDeleteConfirmationValid.value || processing.value) {
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) {
return; return;
} }
deleteProtection.submit(destroy(props.project.id), {
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; }
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} general settings`" /> <Head
:title="t('projectSettings.pageTitle', { project: project.name })"
<div class="flex flex-1 flex-col gap-6 p-4"> />
<div <SettingsLayout :project-name="project.name" :project-key="project.key">
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" <FeedbackState
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} general settings`"
description="Review the core project identity and manage irreversible administrative actions."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status" 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" state="success"
:title="t('projectSettings.updated')"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
> >
{{ status }} <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
</div> <h2 class="text-title">{{ t('navigation.general') }}</h2>
<p class="text-muted-foreground">
<Card> {{ t('projectSettings.generalDescription') }}
<CardHeader> </p>
<CardTitle>Project identity</CardTitle>
<CardDescription>
These values define the project across tickets, boards,
and repository matching.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<CardContent class="grid gap-4 md:grid-cols-2"> <div class="flex flex-wrap items-center gap-2">
<div class="border-border/70 rounded-xl border p-4"> <h3 class="font-semibold">
<p {{ t('projectSettings.identity') }}
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]" </h3>
> <Badge variant="secondary">{{
Project name t('projectSettings.local')
</p> }}</Badge>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.name }}
</p>
</div> </div>
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4"> <div
<p class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]" >
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.name') }}
</dt>
<dd class="break-words font-semibold">
{{ project.name }}
</dd>
</div>
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.key') }}
</dt>
<dd class="break-all font-semibold">
{{ project.key }}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.identityDescription') }}
</p>
<div
class="flex flex-wrap items-center gap-2 border-t pt-4"
>
<h3 class="font-semibold">
{{ t('projectSettings.display') }}
</h3>
<Badge
:variant="
settingsSource === 'projectSettings.inherited'
? 'outline'
: 'secondary'
"
>{{ t(settingsSource) }}</Badge
> >
Project key
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.key }}
</p>
</div> </div>
<dl class="grid gap-3">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showAuthor') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_author
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showStoryPoints') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_story_points
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.displayDescription') }}
</p>
</CardContent> </CardContent>
</Card> </Card>
<TemplateInheritancePanel
<Card> :project-id="project.id"
<CardHeader> :areas="inheritanceAreas"
<CardTitle>Template baseline</CardTitle> :template-state="templateState"
<CardDescription> />
Switch this project to another published template. Use <Card
the inheritance panels to reset individual settings to class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
the currently linked template. >
</CardDescription> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
</CardHeader> <div class="flex items-center gap-2">
<Layers3
<CardContent class="space-y-6"> class="text-primary size-5"
<div class="grid gap-4 md:grid-cols-2"> aria-hidden="true"
<div class="border-border/70 rounded-xl border p-4"> />
<p <h2 class="text-title">
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]" {{ t('projectSettings.template') }}
> </h2>
Current linked template
</p>
<p
class="text-foreground mt-2 text-sm font-semibold"
>
{{
currentTemplate?.name ??
'No linked template'
}}
</p>
<p class="text-muted-foreground mt-2 text-sm">
{{
templateState?.inheritsFromTemplate
? 'Inherited and overridden areas stay visible after switching.'
: 'This project currently behaves like a standalone baseline.'
}}
</p>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Warning
</p>
<p class="text-foreground mt-2 text-sm font-medium">
Switching templates can replace inherited board
workflow, ticket metadata, repositories, and
notifications.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key
to avoid accidental overwrites.
</p>
</div>
</div> </div>
<p class="text-muted-foreground">
<div class="grid gap-6"> {{ t('projectSettings.templateDescription') }}
<form </p>
class="space-y-4 rounded-xl border p-4" </CardHeader>
@submit.prevent="submitSwitchTemplate" <CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.currentTemplate') }}
</p>
<p class="break-words font-semibold">
{{
currentTemplate?.name ??
t('projectSettings.noTemplate')
}}
</p>
<Badge variant="outline" class="justify-self-start">{{
t(
templateState?.inheritsFromTemplate
? 'projectSettings.linked'
: 'projectSettings.standalone',
)
}}</Badge>
<p
v-if="!templateState?.inheritsFromTemplate"
class="text-caption text-muted-foreground"
> >
<div class="space-y-1"> {{ t('projectSettings.standaloneDescription') }}
<h3 class="text-sm font-semibold"> </p>
Switch to another template </div>
</h3> <FeedbackState
<p class="text-muted-foreground text-sm"> v-if="availableSwitchTemplates.length === 0"
Applying a different template replaces the state="empty"
copied areas with that template's current :title="t('projectSettings.noAlternatives')"
baseline. :description="
</p> t('projectSettings.noAlternativesDescription')
</div> "
/>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="switchTemplateForm.processing"
@submit.prevent="submitSwitchTemplate"
>
<FormFeedback
:state="switchProtection.state.value"
:title="
switchTemplateForm.processing
? t('projectSettings.switching')
: undefined
"
@retry="submitSwitchTemplate"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="mb-3 font-semibold">
{{ t('projectSettings.switch') }}
</legend>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
>
{{ t('projectSettings.switchWarning') }}
</p>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="source_template_id" <Label for="source_template_id">{{
>Published template</Label t('projectSettings.publishedTemplate')
> }}</Label>
<select <select
id="source_template_id" id="source_template_id"
v-model=" v-model="
switchTemplateForm.source_template_id switchTemplateForm.source_template_id
" "
:disabled=" 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"
switchTemplateForm.processing || :aria-invalid="
availableSwitchTemplates.length === 0 !!switchTemplateForm.errors
.source_template_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 transition-[color,box-shadow] focus-visible:ring-[3px]" aria-describedby="source-template-error"
> >
<option :value="undefined" disabled> <option :value="undefined" disabled>
Select a template {{
t('projectSettings.selectTemplate')
}}
</option> </option>
<option <option
v-for="template in availableSwitchTemplates" v-for="template in availableSwitchTemplates"
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
</option> </option>
</select> </select>
<InputError <InputError
id="source-template-error"
:message=" :message="
switchTemplateForm.errors switchTemplateForm.errors
.source_template_id .source_template_id
" "
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="switch_confirmation_key" <Label for="switch_confirmation_key">{{
>Confirmation key</Label t('projectSettings.confirmation')
> }}</Label>
<Input <Input
id="switch_confirmation_key" id="switch_confirmation_key"
v-model=" v-model="
switchTemplateForm.confirmation_key switchTemplateForm.confirmation_key
" "
:disabled="switchTemplateForm.processing" autocomplete="off"
placeholder="Type the project key exactly" :placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!switchTemplateForm.errors
.confirmation_key
"
aria-describedby="switch-confirm-help switch-confirm-error"
/> />
<p
id="switch-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError <InputError
id="switch-confirm-error"
:message=" :message="
switchTemplateForm.errors switchTemplateForm.errors
.confirmation_key .confirmation_key
" "
/> />
</div> </div>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
Existing project-specific configuration in
copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="
switchTemplateForm.processing ||
!canSwitchTemplate
"
>
Switch template
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
<Card
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
>
<CardHeader>
<CardTitle class="text-red-900 dark:text-red-100">
Danger zone
</CardTitle>
<CardDescription
class="text-red-800/80 dark:text-red-200/80"
>
Deleting this project is permanent. All tickets, board
columns, ticket metadata, repository connections, and
project permissions will be removed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
>
<p class="text-foreground text-sm font-medium">
Confirm by typing the exact project key:
<span class="font-semibold">{{ project.key }}</span>
</p>
</div>
<form class="space-y-4" @submit.prevent="submitDelete">
<div class="grid gap-2">
<Label for="confirmation_key">
Confirmation key
</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
:disabled="deleteForm.processing"
placeholder="Type the project key exactly"
/>
<InputError
:message="deleteForm.errors.confirmation_key"
/>
</div>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
This action cannot be undone.
</p>
<Button <Button
type="submit" type="submit"
variant="destructive" variant="outline"
:disabled=" class="justify-self-start"
deleteForm.processing || :disabled="!canSwitchTemplate"
!isDeleteConfirmationValid >{{
" t(
switchTemplateForm.processing
? 'projectSettings.switching'
: 'projectSettings.switch',
)
}}</Button
> >
Delete project </fieldset>
</Button>
</div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div> <Card
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
>
<div class="flex items-center gap-2">
<Trash2
class="text-destructive size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.danger') }}
</h2>
</div>
<p class="text-muted-foreground">
{{ t('projectSettings.deleteDescription') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid min-w-0"
:aria-busy="deleteForm.processing"
@submit.prevent="submitDelete"
>
<FormFeedback
:state="deleteProtection.state.value"
:title="
deleteForm.processing
? t('projectSettings.deleting')
: undefined
"
@retry="submitDelete"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="sr-only">
{{ t('projectSettings.delete') }}
</legend>
<p class="text-caption font-medium">
{{ t('projectSettings.irreversible') }}
</p>
<div class="grid gap-2">
<Label for="confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
autocomplete="off"
:placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!deleteForm.errors.confirmation_key
"
aria-describedby="delete-confirm-help delete-confirm-error"
/>
<p
id="delete-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError
id="delete-confirm-error"
:message="
deleteForm.errors.confirmation_key
"
/>
</div>
<Button
type="submit"
variant="destructive"
class="justify-self-start"
:disabled="!isDeleteConfirmationValid"
>{{
t(
deleteForm.processing
? 'projectSettings.deleting'
: 'projectSettings.delete',
)
}}</Button
>
</fieldset>
</form>
</CardContent>
</Card>
</SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+60 -773
View File
@@ -1,121 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController'; import { computed, ref } from 'vue';
import Heading from '@/components/Heading.vue'; import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue';
import MetadataOptionList from '@/components/projects/MetadataOptionList.vue';
import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue';
import TicketFieldDefinitionSettings from '@/components/projects/TicketFieldDefinitionSettings.vue';
import TicketLinkTypeSettings from '@/components/projects/TicketLinkTypeSettings.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge'; import { useLocalization } from '@/composables/useLocalization';
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 AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index'; import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { import { index, show } from '@/routes/projects';
board as boardSettings, import { metadata } from '@/routes/projects/settings';
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { import type {
BreadcrumbItem, BreadcrumbItem,
ProjectTicketMetadataSettingsPageProps, ProjectTicketMetadataSettingsPageProps,
} from '@/types'; } from '@/types';
import { Checkbox } from '@/components/ui/checkbox'; import type { MetadataEditorTarget } from '@/types/project-metadata';
const props = defineProps<ProjectTicketMetadataSettingsPageProps>(); const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const target = ref<MetadataEditorTarget | null>(null);
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'Projects', { title: t('navigation.projects'), href: index() },
href: index(), { title: props.project.name, href: show(props.project.id) },
}, { title: t('metadata.title'), href: metadata(props.project.id) },
{ ]);
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Ticket settings',
href: metadataSettings(props.project.id),
},
];
const replacementTicketTypes = (ticketTypeId: number) =>
props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} ticket settings`" /> <Head :title="t('metadata.pageTitle', { project: project.name })" />
<SettingsLayout :project-name="project.name" :project-key="project.key">
<div class="flex flex-1 flex-col gap-6 p-4"> <header class="grid gap-3">
<div <h2 class="text-title">{{ t('metadata.title') }}</h2>
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" <p class="text-muted-foreground">
> {{ t('metadata.description') }}
<div class="space-y-3"> </p>
<Badge variant="outline" class="px-3 py-1"> </header>
{{ project.key }} <FeedbackState
</Badge>
<Heading
:title="`${project.name} ticket settings`"
description="Configure project-specific ticket types, priorities, custom field definitions, and ticket link labels."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status" v-if="status"
:class="[ :state="error ? 'error' : 'success'"
'rounded-xl border px-4 py-3 text-sm', :title="error ? status : t('metadata.updated')"
error />
? 'border-red-200/80 bg-red-50/60 text-red-800/80 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-200/80'
: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100',
]"
>
{{ status }}
</div>
<TemplateInheritancePanel <TemplateInheritancePanel
:areas="[ :areas="[
'project_settings', 'project_settings',
@@ -127,675 +55,34 @@ const replacementTicketTypes = (ticketTypeId: number) =>
:project-id="project.id" :project-id="project.id"
:template-state="templateState" :template-state="templateState"
/> />
<TicketDisplaySettings :project="project" />
<MetadataOptionList
kind="type"
:entries="ticketTypes"
@edit="target = $event"
/>
<MetadataOptionList
kind="priority"
:entries="ticketPriorities"
@edit="target = $event"
/>
<TicketFieldDefinitionSettings
:definitions="ticketFieldDefinitions"
:field-type-options="fieldTypeOptions"
:project-id="project.id"
/>
<TicketLinkTypeSettings
:link-types="ticketLinkTypes"
:project-id="project.id"
/>
<div class="grid gap-6 xl:grid-cols-2"> <MetadataEditorDialog
<Card class="gap-0 overflow-hidden"> :project-id="project.id"
<CardHeader class="border-b"> :target="target"
<CardTitle>General ticket settings</CardTitle> :types="ticketTypes"
</CardHeader> :priorities="ticketPriorities"
@close="target = null"
<CardContent class="space-y-4 p-6"> />
<Form </SettingsLayout>
v-bind="
ProjectTicketMetadataSettingsController.update.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<Label for="enable-story-points">
<Checkbox
id="enable-story-points"
name="show_story_points"
:default-value="project.show_story_points"
value="1"
/>
Enable story points for tickets
</Label>
<InputError :message="errors.show_story_points" />
<Label for="enable-author">
<Checkbox
id="enable-author"
name="show_author"
:default-value="project.show_author"
value="1"
/>
Show author on ticket details
</Label>
<InputError :message="errors.show_author" />
<Button type="submit" :disabled="processing">
Save
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Ticket types</CardTitle>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeType.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="type-name">New type name</Label>
<Input
id="type-name"
name="name"
required
placeholder="Bug"
/>
<InputError :message="errors.name" />
<Label for="type-is-parent">
<Checkbox
id="type-is-parent"
name="is_parent"
value="1"
/>
Ticket type can act as a parent
</Label>
<InputError :message="errors.is_parent" />
</div>
<Button type="submit" :disabled="processing">
Add type
</Button>
</Form>
<div
v-for="ticketType in ticketTypes"
:key="ticketType.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateType.form(
[project.id, ticketType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<div
v-if="ticketType.is_parent"
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">
Parent type
</Badge>
<span
class="text-muted-foreground text-xs"
>
This flag is fixed after creation.
</span>
</div>
<Label :for="`type-name-${ticketType.id}`">
Name
</Label>
<Input
:id="`type-name-${ticketType.id}`"
name="name"
:default-value="ticketType.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`type-position-${ticketType.id}`"
>
Position
</Label>
<Input
:id="`type-position-${ticketType.id}`"
name="position"
type="number"
min="1"
:max="ticketTypes.length"
:default-value="ticketType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyType.form(
[project.id, ticketType.id],
)
"
class="mt-3 grid gap-3"
v-slot="{ errors, processing: deleting }"
>
<div
v-if="
(ticketType.tickets_count ?? 0) > 0 &&
ticketTypes.length > 1
"
class="grid gap-2"
>
<Label
:for="`replacement-type-${ticketType.id}`"
>
Replacement type for
{{ ticketType.tickets_count }} assigned
ticket{{
ticketType.tickets_count === 1
? ''
: 's'
}}
</Label>
<select
:id="`replacement-type-${ticketType.id}`"
name="replacement_ticket_type_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
required
>
<option value="">Choose type</option>
<option
v-for="replacementTicketType in replacementTicketTypes(
ticketType.id,
)"
:key="replacementTicketType.id"
:value="replacementTicketType.id"
>
{{ replacementTicketType.name }}
</option>
</select>
<InputError
:message="
errors.replacement_ticket_type_id
"
/>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3"
>
<p
v-if="
(ticketType.tickets_count ?? 0) > 0
"
class="text-muted-foreground text-sm"
>
Assigned tickets will be moved before
this type is deleted.
</p>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</div>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Priorities</CardTitle>
<CardDescription>
Priorities are fully project-specific and can be
reordered or removed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storePriority.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="priority-name"
>New priority name</Label
>
<Input
id="priority-name"
name="name"
required
placeholder="Urgent"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add priority
</Button>
</Form>
<div
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updatePriority.form(
[project.id, ticketPriority.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`priority-name-${ticketPriority.id}`"
>
Name
</Label>
<Input
:id="`priority-name-${ticketPriority.id}`"
name="name"
:default-value="ticketPriority.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`priority-position-${ticketPriority.id}`"
>
Position
</Label>
<Input
:id="`priority-position-${ticketPriority.id}`"
name="position"
type="number"
min="1"
:max="ticketPriorities.length"
:default-value="ticketPriority.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyPriority.form(
[project.id, ticketPriority.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Custom fields</CardTitle>
<CardDescription>
Define project-specific user or number fields for
upcoming ticket data.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeFieldDefinition.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="field-label">New field label</Label>
<Input
id="field-label"
name="label"
required
placeholder="Reviewer"
/>
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label for="field-type">Field type</Label>
<select
id="field-type"
name="field_type"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<Button type="submit" :disabled="processing">
Add field
</Button>
</Form>
<div
v-for="fieldDefinition in ticketFieldDefinitions"
:key="fieldDefinition.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`field-label-${fieldDefinition.id}`"
>
Label
</Label>
<Input
:id="`field-label-${fieldDefinition.id}`"
name="label"
:default-value="fieldDefinition.label"
required
/>
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label
:for="`field-type-${fieldDefinition.id}`"
>
Field type
</Label>
<select
:id="`field-type-${fieldDefinition.id}`"
name="field_type"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
:selected="
option.value ===
fieldDefinition.field_type
"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<div class="grid gap-2">
<Label
:for="`field-position-${fieldDefinition.id}`"
>
Position
</Label>
<Input
:id="`field-position-${fieldDefinition.id}`"
name="position"
type="number"
min="1"
:max="ticketFieldDefinitions.length"
:default-value="
fieldDefinition.position
"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Link types</CardTitle>
<CardDescription>
Define project-specific labels for ticket-to-ticket
relationships.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeLinkType.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="link-type-name">
Source label
</Label>
<Input
id="link-type-name"
name="name"
required
placeholder="Blocks"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="link-type-counterpart-name">
Counterpart label
</Label>
<Input
id="link-type-counterpart-name"
name="counterpart_name"
required
placeholder="Blocked by"
/>
<InputError
:message="errors.counterpart_name"
/>
</div>
<Button type="submit" :disabled="processing">
Add link type
</Button>
</Form>
<div
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`link-type-name-${ticketLinkType.id}`"
>
Source label
</Label>
<Input
:id="`link-type-name-${ticketLinkType.id}`"
name="name"
:default-value="ticketLinkType.label"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`link-type-counterpart-name-${ticketLinkType.id}`"
>
Counterpart label
</Label>
<Input
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
name="counterpart_name"
:default-value="
ticketLinkType.counterpart_label
"
required
/>
<InputError
:message="errors.counterpart_name"
/>
</div>
<div class="grid gap-2">
<Label
:for="`link-type-position-${ticketLinkType.id}`"
>
Position
</Label>
<Input
:id="`link-type-position-${ticketLinkType.id}`"
name="position"
type="number"
min="1"
:max="ticketLinkTypes.length"
:default-value="ticketLinkType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout> </AppLayout>
</template> </template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -1,35 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import AppearanceTabs from '@/components/AppearanceTabs.vue'; import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance/index'; import { edit } from '@/routes/appearance/index';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
import { computed } from 'vue';
const breadcrumbItems: BreadcrumbItem[] = [ const { t } = useLocalization();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ {
title: 'Appearance settings', title: t('navigation.appearance'),
href: edit(), href: edit(),
}, },
]; ]);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" /> <Head :title="t('appearance.title')" />
<h1 class="sr-only">Appearance settings</h1>
<SettingsLayout> <SettingsLayout>
<div class="space-y-6"> <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<Heading <CardHeader class="gap-stack border-b p-5 sm:p-6">
variant="small" <h2 class="text-title">{{ t('appearance.title') }}</h2>
title="Appearance settings" <p class="text-muted-foreground">
description="Update your account's appearance settings" {{ t('appearance.description') }}
/> </p>
<AppearanceTabs /> </CardHeader>
</div> <CardContent class="gap-column grid p-5 sm:p-6">
<AppearanceTabs />
<p class="text-caption text-muted-foreground">
{{ t('appearance.persistenceHint') }}
</p>
</CardContent>
</Card>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+290 -170
View File
@@ -1,198 +1,318 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController'; import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { import {
Card, destroy,
CardContent, store,
CardDescription, } from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
CardHeader, import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
CardTitle, import InputError from '@/components/InputError.vue';
} from '@/components/ui/card'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editApplications } from '@/routes/applications/index'; import { edit } from '@/routes/applications/index';
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types'; import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
defineProps<SettingsApplicationsPageProps>(); const props = defineProps<SettingsApplicationsPageProps>();
const { t, locale } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.applications'), href: edit() },
]);
const form = useForm({ name: '' });
const protection = useFormProtection(form);
const deleteForm = useForm({});
const deleteProtection = useFormProtection(deleteForm);
const selected = ref<
SettingsApplicationsPageProps['applications'][number] | null
>(null);
const copied = ref(false);
const copyError = ref(false);
const breadcrumbItems: BreadcrumbItem[] = [ function formatDate(value: string | null): string {
{ if (!value) {
title: 'Applications', return t('applications.never');
href: editApplications(),
},
];
const formatDate = (value: string | null): string => {
if (value === null) {
return 'Never';
} }
return new Intl.DateTimeFormat(locale.value, {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}).format(new Date(value)); }).format(new Date(value));
}; }
function createApplication(): void {
protection.submit(store(), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
}
function closeDelete(open: boolean): void {
if (!open && !deleteForm.processing) {
selected.value = null;
deleteProtection.clearFeedback();
}
}
function deleteApplication(): void {
if (!selected.value) {
return;
}
deleteProtection.submit(destroy(selected.value.id), {
preserveScroll: true,
onSuccess: () => {
selected.value = null;
},
});
}
async function copyKey(): Promise<void> {
const key = props.applicationKey;
if (!key) {
return;
}
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
if (props.applicationKey === key) {
copied.value = true;
}
} catch {
if (props.applicationKey === key) {
copyError.value = true;
}
}
}
watch(
() => props.applicationKey,
() => {
copied.value = false;
copyError.value = false;
},
);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Applications" /> <Head :title="t('applications.title')" />
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col gap-6"> <div class="gap-stack grid">
<div class="space-y-2"> <h2 class="text-title">{{ t('applications.title') }}</h2>
<Heading <p class="text-muted-foreground">
variant="small" {{ t('applications.description') }}
title="Applications" </p>
description="Create API keys for tools that should use KanbanGit with your account permissions." </div>
/> <p
<p class="text-muted-foreground text-sm"> v-if="status"
Keys are shown once. Delete an application when a tool role="status"
should lose access. class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
</p> >
</div> {{ status }}
</p>
<div <section
v-if="status" v-if="applicationKey"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
> aria-labelledby="application-key-title"
{{ status }} >
</div> <h3 id="application-key-title" class="font-semibold">
{{ t('applications.saveKey') }}
<div </h3>
v-if="applicationKey" <p class="text-body text-muted-foreground">
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100" {{ t('applications.keyOnce') }}
> </p>
<p class="font-medium">Save this API key now.</p> <Label for="application-key" class="sr-only">{{
<p class="mt-1"> t('applications.apiKey')
It will not be shown again after you leave this page. }}</Label>
</p> <div class="flex min-w-0 flex-wrap gap-2">
<Input <Input
class="mt-3 font-mono text-xs" id="application-key"
:model-value="applicationKey" :model-value="applicationKey"
readonly readonly
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
/> />
<Button variant="outline" @click="copyKey"
><Check v-if="copied" /><Copy v-else />{{
t(copied ? 'security.copied' : 'security.copyKey')
}}</Button
>
</div> </div>
<p
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]"> v-if="copied"
<Card> role="status"
<CardHeader> class="text-caption text-success-foreground"
<CardTitle>Create application</CardTitle> >
<CardDescription> {{ t('security.copied') }}
Name the tool that will receive an API key. </p>
</CardDescription> <InputError
</CardHeader> v-if="copyError"
:message="t('security.copyError')"
<CardContent> />
<Form </section>
v-bind="UserApplicationController.store.form()" <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
class="space-y-4" <CardHeader class="gap-stack border-b p-5 sm:p-6">
v-slot="{ errors, processing }" <h3 class="flex items-center gap-2 font-semibold">
<KeyRound class="text-primary size-5" />{{
t('applications.create')
}}
</h3>
<p class="text-muted-foreground">
{{ t('applications.nameDescription') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid"
:aria-busy="form.processing"
@submit.prevent="createApplication"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('applications.creating')"
@retry="createApplication"
/>
<div class="grid gap-2">
<Label for="application-name">{{
t('applications.name')
}}</Label>
<Input
id="application-name"
v-model="form.name"
name="name"
required
maxlength="255"
autocomplete="off"
:placeholder="t('applications.namePlaceholder')"
:disabled="form.processing"
:aria-invalid="!!form.errors.name"
aria-describedby="application-name-error"
/>
<InputError
id="application-name-error"
:message="form.errors.name"
/>
</div>
<Button
type="submit"
class="justify-self-start"
:loading="form.processing"
:loading-label="t('applications.creating')"
>{{ t('applications.create') }}</Button
>
</form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
<p class="text-muted-foreground">
{{ t('applications.hiddenKeys') }}
</p>
</CardHeader>
<CardContent class="gap-stack grid p-5 sm:p-6">
<p
v-if="!applications.length"
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
>
{{ t('applications.empty') }}
</p>
<article
v-for="application in applications"
:key="application.id"
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
>
<div class="gap-stack grid min-w-0 flex-1 basis-40">
<h4 class="break-words font-semibold">
{{ application.name }}
</h4>
<dl
class="gap-stack text-caption text-muted-foreground grid"
> >
<div class="grid gap-2"> <div>
<Label for="name">Application name</Label> <dt class="inline font-medium">
<Input {{ t('applications.created') }}
id="name" </dt>
name="name" <dd class="inline">
type="text" {{ formatDate(application.created_at) }}
required </dd>
autofocus
placeholder="Claude Desktop"
/>
<InputError :message="errors.name" />
</div> </div>
<div>
<Button type="submit" :disabled="processing"> <dt class="inline font-medium">
Create application {{ t('applications.lastUsed') }}
</Button> </dt>
</Form> <dd class="inline">
</CardContent> {{
</Card> formatDate(application.last_used_at)
}}
<Card class="gap-0 overflow-hidden"> </dd>
<CardHeader class="border-b">
<CardTitle>Saved applications</CardTitle>
<CardDescription>
Existing keys stay hidden. Create a new
application when another key is needed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="applications.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No applications have been created yet.
</div>
<div
v-for="application in applications"
:key="application.id"
class="border-border rounded-xl border p-4"
>
<div
class="flex flex-wrap items-start justify-between gap-4"
>
<div class="space-y-2">
<div
class="flex flex-wrap items-center gap-2"
>
<Badge>API key</Badge>
<span class="text-sm font-medium">
{{ application.name }}
</span>
</div>
<p
class="text-muted-foreground text-xs"
>
Created:
{{
formatDate(
application.created_at,
)
}}
</p>
<p
class="text-muted-foreground text-xs"
>
Last used:
{{
formatDate(
application.last_used_at,
)
}}
</p>
</div>
<Form
v-bind="
UserApplicationController.destroy.form(
application.id,
)
"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Delete
</Button>
</Form>
</div> </div>
</div> </dl>
</CardContent> </div>
</Card> <Button
</div> variant="outline"
</div> class="text-destructive"
:aria-label="
t('applications.deleteNamed', {
name: application.name,
})
"
@click="selected = application"
><Trash2 />{{ t('applications.delete') }}</Button
>
</article>
</CardContent>
</Card>
<Dialog :open="!!selected" @update:open="closeDelete">
<DialogContent
:show-close-button="!deleteForm.processing"
@escape-key-down="
deleteForm.processing && $event.preventDefault()
"
@interact-outside="
deleteForm.processing && $event.preventDefault()
"
>
<DialogHeader>
<DialogTitle>{{
t('applications.deleteTitle')
}}</DialogTitle>
<DialogDescription>{{
t('applications.deleteDescription', {
name: selected?.name ?? '',
})
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="deleteProtection.state.value"
:response-status="deleteProtection.responseStatus.value"
@retry="deleteApplication"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="deleteForm.processing"
@click="closeDelete(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="deleteForm.processing"
@click="deleteApplication"
>{{ t('applications.delete') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+464 -238
View File
@@ -1,267 +1,493 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController'; import {
import Heading from '@/components/Heading.vue'; GitBranch,
LockKeyhole,
Pencil,
Plus,
Server,
Trash2,
} from 'lucide-vue-next';
import { computed, ref } from 'vue';
import {
destroy,
store,
update,
} from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index'; import { edit } from '@/routes/git-connections/index';
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types'; import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
defineProps<SettingsGitConnectionsPageProps>(); const props = defineProps<SettingsGitConnectionsPageProps>();
const { t } = useLocalization();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.gitConnections'), href: edit() },
]);
const mode = ref<'create' | 'edit' | 'remove'>('create');
const dialogOpen = ref(false);
const selectedConnection = ref<UserGitConnectionModel | null>(null);
// Keep provider credentials out of Inertia's remembered browser history.
const connectionForm = useForm({
provider: 'gitea',
base_url: '',
api_token: '',
});
const connectionProtection = useFormProtection(connectionForm);
const removalForm = useForm({});
const removalProtection = useFormProtection(removalForm);
const processing = computed(
() => connectionForm.processing || removalForm.processing,
);
const dialogTitle = computed(() =>
t(
mode.value === 'create'
? 'gitConnections.add'
: mode.value === 'edit'
? 'gitConnections.edit'
: 'gitConnections.removeTitle',
),
);
const successTitle = computed(() =>
props.status === 'Git connection was added.'
? t('gitConnections.added')
: props.status === 'Git connection was updated.'
? t('gitConnections.updated')
: t('gitConnections.removed'),
);
const page = usePage(); function resetForms(): void {
const appName = String(page.props.name); connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
connectionForm.resetAndClearErrors();
connectionProtection.clearFeedback();
removalForm.resetAndClearErrors();
removalProtection.clearFeedback();
}
const breadcrumbItems: BreadcrumbItem[] = [ function openDialog(
{ nextMode: typeof mode.value,
title: 'Git connections', connection: UserGitConnectionModel | null = null,
href: editGitConnections(), ): void {
}, resetForms();
]; mode.value = nextMode;
selectedConnection.value = connection;
connectionForm.defaults({
provider: connection?.provider ?? 'gitea',
base_url: connection?.base_url ?? '',
api_token: '',
});
connectionForm.reset();
dialogOpen.value = true;
}
function closeDialog(open: boolean): void {
if (open || processing.value || !connectionProtection.confirmLeave()) {
return;
}
dialogOpen.value = false;
resetForms();
}
function finish(): void {
resetForms();
dialogOpen.value = false;
}
function saveConnection(): void {
const target =
mode.value === 'edit' && selectedConnection.value
? update(selectedConnection.value.id)
: store();
connectionProtection.submit(target, {
preserveScroll: true,
onSuccess: finish,
});
}
function removeConnection(): void {
if (!selectedConnection.value) {
return;
}
removalProtection.submit(destroy(selectedConnection.value.id), {
preserveScroll: true,
onSuccess: finish,
});
}
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Git connections" /> <Head :title="t('navigation.gitConnections')" />
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col gap-6"> <Card
<div class="space-y-2"> class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
<Heading >
variant="small" <CardHeader
title="Git connections" class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access."
/>
<p class="text-muted-foreground text-sm">
Each user needs their own application key. Without a
matching personal connection, {{ appName }} can only
show repository names and will link you back to this
page.
</p>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
> >
{{ status }} <div
</div> class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]"> <div class="gap-stack grid min-w-0">
<Card> <h2 class="text-title">
<CardHeader> {{ t('navigation.gitConnections') }}
<CardTitle>Add Gitea connection</CardTitle> </h2>
<CardDescription> <p class="text-muted-foreground">
Connect a Gitea account by storing the instance {{ t('gitConnections.description') }}
base URL and a personal application token. </p>
</CardDescription> </div>
</CardHeader> <Button
type="button"
<CardContent> class="shrink-0 self-start"
<Form @click="openDialog('create')"
v-bind="GitConnectionController.store.form()" >
class="space-y-4" <Plus aria-hidden="true" />{{
v-slot="{ errors, processing }" t('gitConnections.add')
> }}
<input </Button>
name="provider" </div>
type="hidden" <div
value="gitea" class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
/> >
<LockKeyhole
<div class="grid gap-2"> class="text-primary mt-0.5 size-4 shrink-0"
<Label for="base_url">Gitea base URL</Label> aria-hidden="true"
<Input />
id="base_url" <p>{{ t('gitConnections.privacy') }}</p>
name="base_url" </div>
type="url" </CardHeader>
required <CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
autofocus <FeedbackState
placeholder="https://git.example.com" v-if="status"
/> state="success"
<InputError :message="errors.base_url" /> :title="successTitle"
</div> />
<div class="flex flex-wrap items-center gap-2">
<div class="grid gap-2"> <h3 class="font-semibold">
<Label for="api_token" {{ t('gitConnections.saved') }}
>Application token</Label </h3>
> <Badge variant="secondary">{{
<Input gitConnections.length
id="api_token" }}</Badge>
name="api_token" </div>
type="password" <FeedbackState
required v-if="gitConnections.length === 0"
placeholder="Paste your Gitea application key" state="empty"
/> :title="t('gitConnections.empty')"
<InputError :message="errors.api_token" /> :description="t('gitConnections.emptyDescription')"
</div> />
<ul
<Button type="submit" :disabled="processing"> v-else
Add connection class="gap-column grid min-w-0"
</Button> :aria-label="t('gitConnections.saved')"
</Form> >
</CardContent> <li
</Card> v-for="connection in gitConnections"
:key="connection.id"
<Card class="gap-0 overflow-hidden"> class="gap-column grid min-w-0 rounded-lg border p-4"
<CardHeader class="border-b"> >
<CardTitle>Saved connections</CardTitle> <div class="flex min-w-0 items-start gap-3">
<CardDescription>
Tokens stay hidden after save. Updating a
connection requires entering a fresh token.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="gitConnections.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No Git connections have been added yet.
</div>
<div
v-for="gitConnection in gitConnections"
:key="gitConnection.id"
class="border-border rounded-xl border p-4"
>
<div <div
class="mb-4 flex flex-wrap items-start justify-between gap-3" class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
> >
<div class="space-y-1"> <GitBranch
<div class="size-5"
class="flex flex-wrap items-center gap-2" aria-hidden="true"
/>
</div>
<div class="gap-stack grid min-w-0 flex-1">
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<h4
class="min-w-0 break-all font-semibold"
> >
<Badge>{{ {{ connection.account_login }}
gitConnection.provider_label </h4>
}}</Badge> <Badge variant="secondary">{{
<span class="text-sm font-medium"> connection.provider_label
{{ }}</Badge>
gitConnection.account_login
}}
</span>
</div>
<p
class="text-muted-foreground text-sm"
>
{{
gitConnection.account_name ??
'Unnamed remote account'
}}
</p>
<p
class="text-muted-foreground text-xs"
>
{{ gitConnection.base_url }}
</p>
</div> </div>
<p
<p class="text-muted-foreground text-xs"> class="text-caption text-muted-foreground break-words"
Remote user ID: >
{{ gitConnection.provider_user_id }} {{
connection.account_name ??
t('gitConnections.unnamedAccount')
}}
</p>
<div
class="text-caption text-muted-foreground flex min-w-0 items-start gap-2"
>
<Server
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<span class="min-w-0 break-all">{{
connection.base_url
}}</span>
</div>
<p
class="text-caption text-muted-foreground break-all"
>
{{
t('gitConnections.remoteId', {
id: connection.provider_user_id,
})
}}
</p> </p>
</div> </div>
<Form
v-bind="
GitConnectionController.update.form(
gitConnection.id,
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<input
name="provider"
type="hidden"
:value="gitConnection.provider"
/>
<div class="grid gap-2">
<Label
:for="`base-url-${gitConnection.id}`"
>
Gitea base URL
</Label>
<Input
:id="`base-url-${gitConnection.id}`"
name="base_url"
type="url"
:default-value="
gitConnection.base_url
"
required
/>
<InputError
:message="errors.base_url"
/>
</div>
<div class="grid gap-2">
<Label
:for="`token-${gitConnection.id}`"
>
New application token
</Label>
<Input
:id="`token-${gitConnection.id}`"
name="api_token"
type="password"
required
placeholder="Paste a new token to verify and save"
/>
<InputError
:message="errors.api_token"
/>
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save changes
</Button>
</div>
</Form>
<Form
v-bind="
GitConnectionController.destroy.form(
gitConnection.id,
)
"
class="mt-3 flex justify-end"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Remove connection
</Button>
</Form>
</div> </div>
</CardContent> <div
</Card> class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
</div> >
</div> <Button
type="button"
variant="outline"
:aria-label="
t('gitConnections.editAccount', {
account: connection.account_login,
server: connection.base_url,
})
"
@click="openDialog('edit', connection)"
>
<Pencil aria-hidden="true" />{{
t('gitConnections.editAction')
}}
</Button>
<Button
type="button"
variant="ghost"
:aria-label="
t('gitConnections.removeAccount', {
account: connection.account_login,
server: connection.base_url,
})
"
@click="openDialog('remove', connection)"
>
<Trash2 aria-hidden="true" />{{
t('gitConnections.remove')
}}
</Button>
</div>
</li>
</ul>
</CardContent>
</Card>
<Dialog :open="dialogOpen" @update:open="closeDialog">
<DialogContent
:show-close-button="!processing"
@escape-key-down="processing && $event.preventDefault()"
@interact-outside="processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ dialogTitle }}</DialogTitle>
<DialogDescription>{{
t(
mode === 'remove'
? 'gitConnections.removeDescription'
: mode === 'edit'
? 'gitConnections.editDescription'
: 'gitConnections.addDescription',
)
}}</DialogDescription>
</DialogHeader>
<form
v-if="mode !== 'remove'"
class="gap-column grid min-w-0"
:aria-busy="connectionForm.processing"
@submit.prevent="saveConnection"
>
<FormFeedback
:state="connectionProtection.state.value"
:title="
connectionProtection.responseStatus.value ===
403
? t('gitConnections.forbidden')
: connectionForm.processing
? t('gitConnections.verifying')
: undefined
"
:description="
connectionProtection.responseStatus.value ===
403
? t('gitConnections.forbiddenDescription')
: undefined
"
:retryable="
connectionProtection.responseStatus.value !==
403
"
@retry="saveConnection"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="connectionForm.processing"
>
<legend class="sr-only">{{ dialogTitle }}</legend>
<Badge
variant="secondary"
class="justify-self-start"
>{{ t('gitConnections.gitea') }}</Badge
>
<InputError
:message="connectionForm.errors.provider"
/>
<div class="grid gap-2">
<Label for="git-base-url">{{
t('gitConnections.baseUrl')
}}</Label>
<Input
id="git-base-url"
v-model="connectionForm.base_url"
name="base_url"
type="url"
required
placeholder="https://git.example.com"
autocomplete="url"
:aria-invalid="
!!connectionForm.errors.base_url
"
aria-describedby="git-base-url-error"
/>
<InputError
id="git-base-url-error"
:message="connectionForm.errors.base_url"
/>
</div>
<div class="grid gap-2">
<Label for="git-token">{{
t(
mode === 'edit'
? 'gitConnections.newToken'
: 'gitConnections.token',
)
}}</Label>
<Input
id="git-token"
v-model="connectionForm.api_token"
name="api_token"
type="password"
required
autocomplete="new-password"
:placeholder="
t('gitConnections.tokenPlaceholder')
"
:aria-invalid="
!!connectionForm.errors.api_token
"
aria-describedby="git-token-help git-token-error"
/>
<p
id="git-token-help"
class="text-caption text-muted-foreground"
>
{{ t('gitConnections.tokenHelp') }}
</p>
<InputError
id="git-token-error"
:message="connectionForm.errors.api_token"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
@click="closeDialog(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit">{{
t(
connectionForm.processing
? 'gitConnections.verifying'
: mode === 'edit'
? 'gitConnections.save'
: 'gitConnections.add',
)
}}</Button>
</DialogFooter>
</fieldset>
</form>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="removalForm.processing"
@submit.prevent="removeConnection"
>
<div
class="bg-destructive/8 border-destructive/25 text-body grid min-w-0 gap-2 rounded-lg border p-4"
>
<p class="break-all font-semibold">
{{ selectedConnection?.account_login }}
</p>
<p class="text-muted-foreground break-all">
{{ selectedConnection?.base_url }}
</p>
</div>
<FormFeedback
:state="removalProtection.state.value"
:title="
removalProtection.responseStatus.value === 403
? t('gitConnections.forbidden')
: removalForm.processing
? t('gitConnections.removing')
: undefined
"
:description="
removalProtection.responseStatus.value === 403
? t('gitConnections.forbiddenDescription')
: undefined
"
:retryable="
removalProtection.responseStatus.value !== 403
"
@retry="removeConnection"
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="removalForm.processing"
@click="closeDialog(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
variant="destructive"
:disabled="removalForm.processing"
>{{
t(
removalForm.processing
? 'gitConnections.removing'
: 'gitConnections.removeTitle',
)
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+330 -154
View File
@@ -1,209 +1,385 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3'; import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Trash2 } from 'lucide-vue-next'; import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types'; import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import type { UserModel } from '@/types/models/user-model';
defineProps<SettingsProfilePageProps>(); defineProps<SettingsProfilePageProps>();
const breadcrumbItems: BreadcrumbItem[] = [ const { t } = useLocalization();
{
title: 'Profile settings',
href: edit(),
},
];
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); const user = computed(() => page.props.auth.user);
const avatarPreview = ref<string | null>(null); const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{
title: t('navigation.profile'),
href: edit(),
},
]);
const profileForm = useForm('SettingsProfile', {
name: user.value.name,
email: user.value.email,
avatar: null as File | null,
});
const profileProtection = useFormProtection(profileForm);
const avatarDeleteForm = useForm({}); const avatarDeleteForm = useForm({});
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
const avatarPreview = ref<string | null>(null);
const displayedAvatar = computed( const displayedAvatar = computed(
() => avatarPreview.value ?? user.value.avatar, () => avatarPreview.value ?? user.value.avatar,
); );
const displayedUser = computed<UserModel>(() => ({
...user.value,
avatar: displayedAvatar.value,
}));
const canRemoveAvatar = computed( const canRemoveAvatar = computed(
() => user.value.avatar !== null && !user.value.avatar_is_generated, () =>
avatarPreview.value === null &&
user.value.avatar !== null &&
!user.value.avatar_is_generated,
); );
const profileUpdateForm = ProfileController.update.form();
const profileUpdateTarget = {
url: profileUpdateForm.action,
method: profileUpdateForm.method,
};
const clearAvatarPreview = (): void => { function clearAvatarPreview(): void {
if (avatarPreview.value) { if (avatarPreview.value) {
URL.revokeObjectURL(avatarPreview.value); URL.revokeObjectURL(avatarPreview.value);
avatarPreview.value = null; avatarPreview.value = null;
} }
}; }
const handleAvatarChange = (event: Event) => { function clearAvatarSelection(): void {
clearAvatarPreview();
profileForm.avatar = null;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (avatarInput.value) {
avatarInput.value.value = '';
}
}
function handleAvatarChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0] ?? null;
clearAvatarPreview(); clearAvatarPreview();
profileForm.avatar = file;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (file) { if (file) {
avatarPreview.value = URL.createObjectURL(file); avatarPreview.value = URL.createObjectURL(file);
} }
}; }
const removeAvatar = (): void => { function submitProfile(): void {
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), { profileProtection.submit(profileUpdateTarget, {
forceFormData: true,
preserveScroll: true, preserveScroll: true,
onSuccess: clearAvatarPreview, onSuccess: () => {
profileForm.defaults({
name: profileForm.name,
email: profileForm.email,
avatar: null,
});
clearAvatarSelection();
},
}); });
}; }
onBeforeUnmount(() => { function removeAvatar(): void {
clearAvatarPreview(); avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
}); preserveScroll: true,
onSuccess: clearAvatarSelection,
});
}
onBeforeUnmount(clearAvatarPreview);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" /> <Head :title="t('profile.title')" />
<h1 class="sr-only">Profile settings</h1>
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col space-y-6"> <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<Heading <CardHeader class="gap-stack border-b p-5 sm:p-6">
variant="small" <h2 class="text-title">{{ t('profile.title') }}</h2>
title="Profile information" <p class="text-muted-foreground">
description="Update your name, email address, and profile picture" {{ t('profile.description') }}
/> </p>
</CardHeader>
<Form <CardContent class="p-5 sm:p-6">
v-bind="ProfileController.update.form()" <form
class="space-y-6" class="gap-column grid min-w-0"
v-slot="{ :aria-busy="profileForm.processing"
errors, @submit.prevent="submitProfile"
processing, >
progress, <FormFeedback
recentlySuccessful, :state="profileProtection.state.value"
}" :title="
> profileForm.processing
<div class="grid gap-3"> ? t('profile.saving')
<Label for="avatar">Profile picture</Label> : undefined
<div class="flex items-center gap-4"> "
<UserAvatar class="size-16" /> @retry="submitProfile"
<div class="grid flex-1 gap-2"> />
<div class="flex flex-col gap-2 sm:flex-row">
<Input <fieldset
id="avatar" class="gap-column grid min-w-0"
name="avatar" :disabled="profileForm.processing"
type="file" >
accept="image/png,image/jpeg,image/webp" <legend class="sr-only">
@change="handleAvatarChange" {{ t('profile.information') }}
/> </legend>
<Button
v-if="canRemoveAvatar" <section
type="button" aria-labelledby="profile-picture-title"
variant="outline" class="gap-column grid min-w-0 border-b pb-6"
:disabled="avatarDeleteForm.processing" >
data-test="delete-profile-picture-button" <div class="gap-stack grid">
@click="removeAvatar" <h3
id="profile-picture-title"
class="font-semibold"
> >
<Trash2 class="size-4" /> {{ t('profile.picture') }}
Remove </h3>
</Button> <p
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureDescription') }}
</p>
</div> </div>
<p class="text-muted-foreground text-sm">
PNG, JPG, or WebP up to 2 MB. <div
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
>
<UserAvatar
:user="displayedUser"
size="xl"
class="ring-border shrink-0 ring-1"
:label="t('profile.picturePreview')"
/>
<div class="gap-stack grid min-w-0 flex-1">
<div class="flex flex-wrap gap-2">
<Button as-child variant="outline">
<label
for="avatar"
class="cursor-pointer"
>
<Camera
aria-hidden="true"
/>
{{
avatarPreview
? t(
'profile.changePicture',
)
: t(
'profile.choosePicture',
)
}}
</label>
</Button>
<Button
v-if="avatarPreview"
type="button"
variant="ghost"
@click="clearAvatarSelection"
>
<RotateCcw aria-hidden="true" />
{{
t('profile.discardPicture')
}}
</Button>
<Button
v-else-if="canRemoveAvatar"
type="button"
variant="ghost"
:disabled="
avatarDeleteForm.processing
"
data-test="delete-profile-picture-button"
@click="removeAvatar"
>
<Trash2 aria-hidden="true" />
{{ t('profile.removePicture') }}
</Button>
</div>
<input
id="avatar"
ref="avatarInput"
name="avatar"
type="file"
accept="image/png,image/jpeg,image/webp"
class="sr-only"
aria-describedby="avatar-help avatar-error"
:aria-invalid="
!!profileForm.errors.avatar
"
@change="handleAvatarChange"
/>
<p
v-if="profileForm.avatar"
class="text-caption min-w-0 break-all font-medium"
>
{{
t('profile.selectedPicture', {
name: profileForm.avatar
.name,
})
}}
</p>
<p
id="avatar-help"
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureHelp') }}
</p>
<progress
v-if="profileForm.progress"
class="h-1 w-full overflow-hidden rounded-full"
:value="
profileForm.progress.percentage
"
max="100"
>
{{
profileForm.progress.percentage
}}%
</progress>
<InputError
id="avatar-error"
:message="profileForm.errors.avatar"
/>
<FormFeedback
:state="
avatarDeleteProtection.state
.value
"
:title="
avatarDeleteForm.processing
? t(
'profile.removingPicture',
)
: undefined
"
@retry="removeAvatar"
/>
</div>
</div>
</section>
<div class="grid gap-2">
<Label for="name">{{
t('profile.name')
}}</Label>
<Input
id="name"
v-model="profileForm.name"
name="name"
required
autocomplete="name"
:placeholder="t('profile.namePlaceholder')"
:aria-invalid="!!profileForm.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="profileForm.errors.name"
/>
</div>
<div class="grid gap-2">
<Label for="email">{{
t('profile.email')
}}</Label>
<Input
id="email"
v-model="profileForm.email"
type="email"
name="email"
required
autocomplete="username"
:placeholder="t('profile.emailPlaceholder')"
:aria-invalid="!!profileForm.errors.email"
aria-describedby="email-error"
/>
<InputError
id="email-error"
:message="profileForm.errors.email"
/>
</div>
<div
v-if="
mustVerifyEmail && !user.email_verified_at
"
class="bg-muted/55 rounded-lg border p-4"
>
<p class="text-body text-muted-foreground">
{{ t('profile.emailUnverified') }}
<Link
:href="send()"
as="button"
class="text-primary font-medium underline underline-offset-4"
>
{{ t('profile.resendVerification') }}
</Link>
</p>
<p
v-if="status === 'verification-link-sent'"
role="status"
class="text-success-foreground mt-2 text-sm font-medium"
>
{{ t('profile.verificationSent') }}
</p> </p>
</div> </div>
</div>
<progress
v-if="progress"
class="h-1 w-full overflow-hidden rounded-full"
:value="progress.percentage"
max="100"
>
{{ progress.percentage }}%
</progress>
<InputError class="mt-1" :message="errors.avatar" />
</div>
<div class="grid gap-2"> <div class="flex flex-wrap items-center gap-3">
<Label for="name">Name</Label> <Button
<Input type="submit"
id="name" :disabled="profileForm.processing"
class="mt-1 block w-full" data-test="update-profile-button"
name="name" >
:default-value="user.name" {{
required profileForm.processing
autocomplete="name" ? t('profile.saving')
placeholder="Full name" : t('profile.save')
/> }}
<InputError class="mt-2" :message="errors.name" /> </Button>
</div> <p
v-if="profileForm.isDirty"
<div class="grid gap-2"> class="text-caption text-muted-foreground"
<Label for="email">Email address</Label> >
<Input {{ t('profile.unsaved') }}
id="email" </p>
type="email" </div>
class="mt-1 block w-full" </fieldset>
name="email" </form>
:default-value="user.email" </CardContent>
required </Card>
autocomplete="username"
placeholder="Email address"
/>
<InputError class="mt-2" :message="errors.email" />
</div>
<div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="text-muted-foreground -mt-4 text-sm">
Your email address is unverified.
<Link
:href="send()"
as="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 here to resend the verification email.
</Link>
</p>
<div
v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email
address.
</div>
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-profile-button"
>Save</Button
>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
<DeleteUser /> <DeleteUser />
</SettingsLayout> </SettingsLayout>
+309 -191
View File
@@ -1,222 +1,340 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next'; import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted, ref } from 'vue'; import { computed, onUnmounted, ref } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/Heading.vue'; import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue'; import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue'; import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/security/index'; import { edit } from '@/routes/security/index';
import { disable, enable } from '@/routes/two-factor'; import { disable, enable } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
type Props = { const props = withDefaults(
canManageTwoFactor?: boolean; defineProps<{
mustChangePassword?: boolean; canManageTwoFactor?: boolean;
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
status?: string | null; twoFactorEnabled?: boolean;
twoFactorEnabled?: boolean; }>(),
};
withDefaults(defineProps<Props>(), {
canManageTwoFactor: false,
mustChangePassword: false,
requiresConfirmation: false,
status: null,
twoFactorEnabled: false,
});
const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Security settings', canManageTwoFactor: false,
href: edit(), requiresConfirmation: false,
twoFactorEnabled: false,
}, },
]; );
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.security'), href: edit() },
]);
const passwordForm = useForm({
current_password: '',
password: '',
password_confirmation: '',
}).dontRemember('current_password', 'password', 'password_confirmation');
const passwordProtection = useFormProtection(passwordForm, {
isDirty: () => false,
});
const twoFactorForm = useForm({});
const twoFactorProtection = useFormProtection(twoFactorForm);
const showSetupModal = ref(false);
const showDisableDialog = ref(false);
const setupStarted = ref(false);
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth(); function savePassword(): void {
const showSetupModal = ref<boolean>(false); passwordProtection.submit(update(), {
preserveScroll: true,
onUnmounted(() => clearTwoFactorAuthData()); onFinish: () => passwordForm.reset(),
});
}
function startSetup(): void {
if (setupStarted.value) {
showSetupModal.value = true;
return;
}
twoFactorProtection.submit(enable(), {
preserveScroll: true,
onSuccess: () => {
setupStarted.value = true;
showSetupModal.value = true;
},
});
}
function disableTwoFactor(): void {
twoFactorProtection.submit(disable(), {
preserveScroll: true,
onSuccess: () => {
showDisableDialog.value = false;
setupStarted.value = false;
},
});
}
function updateDisableDialog(open: boolean): void {
if (!twoFactorForm.processing) {
showDisableDialog.value = open;
twoFactorProtection.clearFeedback();
}
}
onUnmounted(() => passwordForm.reset());
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Security settings" /> <Head :title="t('security.title')" />
<h1 class="sr-only">Security settings</h1>
<SettingsLayout> <SettingsLayout>
<div <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
v-if="mustChangePassword || status" <CardHeader class="gap-stack border-b p-5 sm:p-6">
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100" <h2 class="text-title">
> {{ t('security.passwordTitle') }}
{{ </h2>
mustChangePassword <p class="text-muted-foreground">
? 'You must change your password before continuing anywhere else in the app.' {{ t('security.passwordDescription') }}
: status
}}
</div>
<div class="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<Form
v-bind="SecurityController.update.form()"
:options="{
preserveScroll: true,
}"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="current_password">Current password</Label>
<PasswordInput
id="current_password"
name="current_password"
class="mt-1 block w-full"
autocomplete="current-password"
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label for="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="New 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"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-password-button"
>
Save password
</Button>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
<div v-if="canManageTwoFactor" class="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
/>
<div
v-if="!twoFactorEnabled"
class="flex flex-col items-start justify-start space-y-4"
>
<p class="text-muted-foreground text-sm">
When you enable two-factor authentication, you will be
prompted for a secure pin during login. This pin can be
retrieved from a TOTP-supported application on your
phone.
</p> </p>
</CardHeader>
<div> <CardContent class="p-5 sm:p-6">
<Button <form
v-if="hasSetupData" class="gap-column grid"
@click="showSetupModal = true" :aria-busy="passwordForm.processing"
@submit.prevent="savePassword"
>
<AuthFormFeedback
:state="passwordProtection.state.value"
:response-status="
passwordProtection.responseStatus.value
"
:loading-title="t('security.savingPassword')"
:success-title="t('security.passwordSaved')"
@retry="savePassword"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="passwordForm.processing"
> >
<ShieldCheck />Continue setup <legend class="sr-only">
</Button> {{ t('security.passwordTitle') }}
<Form </legend>
v-else <div class="grid gap-2">
v-bind="enable.form()" <Label for="current_password">{{
@success="showSetupModal = true" t('security.currentPassword')
#default="{ processing }" }}</Label>
> <PasswordInput
<Button type="submit" :disabled="processing"> id="current_password"
Enable 2FA v-model="passwordForm.current_password"
</Button> name="current_password"
</Form> required
</div> autocomplete="current-password"
</div> :disabled="passwordForm.processing"
:aria-invalid="
<div !!passwordForm.errors.current_password
v-else "
class="flex flex-col items-start justify-start space-y-4" aria-describedby="current-password-error"
> />
<p class="text-muted-foreground text-sm"> <InputError
You will be prompted for a secure, random pin during id="current-password-error"
login, which you can retrieve from the TOTP-supported :message="
application on your phone. passwordForm.errors.current_password
</p> "
/>
<div class="relative inline"> </div>
<Form v-bind="disable.form()" #default="{ processing }"> <div class="grid gap-2">
<Label for="password">{{
t('security.newPassword')
}}</Label>
<PasswordInput
id="password"
v-model="passwordForm.password"
name="password"
required
autocomplete="new-password"
:disabled="passwordForm.processing"
:aria-invalid="
!!passwordForm.errors.password
"
aria-describedby="new-password-error"
/>
<InputError
id="new-password-error"
:message="passwordForm.errors.password"
/>
</div>
<div class="grid gap-2">
<Label for="password_confirmation">{{
t('security.confirmPassword')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="passwordForm.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="passwordForm.processing"
:aria-invalid="
!!passwordForm.errors
.password_confirmation
"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="
passwordForm.errors
.password_confirmation
"
/>
</div>
<Button <Button
variant="destructive"
type="submit" type="submit"
:disabled="processing" class="justify-self-start"
:loading="passwordForm.processing"
:loading-label="t('security.savingPassword')"
data-test="update-password-button"
>{{ t('security.savePassword') }}</Button
> >
Disable 2FA </fieldset>
</Button> </form>
</Form> </CardContent>
</Card>
<Card
v-if="canManageTwoFactor"
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 flex-wrap items-center justify-between gap-3"
>
<h2 class="text-title flex items-center gap-2">
<ShieldCheck class="text-primary size-5" />{{
t('security.twoFactorTitle')
}}
</h2>
<span
class="bg-muted rounded-full px-3 py-1 text-xs font-semibold"
role="status"
>{{
t(
props.twoFactorEnabled
? 'security.enabled'
: setupStarted
? 'security.pending'
: 'security.disabled',
)
}}</span
>
</div> </div>
<p class="text-muted-foreground">
<TwoFactorRecoveryCodes /> {{
</div> t(
props.twoFactorEnabled
<TwoFactorSetupModal ? 'security.enabledDescription'
v-model:isOpen="showSetupModal" : 'security.twoFactorDescription',
:requiresConfirmation="requiresConfirmation" )
:twoFactorEnabled="twoFactorEnabled" }}
/> </p>
</div> </CardHeader>
<CardContent class="gap-column grid p-5 sm:p-6">
<AuthFormFeedback
v-if="!showDisableDialog"
:state="twoFactorProtection.state.value"
:response-status="
twoFactorProtection.responseStatus.value
"
@retry="
props.twoFactorEnabled
? (showDisableDialog = true)
: startSetup()
"
/>
<Button
v-if="!twoFactorEnabled"
class="justify-self-start"
:loading="twoFactorForm.processing"
@click="startSetup"
>{{
t(
setupStarted
? 'security.continueSetup'
: 'security.enable',
)
}}</Button
>
<template v-else>
<Button
variant="outline"
class="text-destructive justify-self-start"
@click="showDisableDialog = true"
>{{ t('security.disable') }}</Button
>
<TwoFactorRecoveryCodes />
</template>
</CardContent>
</Card>
<TwoFactorSetupModal
v-if="canManageTwoFactor"
v-model:is-open="showSetupModal"
:requires-confirmation="requiresConfirmation"
:two-factor-enabled="twoFactorEnabled"
/>
<Dialog
:open="showDisableDialog"
@update:open="updateDisableDialog"
>
<DialogContent
:show-close-button="!twoFactorForm.processing"
@escape-key-down="
twoFactorForm.processing && $event.preventDefault()
"
@interact-outside="
twoFactorForm.processing && $event.preventDefault()
"
>
<DialogHeader>
<DialogTitle>{{
t('security.disableTitle')
}}</DialogTitle>
<DialogDescription>{{
t('security.disableDescription')
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="twoFactorProtection.state.value"
:response-status="
twoFactorProtection.responseStatus.value
"
@retry="disableTwoFactor"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="twoFactorForm.processing"
@click="updateDisableDialog(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="twoFactorForm.processing"
@click="disableTwoFactor"
>{{ t('security.disable') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+31
View File
@@ -0,0 +1,31 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
export type BacklogFilterState = {
assigneeId: BacklogAssigneeFilter;
priorityId: '' | number;
query: string;
statusId: '' | number;
typeId: '' | number;
};
export type BacklogTicketTreeNode = {
children: BacklogTicketTreeNode[];
matchesFilters: boolean;
ticket: TicketModel;
};
export type BacklogTicketTreeProps = {
canEditTickets: boolean;
collapsedTicketIds: Set<number>;
nodes: BacklogTicketTreeNode[];
pendingTicketId: number | null;
projectId: number;
};
export type BacklogTicketTreeEmits = {
moveFinish: [ticketId: number];
moveStart: [ticketId: number];
toggle: [ticketId: number];
};
+2
View File
@@ -1,4 +1,6 @@
export * from './auth'; export * from './auth';
export * from './backlog';
export * from './navigation'; export * from './navigation';
export * from './page-props'; export * from './page-props';
export * from './ticket-tree';
export * from './ui'; export * from './ui';
@@ -4,6 +4,7 @@ export type TicketFieldDefinitionModel = {
label: string; label: string;
position: number; position: number;
project_id: number; project_id: number;
ticket_field_values_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -4,6 +4,7 @@ export type TicketLinkTypeModel = {
counterpart_label: string; counterpart_label: string;
position: number; position: number;
project_id: number; project_id: number;
ticket_links_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
+23
View File
@@ -0,0 +1,23 @@
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
export type MetadataKind = 'type' | 'priority';
export type MetadataEntry = TicketPriorityModel &
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
export type MetadataEditorTarget = { kind: MetadataKind } & (
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: MetadataEntry }
);
export type TicketFieldDefinitionEditorTarget =
| { action: 'create'; entry?: never }
| {
action: 'edit' | 'delete';
entry: TicketFieldDefinitionModel;
};
export type TicketLinkTypeEditorTarget =
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: TicketLinkTypeModel };
@@ -0,0 +1,12 @@
import type { NotificationActionModel } from '@/types/models/notification-action-model';
export type NotificationEditorTarget =
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: NotificationActionModel };
export type NotificationFilterFormState = {
id: number;
attribute: string;
operator: string;
value: string;
};
+20
View File
@@ -0,0 +1,20 @@
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export type WorkflowEditorTarget =
| { kind: 'column'; action: 'create'; column?: never }
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
| {
kind: 'status';
action: 'create';
column: BoardColumnModel | null;
status?: never;
replacement?: never;
}
| {
kind: 'status';
action: 'edit' | 'delete';
column: BoardColumnModel | null;
status: TicketStatusModel;
replacement?: TicketStatusModel;
};
+69
View File
@@ -0,0 +1,69 @@
import type { ProjectTicketPageProps } from '@/types/page-props';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
export type TicketDetailHeaderProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailContentProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'canManageProject'
| 'parentTicketOptions'
| 'ticketFieldDefinitions'
| 'ticketPriorities'
| 'ticketTypes'
| 'users'
>;
export type TicketDetailBlockerProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailMetadataProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
>;
export type TicketDetailGitProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'repositoryBranches'
| 'repositoryPullRequests'
| 'suggestedBranchName'
>;
export type TicketGitBranchesProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'repositoryBranches'
| 'suggestedBranchName'
>;
export type TicketGitPullRequestsProps = Pick<
ProjectTicketPageProps,
'repositoryPullRequests'
>;
export type TicketBranchCreateDialogProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'suggestedBranchName'
> & {
repositoryConnections: RepositoryConnectionModel[];
};
export type TicketDetailRelationshipsProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'parentTicketOptions'
| 'childTicketTree'
| 'linkedTickets'
| 'ticketLinkTypes'
| 'ticketLinkOptions'
>;
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
export type TicketBranchFormData = {
branch_name: string;
repository_connection_id: string;
};
@@ -1,6 +1,7 @@
import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketModel } from '@/types/models/ticket-model';
export type TicketPresentationProps = { export type TicketPresentationProps = {
layout?: 'card' | 'list';
projectId: number; projectId: number;
showDescription?: boolean; showDescription?: boolean;
showStatus?: boolean; showStatus?: boolean;
+6
View File
@@ -0,0 +1,6 @@
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
export type TicketTreeListProps = {
nodes: TicketTreeNodeModel[];
projectId: number;
};
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class AcceptanceCriteriaChecklistTest extends TestCase
{
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
{
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Acceptance criteria behavior checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,25 @@
<?php
namespace Tests\Feature\Admin;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class UserManagementUiTest extends TestCase
{
public function test_user_management_pages_render_responsive_actions_and_protected_forms(): void
{
$process = new Process(['node', 'tests/Fixtures/user-management.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue(
$process->isSuccessful(),
$process->getOutput().$process->getErrorOutput(),
);
$this->assertStringContainsString(
'User management rendering checks passed.',
$process->getOutput(),
);
}
}
@@ -71,6 +71,110 @@ class ProjectBacklogTest extends TestCase
); );
} }
/**
* Ensure the backlog payload supports nested tickets and their allowed status moves.
*/
public function test_backlog_includes_nested_tickets_and_allowed_status_moves(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create([
'key' => 'TREE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$boardStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Ready',
'position' => 1,
]);
$backlogStatus->allowedTargetStatuses()->sync([$boardStatus->id]);
$parentType = $project->ticketTypes()
->where('is_parent', true)
->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$parentTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'TREE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $parentType->id,
'title' => 'Parent ticket',
]);
$childTicket = Ticket::factory()->create([
'parent_ticket_id' => $parentTicket->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'TREE-2',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $taskType->id,
'title' => 'Child ticket',
]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->get(route('projects.backlog.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('canEditTickets', true)
->has('tickets', 2)
->where('tickets.0.id', $childTicket->id)
->where('tickets.0.parent_ticket_id', $parentTicket->id)
->where('tickets.0.ticket_status.allowed_target_statuses.0.id', $boardStatus->id)
->where('tickets.1.id', $parentTicket->id)
->where('tickets.1.parent_ticket_id', null)
->where('tickets.1.ticket_status.allowed_target_statuses.0.id', $boardStatus->id),
);
$this->assertDatabaseHas('tickets', [
'id' => $parentTicket->id,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseHas('tickets', [
'id' => $childTicket->id,
'parent_ticket_id' => $parentTicket->id,
'ticket_status_id' => $backlogStatus->id,
]);
}
/**
* Ensure projects without backlog tickets receive an empty collection.
*/
public function test_projects_without_backlog_tickets_receive_an_empty_collection(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.backlog.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('tickets', []),
);
}
/** /**
* Ensure creating tickets requires the project write permission. * Ensure creating tickets requires the project write permission.
*/ */
@@ -212,6 +212,19 @@ class ProjectBoardControllerTest extends TestCase
'entry_type' => 'status_changed', 'entry_type' => 'status_changed',
'summary' => 'Moved from Todo to Done.', 'summary' => 'Moved from Todo to Done.',
]); ]);
$this->assertSame(1, $ticket->worklogEntries()->count());
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $doneStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHasNoErrors();
$this->assertSame($doneStatus->id, $ticket->fresh()->ticket_status_id);
$this->assertSame(1, $ticket->worklogEntries()->count());
} }
/** /**
@@ -298,6 +298,79 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get()); $this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
} }
/**
* Ensure saved outgoing targets reach both consumers without changing the reverse direction.
*/
public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
$this->syncProjectPermissions($project, $user, ['edit']);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$sourceStatus = TicketStatus::factory()->for($project)->for($column)->create([
'name' => 'Progress',
'position' => 1,
]);
$reviewStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Review',
'position' => 2,
]);
$doneStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Done',
'position' => 3,
]);
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]);
$sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]);
$reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]);
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [$reviewStatus->id],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys());
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.current_status.id', $sourceStatus->id)
->assertJsonCount(1, 'data.target_statuses')
->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id);
$this->get(route('projects.board.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Board')
->where('boardColumns.0.tickets.0.id', $ticket->id)
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1)
->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id),
);
$this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
$this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.target_statuses', []);
$this->get(route('projects.board.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Board')
->where('boardColumns.0.tickets.0.id', $ticket->id)
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0),
);
}
/** /**
* Ensure transition updates only accept targets from the same project and never the source status itself. * Ensure transition updates only accept targets from the same project and never the source status itself.
*/ */
@@ -372,4 +445,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
'id' => $backlogStatus->id, 'id' => $backlogStatus->id,
]); ]);
} }
public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$backlog = TicketStatus::factory()->for($project)->create(['position' => 1]);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$statuses = TicketStatus::factory()->for($project)->for($column)->count(2)
->sequence(['position' => 1], ['position' => 2])->create();
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]);
$statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]);
$this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $column]))
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertModelMissing($column);
$this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id);
$this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all());
$this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all());
$this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null));
$this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
}
public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$status = TicketStatus::factory()->for($project)->for($column)->create();
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]);
$this->actingAs($user)->from(route('projects.settings.board', $project))
->delete(route('projects.settings.board.statuses.destroy', [$project, $status]))
->assertSessionHasErrors('ticket_status_id');
$this->assertModelExists($status);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->actingAs($user)->from(route('projects.settings.board', $project))
->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0])
->assertSessionHasErrors('position');
$this->assertSame(1, $column->fresh()->position);
$this->assertSame($column->name, $column->fresh()->name);
}
} }
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectBoardSettingsUiTest extends TestCase
{
public function test_workflow_renders_ordered_groups_deletion_protection_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-board-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectGeneralSettingsUiTest extends TestCase
{
public function test_general_settings_render_setting_sources_confirmations_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-general-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project general settings rendering checks passed.', $process->getOutput());
}
}
@@ -430,4 +430,66 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->missing('notificationActions.0.delivery_url'), ->missing('notificationActions.0.delivery_url'),
); );
} }
public function test_editor_accepts_array_filters_and_can_clear_discord_mentions(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$notificationAction = NotificationAction::factory()->discord()->for($project)->create([
'channel_configuration' => ['allowed_mentions' => ['users']],
'delivery_url' => 'https://discord.com/api/webhooks/123456/original-secret',
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
'name' => 'Paused alerts',
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
'channel' => NotificationChannel::Discord->value,
'enabled' => false,
'delivery_url' => null,
'message_template' => '$ticket_key: $url',
'channel_configuration' => ['allowed_mentions' => []],
'filters' => [
['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
],
])
->assertSessionHasNoErrors()
->assertRedirect(route('projects.settings.notifications', $project));
$notificationAction->refresh();
$this->assertFalse($notificationAction->enabled);
$this->assertSame(['allowed_mentions' => []], $notificationAction->channel_configuration);
$this->assertSame('https://discord.com/api/webhooks/123456/original-secret', $notificationAction->delivery_url);
$this->assertSame([
['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
], $notificationAction->filters);
}
public function test_invalid_editor_update_does_not_change_saved_configuration(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$notificationAction = NotificationAction::factory()->discord()->for($project)->create();
$original = $notificationAction->fresh()->getRawOriginal();
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.notifications', $project))
->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
'name' => 'Invalid update',
'event_type' => NotificationEventType::TicketCreate->value,
'channel' => NotificationChannel::Discord->value,
'enabled' => false,
'delivery_url' => 'invalid-url',
'message_template' => '',
'channel_configuration' => ['allowed_mentions' => ['unsupported']],
'filters' => [['attribute' => 'ticket.key', 'operator' => 'in', 'value' => []]],
])
->assertSessionHasErrors(['delivery_url', 'message_template', 'channel_configuration.allowed_mentions.0', 'filters.0.value']);
$this->assertSame($original, $notificationAction->fresh()->getRawOriginal());
}
} }
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectNotificationSettingsUiTest extends TestCase
{
public function test_notification_settings_render_actions_and_serialize_filter_values(): void
{
$process = new Process(['node', 'tests/Fixtures/project-notification-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Notification settings checks passed.', $process->getOutput());
}
}

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