Compare commits
10
Commits
v0.2.2
..
3f00e9f2b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f00e9f2b5 | ||
|
|
990e18e149 | ||
|
|
d34216f1b8 | ||
|
|
be89a4900d | ||
|
|
a43aff0477 | ||
|
|
7edd8daf49 | ||
|
|
718f6b991d | ||
|
|
1b2349e977 | ||
|
|
95db152b91 | ||
|
|
5194ed0a17 |
@@ -1,72 +1,18 @@
|
||||
name: "Lint and Test API"
|
||||
description: "Run Pint and the Pest test suite for the Laravel API"
|
||||
name: test_and_check_app
|
||||
description: Build the test image and run all application checks
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||
with:
|
||||
php-version: "8.5"
|
||||
extensions: mbstring, pdo_sqlite
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Verify Composer lockfile
|
||||
- name: Test and check app
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f composer.lock ]; then
|
||||
echo "::error::api/composer.lock not found"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer_cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ${{ steps.composer_cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: Prepare environment file
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
shell: bash
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
shell: bash
|
||||
|
||||
- name: Generate Wayfinder routes and actions
|
||||
run: php artisan wayfinder:generate --with-form
|
||||
shell: bash
|
||||
|
||||
- name: Build Vite app
|
||||
run: pnpm run build
|
||||
shell: bash
|
||||
|
||||
- name: Run ci check
|
||||
run: composer run ci:check
|
||||
shell: bash
|
||||
docker buildx build \
|
||||
--file Dockerfile \
|
||||
--target test \
|
||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||
--progress plain \
|
||||
--load \
|
||||
.
|
||||
|
||||
@@ -2,13 +2,7 @@ name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,19 +11,9 @@ env:
|
||||
REGISTRY: git.twyco.de
|
||||
|
||||
jobs:
|
||||
preconditions:
|
||||
if: >
|
||||
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Conditions met
|
||||
run: |
|
||||
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||
|
||||
test_and_check_app:
|
||||
name: test_and_check_app
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- preconditions
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -43,7 +27,6 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
needs:
|
||||
- preconditions
|
||||
- test_and_check_app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+2
-3
@@ -55,9 +55,8 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
||||
COPY . /var/www
|
||||
|
||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||
&& corepack enable pnpm \
|
||||
&& pnpm install --frozen-lockfile \
|
||||
&& pnpm run build
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
|
||||
FROM build AS test
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -47,15 +47,15 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"pnpm install",
|
||||
"pnpm run build"
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"dev:ssr": [
|
||||
"pnpm run build:ssr",
|
||||
"npm run build:ssr",
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||
],
|
||||
@@ -67,9 +67,9 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"pnpm audit --audit-level=high",
|
||||
"pnpm run lint:check",
|
||||
"pnpm run types:check",
|
||||
"npm audit --audit-level=high",
|
||||
"npm run lint:check",
|
||||
"npm run types:check",
|
||||
"@test"
|
||||
],
|
||||
"test": [
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# UI Rework 1: Englische Lokalisierungsgrundlage
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: Keine.
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Englische Lokalisierungsgrundlage als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/app.ts](../../resources/js/app.ts); [resources/js/ssr.ts](../../resources/js/ssr.ts); [resources/js/types](../../resources/js/types). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Eine zentrale Übersetzungsstruktur für Vue einführen.
|
||||
2. Englischen Katalog, Interpolation, Pluralformen und Formatierung festlegen.
|
||||
3. Serverseitige Validierungstexte einheitlich Englisch halten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Neue UI-Texte verwenden Schlüssel.
|
||||
- [ ] Fehlende Schlüssel fallen im Test auf.
|
||||
- [ ] Spätere Sprachen benötigen keine Änderungen an Komponenten.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,39 @@
|
||||
# UI Rework 10: Persönliches Dashboard mit vorhandenen Daten
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 8](epic-1-issue-8.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Persönliches Dashboard mit vorhandenen Daten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/Dashboard.vue](../../resources/js/pages/Dashboard.vue); [app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php](../../app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php); [routes/web.php](../../routes/web.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Persönliches Dashboard — 30b69ed84e5c4bdf8cd3fe26d7a9e803. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Dashboard aus zugewiesenen Tickets und sichtbaren Projekten aufbauen.
|
||||
2. Vorhandene Action über einen Inertia-Controller und Resources anbinden.
|
||||
3. Aus dem Mockup nicht belegbare Kennzahlen und Aktivitätsfeeds weglassen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine Platzhalterdaten.
|
||||
- [ ] Keine fremden Tickets.
|
||||
- [ ] Leere und gefüllte Dashboards funktionieren.
|
||||
- [ ] Neue Datenweitergabe ist durch Featuretests abgesichert.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/DashboardTest.php](../../tests/Feature/DashboardTest.php), [tests/Feature/Api/AssignedTicketsApiTest.php](../../tests/Feature/Api/AssignedTicketsApiTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,36 @@
|
||||
# UI Rework 11: Ticketkarten und wiederverwendbare Ticketlisten
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 3](epic-1-issue-3.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Ticketkarten und wiederverwendbare Ticketlisten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/components](../../resources/js/components); [resources/js/types/models/ticket-model.ts](../../resources/js/types/models/ticket-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Neue Karten und Listenzeilen mit Ticketkey, Titel, Typ, Priorität, Zuweisung und Blockerzustand entwickeln.
|
||||
2. Darstellung mit und ohne optionale Daten definieren.
|
||||
3. Gemeinsame Props und Ereignisse für Board, Backlog und Dashboard festlegen. Ticketlinks über Wayfinder aufbauen und Fokusführung sowie Touch-Ziele prüfen; die Komponenten dürfen keine eigenen Datenabfragen auslösen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Lange Titel bleiben bedienbar.
|
||||
- [ ] Prioritäten und Status stammen aus Projektdaten.
|
||||
- [ ] Links, Fokus und Touch-Ziele funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 12: Boardansicht für Desktop und Mobilgeräte
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 8](epic-1-issue-8.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Boardansicht für Desktop und Mobilgeräte als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Board.vue](../../resources/js/pages/projects/Board.vue); [app/Actions/Projects/BuildProjectBoardPayload.php](../../app/Actions/Projects/BuildProjectBoardPayload.php); [app/Http/Resources/BoardColumnResource.php](../../app/Http/Resources/BoardColumnResource.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Horizontales Board mit konfigurierten Spalten neu bauen.
|
||||
2. Mobil eine Spalte mit Auswahl anbieten.
|
||||
3. Statusunterteilung, Zähler, Leerzustände und vorhandene Statusauswahl darstellen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Beliebige Spaltenanzahl funktioniert.
|
||||
- [ ] Mehrere Status pro Spalte bleiben sichtbar.
|
||||
- [ ] Board und Backlog behalten ihre bestehende fachliche Trennung.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,40 @@
|
||||
# UI Rework 13: Drag-and-drop für bestehende Statuswechsel
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 12](epic-1-issue-12.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Drag-and-drop für bestehende Statuswechsel als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Board.vue](../../resources/js/pages/projects/Board.vue); [app/Http/Requests/MoveProjectTicketRequest.php](../../app/Http/Requests/MoveProjectTicketRequest.php); [app/Actions/Tickets/MoveProjectTicket.php](../../app/Actions/Tickets/MoveProjectTicket.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Ziehen per Maus und Touch ergänzen.
|
||||
2. Zulässige Ziele aus Workflowdaten ableiten.
|
||||
3. Bei mehreren Zielstatus Auswahl zeigen.
|
||||
4. Vorhandenen Statusendpunkt über Wayfinder verwenden und Fehler zurückrollen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Unzulässige Ziele sind gesperrt.
|
||||
- [ ] Alternative Tastatur-/Menübedienung bleibt möglich.
|
||||
- [ ] Kein doppeltes Worklog.
|
||||
- [ ] Keine persistierte freie Kartenreihenfolge.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 14: Backlog und Ticketbaum
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Backlog und Ticketbaum als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Backlog.vue](../../resources/js/pages/projects/Backlog.vue); [resources/js/components/TicketTreeList.vue](../../resources/js/components/TicketTreeList.vue); [app/Actions/Projects/BuildProjectBacklogPayload.php](../../app/Actions/Projects/BuildProjectBacklogPayload.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Backlog mit vorhandenen Filtern und Hierarchie neu darstellen.
|
||||
2. Eltern-Kind-Beziehungen, einklappbare Ebenen und vorhandene Aktionen integrieren.
|
||||
3. Mobile Listenansicht erstellen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Verschachtelte Tickets und leere Treffer funktionieren.
|
||||
- [ ] Berechtigungen und Statusübergänge bleiben erhalten.
|
||||
- [ ] Filter verändern keine fachlichen Daten.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBacklogTest.php](../../tests/Feature/Projects/ProjectBacklogTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 15: Neue Ticketdetailansicht und Seitenstruktur
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Neue Ticketdetailansicht und Seitenstruktur als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Actions/Projects/BuildProjectTicketDetailPayload.php](../../app/Actions/Projects/BuildProjectTicketDetailPayload.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Detailansicht in klar abgegrenzte Komponenten aufteilen.
|
||||
2. Desktop-Inhalt und Metadatenbereich sowie mobile Stapelansicht neu aufbauen.
|
||||
3. Stabile Einhängepunkte für Bearbeitung, Checkliste, Beziehungen, Historie und Git definieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Alle bestehenden Informationen bleiben zugänglich.
|
||||
- [ ] Keine bestehende Funktion wird beim Umbau entfernt.
|
||||
- [ ] Direkte Ticket-URLs funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 16: Ticketanlage und Bearbeitungsformulare
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 15](epic-1-issue-15.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Ticketanlage und Bearbeitungsformulare als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/TicketCreate.vue](../../resources/js/pages/projects/TicketCreate.vue); [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Requests/StoreProjectTicketRequest.php](../../app/Http/Requests/StoreProjectTicketRequest.php); [app/Http/Requests/UpdateProjectTicketRequest.php](../../app/Http/Requests/UpdateProjectTicketRequest.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Formulare für vorhandene Ticketattribute neu erstellen.
|
||||
2. Gemeinsame Feldkomponenten für Anlage und Bearbeitung verwenden.
|
||||
3. Projektkonfigurierte Felder, Autor-/Story-Point-Schalter und Validierung erhalten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Anlage und Bearbeitung speichern dieselben Attribute wie bisher.
|
||||
- [ ] Fehler und ungespeicherte Änderungen sind abgedeckt.
|
||||
- [ ] Gesperrte Aktionen bleiben serverseitig abgesichert.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 17: Akzeptanzkriterien-Checkliste
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 16](epic-1-issue-16.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Akzeptanzkriterien-Checkliste als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/lib/acceptance-criteria.ts](../../resources/js/lib/acceptance-criteria.ts); [resources/js/types/models/acceptance-criteria-model.ts](../../resources/js/types/models/acceptance-criteria-model.ts); [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Bestehende hierarchische Checkliste als neue Komponente aufbauen.
|
||||
2. Hinzufügen, Bearbeiten, Verschachteln und Abhaken entsprechend dem Bestand erhalten.
|
||||
3. In Anlage und Detail integrieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Hierarchie und Reihenfolge bleiben beim Speichern erhalten.
|
||||
- [ ] Bestehende Kriterien gehen nicht verloren.
|
||||
- [ ] Touch und Tastatur funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 18: Ticketbeziehungen und Hierarchie
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 15](epic-1-issue-15.md), [Issue 16](epic-1-issue-16.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Ticketbeziehungen und Hierarchie als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Controllers/ProjectTicketLinkController.php](../../app/Http/Controllers/ProjectTicketLinkController.php); [resources/js/components/TicketTreeList.vue](../../resources/js/components/TicketTreeList.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Eltern-/Kindanzeige und Ticketverknüpfungen neu gestalten.
|
||||
2. Bestehende Linktypen und Richtung verwenden.
|
||||
3. Erlaubte Hinzufüge-/Entfernaktionen integrieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Bestehende Hierarchievalidierung bleibt wirksam.
|
||||
- [ ] Linkrichtung wird korrekt angezeigt.
|
||||
- [ ] Kein neuer Beziehungstyp wird eingeführt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketLinkControllerTest.php](../../tests/Feature/Projects/ProjectTicketLinkControllerTest.php), [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 19: Blocker und Ticketänderungshistorie
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 15](epic-1-issue-15.md), [Issue 16](epic-1-issue-16.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Blocker und Ticketänderungshistorie als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [resources/js/types/models/ticket-worklog-entry-model.ts](../../resources/js/types/models/ticket-worklog-entry-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Blockieren/Entblockieren und Kommentarerfassung neu darstellen.
|
||||
2. Vorhandenes Worklog als lesbare Historie integrieren.
|
||||
3. Mobile Darstellung und lokale Datumsformatierung nutzen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Blockieren erfordert weiterhin Kommentar.
|
||||
- [ ] Historie zeigt echte Ereignisse und Akteure.
|
||||
- [ ] Keine neue Kommentar- oder Aktivitätsfunktion.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,36 @@
|
||||
# UI Rework 2: Emerald-Designsystem und Hell/Dunkel/System
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 1](epic-1-issue-1.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Emerald-Designsystem und Hell/Dunkel/System als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/css/app.css](../../resources/css/app.css); [resources/js/composables/useAppearance.ts](../../resources/js/composables/useAppearance.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Farben, Typografie, Abstände, Radien und Fokuszustände aus Stitch als semantische Tokens definieren.
|
||||
2. Inter lokal ausliefern.
|
||||
3. Hell/Dunkel/System einschließlich initialem Laden umsetzen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Beide Themes sind lesbar.
|
||||
- [ ] Systemwechsel aktualisieren die Ansicht.
|
||||
- [ ] Reduzierte Bewegung wird berücksichtigt.
|
||||
- [ ] Keine Starterkit-Optik.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 20: Git-Bereich und Branch-Erstellung am Ticket
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 15](epic-1-issue-15.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Git-Bereich und Branch-Erstellung am Ticket als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Controllers/ProjectTicketBranchController.php](../../app/Http/Controllers/ProjectTicketBranchController.php); [resources/js/types/models/ticket-repository-branch-model.ts](../../resources/js/types/models/ticket-repository-branch-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Branch-/Pull-Request-Anzeigen sowie bestehenden Branch-Erstellablauf in eigene neue Komponenten überführen.
|
||||
2. Repositoryauswahl, Namensvorschlag und vorhandene Prüfungen erhalten.
|
||||
3. Laufende Anfragen, fehlenden Git-Zugriff und Providerfehler als eigene Zustände abbilden. Den Weg zu vorhandenen Verbindungseinstellungen anbieten, Formulareingaben bei Fehlern erhalten und externe Git-Links entsprechend kennzeichnen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Fehlende Git-Verbindung und Providerfehler sind verständlich.
|
||||
- [ ] Branch-Erstellung funktioniert.
|
||||
- [ ] Fremde Git-Links öffnen extern.
|
||||
- [ ] Bestehende Zuordnungsregeln bleiben erhalten.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketBranchControllerTest.php](../../tests/Feature/Projects/ProjectTicketBranchControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 21: Profil und Erscheinungsbild
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Profil und Erscheinungsbild als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/settings/Profile.vue](../../resources/js/pages/settings/Profile.vue); [resources/js/pages/settings/Appearance.vue](../../resources/js/pages/settings/Appearance.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Profilfelder, Avatar-Upload/Entfernen und Erscheinungsbild neu gestalten.
|
||||
2. Gemeinsame Einstellungsnavigation aufbauen.
|
||||
3. Datenschutz durch bestehende Rechte und Endpunkte erhalten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Avatar und Profiländerungen funktionieren.
|
||||
- [ ] Hell/Dunkel/System bleibt nach Neustart erhalten.
|
||||
- [ ] Uploadfehler und mobile Dateiauswahl sind berücksichtigt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Settings/ProfileUpdateTest.php](../../tests/Feature/Settings/ProfileUpdateTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 22: Sicherheit und Anwendungsschlüssel
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 6](epic-1-issue-6.md), [Issue 7](epic-1-issue-7.md), [Issue 21](epic-1-issue-21.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Sicherheit und Anwendungsschlüssel als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/settings/Security.vue](../../resources/js/pages/settings/Security.vue); [resources/js/pages/settings/Applications.vue](../../resources/js/pages/settings/Applications.vue); [resources/js/components/TwoFactorSetupModal.vue](../../resources/js/components/TwoFactorSetupModal.vue); [resources/js/components/TwoFactorRecoveryCodes.vue](../../resources/js/components/TwoFactorRecoveryCodes.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Passwortverwaltung, 2FA-Einrichtung, Recovery-Codes und vorhandene Anwendungsschlüssel neu darstellen.
|
||||
2. Sensible Bestätigungsabläufe erhalten.
|
||||
3. Zustände vor und nach 2FA-Bestätigung sowie beim Anzeigen und Erneuern von Recovery-Codes anhand der bestehenden Endpunkte abbilden. Schlüsselanzeige und Kopierfunktion an die vorhandene Freigabelogik binden; fehlgeschlagene Bestätigungen direkt am Formular erklären.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] 2FA-Aktivieren/Deaktivieren und Recovery-Codes funktionieren.
|
||||
- [ ] Schlüssel werden weiterhin nur nach bestehender Logik angezeigt.
|
||||
- [ ] Abbruch und Fehler sind sicher.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Settings/SecurityTest.php](../../tests/Feature/Settings/SecurityTest.php), [tests/Feature/Settings/UserApplicationTest.php](../../tests/Feature/Settings/UserApplicationTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 23: Persönliche Git-Verbindungen
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 21](epic-1-issue-21.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Persönliche Git-Verbindungen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/settings/GitConnections.vue](../../resources/js/pages/settings/GitConnections.vue); [app/Http/Controllers/Settings/GitConnectionController.php](../../app/Http/Controllers/Settings/GitConnectionController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Vorhandene Git-Verbindungen und Formulare neu gestalten.
|
||||
2. Provider- und Serverangaben klar darstellen.
|
||||
3. Validierungs- und Providerfehler in das neue Rückmeldesystem integrieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Erstellen, Bearbeiten und Entfernen bleiben möglich.
|
||||
- [ ] Zugangsdaten werden nicht zusätzlich offengelegt.
|
||||
- [ ] Leere Listen und fehlende Berechtigungen funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Settings/GitConnectionTest.php](../../tests/Feature/Settings/GitConnectionTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 24: Allgemeine Projekteinstellungen und Vererbung
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 9](epic-1-issue-9.md), [Issue 5](epic-1-issue-5.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Allgemeine Projekteinstellungen und Vererbung als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/General.vue](../../resources/js/pages/projects/settings/General.vue); [resources/js/components/projects/TemplateInheritancePanel.vue](../../resources/js/components/projects/TemplateInheritancePanel.vue); [app/Http/Controllers/ProjectController.php](../../app/Http/Controllers/ProjectController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Einstellungsnavigation und allgemeine Projektseite neu aufbauen.
|
||||
2. Vererbung und lokale Overrides nachvollziehbar anzeigen.
|
||||
3. Bestehende Vorlagenwechsel-/Reset- und Löschbestätigungen erhalten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Geerbte und lokale Werte sind unterscheidbar.
|
||||
- [ ] Destruktive Aktionen erfordern bestehende Bestätigung.
|
||||
- [ ] Keine neue Vorlagenlogik.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTemplateOverrideTest.php](../../tests/Feature/Projects/ProjectTemplateOverrideTest.php), [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 25: Workflow-Einstellungen für Spalten und Status
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 24](epic-1-issue-24.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Workflow-Einstellungen für Spalten und Status als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/Board.vue](../../resources/js/pages/projects/settings/Board.vue); [app/Http/Controllers/ProjectBoardSettingsController.php](../../app/Http/Controllers/ProjectBoardSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Spalten, Statuszuordnung, Namen und vorhandene Sortierung neu bearbeiten lassen.
|
||||
2. Backlogstatus und Löschbedingungen aus dem Bestand übernehmen.
|
||||
3. Übergangsmatrix als separate Komponente vorbereiten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Vorhandene CRUD-Aktionen funktionieren.
|
||||
- [ ] Gültige Positionen bleiben erhalten.
|
||||
- [ ] Genutzte Status und Spalten werden nicht unzulässig entfernt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardSettingsControllerTest.php](../../tests/Feature/Projects/ProjectBoardSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 26: Bearbeitung erlaubter Statusübergänge
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 25](epic-1-issue-25.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Bearbeitung erlaubter Statusübergänge als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/Board.vue](../../resources/js/pages/projects/settings/Board.vue); [app/Http/Controllers/ProjectBoardSettingsController.php](../../app/Http/Controllers/ProjectBoardSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Vorhandene Übergangsregeln als übersichtliche, mobil bedienbare Oberfläche neu erstellen.
|
||||
2. Nur existierende Workflowfähigkeiten abbilden.
|
||||
3. Gespeicherten Zustand und Fehler klar anzeigen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Erlaubte und verbotene Übergänge werden korrekt gespeichert.
|
||||
- [ ] Board-Dropziele entsprechen der Konfiguration.
|
||||
- [ ] Keine neue Workflow-Engine.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardSettingsControllerTest.php](../../tests/Feature/Projects/ProjectBoardSettingsControllerTest.php), [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 27: Tickettypen, Prioritäten und Anzeigeoptionen
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 24](epic-1-issue-24.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Tickettypen, Prioritäten und Anzeigeoptionen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/Metadata.vue](../../resources/js/pages/projects/settings/Metadata.vue); [app/Http/Controllers/ProjectTicketMetadataSettingsController.php](../../app/Http/Controllers/ProjectTicketMetadataSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Typen, Prioritäten und bestehende Autor-/Story-Point-Optionen neu darstellen.
|
||||
2. Hinzufügen, Umbenennen, Reihenfolge und bestehende Ersetzungsregeln beim Löschen erhalten.
|
||||
3. Geschützte und bearbeitbare Einträge anhand vorhandener Berechtigungen unterscheiden. Vor dem Löschen die benötigte Ersatzzuordnung erklären und Validierungsfehler am betroffenen Eintrag anzeigen, ohne projektseitige Regeln zu verändern.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Geschützte Typen bleiben geschützt.
|
||||
- [ ] Löschersatz und Reihenfolge funktionieren.
|
||||
- [ ] Inhalte bleiben projektkonfigurierbar.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php](../../tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 28: Benutzerdefinierte Felder und Linktypen
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 27](epic-1-issue-27.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Benutzerdefinierte Felder und Linktypen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/Metadata.vue](../../resources/js/pages/projects/settings/Metadata.vue); [app/Http/Controllers/ProjectTicketMetadataSettingsController.php](../../app/Http/Controllers/ProjectTicketMetadataSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Bestehende Felddefinitionen und Linktypen in separate neue Einstellungsabschnitte aufteilen.
|
||||
2. Vorhandene Feldtypen, Richtung und Gegenbezeichnungen erhalten.
|
||||
3. Anlage, Bearbeitung und vorhandene Löschabläufe über die bestehenden Endpunkte anbinden. Ungültige Definitionen mit konkreten Feldfehlern zurückmelden und bestehende Schutzregeln für bereits verwendete Felder und Linktypen in der Oberfläche nachvollziehbar machen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine neuen Feldtypen.
|
||||
- [ ] Vorhandene Feldwerte bleiben erhalten.
|
||||
- [ ] Linktypen und Gegenrichtung werden korrekt angezeigt und bearbeitet.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php](../../tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 29: Repository-Einstellungen des Projekts
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 23](epic-1-issue-23.md), [Issue 24](epic-1-issue-24.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Repository-Einstellungen des Projekts als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/Repositories.vue](../../resources/js/pages/projects/settings/Repositories.vue); [app/Http/Controllers/ProjectRepositorySettingsController.php](../../app/Http/Controllers/ProjectRepositorySettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Projekt-Repository-Verbindungen mit vorhandener Discovery, Auswahl und Bearbeitung neu aufbauen.
|
||||
2. Persönliche Verbindungen und Projektzuordnung klar unterscheiden.
|
||||
3. Ladezustände, leere Suchergebnisse, fehlende persönliche Verbindung und Providerfehler getrennt darstellen. Auf die vorhandenen persönlichen Git-Einstellungen verlinken und nach erfolgreicher Zuordnung die Projektliste aus der Serverantwort aktualisieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Vorhandene Provider werden unterstützt.
|
||||
- [ ] Fehlende Verbindung und Providerfehler sind abgedeckt.
|
||||
- [ ] Tickets nutzen weiterhin dieselben Repositories.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php](../../tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,35 @@
|
||||
# UI Rework 3: Neue grundlegende UI-Komponenten
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 1](epic-1-issue-1.md), [Issue 2](epic-1-issue-2.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Neue grundlegende UI-Komponenten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/components/ui](../../resources/js/components/ui). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Buttons, Eingaben, Auswahlfelder, Checkboxen, Badges und Avatare neu gestalten.
|
||||
2. Zugängliches Verhalten vorhandener Headless-Primitiven wiederverwenden.
|
||||
3. Öffentliche Props für Folgetickets stabil festlegen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Tastatur, Fokus, disabled, invalid und loading funktionieren.
|
||||
- [ ] Sichtbare Texte sind übersetzbar.
|
||||
- [ ] Komponenten funktionieren in beiden Themes.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 30: Projekt-Benachrichtigungseinstellungen
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 24](epic-1-issue-24.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Projekt-Benachrichtigungseinstellungen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/settings/Notifications.vue](../../resources/js/pages/projects/settings/Notifications.vue); [app/Http/Controllers/ProjectNotificationSettingsController.php](../../app/Http/Controllers/ProjectNotificationSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Bestehende Aktionen, Trigger, Filter und Nachrichtenvorlagen neu gestalten.
|
||||
2. Vorhandene Delivery-Informationen und Validierung nutzen.
|
||||
3. Sensible Webhookfelder sorgfältig darstellen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Bestehende Benachrichtigungen lassen sich unverändert konfigurieren.
|
||||
- [ ] Keine neuen Trigger oder Push-Dienste.
|
||||
- [ ] Fehlerfälle sind getestet.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php](../../tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 31: Verwaltung wiederverwendbarer Projektvorlagen
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 9](epic-1-issue-9.md), [Issue 24](epic-1-issue-24.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Verwaltung wiederverwendbarer Projektvorlagen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/project-templates/Index.vue](../../resources/js/pages/project-templates/Index.vue); [resources/js/pages/project-templates/Create.vue](../../resources/js/pages/project-templates/Create.vue); [resources/js/pages/project-templates/Edit.vue](../../resources/js/pages/project-templates/Edit.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Vorlagenübersicht, Erstellung und Bearbeitung neu aufbauen.
|
||||
2. Auswirkungen auf vererbende Projekte und Bestätigungen darstellen.
|
||||
3. Interne Basisvorlagen weiterhin ausblenden.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Vorhandene Erstell-/Bearbeitungsflüsse funktionieren.
|
||||
- [ ] Unveränderliche Slugs bleiben gesperrt.
|
||||
- [ ] Keine Änderungen an Vererbungsregeln.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTemplateControllerTest.php](../../tests/Feature/Projects/ProjectTemplateControllerTest.php), [tests/Feature/Projects/ProjectTemplateOverrideTest.php](../../tests/Feature/Projects/ProjectTemplateOverrideTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,36 @@
|
||||
# UI Rework 32: Teams und Mitgliedschaft
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Teams und Mitgliedschaft als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/teams/Index.vue](../../resources/js/pages/teams/Index.vue); [app/Http/Controllers/TeamController.php](../../app/Http/Controllers/TeamController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Teamübersicht, Teamverwaltung und Mitgliedsauswahl im neuen Design umsetzen.
|
||||
2. Alle bestehenden Rechte und Aktionen übernehmen.
|
||||
3. Mobile Bedienung sicherstellen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Team- und Mitgliedsaktionen entsprechen dem Bestand.
|
||||
- [ ] Fehlende Rechte und Validierungsfehler sind abgedeckt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Teams/TeamControllerTest.php](../../tests/Feature/Teams/TeamControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 33: Projektzugriff und Rechtezuweisung
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 8](epic-1-issue-8.md), [Issue 32](epic-1-issue-32.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Projektzugriff und Rechtezuweisung als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Access.vue](../../resources/js/pages/projects/Access.vue); [app/Http/Controllers/ProjectController.php](../../app/Http/Controllers/ProjectController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Direkte Benutzerrechte und Teamrechte neu darstellen.
|
||||
2. Effektive Rechte und Bearbeitungsgrenzen nachvollziehbar machen.
|
||||
3. Bestehende Zuweisungsendpunkte weiterverwenden.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine neue Rollenlogik.
|
||||
- [ ] Rechte gelten weiterhin additiv nach Bestand.
|
||||
- [ ] Unberechtigte Zuweisungen werden serverseitig abgewiesen.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php), [tests/Feature/Permissions/PermissionServiceTest.php](../../tests/Feature/Permissions/PermissionServiceTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 34: Benutzeradministration
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Benutzeradministration als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/admin/users/Index.vue](../../resources/js/pages/admin/users/Index.vue); [resources/js/pages/admin/users/Create.vue](../../resources/js/pages/admin/users/Create.vue); [resources/js/pages/admin/users/Edit.vue](../../resources/js/pages/admin/users/Edit.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Übersicht, Benutzeranlage und Bearbeitung vollständig neu gestalten.
|
||||
2. Vorhandene Felder, Adminoptionen und erzwungenen Passwortwechsel erhalten.
|
||||
3. Verfügbare Aktionen aus den bestehenden Berechtigungen ableiten, sensible Änderungen bestätigen lassen und Validierungsfehler direkt den Eingaben zuordnen. Nach Speichern oder Abbruch zur passenden Übersicht zurückführen, ohne neue Rollen oder Verwaltungsaktionen einzuführen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Bestehende Adminaktionen funktionieren.
|
||||
- [ ] Nicht-Admins erhalten keinen Zugriff.
|
||||
- [ ] Formfehler und sensible Aktionen sind abgedeckt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Admin/UserManagementControllerTest.php](../../tests/Feature/Admin/UserManagementControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,36 @@
|
||||
# UI Rework 35: Öffentliche Einstiegsseite
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 2](epic-1-issue-2.md), [Issue 3](epic-1-issue-3.md), [Issue 6](epic-1-issue-6.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Öffentliche Einstiegsseite als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/Welcome.vue](../../resources/js/pages/Welcome.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Landing Page - Open Source & Self-Hosted — 41a744db2b1f4e01a9c597835b58759e. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Neue kompakte öffentliche Einstiegsseite mit KanbanGit-Branding und Anmeldung entwickeln.
|
||||
2. Stitch-Landingpage ausschließlich als Stilreferenz nutzen.
|
||||
3. Nur vorhandene Funktionen beschreiben.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine erfundenen Produktfunktionen, Tarife oder Statistiken.
|
||||
- [ ] Responsiv und lokalisierbar.
|
||||
- [ ] Einstieg in Anmeldung funktioniert.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 36: Tauri-Grundgerüst und macOS-Entwicklungsbuild
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: Keine.
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Tauri-Grundgerüst und macOS-Entwicklungsbuild als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native/` (in diesem Ticket neu anzulegen); [.gitignore](../../.gitignore). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Remote-Inhalte und native Berechtigungen](https://v2.tauri.app/security/capabilities/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Isoliertes Tauri-2-Projekt im gemeinsamen Repository anlegen.
|
||||
2. Minimale lokale Startansicht und Server-WebView erstellen.
|
||||
3. Entwicklungsbuild auf macOS mit vorhandenem KanbanGit prüfen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Laravel bleibt am bisherigen Ort.
|
||||
- [ ] macOS-App startet und zeigt die Serveroberfläche.
|
||||
- [ ] Keine allgemeine native API für Remote-Inhalte.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 37: Serverauswahl, Sitzungen und Verbindungsfehler
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 36](epic-1-issue-36.md), [Issue 6](epic-1-issue-6.md), [Issue 7](epic-1-issue-7.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Serverauswahl, Sitzungen und Verbindungsfehler als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native` (entsteht ab Issue 36); [resources/js/pages/auth](../../resources/js/pages/auth). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Remote-Inhalte und native Berechtigungen](https://v2.tauri.app/security/capabilities/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Eine aktive, frei wählbare HTTPS-Installation speichern.
|
||||
2. Serverwechsel und lokale Fehleransicht umsetzen.
|
||||
3. Sitzungen, Logout, 2FA, Start ohne Verbindung und Formularfehler prüfen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Kein Zertifikats-Bypass.
|
||||
- [ ] Sitzungserhalt folgt Laravel.
|
||||
- [ ] Serverwechsel übernimmt keine fremden Zugangsdaten.
|
||||
- [ ] Bestehende Eingaben werden bei Verbindungsfehler nicht durch einen leeren Bildschirm ersetzt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 38: Android-App und Plattformanpassung
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 37](epic-1-issue-37.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Android-App und Plattformanpassung als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native (Android-Plattformprojekt)` (entsteht ab Issue 36). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Android-Ziel konfigurieren und Entwicklungsbuild erstellen.
|
||||
2. Zurück-Taste, Systemleisten, Safe Areas und Bildschirmtastatur anpassen.
|
||||
3. Bestehende Server-/Anmeldeabläufe prüfen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] App startet auf Emulator und geeignetem Gerät.
|
||||
- [ ] Login, 2FA, Navigation und Wiederaufnahme funktionieren.
|
||||
- [ ] Keine eigenständige zweite Fachoberfläche.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 39: iOS/iPadOS-App für lokale Xcode-Builds
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 37](epic-1-issue-37.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
iOS/iPadOS-App für lokale Xcode-Builds als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native (Apple-Plattformprojekt)` (entsteht ab Issue 36). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. IOS/iPadOS-Ziel für lokalen Xcode-Build konfigurieren.
|
||||
2. WKWebView-Sitzungen, Safe Areas, Tastatur und Größenwechsel prüfen.
|
||||
3. Benötigte lokale Einrichtung im Ticket dokumentieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Simulatorbuild funktioniert.
|
||||
- [ ] Gerätebuild nutzt vom Nutzer bereitgestellte Signierung.
|
||||
- [ ] Kein TestFlight- oder Store-Upload.
|
||||
- [ ] IPad bleibt bedienbar.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,35 @@
|
||||
# UI Rework 4: Responsive Anwendungsnavigation
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 3](epic-1-issue-3.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Responsive Anwendungsnavigation als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/layouts](../../resources/js/layouts); [resources/js/components/AppSidebar.vue](../../resources/js/components/AppSidebar.vue); [resources/js/types/navigation.ts](../../resources/js/types/navigation.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Neue Sidebar, obere Werkzeugleiste, Projektnavigation und mobile Navigation aufbauen.
|
||||
2. Nur vorhandene und erlaubte Bereiche verlinken.
|
||||
3. Aktuelle Auswahl und kompakte Bildschirmgrößen berücksichtigen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Alle bestehenden Bereiche bleiben erreichbar.
|
||||
- [ ] Rollen sehen nur erlaubte Links.
|
||||
- [ ] Browser-Zurück und mobile Navigation funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 40: App-Links, externe Links und Dateiauswahl
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 20](epic-1-issue-20.md), [Issue 21](epic-1-issue-21.md), [Issue 38](epic-1-issue-38.md), [Issue 39](epic-1-issue-39.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
App-Links, externe Links und Dateiauswahl als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native` (entsteht ab Issue 36); [resources/js/components](../../resources/js/components). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Remote-Inhalte und native Berechtigungen](https://v2.tauri.app/security/capabilities/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Interne App-Links zur ausgewählten Installation und externe Browserlinks umsetzen.
|
||||
2. Avatar-Dateiauswahl, Zwischenablage und Browser-Zurück plattformübergreifend absichern.
|
||||
3. Native Schnittstellen eng begrenzen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Fremde Links wechseln nicht ungefragt den Server.
|
||||
- [ ] Externe Git-Links funktionieren.
|
||||
- [ ] Uploads und Kopieren funktionieren auf allen App-Plattformen.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 41: Signierter macOS-Release-Download
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 40](epic-1-issue-40.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Signierter macOS-Release-Download als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native` (entsteht ab Issue 36); [.github/workflows](../../.github/workflows). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [macOS-Signierung](https://v2.tauri.app/distribute/sign/macos/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Reproduzierbaren macOS-Releasebuild mit Signierung und Notarisierung vorbereiten.
|
||||
2. Zertifikate nur als externe Secrets erwarten.
|
||||
3. Installationsartefakt für Git-Releases erzeugen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Builds verwenden keine eingecheckten Schlüssel.
|
||||
- [ ] Signiertes Artefakt lässt sich mit bereitgestellten Voraussetzungen installieren.
|
||||
- [ ] Fehlende Secrets werden klar gemeldet.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Rework 42: Signiertes Android-APK für Git-Releases
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 40](epic-1-issue-40.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Signiertes Android-APK für Git-Releases als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native` (entsteht ab Issue 36); [.github/workflows](../../.github/workflows). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Android-Releasevorbereitung](https://developer.android.com/studio/publish/preparing).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Reproduzierbaren signierten APK-Releasebuild konfigurieren.
|
||||
2. Dauerhafte Signing-Identität über Secrets beziehen.
|
||||
3. Versionierung und Upgrade vorhandener Installationen berücksichtigen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] APK ist direkt installierbar.
|
||||
- [ ] Updates behalten Daten/Sitzungen entsprechend Plattformverhalten.
|
||||
- [ ] Schlüssel fehlen im Repository.
|
||||
- [ ] Kein Play-Store-Upload.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,39 @@
|
||||
# UI Rework 43: Getrennte Prüf- und Build-Pipelines
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 39](epic-1-issue-39.md), [Issue 41](epic-1-issue-41.md), [Issue 42](epic-1-issue-42.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Getrennte Prüf- und Build-Pipelines als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [Dockerfile](../../Dockerfile); [.github/workflows/ci_cd.yml](../../.github/workflows/ci_cd.yml); [.github/tests/test_and_check_app/action.yml](../../.github/tests/test_and_check_app/action.yml). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
Technische Referenz: [Tauri 2](https://v2.tauri.app/).
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Laravel/Web-Prüfungen von Tauri-Builds trennen.
|
||||
2. Passende Linux-/macOS-Runner festlegen.
|
||||
3. Änderungen nach Pfaden zuordnen, gemeinsame Änderungen vollständig prüfen.
|
||||
4. Bestehendes Serverrelease erhalten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Serverimage enthält keine native Toolchain.
|
||||
- [ ] Erforderliche Prüfungen werden nicht übersprungen.
|
||||
- [ ] macOS/Android-Artefakte sind getrennt.
|
||||
- [ ] iOS bleibt Selbstbau.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 44: Web-Abnahme und Entfernung des alten Frontends
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 1](epic-1-issue-1.md), [Issue 2](epic-1-issue-2.md), [Issue 3](epic-1-issue-3.md), [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md), [Issue 6](epic-1-issue-6.md), [Issue 7](epic-1-issue-7.md), [Issue 8](epic-1-issue-8.md), [Issue 9](epic-1-issue-9.md), [Issue 10](epic-1-issue-10.md), [Issue 11](epic-1-issue-11.md), [Issue 12](epic-1-issue-12.md), [Issue 13](epic-1-issue-13.md), [Issue 14](epic-1-issue-14.md), [Issue 15](epic-1-issue-15.md), [Issue 16](epic-1-issue-16.md), [Issue 17](epic-1-issue-17.md), [Issue 18](epic-1-issue-18.md), [Issue 19](epic-1-issue-19.md), [Issue 20](epic-1-issue-20.md), [Issue 21](epic-1-issue-21.md), [Issue 22](epic-1-issue-22.md), [Issue 23](epic-1-issue-23.md), [Issue 24](epic-1-issue-24.md), [Issue 25](epic-1-issue-25.md), [Issue 26](epic-1-issue-26.md), [Issue 27](epic-1-issue-27.md), [Issue 28](epic-1-issue-28.md), [Issue 29](epic-1-issue-29.md), [Issue 30](epic-1-issue-30.md), [Issue 31](epic-1-issue-31.md), [Issue 32](epic-1-issue-32.md), [Issue 33](epic-1-issue-33.md), [Issue 34](epic-1-issue-34.md), [Issue 35](epic-1-issue-35.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Web-Abnahme und Entfernung des alten Frontends als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js](../../resources/js); [resources/css](../../resources/css); [tests/Feature](../../tests/Feature). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Alle vorhandenen Seiten und Aktionen gegen die Bestandsliste prüfen.
|
||||
2. Desktop/Tablet/Mobil sowie Hell/Dunkel/System visuell und funktional abnehmen.
|
||||
3. Nur nicht mehr referenzierte alte UI-Dateien entfernen.
|
||||
4. Größere neue Befunde in eigene Folgeaufgaben abgrenzen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine Starterkit-Seite oder unübersetzte neue UI-Texte.
|
||||
- [ ] Keine verlorenen Bestandsfunktionen.
|
||||
- [ ] Keine bestehenden Tests entfernt.
|
||||
- [ ] Relevante Tests, Build, Lint und abschließender Typecheck bestehen.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,36 @@
|
||||
# UI Rework 45: Plattformübergreifende App-Abnahme
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 40](epic-1-issue-40.md), [Issue 41](epic-1-issue-41.md), [Issue 42](epic-1-issue-42.md), [Issue 43](epic-1-issue-43.md), [Issue 44](epic-1-issue-44.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Plattformübergreifende App-Abnahme als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: `native` (entsteht ab Issue 36); [resources/js](../../resources/js). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. macOS, Android und iOS/iPadOS mit fertigem Webfrontend prüfen.
|
||||
2. Serverwechsel, Sessionablauf, Serverupdate, Verbindungsabbruch, Links und Dateiauswahl abnehmen.
|
||||
3. Dokumentierte reproduzierbare Befunde mit klaren Reproduktionsschritten festhalten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Alle vereinbarten App-Abläufe bestehen.
|
||||
- [ ] Keine Produktionsdaten als Testfixtures verändert.
|
||||
- [ ] Releasewege entsprechen Vereinbarung.
|
||||
- [ ] Größere Defekte werden nicht im Abnahmeticket versteckt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,35 @@
|
||||
# UI Rework 5: Dialoge, Rückmeldungen und Eingabeschutz
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 3](epic-1-issue-3.md), [Issue 4](epic-1-issue-4.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Dialoge, Rückmeldungen und Eingabeschutz als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/components](../../resources/js/components); [resources/js/composables](../../resources/js/composables). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Dialoge, Menüs, Leer-, Lade- und Fehlerzustände vereinheitlichen.
|
||||
2. Formfehler und Wiederholaktionen definieren.
|
||||
3. Ungespeicherte Eingaben gegen Navigation und fehlgeschlagene Requests absichern.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine verlorenen Eingaben bei Requestfehlern.
|
||||
- [ ] Dialogfokus wird korrekt zurückgegeben.
|
||||
- [ ] Netzwerkfehler und serverseitige Validierung sind unterscheidbar.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 6: Anmeldung, E-Mail-Verifikation und 2FA-Challenge
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Anmeldung, E-Mail-Verifikation und 2FA-Challenge als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/auth/Login.vue](../../resources/js/pages/auth/Login.vue); [resources/js/pages/auth/VerifyEmail.vue](../../resources/js/pages/auth/VerifyEmail.vue); [resources/js/pages/auth/TwoFactorChallenge.vue](../../resources/js/pages/auth/TwoFactorChallenge.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Die drei Ansichten vollständig neu erstellen.
|
||||
2. Bestehende Fortify-Routen und Sessionflüsse verwenden.
|
||||
3. Mobile Tastatur, Autofill und Recovery-Codes berücksichtigen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Normale und zweistufige Anmeldung funktionieren.
|
||||
- [ ] Fehler erhalten passende Eingaben.
|
||||
- [ ] Bestehende Autorisierungsregeln bleiben wirksam.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Auth/AuthenticationTest.php](../../tests/Feature/Auth/AuthenticationTest.php), [tests/Feature/Auth/TwoFactorChallengeTest.php](../../tests/Feature/Auth/TwoFactorChallengeTest.php), [tests/Feature/Auth/EmailVerificationTest.php](../../tests/Feature/Auth/EmailVerificationTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 7: Passwort- und Bestätigungsseiten
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 6](epic-1-issue-6.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Passwort- und Bestätigungsseiten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/auth/ForgotPassword.vue](../../resources/js/pages/auth/ForgotPassword.vue); [resources/js/pages/auth/ResetPassword.vue](../../resources/js/pages/auth/ResetPassword.vue); [resources/js/pages/auth/ConfirmPassword.vue](../../resources/js/pages/auth/ConfirmPassword.vue); [resources/js/pages/auth/ForcePasswordChange.vue](../../resources/js/pages/auth/ForcePasswordChange.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Passwort-Reset, sensible Aktionsbestätigung und erzwungenen Passwortwechsel neu gestalten.
|
||||
2. Bestehende Fortify-Verträge erhalten.
|
||||
3. Zustände abgelaufener oder ungültiger Links berücksichtigen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Alle vier Flüsse funktionieren mit bestehenden Endpunkten.
|
||||
- [ ] Sensible Werte werden nicht in Entwürfen gespeichert.
|
||||
- [ ] Erfolg und Fehler sind klar.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Auth/PasswordResetTest.php](../../tests/Feature/Auth/PasswordResetTest.php), [tests/Feature/Auth/PasswordConfirmationTest.php](../../tests/Feature/Auth/PasswordConfirmationTest.php), [tests/Feature/Settings/SecurityTest.php](../../tests/Feature/Settings/SecurityTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 8: Projektübersicht und Projekteinstieg
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Projektübersicht und Projekteinstieg als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Index.vue](../../resources/js/pages/projects/Index.vue); [resources/js/pages/projects/Show.vue](../../resources/js/pages/projects/Show.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Projektübersicht und Projekteinstieg neu aufbauen.
|
||||
2. Erlaubte Aktionen und direkte Wege zu Board, Backlog und Einstellungen anbieten.
|
||||
3. Vorhandene Resources nutzen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Nur sichtbare Projekte erscheinen.
|
||||
- [ ] Leerzustand und eingeschränkte Rechte sind abgedeckt.
|
||||
- [ ] Keine Mockup-Funktionen ohne Backend.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,37 @@
|
||||
# UI Rework 9: Projektanlage, Duplikation und Vorlagenauswahl
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 8](epic-1-issue-8.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Projektanlage, Duplikation und Vorlagenauswahl als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Create.vue](../../resources/js/pages/projects/Create.vue); [app/Http/Controllers/ProjectController.php](../../app/Http/Controllers/ProjectController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Neuen Erstellablauf für leeres Projekt, Duplikat und Vorlage bauen.
|
||||
2. Bedingte Felder und Kopieroptionen aus dem Bestand übernehmen.
|
||||
3. Backend-Verträge nur bei Darstellungsbedarf anpassen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Alle drei vorhandenen Erstellwege bleiben korrekt.
|
||||
- [ ] Validierung ist verständlich.
|
||||
- [ ] Operative Ticketdaten werden weiterhin nicht dupliziert.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php), [tests/Feature/Projects/ProjectTemplateControllerTest.php](../../tests/Feature/Projects/ProjectTemplateControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Epic 1: KanbanGit UI Rework und Tauri-Apps
|
||||
|
||||
## Scope
|
||||
|
||||
Die vorhandene, aus dem Laravel-Starterkit entstandene Oberfläche vollständig durch eine neue Vue/Inertia-Oberfläche im Stil von [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015) ersetzen. Alle bestehenden Produktfunktionen erhalten ein neues, auf Desktop und Mobilgeräte abgestimmtes Erscheinungsbild.
|
||||
|
||||
Eine gemeinsame servergeladene Oberfläche versorgt Browser sowie Tauri-Apps für macOS, iOS/iPadOS und Android. Backend und Clients bleiben im selben Repository. Selbst gehostete Installationen werden über eine frei wählbare HTTPS-Adresse angebunden; jeweils eine Installation ist aktiv.
|
||||
|
||||
Zum Zielumfang gehören Hell/Dunkel/System, eine Lokalisierungsstruktur mit zunächst englischen Texten und bessere Frontend-Bedienung einschließlich Drag-and-drop für bestehende Statuswechsel. Anpassungen an Resources, Inertia-Daten und erforderlichen Routen sind zulässig.
|
||||
|
||||
Ausgeschlossen sind neue fachliche Backend-Funktionen, zusätzliche Mockup-Module, Offline-Datensynchronisierung, persistierte freie Kartenreihenfolge und Hintergrund-Push. macOS und Android werden als signierte Git-Release-Downloads vorbereitet; iOS/iPadOS wird zunächst selbst über Xcode gebaut. Stores folgen später.
|
||||
|
||||
## Abschluss
|
||||
|
||||
Die neue Oberfläche deckt die bestehenden Funktionen ab, funktioniert auf allen vereinbarten Plattformen und hat das alte Starterkit-Frontend vollständig abgelöst. Die Umsetzung ist in eigenständige, begrenzte Agentensitzungen aufgeteilt.
|
||||
@@ -25,7 +25,6 @@ const prettierOptions = {
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [
|
||||
'bootstrap/ssr/**',
|
||||
'**/dist/**',
|
||||
'node_modules/**',
|
||||
'.github/**',
|
||||
|
||||
Generated
+7269
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,6 @@
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:ssr": "vite build && vite build --ssr",
|
||||
@@ -46,7 +39,6 @@
|
||||
"vue-tsc": "^2.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.18",
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
||||
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
||||
|
||||
Generated
-4832
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
minimumReleaseAge: 10080
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
@@ -1,209 +0,0 @@
|
||||
<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>
|
||||
@@ -1,93 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||
import { reactive } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { TicketTreeListProps } from '@/types/ticket-tree';
|
||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||
|
||||
defineProps<TicketTreeListProps>();
|
||||
|
||||
const { t } = useLocalization();
|
||||
const collapsedTicketIds = reactive(new Set<number>());
|
||||
|
||||
function toggleTicket(ticketId: number): void {
|
||||
if (collapsedTicketIds.has(ticketId)) {
|
||||
collapsedTicketIds.delete(ticketId);
|
||||
} else {
|
||||
collapsedTicketIds.add(ticketId);
|
||||
}
|
||||
}
|
||||
defineProps<{
|
||||
nodes: TicketTreeNodeModel[];
|
||||
projectId: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="grid min-w-0 gap-3">
|
||||
<li v-for="node in nodes" :key="node.id" class="min-w-0">
|
||||
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||
<div class="flex justify-center pt-1">
|
||||
<button
|
||||
v-if="node.children.length > 0"
|
||||
type="button"
|
||||
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||
:aria-expanded="!collapsedTicketIds.has(node.id)"
|
||||
:aria-controls="`ticket-tree-children-${node.id}`"
|
||||
:aria-label="
|
||||
t(
|
||||
collapsedTicketIds.has(node.id)
|
||||
? 'tickets.tree.expand'
|
||||
: 'tickets.tree.collapse',
|
||||
{ key: node.ticket_key },
|
||||
)
|
||||
<ul class="space-y-3">
|
||||
<li v-for="node in nodes" :key="node.id">
|
||||
<div class="border-border rounded-xl border px-4 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: projectId,
|
||||
ticket: node.id,
|
||||
})
|
||||
"
|
||||
@click="toggleTicket(node.id)"
|
||||
class="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
<ChevronRight
|
||||
v-if="collapsedTicketIds.has(node.id)"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="bg-border mt-4 size-1.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ node.ticket_key }}
|
||||
</Link>
|
||||
|
||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||
{{ node.ticket_type_name }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: projectId,
|
||||
ticket: node.id,
|
||||
})
|
||||
"
|
||||
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ node.ticket_key }}
|
||||
</Badge>
|
||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||
{{ node.ticket_type_name }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||
>
|
||||
{{ node.title }}
|
||||
</span>
|
||||
</Link>
|
||||
<div class="text-foreground mt-2 text-sm">
|
||||
{{ node.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
node.children.length > 0 && !collapsedTicketIds.has(node.id)
|
||||
"
|
||||
:id="`ticket-tree-children-${node.id}`"
|
||||
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||
v-if="node.children.length > 0"
|
||||
class="border-border mt-3 border-l pl-4"
|
||||
>
|
||||
<TicketTreeList
|
||||
:nodes="node.children"
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
} from '@/types/ticket-presentation';
|
||||
|
||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||
layout: 'card',
|
||||
showDescription: true,
|
||||
showStatus: true,
|
||||
});
|
||||
@@ -23,85 +22,63 @@ const { t } = useLocalization();
|
||||
<template>
|
||||
<article
|
||||
:class="[
|
||||
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||
layout === 'card' ? 'shadow-sm' : '',
|
||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
layout === 'list'
|
||||
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
|
||||
: ''
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
"
|
||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
||||
@click="emit('open', ticket.id)"
|
||||
>
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
"
|
||||
:class="[
|
||||
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
|
||||
layout === 'list'
|
||||
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
|
||||
: 'rounded-t-md',
|
||||
]"
|
||||
@click="emit('open', ticket.id)"
|
||||
>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ 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" />
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h3>
|
||||
|
||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||
|
||||
<p
|
||||
v-if="showDescription && ticket.description"
|
||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||
>
|
||||
{{
|
||||
t('tickets.blockerComment', {
|
||||
comment: ticket.blocked_comment,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div v-if="$slots.actions" class="border-t p-4">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<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>
|
||||
@@ -1,157 +0,0 @@
|
||||
<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>
|
||||
@@ -1,613 +0,0 @@
|
||||
<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 } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import {
|
||||
acceptanceCriteriaToEditorState,
|
||||
buildAcceptanceCriteria,
|
||||
formatChecklistLines,
|
||||
normalizeAcceptanceCriteria,
|
||||
syncAcceptanceCriteriaDescendantCheckedState,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailContentProps>();
|
||||
const { t } = useLocalization();
|
||||
const canEditAuthor = computed(
|
||||
() => props.canEditTicket && props.canManageProject,
|
||||
);
|
||||
const canViewAuthor = computed(
|
||||
() => props.project.show_author || props.canManageProject,
|
||||
);
|
||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
{ checklist: acceptanceCriteriaEditorState.checklist },
|
||||
`${draftKey}:checklist`,
|
||||
);
|
||||
const acceptanceCriteriaChecklist = toRef(
|
||||
unref(acceptanceCriteriaDraft),
|
||||
'checklist',
|
||||
);
|
||||
const savedAcceptanceCriteria = ref(
|
||||
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||
);
|
||||
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||
const acceptanceCriteriaToggleError = ref('');
|
||||
|
||||
const savedFormData = () => ({
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||
author_user_id: props.ticket.author_user_id ?? '',
|
||||
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||
description: props.ticket.description ?? '',
|
||||
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
||||
const currentFieldValue = props.ticket.field_values?.find(
|
||||
(fieldValue) =>
|
||||
fieldValue.ticket_field_definition_id ===
|
||||
ticketFieldDefinition.id,
|
||||
);
|
||||
|
||||
return {
|
||||
ticket_field_definition_id: ticketFieldDefinition.id,
|
||||
value_number: currentFieldValue?.value_number ?? '',
|
||||
value_user_id: currentFieldValue?.value_user_id ?? '',
|
||||
};
|
||||
}),
|
||||
is_blocked: props.ticket.is_blocked,
|
||||
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
||||
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||
ticket_type_id: props.ticket.ticket_type_id,
|
||||
title: props.ticket.title,
|
||||
story_points: props.ticket.story_points,
|
||||
});
|
||||
|
||||
const form = useForm(draftKey, savedFormData());
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(
|
||||
() => savedAcceptanceCriteria.value,
|
||||
);
|
||||
|
||||
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||
() =>
|
||||
acceptanceCriteriaChecklist.value !==
|
||||
formatChecklistLines(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();
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
protection.clearFeedback();
|
||||
};
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('acceptance_criteria.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const buildAcceptanceCriteriaForSubmit = () => {
|
||||
const draftItems = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
return draftItems.map((item, index) => ({
|
||||
...item,
|
||||
...(savedAcceptanceCriteria.value[index]?.id !== undefined
|
||||
? { id: savedAcceptanceCriteria.value[index].id }
|
||||
: {}),
|
||||
}));
|
||||
};
|
||||
|
||||
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 previousChecklist = formatChecklistLines(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
|
||||
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;
|
||||
|
||||
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
||||
acceptanceCriteriaChecklist.value = nextChecklist;
|
||||
form.acceptance_criteria = nextAcceptanceCriteria;
|
||||
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||
}
|
||||
} catch (_error) {
|
||||
acceptanceCriteriaToggleError.value = t(
|
||||
'ticketDetail.checklistSaveFailed',
|
||||
);
|
||||
} finally {
|
||||
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onAcceptanceCriteriaCheckboxChange = async (
|
||||
index: number,
|
||||
event: Event,
|
||||
): Promise<void> => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await persistAcceptanceCriteriaToggle(index, target.checked);
|
||||
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (
|
||||
!props.canEditTicket ||
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
||||
|
||||
protection.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
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>
|
||||
<section
|
||||
id="ticket-checklist"
|
||||
aria-labelledby="ticket-checklist-title"
|
||||
class="scroll-mt-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader
|
||||
><h2
|
||||
id="ticket-checklist-title"
|
||||
class="text-lg font-semibold"
|
||||
>
|
||||
{{ t('ticketDetail.checklist') }}
|
||||
</h2></CardHeader
|
||||
>
|
||||
<CardContent class="space-y-3">
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="item.id ?? index"
|
||||
:style="{
|
||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
||||
}"
|
||||
class="flex min-w-0 items-start gap-2"
|
||||
>
|
||||
<input
|
||||
:id="`acceptance-criteria-${item.id ?? index}`"
|
||||
:checked="item.checked"
|
||||
type="checkbox"
|
||||
:disabled="
|
||||
!canEditTicket ||
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle ||
|
||||
hasUnsavedAcceptanceCriteriaDraft
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
|
||||
@change="
|
||||
onAcceptanceCriteriaCheckboxChange(
|
||||
index,
|
||||
$event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<label
|
||||
:for="`acceptance-criteria-${item.id ?? index}`"
|
||||
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
|
||||
>
|
||||
{{ item.text }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.noChecklist') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.saveChecklistFirst') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="acceptanceCriteriaToggleError"
|
||||
role="alert"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ acceptanceCriteriaToggleError }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<details
|
||||
v-if="canEditTicket"
|
||||
id="ticket-edit"
|
||||
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>
|
||||
<TicketFormField
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.checklistEditor')"
|
||||
:hint="t('ticketForm.checklistHelp')"
|
||||
:placeholder="
|
||||
t('ticketDetail.checklistPlaceholder')
|
||||
"
|
||||
:error="acceptanceCriteriaError"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #metadata>
|
||||
<TicketFormField
|
||||
v-if="canViewAuthor"
|
||||
id="author_user_id"
|
||||
v-model="form.author_user_id"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.author')"
|
||||
:hint="
|
||||
!canEditAuthor
|
||||
? t('ticketForm.authorRestricted')
|
||||
: undefined
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
value: '',
|
||||
label: t('ticketDetail.noAuthor'),
|
||||
},
|
||||
...users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
})),
|
||||
]"
|
||||
:error="form.errors.author_user_id"
|
||||
:disabled="!canEditAuthor || form.processing"
|
||||
/>
|
||||
</template>
|
||||
</TicketFormFields>
|
||||
<fieldset
|
||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>
|
||||
<legend class="px-2 text-sm font-semibold">
|
||||
{{ t('ticketForm.blocker') }}
|
||||
</legend>
|
||||
<div class="min-h-control flex items-center gap-3">
|
||||
<input
|
||||
id="is_blocked"
|
||||
v-model="form.is_blocked"
|
||||
type="checkbox"
|
||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||
aria-describedby="is_blocked-error"
|
||||
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<Label for="is_blocked">{{
|
||||
t('ticketDetail.blocked')
|
||||
}}</Label>
|
||||
</div>
|
||||
<InputError
|
||||
id="is_blocked-error"
|
||||
:message="form.errors.is_blocked"
|
||||
/>
|
||||
<TicketFormField
|
||||
id="blocked_comment"
|
||||
v-model="form.blocked_comment"
|
||||
kind="textarea"
|
||||
:rows="3"
|
||||
:label="t('ticketDetail.blockerComment')"
|
||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||
:error="form.errors.blocked_comment"
|
||||
:required="form.is_blocked"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>
|
||||
<legend class="px-2 text-sm font-semibold">
|
||||
{{ t('ticketDetail.customFields') }}
|
||||
</legend>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.customFieldsDescription') }}
|
||||
</p>
|
||||
<InputError :message="form.errors.field_values" />
|
||||
<p
|
||||
v-if="!ticketFieldDefinitions.length"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noCustomFields') }}
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||
<template
|
||||
v-for="(
|
||||
definition, index
|
||||
) in ticketFieldDefinitions"
|
||||
:key="definition.id"
|
||||
>
|
||||
<TicketFormField
|
||||
v-if="definition.field_type === 'user'"
|
||||
:id="`field-${definition.id}`"
|
||||
v-model="
|
||||
form.field_values[index].value_user_id
|
||||
"
|
||||
kind="select"
|
||||
:label="definition.label"
|
||||
:options="[
|
||||
{
|
||||
value: '',
|
||||
label: t('tickets.unassigned'),
|
||||
},
|
||||
...users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
})),
|
||||
]"
|
||||
:error="
|
||||
form.errors[
|
||||
`field_values.${index}.value_user_id`
|
||||
] ??
|
||||
form.errors[
|
||||
`field_values.${index}.ticket_field_definition_id`
|
||||
]
|
||||
"
|
||||
/>
|
||||
<TicketFormField
|
||||
v-else
|
||||
:id="`field-${definition.id}`"
|
||||
v-model="
|
||||
form.field_values[index].value_number
|
||||
"
|
||||
kind="number"
|
||||
step="0.1"
|
||||
:label="definition.label"
|
||||
:error="
|
||||
form.errors[
|
||||
`field_values.${index}.value_number`
|
||||
] ??
|
||||
form.errors[
|
||||
`field_values.${index}.ticket_field_definition_id`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>{{
|
||||
form.processing
|
||||
? t('ticketForm.saving')
|
||||
: t('ticketDetail.saveChanges')
|
||||
}}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle ||
|
||||
!isDirty
|
||||
"
|
||||
@click="reset"
|
||||
>{{ t('ticketForm.reset') }}</Button
|
||||
>
|
||||
<p role="status" class="text-muted-foreground text-sm">
|
||||
{{
|
||||
isDirty
|
||||
? t('ticketForm.unsaved')
|
||||
: t('ticketForm.saved')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,658 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type {
|
||||
TicketDetailGitProps,
|
||||
TicketBranchFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailGitProps>();
|
||||
const { t } = useLocalization();
|
||||
const appName = String(usePage().props.name);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||
const visibleRepositoryBranches = computed(() =>
|
||||
props.repositoryBranches.filter(
|
||||
(repositoryBranch) =>
|
||||
repositoryBranch.branches.length > 0 ||
|
||||
repositoryBranch.error !== null ||
|
||||
!repositoryBranch.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
|
||||
const visibleRepositoryPullRequests = computed(() =>
|
||||
props.repositoryPullRequests.filter(
|
||||
(repositoryPullRequest) =>
|
||||
repositoryPullRequest.pull_requests.length > 0 ||
|
||||
repositoryPullRequest.error !== null ||
|
||||
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||
props.repositoryBranches.map(
|
||||
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||
),
|
||||
);
|
||||
|
||||
const canCreateBranches = computed(
|
||||
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
||||
);
|
||||
const defaultBranchRepositoryConnectionId = computed(() =>
|
||||
String(
|
||||
linkedRepositoryConnections.value.find(
|
||||
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||
)?.id ??
|
||||
linkedRepositoryConnections.value[0]?.id ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
|
||||
const isCreateBranchDialogOpen = ref(false);
|
||||
const isCheckingBranchName = ref(false);
|
||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||
|
||||
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
|
||||
branch_name: props.suggestedBranchName,
|
||||
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
||||
});
|
||||
const branchProtection = useFormProtection(branchCreationForm, {
|
||||
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
|
||||
});
|
||||
|
||||
const updateBranchDialogOpen = (open: boolean): void => {
|
||||
if (!open && !branchProtection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
isCreateBranchDialogOpen.value = open;
|
||||
};
|
||||
|
||||
const selectedBranchRepositoryConnection = computed(
|
||||
(): RepositoryConnectionModel | null =>
|
||||
linkedRepositoryConnections.value.find(
|
||||
(repositoryConnection) =>
|
||||
String(repositoryConnection.id) ===
|
||||
branchCreationForm.repository_connection_id,
|
||||
) ?? null,
|
||||
);
|
||||
|
||||
const selectedBranchRepositoryHasPersonalAccess = computed(
|
||||
() =>
|
||||
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
||||
);
|
||||
|
||||
const initializeBranchCreationForm = (): void => {
|
||||
if (branchCreationForm.isDirty) {
|
||||
return;
|
||||
}
|
||||
branchCreationForm.clearErrors();
|
||||
branchCreationForm.branch_name = props.suggestedBranchName;
|
||||
branchCreationForm.repository_connection_id =
|
||||
defaultBranchRepositoryConnectionId.value;
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => isCreateBranchDialogOpen.value,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
branchCreationForm.clearErrors();
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
initializeBranchCreationForm();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[
|
||||
() => isCreateBranchDialogOpen.value,
|
||||
() => branchCreationForm.repository_connection_id,
|
||||
() => branchCreationForm.branch_name,
|
||||
],
|
||||
(
|
||||
[isOpen, repositoryConnectionId, branchName],
|
||||
_previousValues,
|
||||
onCleanup,
|
||||
) => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repositoryConnection = selectedBranchRepositoryConnection.value;
|
||||
|
||||
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||
branchNameValidation.value = null;
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repositoryConnection.has_personal_access) {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.connectGitFirst'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (branchName.trim() === '') {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchRequired'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
isCheckingBranchName.value = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
ProjectTicketBranchController.validate.url(
|
||||
{
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
branch_name: branchName,
|
||||
repository_connection_id:
|
||||
repositoryConnectionId,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
method: 'GET',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Branch validation failed.');
|
||||
}
|
||||
|
||||
branchNameValidation.value =
|
||||
(await response.json()) as BranchNameValidationModel;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchValidationFailed', {
|
||||
app: appName,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
isCheckingBranchName.value = false;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
onCleanup(() => {
|
||||
abortController.abort();
|
||||
window.clearTimeout(timeoutId);
|
||||
isCheckingBranchName.value = false;
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const submitBranchCreation = (): void => {
|
||||
if (
|
||||
!canCreateBranches.value ||
|
||||
!selectedBranchRepositoryHasPersonalAccess.value ||
|
||||
branchNameValidation.value?.is_valid !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
branchProtection.submit(
|
||||
ProjectTicketBranchController.store({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
isCreateBranchDialogOpen.value = false;
|
||||
},
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-git"
|
||||
aria-labelledby="ticket-git-title"
|
||||
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||
>
|
||||
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.git') }}
|
||||
</h2>
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<Card class="h-fit">
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.branchesDescription')
|
||||
}}</CardDescription>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
:open="isCreateBranchDialogOpen"
|
||||
@update:open="updateBranchDialogOpen"
|
||||
>
|
||||
<Button
|
||||
v-if="canCreateBranches"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
@click="isCreateBranchDialogOpen = true"
|
||||
>
|
||||
<GitBranchPlus class="size-4" />{{
|
||||
t('ticketDetail.createBranch')
|
||||
}}</Button
|
||||
>
|
||||
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle>{{
|
||||
t('ticketDetail.createBranchTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('ticketDetail.createBranchDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
class="grid gap-4"
|
||||
@submit.prevent="submitBranchCreation"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="branchProtection.state.value"
|
||||
@retry="submitBranchCreation"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="repository_connection_id">{{
|
||||
t('ticketDetail.repository')
|
||||
}}</Label>
|
||||
<select
|
||||
id="repository_connection_id"
|
||||
v-model="
|
||||
branchCreationForm.repository_connection_id
|
||||
"
|
||||
:disabled="
|
||||
branchCreationForm.processing
|
||||
"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="repositoryConnection in linkedRepositoryConnections"
|
||||
:key="repositoryConnection.id"
|
||||
:value="
|
||||
String(repositoryConnection.id)
|
||||
"
|
||||
>
|
||||
{{
|
||||
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
branchCreationForm.errors
|
||||
.repository_connection_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="branch_name">{{
|
||||
t('ticketDetail.branchName')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="branch_name"
|
||||
v-model="branchCreationForm.branch_name"
|
||||
:disabled="
|
||||
branchCreationForm.processing ||
|
||||
!selectedBranchRepositoryHasPersonalAccess
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="
|
||||
branchCreationForm.errors
|
||||
.branch_name
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border px-4 py-3"
|
||||
:class="
|
||||
branchNameValidation?.is_valid
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
||||
"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<CheckCircle2
|
||||
v-if="
|
||||
branchNameValidation?.is_valid
|
||||
"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<XCircle
|
||||
v-else-if="
|
||||
branchNameValidation !== null
|
||||
"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{{
|
||||
isCheckingBranchName
|
||||
? t(
|
||||
'ticketDetail.checkingBranch',
|
||||
)
|
||||
: (branchNameValidation?.message ??
|
||||
t(
|
||||
'ticketDetail.selectRepository',
|
||||
))
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!selectedBranchRepositoryHasPersonalAccess &&
|
||||
selectedBranchRepositoryConnection !==
|
||||
null
|
||||
"
|
||||
class="border-border rounded-xl border border-dashed px-4 py-4"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.branchAccess') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="updateBranchDialogOpen(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
branchCreationForm.processing ||
|
||||
isCheckingBranchName ||
|
||||
branchNameValidation?.is_valid !==
|
||||
true
|
||||
"
|
||||
>{{
|
||||
t('ticketDetail.createNow')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryBranches.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="t('tickets.noBranchesDescription')"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="repositoryBranch in visibleRepositoryBranches"
|
||||
:key="repositoryBranch.repository_connection.id"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.base_url
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!repositoryBranch.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.inspectBranches') }}
|
||||
</p>
|
||||
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="repositoryBranch.error"
|
||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{ repositoryBranch.error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-3 flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="branch in repositoryBranch.branches"
|
||||
:key="branch.name"
|
||||
variant="outline"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ branch.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.pullRequestsDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryPullRequests.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="t('tickets.noPullRequestsDescription')"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
||||
:key="
|
||||
repositoryPullRequest.repository_connection.id
|
||||
"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.repository_full_name
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.base_url
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!repositoryPullRequest.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.inspectPullRequests') }}
|
||||
</p>
|
||||
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="repositoryPullRequest.error"
|
||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{ repositoryPullRequest.error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||
:key="pullRequest.html_url"
|
||||
class="border-border/70 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
#{{ pullRequest.number }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ pullRequest.status }}
|
||||
</Badge>
|
||||
<a
|
||||
:href="pullRequest.html_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
{{ pullRequest.title }}
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
{{
|
||||
t('ticketDetail.sourceBranch', {
|
||||
name: pullRequest.source_branch,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
|
||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { show } from '@/routes/projects';
|
||||
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
|
||||
defineProps<TicketDetailHeaderProps>();
|
||||
const { t } = useLocalization();
|
||||
const sections = [
|
||||
'description',
|
||||
'checklist',
|
||||
'metadata',
|
||||
'relationships',
|
||||
'history',
|
||||
'git',
|
||||
] as const;
|
||||
</script>
|
||||
<template>
|
||||
<header class="flex min-w-0 flex-col gap-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button as-child variant="ghost" class="max-w-full"
|
||||
><Link :href="show(project.id)"
|
||||
><ArrowLeft aria-hidden="true" />{{
|
||||
t('ticketDetail.backToProject')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button as-child variant="outline"
|
||||
><Link :href="backlogIndex(project.id)"
|
||||
><ListTree aria-hidden="true" />{{
|
||||
t('ticketDetail.openBacklog')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
<Button as-child variant="outline"
|
||||
><Link :href="boardIndex(project.id)"
|
||||
><Columns3 aria-hidden="true" />{{
|
||||
t('ticketDetail.openBoard')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-primary font-mono text-sm font-semibold">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><Badge v-if="!canEditTicket" variant="outline">{{
|
||||
t('ticketDetail.readOnly')
|
||||
}}</Badge>
|
||||
</div>
|
||||
<h1
|
||||
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h1>
|
||||
<TicketMetadata :ticket="ticket" />
|
||||
</div>
|
||||
<nav
|
||||
:aria-label="t('ticketDetail.sections')"
|
||||
class="flex flex-wrap gap-1 border-b pb-3"
|
||||
>
|
||||
<a
|
||||
v-for="section in sections"
|
||||
:key="section"
|
||||
:href="`#ticket-${section}`"
|
||||
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||
>{{ t(`ticketDetail.${section}`) }}</a
|
||||
>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||
defineProps<TicketDetailHistoryProps>();
|
||||
const { t, date } = useLocalization();
|
||||
const formatDateTime = (value: string): string =>
|
||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-history"
|
||||
aria-labelledby="ticket-history-title"
|
||||
class="wrap-anywhere min-w-0 scroll-mt-6"
|
||||
>
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<h2 id="ticket-history-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.history') }}
|
||||
</h2>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.historyDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noHistory') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="worklogEntry in ticket.worklog_entries"
|
||||
:key="worklogEntry.id"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ worklogEntry.entry_type }}
|
||||
</Badge>
|
||||
<span class="text-foreground text-sm font-medium">
|
||||
{{
|
||||
worklogEntry.actor?.name ??
|
||||
t('ticketDetail.system')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-foreground mt-3 text-sm">
|
||||
{{ worklogEntry.summary }}
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-2 text-xs">
|
||||
{{ formatDateTime(worklogEntry.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,165 +0,0 @@
|
||||
<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>
|
||||
@@ -1,223 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { Form, Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||
const { t } = useLocalization();
|
||||
const currentParentTicket = computed(
|
||||
() =>
|
||||
props.ticket.parent_ticket ??
|
||||
props.parentTicketOptions.find(
|
||||
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
|
||||
) ??
|
||||
null,
|
||||
);
|
||||
</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 v-if="ticket.ticket_type?.is_parent" class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.childrenDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div
|
||||
v-if="childTicketTree.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noChildren') }}
|
||||
</div>
|
||||
|
||||
<TicketTreeList
|
||||
v-else
|
||||
:nodes="childTicketTree"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="currentParentTicket !== null" class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.parentDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: currentParentTicket.id,
|
||||
})
|
||||
"
|
||||
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ currentParentTicket.ticket_key }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-sm">
|
||||
{{ currentParentTicket.title }}
|
||||
</div>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.linkedTicketsDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<Form
|
||||
v-if="
|
||||
canEditTicket &&
|
||||
ticketLinkTypes.length > 0 &&
|
||||
ticketLinkOptions.length > 0
|
||||
"
|
||||
v-bind="
|
||||
ProjectTicketLinkController.store.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="ticket_link_type_id">{{
|
||||
t('ticketDetail.linkType')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_link_type_id"
|
||||
name="ticket_link_type_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]"
|
||||
>
|
||||
<option
|
||||
v-for="ticketLinkType in ticketLinkTypes"
|
||||
:key="ticketLinkType.id"
|
||||
:value="ticketLinkType.id"
|
||||
>
|
||||
{{ ticketLinkType.label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="errors.ticket_link_type_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="linked_ticket_id">{{
|
||||
t('ticketDetail.linkedTicket')
|
||||
}}</Label>
|
||||
<select
|
||||
id="linked_ticket_id"
|
||||
name="linked_ticket_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]"
|
||||
>
|
||||
<option
|
||||
v-for="ticketLinkOption in ticketLinkOptions"
|
||||
:key="ticketLinkOption.id"
|
||||
:value="ticketLinkOption.id"
|
||||
>
|
||||
{{
|
||||
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="errors.linked_ticket_id" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">{{
|
||||
t('ticketDetail.saveLink')
|
||||
}}</Button>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
v-if="linkedTickets.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noLinks') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="linkedTicket in linkedTickets"
|
||||
:key="linkedTicket.id"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{ linkedTicket.ticket_link_type.label }}
|
||||
</Badge>
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: linkedTicket.linked_ticket.id,
|
||||
})
|
||||
"
|
||||
class="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
{{ linkedTicket.linked_ticket.ticket_key }}
|
||||
</Link>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{ linkedTicket.linked_ticket.title }}
|
||||
</p>
|
||||
|
||||
<Form
|
||||
v-if="canEditTicket"
|
||||
v-bind="
|
||||
ProjectTicketLinkController.destroy.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
ticketLink: linkedTicket.id,
|
||||
})
|
||||
"
|
||||
class="mt-3 flex justify-end"
|
||||
v-slot="{ processing: deleting }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:disabled="deleting"
|
||||
>{{ t('ticketDetail.removeLink') }}</Button
|
||||
>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,154 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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))
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,4 @@
|
||||
export const en = {
|
||||
'board.title': '{name} board',
|
||||
'board.description':
|
||||
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
||||
'board.columns': 'Workflow columns',
|
||||
'board.selectColumn': 'Choose a column',
|
||||
'board.columnOption': '{name} ({count})',
|
||||
'board.scrollHint': 'Scroll horizontally to explore all columns.',
|
||||
'board.openBacklog': 'Open backlog',
|
||||
'board.backToProject': 'Project overview',
|
||||
'board.empty.title': 'No workflow columns yet',
|
||||
'board.empty.description':
|
||||
'Set up columns to start tracking work on this board.',
|
||||
'board.emptyColumn': 'No tickets in this column.',
|
||||
'board.emptyStatus': 'No tickets in this status.',
|
||||
'board.noStatuses': 'No statuses configured for this column.',
|
||||
'board.moveTicket': 'Move {key}',
|
||||
'board.updateStatus': 'Update status',
|
||||
'board.updatingStatus': 'Updating…',
|
||||
'board.noMoves': 'No allowed moves',
|
||||
'board.drag': 'Drag to move',
|
||||
'board.dragTicket': 'Drag {key} to change status',
|
||||
'board.dragHint':
|
||||
'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.',
|
||||
'board.dropHint': 'Drop on a column. Hold near an edge to scroll.',
|
||||
'board.dragging':
|
||||
'Dragging {key}. Drop on an allowed column or press Escape to cancel.',
|
||||
'board.chooseStatus': 'Choose a destination status',
|
||||
'board.chooseStatusDescription':
|
||||
'More than one status is available for {key} in this column.',
|
||||
'board.targetStatus': 'Destination status',
|
||||
'board.noMovesDescription':
|
||||
'This status does not allow moving anywhere else.',
|
||||
'backlog.title': '{name} backlog',
|
||||
'backlog.description':
|
||||
'Shape incoming work without losing sight of parent and child tickets.',
|
||||
'backlog.openBoard': 'Open board',
|
||||
'backlog.backToProject': 'Project overview',
|
||||
'backlog.moveTicket': 'Move {key}',
|
||||
'backlog.filters.title': 'Find backlog tickets',
|
||||
'backlog.filters.description':
|
||||
'Filter this view locally. Matching child tickets keep their parent context visible.',
|
||||
'backlog.filters.search': 'Search',
|
||||
'backlog.filters.searchPlaceholder': 'Key, title, description, or metadata',
|
||||
'backlog.filters.type': 'Type',
|
||||
'backlog.filters.allTypes': 'All types',
|
||||
'backlog.filters.priority': 'Priority',
|
||||
'backlog.filters.allPriorities': 'All priorities',
|
||||
'backlog.filters.status': 'Status',
|
||||
'backlog.filters.allStatuses': 'All backlog statuses',
|
||||
'backlog.filters.assignee': 'Assignee',
|
||||
'backlog.filters.allAssignees': 'All assignees',
|
||||
'backlog.filters.clear': 'Clear filters',
|
||||
'backlog.filters.parentContext': 'Parent context',
|
||||
'backlog.filters.empty.title': 'No tickets match these filters',
|
||||
'backlog.filters.empty.description':
|
||||
'Try a broader search or clear the filters to see the complete backlog.',
|
||||
'backlog.results.title': 'Backlog tickets',
|
||||
'backlog.results.count': 'Showing {count} of {total} tickets',
|
||||
'backlog.empty.title': 'Your backlog is clear',
|
||||
'backlog.empty.description':
|
||||
'New tickets without a workflow column will appear here.',
|
||||
'backlog.empty.action': 'Create the first ticket',
|
||||
'backlog.tree.expand': 'Expand children of {key}',
|
||||
'backlog.tree.collapse': 'Collapse children of {key}',
|
||||
'backlog.tree.expandAll': 'Expand all',
|
||||
'backlog.tree.collapseAll': 'Collapse all',
|
||||
'projectCreation.title': 'Create a project',
|
||||
'projectCreation.description':
|
||||
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
||||
@@ -184,24 +118,6 @@ export const en = {
|
||||
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||
'projects.inheritedArea': '{name} · inherited',
|
||||
'projects.overriddenArea': '{name} · overridden',
|
||||
'dashboard.eyebrow': 'Personal workspace',
|
||||
'dashboard.greeting': 'Welcome back, {name}',
|
||||
'dashboard.description':
|
||||
'A current view of tickets assigned to you in projects you can access.',
|
||||
'dashboard.summary': 'Work summary',
|
||||
'dashboard.summary.assignedTickets': 'Assigned tickets',
|
||||
'dashboard.summary.projects': 'Projects with assigned work',
|
||||
'dashboard.summary.blockedTickets': 'Blocked tickets',
|
||||
'dashboard.assignedWork': 'My assigned tickets',
|
||||
'dashboard.assignedWorkDescription':
|
||||
'Your work is grouped by project and ordered with the newest tickets first.',
|
||||
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
|
||||
'dashboard.openProject': 'Open {name}',
|
||||
'dashboard.openProjectAction': 'Open project',
|
||||
'dashboard.empty.title': 'No tickets assigned to you',
|
||||
'dashboard.empty.description':
|
||||
'Tickets assigned to you in projects you can access will appear here.',
|
||||
'dashboard.empty.action': 'Browse projects',
|
||||
'auth.home': 'KanbanGit home',
|
||||
'auth.accountAccess': 'Account access',
|
||||
'auth.backToLogin': 'Return to sign in',
|
||||
@@ -390,8 +306,6 @@ export const en = {
|
||||
'tickets.noStatus': 'No status',
|
||||
'tickets.assignedTo': 'Assigned to {name}',
|
||||
'tickets.unassigned': 'Unassigned',
|
||||
'tickets.tree.expand': 'Expand children of {key}',
|
||||
'tickets.tree.collapse': 'Collapse children of {key}',
|
||||
'common.save': 'Save',
|
||||
'common.loading': 'Loading…',
|
||||
'common.error': 'Something went wrong.',
|
||||
@@ -400,126 +314,4 @@ export const en = {
|
||||
one: '{count} ticket',
|
||||
other: '{count} tickets',
|
||||
},
|
||||
'ticketDetail.description': 'Description',
|
||||
'ticketDetail.noDescription': 'No description has been added yet.',
|
||||
'ticketDetail.checklist': 'Acceptance criteria',
|
||||
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
|
||||
'ticketDetail.saveChecklistFirst':
|
||||
'Save checklist text changes before toggling completion directly.',
|
||||
'ticketDetail.edit': 'Edit ticket',
|
||||
'ticketDetail.editDescription':
|
||||
'Tracked ticket changes create worklog entries automatically.',
|
||||
'ticketDetail.git': 'Git',
|
||||
'ticketDetail.relationships': 'Relationships',
|
||||
'ticketDetail.history': 'Worklog',
|
||||
'ticketDetail.metadata': 'Ticket information',
|
||||
'ticketDetail.sections': 'Ticket sections',
|
||||
'ticketDetail.status': 'Status',
|
||||
'ticketDetail.type': 'Type',
|
||||
'ticketDetail.priority': 'Priority',
|
||||
'ticketDetail.assignee': 'Assignee',
|
||||
'ticketDetail.author': 'Author',
|
||||
'ticketDetail.noAuthor': 'No author',
|
||||
'ticketDetail.storyPoints': 'Estimated story points',
|
||||
'ticketDetail.notSet': 'Not set',
|
||||
'ticketDetail.created': 'Created',
|
||||
'ticketDetail.updated': 'Last updated',
|
||||
'ticketDetail.backToProject': 'Back to project',
|
||||
'ticketDetail.openBacklog': 'Open backlog',
|
||||
'ticketDetail.openBoard': 'Open board',
|
||||
'ticketDetail.readOnly': 'Read only',
|
||||
'ticketDetail.title': 'Title',
|
||||
'ticketDetail.parent': 'Parent ticket',
|
||||
'ticketDetail.noParent': 'No parent ticket',
|
||||
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
|
||||
'ticketDetail.checklistHelp':
|
||||
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
|
||||
'ticketDetail.checklistPlaceholder':
|
||||
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||
'ticketDetail.blockerComment': 'Blocker comment',
|
||||
'ticketDetail.blockerPlaceholder':
|
||||
'Describe what currently blocks progress on this ticket.',
|
||||
'ticketDetail.customFields': 'Custom fields',
|
||||
'ticketDetail.customFieldsDescription':
|
||||
'Project-configured fields can be tracked directly on the ticket.',
|
||||
'ticketDetail.noCustomFields':
|
||||
'No custom fields are configured for this project yet.',
|
||||
'ticketDetail.saveChanges': 'Save changes',
|
||||
'ticketDetail.branches': 'Branches',
|
||||
'ticketDetail.branchesDescription':
|
||||
'Matching branches are discovered from linked repositories when the branch name starts with this ticket key.',
|
||||
'ticketDetail.createBranch': 'Create branch',
|
||||
'ticketDetail.createBranchTitle': 'Create branch from ticket',
|
||||
'ticketDetail.createBranchDescription':
|
||||
'Pick a linked repository, confirm the suggested branch name, and create it directly in Gitea.',
|
||||
'ticketDetail.repository': 'Repository',
|
||||
'ticketDetail.branchName': 'Branch name',
|
||||
'ticketDetail.branchAccess':
|
||||
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||
'ticketDetail.createNow': 'Create now',
|
||||
'ticketDetail.inspectBranches':
|
||||
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||
'ticketDetail.pullRequests': 'Pull requests',
|
||||
'ticketDetail.pullRequestsDescription':
|
||||
'Matching pull requests are detected from linked repositories by checking whether the source branch starts with this ticket key.',
|
||||
'ticketDetail.inspectPullRequests':
|
||||
'Connect a matching Git account in your user settings to inspect pull requests for this repository.',
|
||||
'ticketDetail.checkingBranch':
|
||||
'Checking whether this branch name is valid and still available...',
|
||||
'ticketDetail.selectRepository':
|
||||
'Select a linked repository to validate the branch name.',
|
||||
'ticketDetail.connectGitFirst':
|
||||
'Connect a matching Git connection in your user settings first.',
|
||||
'ticketDetail.branchRequired': 'Branch name is required.',
|
||||
'ticketDetail.branchValidationFailed':
|
||||
'{app} could not validate this branch name right now.',
|
||||
'ticketDetail.checklistSaveFailed':
|
||||
'Checklist status could not be saved right now.',
|
||||
'ticketDetail.children': 'Child tickets',
|
||||
'ticketDetail.childrenDescription':
|
||||
'Parent tickets expose their full child tree here, including nested parent chains.',
|
||||
'ticketDetail.noChildren':
|
||||
'No child tickets are assigned to this ticket yet.',
|
||||
'ticketDetail.parentDescription':
|
||||
'This ticket belongs to the parent shown here.',
|
||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||
'ticketDetail.linkedTicketsDescription':
|
||||
'Track cross-ticket relationships with project-defined link labels.',
|
||||
'ticketDetail.linkType': 'Link type',
|
||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||
'ticketDetail.saveLink': 'Save link',
|
||||
'ticketDetail.noLinks':
|
||||
'No ticket links are configured for this ticket yet.',
|
||||
'ticketDetail.removeLink': 'Remove',
|
||||
'ticketDetail.historyDescription':
|
||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||
'ticketDetail.system': 'System',
|
||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||
'ticketForm.create': 'Create ticket',
|
||||
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||
'ticketForm.createDescription': 'Add a ticket to {project}.',
|
||||
'ticketForm.backToBoard': 'Back to board',
|
||||
'ticketForm.backToBacklog': 'Back to backlog',
|
||||
'ticketForm.details': 'Ticket details',
|
||||
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
|
||||
'ticketForm.titlePlaceholder': 'Implement repository sync',
|
||||
'ticketForm.descriptionPlaceholder':
|
||||
'Describe the expected behavior and relevant context.',
|
||||
'ticketForm.checklistHelp':
|
||||
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
|
||||
'ticketForm.checklistPreview': 'Checklist preview',
|
||||
'ticketForm.metadataRequired':
|
||||
'Ticket creation needs at least one type, one priority, and one status.',
|
||||
'ticketForm.openSettings': 'Open ticket settings',
|
||||
'ticketForm.creating': 'Creating ticket…',
|
||||
'ticketForm.saving': 'Saving changes…',
|
||||
'ticketForm.reset': 'Discard changes',
|
||||
'ticketForm.unsaved': 'Unsaved changes',
|
||||
'ticketForm.ready': 'Ready for a new ticket',
|
||||
'ticketForm.saved': 'All changes saved',
|
||||
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||
'ticketForm.blocker': 'Blocker',
|
||||
} as const;
|
||||
|
||||
@@ -1,211 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
FolderGit2,
|
||||
ListTodo,
|
||||
OctagonAlert,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { TicketCard } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import { index as projectsIndex, show as showProject } from '@/routes/projects';
|
||||
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const props = defineProps<DashboardPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.dashboard'), href: dashboard() },
|
||||
]);
|
||||
const assignedTickets = computed(() =>
|
||||
props.assignedTicketsByProject.flatMap((group) => group.tickets),
|
||||
);
|
||||
const summaryItems = computed(() => [
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
label: t('dashboard.summary.assignedTickets'),
|
||||
value: assignedTickets.value.length,
|
||||
icon: ListTodo,
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
},
|
||||
{
|
||||
label: t('dashboard.summary.projects'),
|
||||
value: props.assignedTicketsByProject.length,
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.summary.blockedTickets'),
|
||||
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
|
||||
.length,
|
||||
icon: OctagonAlert,
|
||||
},
|
||||
]);
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('navigation.dashboard')" />
|
||||
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('dashboard.eyebrow') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{
|
||||
t('dashboard.greeting', {
|
||||
name: $page.props.auth.user.name,
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section :aria-label="t('dashboard.summary')">
|
||||
<ul class="grid gap-3 sm:grid-cols-3">
|
||||
<li
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<Card class="h-full gap-0 rounded-md py-0">
|
||||
<CardContent
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="text-caption text-muted-foreground break-words"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-title mt-1 tabular-nums">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="assigned-work-title"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<h2 id="assigned-work-title" class="text-title">
|
||||
{{ t('dashboard.assignedWork') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.assignedWorkDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="assignedTicketsByProject.length === 0"
|
||||
state="empty"
|
||||
:title="t('dashboard.empty.title')"
|
||||
:description="t('dashboard.empty.description')"
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||
>
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectsIndex()">
|
||||
<FolderGit2 aria-hidden="true" />
|
||||
{{ t('dashboard.empty.action') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<div v-else class="gap-page grid min-w-0">
|
||||
<section
|
||||
v-for="group in assignedTicketsByProject"
|
||||
:key="group.project.id"
|
||||
:aria-labelledby="`dashboard-project-${group.project.id}`"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="max-w-full shrink-0 whitespace-normal break-all"
|
||||
>
|
||||
{{ group.project.key }}
|
||||
</Badge>
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
:id="`dashboard-project-${group.project.id}`"
|
||||
class="text-body break-words font-semibold"
|
||||
>
|
||||
{{ group.project.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('tickets.count', {
|
||||
count: group.tickets.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
as-child
|
||||
variant="link"
|
||||
class="self-start sm:self-auto"
|
||||
>
|
||||
<Link
|
||||
:href="showProject(group.project.id)"
|
||||
:aria-label="
|
||||
t('dashboard.openProject', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t('dashboard.openProjectAction') }}
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
:aria-label="
|
||||
t('dashboard.projectTickets', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
|
||||
>
|
||||
<li
|
||||
v-for="ticket in group.tickets"
|
||||
:key="ticket.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<TicketCard
|
||||
:project-id="group.project.id"
|
||||
:ticket="ticket"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,155 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Columns3,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Search,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { TicketListItem } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildBacklogTicketTree,
|
||||
countBacklogTreeTickets,
|
||||
expandableBacklogTicketIds,
|
||||
filterBacklogTicketTree,
|
||||
} from '@/lib/backlog';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { create as createTicket } from '@/routes/projects/tickets';
|
||||
import type {
|
||||
BacklogFilterState,
|
||||
BreadcrumbItem,
|
||||
ProjectBacklogPageProps,
|
||||
} from '@/types';
|
||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
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 filters = reactive<BacklogFilterState>({
|
||||
assigneeId: '',
|
||||
priorityId: '',
|
||||
query: '',
|
||||
statusId: '',
|
||||
typeId: '',
|
||||
});
|
||||
const collapsedTicketIds = reactive(new Set<number>());
|
||||
const pendingTicketId = ref<number | null>(null);
|
||||
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
||||
const filteredTicketTree = computed(() =>
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
function clearFilters(): void {
|
||||
filters.query = '';
|
||||
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;
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
);
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Backlog',
|
||||
href: backlogIndex(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('backlog.title', { name: project.name })" />
|
||||
<Head :title="`${project.name} backlog`" />
|
||||
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ 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 class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} backlog`"
|
||||
description="All newly created tickets start here before they move onto the board."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
@@ -159,286 +74,138 @@ watch(
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
{{ t('navigation.createTicket') }}
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardIndex(project.id)">
|
||||
<Columns3 aria-hidden="true" />
|
||||
{{ t('backlog.openBoard') }}
|
||||
</Link>
|
||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
||||
</Button>
|
||||
<Button as-child variant="ghost">
|
||||
<Link :href="show(project.id)">
|
||||
<ArrowLeft aria-hidden="true" />
|
||||
{{ t('backlog.backToProject') }}
|
||||
</Link>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
|
||||
>
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Backlog tickets</CardTitle>
|
||||
<CardDescription>
|
||||
Tickets remain here while their status belongs to
|
||||
backlog.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div
|
||||
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
|
||||
v-if="tickets.length === 0"
|
||||
class="text-muted-foreground p-6 text-sm"
|
||||
>
|
||||
<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>
|
||||
No backlog tickets exist yet.
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-1.5">
|
||||
<label
|
||||
for="backlog-type"
|
||||
class="text-caption font-semibold"
|
||||
<ul v-else class="gap-column grid p-4">
|
||||
<TicketListItem
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
:project-id="project.id"
|
||||
:ticket="ticket"
|
||||
>
|
||||
{{ 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
|
||||
v-else-if="visibleTicketCount === 0"
|
||||
class="grid justify-items-start gap-3"
|
||||
>
|
||||
<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>
|
||||
|
||||
<section
|
||||
v-else
|
||||
aria-labelledby="backlog-results-title"
|
||||
class="grid min-w-0 gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<h2 id="backlog-results-title" class="text-title">
|
||||
{{ t('backlog.results.title') }}
|
||||
</h2>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{
|
||||
t('backlog.results.count', {
|
||||
count: visibleTicketCount,
|
||||
total: tickets.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="expandableTicketIds.length > 0"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleAllBranches"
|
||||
>
|
||||
<ChevronsUpDown
|
||||
v-if="allVisibleBranchesCollapsed"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronsDownUp v-else aria-hidden="true" />
|
||||
{{
|
||||
t(
|
||||
allVisibleBranchesCollapsed
|
||||
? 'backlog.tree.expandAll'
|
||||
: 'backlog.tree.collapseAll',
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<BacklogTicketTree
|
||||
:nodes="filteredTicketTree"
|
||||
:project-id="project.id"
|
||||
:can-edit-tickets="canEditTickets"
|
||||
:collapsed-ticket-ids="collapsedTicketIds"
|
||||
:pending-ticket-id="pendingTicketId"
|
||||
@toggle="toggleTicket"
|
||||
@move-start="pendingTicketId = $event"
|
||||
@move-finish="finishMove"
|
||||
/>
|
||||
</section>
|
||||
<template v-if="canEditTickets" #actions>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,248 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { TicketCard } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||
import { board as boardSettings } from '@/routes/projects/settings';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { create as createTicket } from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
const props = defineProps<ProjectBoardPageProps>();
|
||||
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.board'), href: boardIndex(props.project.id) },
|
||||
]);
|
||||
const selectedColumnId = ref<number>();
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
props.project.id,
|
||||
props.boardColumns.map((column) => column.id),
|
||||
] as const,
|
||||
([projectId, columnIds], previous) => {
|
||||
if (
|
||||
projectId !== previous?.[0] ||
|
||||
!columnIds.includes(selectedColumnId.value ?? -1)
|
||||
) {
|
||||
selectedColumnId.value = columnIds[0];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const columns = computed(() =>
|
||||
props.boardColumns.map((column) => {
|
||||
const ticketsByStatus = new Map<number | null, TicketModel[]>();
|
||||
for (const ticket of column.tickets ?? []) {
|
||||
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
|
||||
tickets.push(ticket);
|
||||
ticketsByStatus.set(ticket.ticket_status_id, tickets);
|
||||
}
|
||||
return {
|
||||
...column,
|
||||
count: column.tickets?.length ?? 0,
|
||||
groups: (column.ticket_statuses ?? []).map((status) => ({
|
||||
status,
|
||||
tickets: ticketsByStatus.get(status.id) ?? [],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const ticketCount = computed(() =>
|
||||
columns.value.reduce((count, column) => count + column.count, 0),
|
||||
);
|
||||
const allowedTargetStatuses = (ticket: TicketModel) =>
|
||||
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(status) => status.id !== ticket.ticket_status_id,
|
||||
);
|
||||
|
||||
const 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 breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectIndex(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
);
|
||||
};
|
||||
const choiceTicket = computed(() =>
|
||||
choice.value ? findTicket(choice.value.ticketId) : undefined,
|
||||
);
|
||||
const choiceTargets = computed(() =>
|
||||
choice.value && choiceTicket.value
|
||||
? targetsForColumn(choiceTicket.value, choice.value.columnId)
|
||||
: [],
|
||||
);
|
||||
const choiceOpen = computed({
|
||||
get: () => choice.value !== null,
|
||||
set: (open: boolean) => {
|
||||
if (!open) {
|
||||
choice.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function submitMove(ticketId: number, statusId: number): void {
|
||||
const ticket = findTicket(ticketId);
|
||||
if (
|
||||
busy.value ||
|
||||
!props.canEditTickets ||
|
||||
!ticket ||
|
||||
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastMove.value = { ticketId, statusId };
|
||||
moveForm.ticket_status_id = statusId;
|
||||
moveForm.clearErrors();
|
||||
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
|
||||
errorBag: `board-ticket-${ticketId}`,
|
||||
preserveScroll: true,
|
||||
onSuccess: async () => {
|
||||
const destination = props.boardColumns.find((column) =>
|
||||
column.ticket_statuses?.some(
|
||||
(status) => status.id === statusId,
|
||||
),
|
||||
);
|
||||
if (destination) {
|
||||
selectedColumnId.value = destination.id;
|
||||
}
|
||||
await nextTick();
|
||||
document
|
||||
.getElementById(`ticket-status-${ticketId}`)
|
||||
?.focus({ preventScroll: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function dropTicket(ticket: TicketModel, columnId: number): void {
|
||||
const current = findTicket(ticket.id);
|
||||
if (!current || busy.value || !props.canEditTickets) {
|
||||
return;
|
||||
}
|
||||
const targets = targetsForColumn(current, columnId);
|
||||
if (targets.length === 1) {
|
||||
submitMove(current.id, targets[0].id);
|
||||
} else if (targets.length > 1) {
|
||||
moveForm.ticket_status_id = targets[0].id;
|
||||
choice.value = { ticketId: current.id, columnId };
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
draggedTicket,
|
||||
columnId: dropColumnId,
|
||||
x: dragX,
|
||||
y: dragY,
|
||||
start: startDrag,
|
||||
cancel: cancelDrag,
|
||||
} = useBoardDrag({
|
||||
canStart: (ticket) =>
|
||||
props.canEditTickets &&
|
||||
!busy.value &&
|
||||
!choice.value &&
|
||||
allowedTargetStatuses(ticket).length > 0,
|
||||
drop: dropTicket,
|
||||
});
|
||||
const canDrop = (columnId: number) =>
|
||||
!!draggedTicket.value &&
|
||||
targetsForColumn(draggedTicket.value, columnId).length > 0;
|
||||
const dropClasses = (columnId: number) =>
|
||||
!draggedTicket.value
|
||||
? {}
|
||||
: {
|
||||
'border-primary border-dashed': canDrop(columnId),
|
||||
'ring-2 ring-primary bg-primary/10':
|
||||
canDrop(columnId) && dropColumnId.value === columnId,
|
||||
'opacity-40': !canDrop(columnId),
|
||||
};
|
||||
function confirmMove(): void {
|
||||
if (
|
||||
!choice.value ||
|
||||
!choiceTargets.value.some(
|
||||
(status) => status.id === moveForm.ticket_status_id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const ticketId = choice.value.ticketId;
|
||||
choice.value = null;
|
||||
submitMove(ticketId, moveForm.ticket_status_id);
|
||||
}
|
||||
function focusStatus(ticketId: number): void {
|
||||
document.getElementById(`ticket-status-${ticketId}`)?.focus();
|
||||
}
|
||||
watch(
|
||||
() => [props.project.id, props.boardColumns, props.canEditTickets],
|
||||
() => {
|
||||
cancelDrag();
|
||||
choice.value = null;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('board.title', { name: project.name })" />
|
||||
<Head :title="`${project.name} board`" />
|
||||
|
||||
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>{{ project.key }}</Badge
|
||||
>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('tickets.count', { count: ticketCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
<h1
|
||||
class="text-headline-mobile md:text-headline wrap-anywhere"
|
||||
>
|
||||
{{ t('board.title', { name: project.name }) }}
|
||||
</h1>
|
||||
<p class="text-caption text-muted-foreground max-w-2xl">
|
||||
{{ t('board.description') }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} board`"
|
||||
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
@@ -252,427 +75,187 @@ watch(
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus aria-hidden="true" />{{
|
||||
t('navigation.createTicket')
|
||||
}}
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="backlogIndex(project.id)"
|
||||
><ListTodo aria-hidden="true" />{{
|
||||
t('board.openBacklog')
|
||||
}}</Link
|
||||
>Open backlog</Link
|
||||
>
|
||||
</Button>
|
||||
<Button as-child variant="ghost">
|
||||
<Link :href="show(project.id)"
|
||||
><ArrowLeft aria-hidden="true" />{{
|
||||
t('board.backToProject')
|
||||
}}</Link
|
||||
>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-if="columns.length === 0"
|
||||
state="empty"
|
||||
:title="t('board.empty.title')"
|
||||
:description="t('board.empty.description')"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid min-w-0 gap-2 md:hidden">
|
||||
<label for="board-column" class="text-body font-semibold">{{
|
||||
t('board.selectColumn')
|
||||
}}</label>
|
||||
<select
|
||||
id="board-column"
|
||||
v-model="selectedColumnId"
|
||||
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"
|
||||
:aria-controls="`board-column-${selectedColumnId}`"
|
||||
>
|
||||
<option
|
||||
v-for="column in columns"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
>
|
||||
{{
|
||||
t('board.columnOption', {
|
||||
name: column.name,
|
||||
count: column.count,
|
||||
})
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
id="board-scroll-hint"
|
||||
class="text-caption text-muted-foreground hidden md:block"
|
||||
<Card v-if="boardColumns.length === 0">
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
The board has no workflow columns yet, so no tickets can
|
||||
appear here.
|
||||
</p>
|
||||
<div v-if="canEditTickets">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Open board settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="boardColumn in boardColumns"
|
||||
:key="boardColumn.id"
|
||||
class="flex h-full flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
{{ t('board.scrollHint') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="canEditTickets"
|
||||
id="board-drag-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('board.dragHint') }}
|
||||
</p>
|
||||
<section
|
||||
:aria-label="t('board.columns')"
|
||||
aria-describedby="board-scroll-hint"
|
||||
tabindex="0"
|
||||
data-board-drag-scroll
|
||||
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
||||
>
|
||||
<section
|
||||
v-for="column in columns"
|
||||
:id="`board-column-${column.id}`"
|
||||
:key="`${project.id}-${column.id}`"
|
||||
:aria-labelledby="`board-column-title-${column.id}`"
|
||||
:data-board-drop-column="column.id"
|
||||
:class="[
|
||||
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
||||
selectedColumnId === column.id ? 'flex' : 'hidden',
|
||||
dropClasses(column.id),
|
||||
]"
|
||||
>
|
||||
<header
|
||||
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
|
||||
>
|
||||
<h2
|
||||
:id="`board-column-title-${column.id}`"
|
||||
class="text-title wrap-anywhere"
|
||||
>
|
||||
{{ column.name }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="shrink-0 tabular-nums"
|
||||
:aria-label="
|
||||
t('tickets.count', { count: column.count })
|
||||
"
|
||||
>{{ column.count }}</Badge
|
||||
>
|
||||
</header>
|
||||
<p
|
||||
v-if="column.count === 0"
|
||||
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
|
||||
>
|
||||
{{ t('board.emptyColumn') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="column.groups.length === 0"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('board.noStatuses') }}
|
||||
</p>
|
||||
<section
|
||||
v-for="group in column.groups"
|
||||
:key="group.status.id"
|
||||
:aria-labelledby="`board-status-${group.status.id}`"
|
||||
class="grid min-w-0 gap-3"
|
||||
>
|
||||
<div
|
||||
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
|
||||
>
|
||||
<h3
|
||||
:id="`board-status-${group.status.id}`"
|
||||
class="wrap-anywhere font-semibold"
|
||||
>
|
||||
{{ group.status.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="shrink-0 tabular-nums"
|
||||
:aria-label="
|
||||
t('tickets.count', {
|
||||
count: group.tickets.length,
|
||||
})
|
||||
<CardHeader class="bg-muted/20 border-b">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{{ boardColumn.name }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ boardColumn.tickets?.length ?? 0 }}
|
||||
active ticket(s)
|
||||
</CardDescription>
|
||||
<div
|
||||
v-if="
|
||||
(boardColumn.ticket_statuses?.length ??
|
||||
0) > 0
|
||||
"
|
||||
>{{ group.tickets.length }}</span
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="group.tickets.length === 0"
|
||||
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
|
||||
>
|
||||
{{ t('board.emptyStatus') }}
|
||||
</p>
|
||||
<ul v-else class="grid min-w-0 gap-3">
|
||||
<li
|
||||
v-for="ticket in group.tickets"
|
||||
:key="ticket.id"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'opacity-50':
|
||||
draggedTicket?.id === ticket.id,
|
||||
}"
|
||||
>
|
||||
<TicketCard
|
||||
:project-id="project.id"
|
||||
:ticket="ticket"
|
||||
:show-status="false"
|
||||
<Badge
|
||||
v-for="ticketStatus in boardColumn.ticket_statuses"
|
||||
:key="ticketStatus.id"
|
||||
variant="outline"
|
||||
>
|
||||
<template
|
||||
v-if="canEditTickets"
|
||||
#actions
|
||||
{{ ticketStatus.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary">
|
||||
#{{ boardColumn.position }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex flex-1 flex-col gap-4 p-4">
|
||||
<div
|
||||
v-if="(boardColumn.tickets?.length ?? 0) === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
No tickets are currently in this column.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<TicketCard
|
||||
v-for="ticket in boardColumn.tickets"
|
||||
:key="ticket.id"
|
||||
:project-id="project.id"
|
||||
:ticket="ticket"
|
||||
>
|
||||
<template v-if="canEditTickets" #actions>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="board"
|
||||
/>
|
||||
<label
|
||||
:for="`ticket-status-${ticket.id}`"
|
||||
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||
Move ticket
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<select
|
||||
:id="`ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
busy ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
).length === 0 || processing
|
||||
"
|
||||
: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
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
v-slot="{ processing, errors }"
|
||||
:error-bag="`board-ticket-${ticket.id}`"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
:on-before="
|
||||
() =>
|
||||
!busy && !draggedTicket
|
||||
"
|
||||
@start="menuPending = ticket.id"
|
||||
@finish="menuPending = null"
|
||||
class="grid min-w-0 gap-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="board"
|
||||
/>
|
||||
<label
|
||||
:for="`ticket-status-${ticket.id}`"
|
||||
class="text-caption text-muted-foreground font-semibold"
|
||||
>{{
|
||||
t('board.moveTicket', {
|
||||
key: ticket.ticket_key,
|
||||
})
|
||||
}}</label
|
||||
>
|
||||
<select
|
||||
:id="`ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
busy ||
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
:aria-invalid="
|
||||
!!errors.ticket_status_id ||
|
||||
undefined
|
||||
"
|
||||
:aria-describedby="
|
||||
errors.ticket_status_id
|
||||
? `ticket-status-error-${ticket.id}`
|
||||
: undefined
|
||||
"
|
||||
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
value=""
|
||||
>
|
||||
{{ t('board.noMoves') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="target in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="target.id"
|
||||
:value="target.id"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
target,
|
||||
t(
|
||||
'tickets.noStatus',
|
||||
),
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:id="`ticket-status-error-${ticket.id}`"
|
||||
:message="
|
||||
errors.ticket_status_id
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="
|
||||
busy ||
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
>{{
|
||||
t(
|
||||
processing
|
||||
? 'board.updatingStatus'
|
||||
: 'board.updateStatus',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
<p
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
class="text-caption text-muted-foreground"
|
||||
value=""
|
||||
>
|
||||
No allowed moves
|
||||
</option>
|
||||
<option
|
||||
v-for="ticketStatus in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'board.noMovesDescription',
|
||||
ticketStatusLabel(
|
||||
ticketStatus,
|
||||
'Unknown status',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</Form>
|
||||
</template>
|
||||
</TicketCard>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</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>
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
>
|
||||
Update status
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
This status does not allow moving
|
||||
anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
</template>
|
||||
</TicketCard>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,8 @@
|
||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
||||
import { computed, toRef, unref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
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 {
|
||||
buildAcceptanceCriteria,
|
||||
@@ -26,22 +27,20 @@ import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
const props = defineProps<ProjectTicketCreatePageProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const returnHref =
|
||||
props.source === 'board'
|
||||
? boardIndex(props.project.id)
|
||||
: backlogIndex(props.project.id);
|
||||
const returnLabel =
|
||||
props.source === 'board'
|
||||
? t('ticketForm.backToBoard')
|
||||
: t('ticketForm.backToBacklog');
|
||||
props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: t('navigation.projects'),
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
@@ -49,26 +48,21 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title:
|
||||
props.source === 'board'
|
||||
? t('navigation.board')
|
||||
: t('navigation.backlog'),
|
||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
||||
href: returnHref,
|
||||
},
|
||||
{
|
||||
title: t('ticketForm.create'),
|
||||
title: 'Create ticket',
|
||||
href: ProjectTicketController.create({
|
||||
project: props.project.id,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const hasTicketMetadata = computed(
|
||||
() =>
|
||||
props.ticketTypes.length > 0 &&
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0,
|
||||
);
|
||||
const hasTicketMetadata =
|
||||
props.ticketTypes.length > 0 &&
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
|
||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
@@ -96,19 +90,9 @@ const form = useForm(draftKey, {
|
||||
title: '',
|
||||
});
|
||||
|
||||
const isDirty = computed(
|
||||
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
||||
const reset = (): void => {
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
acceptanceCriteriaChecklist.value = '';
|
||||
protection.clearFeedback();
|
||||
};
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
@@ -125,9 +109,6 @@ const acceptanceCriteriaError = computed(() => {
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
if (!hasTicketMetadata.value || form.processing) {
|
||||
return;
|
||||
}
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
@@ -136,22 +117,31 @@ const submit = (): void => {
|
||||
ProjectTicketController.store({ project: props.project.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
acceptanceCriteriaChecklist.value = '';
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
||||
<Head :title="`Create ${project.name} ticket`" />
|
||||
|
||||
<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 class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
@@ -159,16 +149,10 @@ const submit = (): void => {
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<h1 class="text-headline font-semibold tracking-tight">
|
||||
{{ t('ticketForm.create') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('ticketForm.createDescription', {
|
||||
project: project.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<Heading
|
||||
title="Create ticket"
|
||||
:description="`Add a ticket to ${project.name}.`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@@ -176,9 +160,7 @@ const submit = (): void => {
|
||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">{{
|
||||
t('ticketDetail.backToProject')
|
||||
}}</Link>
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,9 +174,9 @@ const submit = (): void => {
|
||||
|
||||
<Card v-if="hasTicketMetadata">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
||||
<CardTitle>Ticket details</CardTitle>
|
||||
<CardDescription>
|
||||
{{ t('ticketForm.keyAssigned') }}
|
||||
The ticket key is assigned after creation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -204,93 +186,258 @@ const submit = (): void => {
|
||||
: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:status="form.ticket_status_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"
|
||||
:ticket-status-options="ticketStatusOptions"
|
||||
:users="users"
|
||||
:parent-ticket-options="parentTicketOptions"
|
||||
:errors="form.errors"
|
||||
:disabled="form.processing"
|
||||
autofocus
|
||||
>
|
||||
<template #content>
|
||||
<TicketFormField
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.checklistEditor')"
|
||||
:hint="t('ticketForm.checklistHelp')"
|
||||
:placeholder="
|
||||
t('ticketDetail.checklistPlaceholder')
|
||||
"
|
||||
:error="acceptanceCriteriaError"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length"
|
||||
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
{{ t('ticketForm.checklistPreview') }}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
||||
}"
|
||||
class="flex min-w-0 items-start gap-2"
|
||||
>
|
||||
<span aria-hidden="true">{{
|
||||
item.checked ? '☑' : '☐'
|
||||
}}</span>
|
||||
<span
|
||||
class="wrap-anywhere min-w-0"
|
||||
>{{ item.text }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</TicketFormFields>
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
placeholder="Implement repository sync"
|
||||
/>
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
>
|
||||
<Button type="submit" :disabled="form.processing">{{
|
||||
form.processing
|
||||
? t('ticketForm.creating')
|
||||
: t('ticketForm.create')
|
||||
}}</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing || !isDirty"
|
||||
@click="reset"
|
||||
>{{ t('ticketForm.reset') }}</Button
|
||||
>
|
||||
<p
|
||||
role="status"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{
|
||||
isDirty
|
||||
? t('ticketForm.unsaved')
|
||||
: t('ticketForm.ready')
|
||||
}}
|
||||
</p>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
: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"
|
||||
placeholder="Describe the expected behavior and relevant context."
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_checklist"
|
||||
>
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="
|
||||
acceptanceCriteriaChecklist
|
||||
"
|
||||
rows="6"
|
||||
: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"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<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>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -299,12 +446,13 @@ const submit = (): void => {
|
||||
<Card v-else>
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('ticketForm.metadataRequired') }}
|
||||
Ticket creation needs at least one type, one priority,
|
||||
and one status.
|
||||
</p>
|
||||
<div>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
{{ t('ticketForm.openSettings') }}
|
||||
Open ticket settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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];
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './backlog';
|
||||
export * from './navigation';
|
||||
export * from './page-props';
|
||||
export * from './ticket-tree';
|
||||
export * from './ui';
|
||||
|
||||
@@ -39,15 +39,6 @@ export type ProjectDuplicateCopyAreaOption = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AssignedTicketsProjectGroup = {
|
||||
project: ProjectModel;
|
||||
tickets: TicketModel[];
|
||||
};
|
||||
|
||||
export type DashboardPageProps = {
|
||||
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
||||
};
|
||||
|
||||
export type ProjectsIndexPageProps = {
|
||||
projects: ProjectModel[];
|
||||
status?: string | null;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||
export type TicketDetailHeaderProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canEditTicket'
|
||||
>;
|
||||
export type TicketDetailContentProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'canManageProject'
|
||||
| 'parentTicketOptions'
|
||||
| 'ticketFieldDefinitions'
|
||||
| 'ticketPriorities'
|
||||
| 'ticketTypes'
|
||||
| 'users'
|
||||
>;
|
||||
export type TicketDetailMetadataProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||
>;
|
||||
export type TicketDetailGitProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'repositoryBranches'
|
||||
| 'repositoryPullRequests'
|
||||
| 'suggestedBranchName'
|
||||
>;
|
||||
export type TicketDetailRelationshipsProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'parentTicketOptions'
|
||||
| 'childTicketTree'
|
||||
| 'linkedTickets'
|
||||
| 'ticketLinkTypes'
|
||||
| 'ticketLinkOptions'
|
||||
>;
|
||||
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
|
||||
export type TicketBranchFormData = {
|
||||
branch_name: string;
|
||||
repository_connection_id: string;
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
|
||||
export type TicketPresentationProps = {
|
||||
layout?: 'card' | 'list';
|
||||
projectId: number;
|
||||
showDescription?: boolean;
|
||||
showStatus?: boolean;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||
|
||||
export type TicketTreeListProps = {
|
||||
nodes: TicketTreeNodeModel[];
|
||||
projectId: number;
|
||||
};
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -18,7 +17,7 @@ Route::get('api/docs/openapi.json', function (): Response {
|
||||
})->name('api.docs.openapi');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||
|
||||
require __DIR__.'/web/admin.php';
|
||||
require __DIR__.'/web/project-templates.php';
|
||||
|
||||
@@ -2,117 +2,26 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guests_are_redirected_to_the_login_page(): void
|
||||
public function test_guests_are_redirected_to_the_login_page()
|
||||
{
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_authenticated_users_see_an_empty_dashboard_without_assigned_tickets(): void
|
||||
public function test_authenticated_users_can_visit_the_dashboard()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('dashboard'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Dashboard')
|
||||
->has('assignedTicketsByProject', 0));
|
||||
}
|
||||
|
||||
public function test_dashboard_only_contains_the_users_tickets_from_visible_projects(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$directProject = Project::factory()->create([
|
||||
'key' => 'WEB',
|
||||
'name' => 'Web platform',
|
||||
]);
|
||||
$teamProject = Project::factory()->create([
|
||||
'key' => 'OPS',
|
||||
'name' => 'Operations',
|
||||
]);
|
||||
$hiddenProject = Project::factory()->create([
|
||||
'key' => 'SEC',
|
||||
'name' => 'Private security',
|
||||
]);
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$team->users()->attach($user);
|
||||
$this->syncProjectPermissions($directProject, $user, ['view']);
|
||||
$this->syncProjectPermissions($teamProject, $team, ['view']);
|
||||
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $directProject->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'WEB-1',
|
||||
'title' => 'Older assigned ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $directProject->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'WEB-2',
|
||||
'title' => 'Newest assigned ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $teamProject->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'OPS-1',
|
||||
'title' => 'Team-visible assigned ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $otherUser->id,
|
||||
'project_id' => $directProject->id,
|
||||
'sequence_number' => 3,
|
||||
'ticket_key' => 'WEB-3',
|
||||
'title' => 'Another users ticket',
|
||||
]);
|
||||
Ticket::factory()->create([
|
||||
'assignee_user_id' => $user->id,
|
||||
'project_id' => $hiddenProject->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'SEC-1',
|
||||
'title' => 'Hidden project ticket',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('dashboard'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Dashboard')
|
||||
->has('assignedTicketsByProject', 2)
|
||||
->where('assignedTicketsByProject.0.project.id', $teamProject->id)
|
||||
->has('assignedTicketsByProject.0.tickets', 1)
|
||||
->where('assignedTicketsByProject.0.tickets.0.ticket_key', 'OPS-1')
|
||||
->where('assignedTicketsByProject.1.project.id', $directProject->id)
|
||||
->has('assignedTicketsByProject.1.tickets', 2)
|
||||
->where('assignedTicketsByProject.1.tickets.0.ticket_key', 'WEB-2')
|
||||
->where('assignedTicketsByProject.1.tickets.1.ticket_key', 'WEB-1'));
|
||||
}
|
||||
|
||||
public function test_dashboard_presentation_uses_real_data_and_handles_empty_state(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/dashboard.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Dashboard behavior checks passed.', $process->getOutput());
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertOk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,110 +71,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -212,19 +212,6 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'entry_type' => 'status_changed',
|
||||
'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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectBoardPresentationTest extends TestCase
|
||||
{
|
||||
public function test_board_renders_configured_status_groups_counts_and_permission_aware_actions(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/board.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Board behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -1511,74 +1511,6 @@ class ProjectTicketControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): 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.tickets.create', $project))
|
||||
->assertForbidden();
|
||||
|
||||
$this->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'Unauthorized ticket',
|
||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
|
||||
])->assertForbidden();
|
||||
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
}
|
||||
|
||||
public function test_ticket_forms_reject_metadata_from_another_project(): void
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$otherProject = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||
$parent = Ticket::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'ticket_key' => 'OTHER-1',
|
||||
'ticket_type_id' => $this->parentType($otherProject)->id,
|
||||
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$invalidMetadata = [
|
||||
'parent_ticket_id' => $parent->id,
|
||||
'ticket_type_id' => $this->nonParentType($otherProject)->id,
|
||||
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||
];
|
||||
|
||||
$this->actingAs($editor)
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
...$invalidMetadata,
|
||||
'title' => 'Rejected creation',
|
||||
'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
|
||||
])
|
||||
->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
|
||||
|
||||
$this->assertSame(0, $project->tickets()->count());
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'ticket_key' => 'LOCAL-1',
|
||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'title' => 'Original title',
|
||||
]);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
|
||||
...$invalidMetadata,
|
||||
'title' => 'Rejected update',
|
||||
]))->assertSessionHasErrors(array_keys($invalidMetadata));
|
||||
|
||||
$this->assertSame('Original title', $ticket->fresh()->title);
|
||||
$this->assertNull($ticket->fresh()->parent_ticket_id);
|
||||
}
|
||||
|
||||
private function nonParentType(Project $project): TicketType
|
||||
{
|
||||
return $project->ticketTypes()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketDetailPresentationTest extends TestCase
|
||||
{
|
||||
public function test_ticket_detail_sections_preserve_information_actions_and_permissions(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/ticket-detail.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Ticket detail behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const { chromium } = playwright.default ?? playwright;
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const base = 'http://127.0.0.1:4181/projects/7/board';
|
||||
const content = () => page.locator('#main-content');
|
||||
const visibleColumns = () =>
|
||||
content().locator('[id^="board-column-title-"]:visible');
|
||||
const visit = async (query = '') => {
|
||||
await page.goto(base + query);
|
||||
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
};
|
||||
const tabTo = async (locator) => {
|
||||
await content().focus();
|
||||
for (let step = 0; step < 40; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (
|
||||
await locator.evaluate(
|
||||
(element) => element === document.activeElement,
|
||||
)
|
||||
) {
|
||||
assert.equal(
|
||||
await locator.evaluate((element) =>
|
||||
element.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail('Board control is unreachable by keyboard');
|
||||
};
|
||||
try {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
for (const [query, state] of [
|
||||
['', 'filled'],
|
||||
['?empty', 'empty'],
|
||||
['?one&noTickets', 'one-empty'],
|
||||
]) {
|
||||
await visit(query);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`${state} overflow at ${width}/${theme}`,
|
||||
);
|
||||
assert.equal(
|
||||
await visibleColumns().count(),
|
||||
state === 'empty'
|
||||
? 0
|
||||
: width < 768 || state === 'one-empty'
|
||||
? 1
|
||||
: 8,
|
||||
);
|
||||
if (width < 768 && state !== 'empty') {
|
||||
assert.ok(
|
||||
(await page.getByLabel('Choose a column').boundingBox())
|
||||
.height >= 44,
|
||||
);
|
||||
}
|
||||
if (state === 'filled' && width >= 768) {
|
||||
const region = content().getByRole('region', {
|
||||
name: 'Workflow columns',
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(
|
||||
await region.evaluate(
|
||||
(el) => el.scrollWidth > el.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await region.focus();
|
||||
await page.keyboard.press('End');
|
||||
await content()
|
||||
.locator('#board-column-8')
|
||||
.scrollIntoViewIfNeeded();
|
||||
assert.ok(await region.evaluate((el) => el.scrollLeft > 0));
|
||||
await region.evaluate((el) => {
|
||||
el.scrollLeft = 0;
|
||||
});
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan27-board-${state}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await visit();
|
||||
const chooseColumn = page.getByLabel('Choose a column');
|
||||
await tabTo(chooseColumn);
|
||||
// Native popup selection is handled by macOS outside the browser DOM.
|
||||
await chooseColumn.selectOption('2');
|
||||
assert.equal(await chooseColumn.inputValue(), '2');
|
||||
assert.equal(await visibleColumns().innerText(), 'In development');
|
||||
await chooseColumn.selectOption('8');
|
||||
assert.equal(await visibleColumns().innerText(), 'Column without statuses');
|
||||
await content()
|
||||
.getByText('No statuses configured for this column.')
|
||||
.waitFor();
|
||||
await chooseColumn.selectOption('1');
|
||||
await content()
|
||||
.getByRole('heading', { name: 'Ready · Planned', exact: true })
|
||||
.waitFor();
|
||||
await content()
|
||||
.getByRole('heading', { name: 'Ready · Active', exact: true })
|
||||
.waitFor();
|
||||
const terminalSelect = page.getByLabel('Move ENG-43', { exact: true });
|
||||
assert.equal(await terminalSelect.isDisabled(), true);
|
||||
const moveSelect = page.getByLabel('Move ENG-42', { exact: true });
|
||||
assert.deepEqual(
|
||||
await moveSelect
|
||||
.locator('option')
|
||||
.evaluateAll((options) => options.map((option) => option.value)),
|
||||
['20', '11', '99'],
|
||||
);
|
||||
await tabTo(moveSelect);
|
||||
await moveSelect.selectOption('20');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.locator(':focus').innerText(), 'Update status');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await chooseColumn.inputValue(), '1');
|
||||
assert.match(
|
||||
await chooseColumn.locator('option').first().innerText(),
|
||||
/\(1\)/,
|
||||
);
|
||||
await chooseColumn.selectOption('2');
|
||||
await content()
|
||||
.getByRole('link', {
|
||||
name: 'Open ENG-42: Ship accessible board navigation',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
|
||||
await visit();
|
||||
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('99');
|
||||
await content()
|
||||
.locator('article')
|
||||
.filter({ hasText: 'ENG-42' })
|
||||
.getByRole('button', { name: 'Update status' })
|
||||
.click();
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await content()
|
||||
.getByRole('link', {
|
||||
name: 'Open ENG-42: Ship accessible board navigation',
|
||||
exact: true,
|
||||
})
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.match(
|
||||
await chooseColumn.locator('option').first().innerText(),
|
||||
/\(1\)/,
|
||||
);
|
||||
|
||||
await visit('?reject');
|
||||
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('20');
|
||||
await content()
|
||||
.locator('article')
|
||||
.filter({ hasText: 'ENG-42' })
|
||||
.getByRole('button', { name: 'Update status' })
|
||||
.click();
|
||||
await page
|
||||
.getByText('This transition is no longer allowed.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Move ENG-42', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await content()
|
||||
.getByText('This transition is no longer allowed.', { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Move ENG-43', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
null,
|
||||
);
|
||||
|
||||
await visit('?readonly');
|
||||
assert.equal(await content().locator('form').count(), 0);
|
||||
const ticketLink = content().getByRole('link', {
|
||||
name: 'Open ENG-42: Ship accessible board navigation',
|
||||
exact: true,
|
||||
});
|
||||
await tabTo(ticketLink);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects/7/tickets/42');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||
.waitFor();
|
||||
await visit('?empty');
|
||||
await tabTo(
|
||||
content().getByRole('link', { name: 'Open backlog', exact: true }),
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects/7/backlog');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Board browser checks passed: responsive columns, keyboard, status changes and validation, permissions, empty states and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const { chromium } = playwright.default ?? playwright;
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
hasTouch: true,
|
||||
});
|
||||
page.setDefaultTimeout(10000);
|
||||
const failures = [];
|
||||
const requests = [];
|
||||
page.on('pageerror', (error) => failures.push(error.message));
|
||||
page.on('request', (request) => {
|
||||
if (['POST', 'PATCH'].includes(request.method())) {
|
||||
requests.push(request);
|
||||
}
|
||||
});
|
||||
const base = 'http://127.0.0.1:4181/projects/7/board';
|
||||
const handle = () =>
|
||||
page.getByRole('button', {
|
||||
name: 'Drag ENG-42 to change status',
|
||||
exact: true,
|
||||
});
|
||||
const card = () => page.locator('article').filter({ hasText: 'ENG-42' });
|
||||
const visit = async (query = '') => {
|
||||
await page.goto(base + query);
|
||||
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||
requests.length = 0;
|
||||
};
|
||||
const center = async (locator) => {
|
||||
const box = await locator.boundingBox();
|
||||
assert.ok(box);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
};
|
||||
const begin = async () => {
|
||||
await handle().scrollIntoViewIfNeeded();
|
||||
const from = await center(handle());
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(from.x + 12, from.y, { steps: 3 });
|
||||
};
|
||||
const moveToColumn = async (id) => {
|
||||
const to = await center(page.locator(`#board-column-title-${id}`));
|
||||
await page.mouse.move(to.x, to.y, { steps: 10 });
|
||||
};
|
||||
const dragToColumn = async (id) => {
|
||||
await begin();
|
||||
await moveToColumn(id);
|
||||
await page.mouse.up();
|
||||
};
|
||||
const originalCard = async () => {
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#board-column-1 article')
|
||||
.filter({ hasText: 'ENG-42' })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
};
|
||||
try {
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
await visit();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Drag ENG-43 to change status' })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
await begin();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#board-column-2')
|
||||
.evaluate((el) => el.classList.contains('border-primary')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#board-column-3')
|
||||
.evaluate((el) => el.classList.contains('opacity-40')),
|
||||
true,
|
||||
);
|
||||
await moveToColumn(3);
|
||||
await page.mouse.up();
|
||||
await originalCard();
|
||||
assert.equal(requests.length, 0);
|
||||
|
||||
await begin();
|
||||
await moveToColumn(2);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
assert.equal(requests.length, 0);
|
||||
await originalCard();
|
||||
|
||||
// Pointer capture cancellation must never become a drop.
|
||||
await begin();
|
||||
await handle().dispatchEvent('pointercancel', { pointerId: 1 });
|
||||
await page.mouse.up();
|
||||
assert.equal(requests.length, 0);
|
||||
|
||||
await visit('?multiple');
|
||||
await dragToColumn(2);
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
assert.equal(requests.length, 0);
|
||||
assert.deepEqual(
|
||||
await dialog
|
||||
.locator('option')
|
||||
.evaluateAll((options) =>
|
||||
options.map((option) => option.value),
|
||||
),
|
||||
['20', '21'],
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await handle().evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assert.equal(requests.length, 0);
|
||||
await originalCard();
|
||||
await dragToColumn(2);
|
||||
const select = dialog.getByLabel('Destination status');
|
||||
await select.focus();
|
||||
await select.selectOption('21');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.locator(':focus').innerText(), 'Update status');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.deepEqual(requests[0].postDataJSON(), {
|
||||
ticket_status_id: 21,
|
||||
redirect_to: 'board',
|
||||
});
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#board-column-2 article')
|
||||
.filter({ hasText: 'ENG-42' })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
|
||||
await visit('?reject');
|
||||
await dragToColumn(2);
|
||||
await page
|
||||
.getByText('This transition is no longer allowed.', { exact: true })
|
||||
.waitFor();
|
||||
await originalCard();
|
||||
assert.equal(requests.length, 1);
|
||||
|
||||
await visit('?slow');
|
||||
await dragToColumn(2);
|
||||
await page.locator('[data-form-state="loading"]').waitFor();
|
||||
assert.equal(await handle().isDisabled(), true);
|
||||
assert.equal(
|
||||
await page.getByLabel('Move ENG-42', { exact: true }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
// Attempt a competing form submit even though its controls are disabled.
|
||||
await card()
|
||||
.locator('form')
|
||||
.evaluate((form) => form.requestSubmit());
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(requests.length, 1);
|
||||
|
||||
await visit('?slow');
|
||||
await card()
|
||||
.getByRole('button', { name: 'Update status', exact: true })
|
||||
.click();
|
||||
assert.equal(await handle().isDisabled(), true);
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(requests.length, 1);
|
||||
|
||||
await visit();
|
||||
await page.route('**/tickets/42/status', (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await dragToColumn(2);
|
||||
await page.locator('[data-form-state="network"]').waitFor();
|
||||
await originalCard();
|
||||
assert.equal(await handle().isEnabled(), true);
|
||||
await page.unroute('**/tickets/42/status');
|
||||
await page
|
||||
.getByRole('button', { name: 'Try again', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(requests.length, 2);
|
||||
|
||||
await visit();
|
||||
await page.route('**/tickets/42/status', (route) =>
|
||||
route.fulfill({ status: 500, body: 'Unavailable' }),
|
||||
);
|
||||
await dragToColumn(2);
|
||||
await page.locator('[data-form-state="server"]').waitFor();
|
||||
await originalCard();
|
||||
await page.unroute('**/tickets/42/status');
|
||||
|
||||
// Normal touch gestures still scroll; only the explicit handle captures a drag.
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await visit('?multiple');
|
||||
await handle().scrollIntoViewIfNeeded();
|
||||
assert.equal(
|
||||
await handle().evaluate((el) => getComputedStyle(el).touchAction),
|
||||
'none',
|
||||
);
|
||||
assert.notEqual(
|
||||
await card().evaluate((el) => getComputedStyle(el).touchAction),
|
||||
'none',
|
||||
);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const from = await center(handle());
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ ...from, id: 1 }],
|
||||
});
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: from.x + 16, y: from.y, id: 1 }],
|
||||
});
|
||||
const tray = page.locator('[data-board-mobile-targets]');
|
||||
await tray.waitFor();
|
||||
const to = await center(tray.locator('[data-board-drop-column="2"]'));
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ ...to, id: 1 }],
|
||||
});
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan28-mobile-drag-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.getByRole('dialog').waitFor();
|
||||
await page.getByLabel('Destination status').selectOption('20');
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Update status' })
|
||||
.click();
|
||||
await page
|
||||
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Choose a column').inputValue(),
|
||||
'2',
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
await cdp.detach();
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan28-mobile-result-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await visit('?readonly');
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: /Drag ENG/ }).count(),
|
||||
0,
|
||||
);
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
console.log(
|
||||
'Board drag checks passed: mouse, touch, workflow restrictions, selection, cancellation, focus, request guards and recovery in both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Board from '../../resources/js/pages/projects/Board.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Board'
|
||||
? Board
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,287 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/board-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4181, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const project = {
|
||||
id: 7,
|
||||
key: 'ENG',
|
||||
name: 'Engineering · Platform and developer experience',
|
||||
};
|
||||
const columnNames = [
|
||||
'Ready',
|
||||
'In development',
|
||||
'Review',
|
||||
'Testing',
|
||||
'Release',
|
||||
'Done',
|
||||
'Archived',
|
||||
'Column without statuses',
|
||||
];
|
||||
const columns = columnNames.map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
position: index + 1,
|
||||
project_id: 7,
|
||||
tickets: [],
|
||||
ticket_statuses: [],
|
||||
}));
|
||||
const statuses = columns.slice(0, -1).flatMap((column) =>
|
||||
['Planned', 'Active'].map((name, index) => ({
|
||||
id: column.id * 10 + index,
|
||||
name: `${column.name} · ${name}`,
|
||||
position: index + 1,
|
||||
project_id: 7,
|
||||
board_column_id: column.id,
|
||||
board_column: { ...column },
|
||||
})),
|
||||
);
|
||||
const backlog = {
|
||||
id: 99,
|
||||
name: 'Backlog',
|
||||
board_column_id: null,
|
||||
board_column: null,
|
||||
};
|
||||
for (const column of columns) {
|
||||
column.ticket_statuses = statuses.filter(
|
||||
(status) => status.board_column_id === column.id,
|
||||
);
|
||||
}
|
||||
function ticket(id, status, title) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
ticket_key: `ENG-${id}`,
|
||||
project_id: 7,
|
||||
sequence_number: id,
|
||||
description:
|
||||
'A real ticket card in the isolated board behavior fixture.',
|
||||
ticket_status_id: status.id,
|
||||
ticket_status: {
|
||||
...status,
|
||||
allowed_target_statuses:
|
||||
id === 42 ? [status, statuses[2], statuses[1], backlog] : [],
|
||||
},
|
||||
ticket_priority: { id: 1, name: 'Urgent custom priority', position: 1 },
|
||||
ticket_type: { id: 1, name: 'Development', is_parent: false },
|
||||
assignee: null,
|
||||
assignee_user_id: null,
|
||||
is_blocked: id === 43,
|
||||
blocked_comment: id === 43 ? 'Awaiting the external review.' : null,
|
||||
};
|
||||
}
|
||||
function makePage(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const params = parsed.searchParams;
|
||||
const isBoard = parsed.pathname === '/projects/7/board';
|
||||
const boardColumns = structuredClone(
|
||||
params.has('empty')
|
||||
? []
|
||||
: params.has('one')
|
||||
? columns.slice(0, 1)
|
||||
: columns,
|
||||
);
|
||||
if (!params.has('noTickets')) {
|
||||
const target =
|
||||
[...statuses, backlog].find(
|
||||
(status) => status.id === Number(params.get('target')),
|
||||
) ?? statuses[0];
|
||||
const tickets = [
|
||||
ticket(42, target, 'Ship accessible board navigation'),
|
||||
ticket(
|
||||
43,
|
||||
statuses[1],
|
||||
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
||||
),
|
||||
];
|
||||
if (params.has('multiple')) {
|
||||
tickets[0].ticket_status.allowed_target_statuses.push(statuses[3]);
|
||||
}
|
||||
for (const column of boardColumns) {
|
||||
column.tickets = tickets.filter(
|
||||
(item) => item.ticket_status.board_column_id === column.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
component: isBoard ? 'Board' : 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: params.has('rejected')
|
||||
? {
|
||||
'board-ticket-42': {
|
||||
ticket_status_id:
|
||||
'This transition is no longer allowed.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
sidebarOpen: true,
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
},
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
project: {
|
||||
...project,
|
||||
canManage: false,
|
||||
canCreateTickets: !params.has('readonly'),
|
||||
},
|
||||
},
|
||||
project,
|
||||
boardColumns,
|
||||
canCreateTickets: !params.has('readonly'),
|
||||
canEditTickets: !params.has('readonly'),
|
||||
status: params.has('target') ? 'ENG-42 moved successfully.' : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
if (request.method === 'POST' || request.method === 'PATCH') {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const data = JSON.parse(body);
|
||||
assert.equal(
|
||||
request.url,
|
||||
request.method === 'POST'
|
||||
? '/projects/7/tickets/42/status?_method=PATCH'
|
||||
: '/projects/7/tickets/42/status',
|
||||
);
|
||||
assert.deepEqual(Object.keys(data).sort(), [
|
||||
'redirect_to',
|
||||
'ticket_status_id',
|
||||
]);
|
||||
assert.equal(data.redirect_to, 'board');
|
||||
assert.ok([11, 20, 21, 99].includes(Number(data.ticket_status_id)));
|
||||
const previous = new URL(request.headers.referer);
|
||||
const reject = previous.searchParams.has('reject');
|
||||
if (previous.searchParams.has('slow')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
response.writeHead(303, {
|
||||
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const page = makePage(request.url);
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/board-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log(
|
||||
'Board fixture ready at http://127.0.0.1:4181/projects/7/board',
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { default: Board } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/Board.vue',
|
||||
);
|
||||
const render = async (query = '') => {
|
||||
const result = await createInertiaApp({
|
||||
page: makePage(`/projects/7/board${query}`),
|
||||
resolve: () => Board,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance('light')),
|
||||
});
|
||||
return result.body.split('id="main-content"')[1];
|
||||
};
|
||||
const board = await render();
|
||||
assert.equal((board.match(/id="board-column-title-/g) ?? []).length, 8);
|
||||
assert.equal((board.match(/id="board-status-/g) ?? []).length, 14);
|
||||
assert.match(board, /Ready · Planned/);
|
||||
assert.match(board, /Ready · Active/);
|
||||
assert.match(board, /No statuses configured for this column/);
|
||||
assert.match(board, /No tickets in this column/);
|
||||
assert.match(board, /No tickets in this status/);
|
||||
assert.match(board, /2 tickets/);
|
||||
assert.match(board, /Urgent custom priority/);
|
||||
assert.match(board, /href="\/projects\/7\/tickets\/42"/);
|
||||
assert.match(
|
||||
board,
|
||||
/action="\/projects\/7\/tickets\/42\/status\?_method=PATCH"/,
|
||||
);
|
||||
assert.match(
|
||||
board,
|
||||
/href="\/projects\/7\/tickets\/create\?source=board"/,
|
||||
);
|
||||
assert.match(board, /href="\/projects\/7\/backlog"/);
|
||||
assert.match(board, /No allowed moves/);
|
||||
assert.equal((board.match(/<article/g) ?? []).length, 2);
|
||||
const readonly = await render('?readonly');
|
||||
assert.doesNotMatch(readonly, /<form|Move ENG-|New ticket/);
|
||||
const empty = await render('?empty');
|
||||
assert.match(empty, /No workflow columns yet/);
|
||||
assert.doesNotMatch(empty, /id="board-column"|<article/);
|
||||
const one = await render('?one&noTickets');
|
||||
assert.equal((one.match(/id="board-column-title-/g) ?? []).length, 1);
|
||||
assert.match(one, /0 tickets/);
|
||||
assert.doesNotMatch(one, /<article/);
|
||||
console.log('Board behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const { chromium } = playwright.default ?? playwright;
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4180';
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const content = () => page.locator('#main-content');
|
||||
const focusWithTab = async (link) => {
|
||||
await content().focus();
|
||||
for (let step = 0; step < 40; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (
|
||||
await link.evaluate((element) => element === document.activeElement)
|
||||
) {
|
||||
assert.equal(
|
||||
await link.evaluate((element) =>
|
||||
element.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail('Dashboard action was not reachable by keyboard');
|
||||
};
|
||||
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
for (const [path, state] of [
|
||||
['/dashboard', 'filled'],
|
||||
['/dashboard?empty', 'empty'],
|
||||
]) {
|
||||
await page.goto(base + path);
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Welcome back, Alex Morgan',
|
||||
exact: true,
|
||||
level: 1,
|
||||
})
|
||||
.waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((element) =>
|
||||
element.classList.contains('dark'),
|
||||
),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`${state} dashboard overflows at ${width} in ${theme}`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan25-dashboard-${state}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await page.goto(`${base}/dashboard`);
|
||||
const ticket = content().getByRole('link', {
|
||||
name: 'Open ENG-42: Ship the dashboard with real assigned work',
|
||||
exact: true,
|
||||
});
|
||||
await focusWithTab(ticket);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects/7/tickets/42');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await page.goto(`${base}/dashboard?empty`);
|
||||
const projects = content().getByRole('link', {
|
||||
name: 'Browse projects',
|
||||
exact: true,
|
||||
});
|
||||
await focusWithTab(projects);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/projects');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Dashboard browser checks passed: keyboard, empty and filled states, 320/390/1440px and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Dashboard from '../../resources/js/pages/Dashboard.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ Dashboard })[name] ?? {
|
||||
render: () => h('h1', 'Destination reached'),
|
||||
},
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/dashboard-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4180, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
const user = {
|
||||
id: 8,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
avatar_color: '#22c55e',
|
||||
avatar_is_generated: true,
|
||||
email_verified_at: '2026-09-11T00:00:00Z',
|
||||
must_change_password_on_next_login: false,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
};
|
||||
const project = {
|
||||
id: 7,
|
||||
key: 'ENG',
|
||||
name: 'Engineering · Platform and developer experience',
|
||||
show_author: true,
|
||||
show_story_points: true,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
};
|
||||
const secondProject = {
|
||||
...project,
|
||||
id: 9,
|
||||
key: 'OPS',
|
||||
name: 'Operations',
|
||||
};
|
||||
const ticketDefaults = {
|
||||
acceptance_criteria: [],
|
||||
assignee: user,
|
||||
assignee_user_id: user.id,
|
||||
author: null,
|
||||
author_user_id: null,
|
||||
blocked_comment: null,
|
||||
description: 'Real work loaded from the server.',
|
||||
is_blocked: false,
|
||||
parent_ticket_id: null,
|
||||
story_points: 3,
|
||||
ticket_priority: {
|
||||
id: 1,
|
||||
name: 'Medium',
|
||||
position: 3,
|
||||
project_id: 7,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
ticket_priority_id: 1,
|
||||
ticket_status: {
|
||||
id: 3,
|
||||
board_column_id: 2,
|
||||
name: 'In Progress',
|
||||
position: 1,
|
||||
project_id: 7,
|
||||
slug: 'in-progress',
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
board_column: {
|
||||
id: 2,
|
||||
name: 'In Progress',
|
||||
position: 2,
|
||||
project_id: 7,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
},
|
||||
ticket_status_id: 3,
|
||||
ticket_type: {
|
||||
id: 1,
|
||||
is_parent: false,
|
||||
name: 'Dev task',
|
||||
position: 1,
|
||||
project_id: 7,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
ticket_type_id: 1,
|
||||
created_at: '2026-09-11T00:00:00Z',
|
||||
updated_at: '2026-09-11T00:00:00Z',
|
||||
};
|
||||
const tickets = [
|
||||
{
|
||||
...ticketDefaults,
|
||||
id: 42,
|
||||
project_id: project.id,
|
||||
sequence_number: 42,
|
||||
ticket_key: 'ENG-42',
|
||||
title: 'Ship the dashboard with real assigned work',
|
||||
},
|
||||
{
|
||||
...ticketDefaults,
|
||||
id: 41,
|
||||
project_id: project.id,
|
||||
sequence_number: 41,
|
||||
ticket_key: 'ENG-41',
|
||||
title: 'Verify keyboard navigation',
|
||||
},
|
||||
];
|
||||
const blockedTicket = {
|
||||
...ticketDefaults,
|
||||
id: 12,
|
||||
blocked_comment: 'Waiting for review.',
|
||||
is_blocked: true,
|
||||
project_id: secondProject.id,
|
||||
sequence_number: 12,
|
||||
ticket_key: 'OPS-12',
|
||||
title: 'Resolve the deployment blocker',
|
||||
};
|
||||
const assignedTicketsByProject = [
|
||||
{ project: secondProject, tickets: [blockedTicket] },
|
||||
{ project, tickets },
|
||||
];
|
||||
|
||||
function makePage(url) {
|
||||
const parsedUrl = new URL(url, 'http://localhost');
|
||||
const isDashboard = parsedUrl.pathname === '/dashboard';
|
||||
return {
|
||||
component: isDashboard ? 'Dashboard' : 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
auth: {
|
||||
user,
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
...(isDashboard
|
||||
? {
|
||||
assignedTicketsByProject: parsedUrl.searchParams.has(
|
||||
'empty',
|
||||
)
|
||||
? []
|
||||
: assignedTicketsByProject,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
const page = makePage(request.url);
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/dashboard-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Dashboard fixture ready at http://127.0.0.1:4180/dashboard');
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { default: Dashboard } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/Dashboard.vue',
|
||||
);
|
||||
const render = async (url) => {
|
||||
const response = await createInertiaApp({
|
||||
page: makePage(url),
|
||||
resolve: () => Dashboard,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance('light')),
|
||||
});
|
||||
return response.body.split('<main')[1].split('</main>')[0];
|
||||
};
|
||||
|
||||
const dashboard = await render('/dashboard');
|
||||
assert.match(dashboard, /Welcome back, Alex Morgan/);
|
||||
assert.match(dashboard, /Assigned tickets/);
|
||||
assert.match(dashboard, /Projects with assigned work/);
|
||||
assert.match(dashboard, /Blocked tickets/);
|
||||
assert.match(dashboard, /Ship the dashboard with real assigned work/);
|
||||
assert.match(dashboard, /Resolve the deployment blocker/);
|
||||
assert.match(dashboard, /href="\/projects\/7\/tickets\/42"/);
|
||||
assert.match(dashboard, /href="\/projects\/9"/);
|
||||
assert.doesNotMatch(
|
||||
dashboard,
|
||||
/API-integration|Finished this week|Recent activity|PlaceholderPattern/,
|
||||
);
|
||||
|
||||
const empty = await render('/dashboard?empty');
|
||||
assert.match(empty, /No tickets assigned to you/);
|
||||
assert.match(empty, /href="\/projects"/);
|
||||
assert.doesNotMatch(empty, /ENG-42|OPS-12|<article/);
|
||||
|
||||
console.log('Dashboard behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,6 @@ try {
|
||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||
|
||||
await page.goto(`${base}/projects/7/tickets/31`);
|
||||
await page.locator('#ticket-edit > summary').click();
|
||||
await page.locator('#title').fill('Updated ticket draft');
|
||||
await page
|
||||
.locator('#acceptance_criteria_checklist')
|
||||
|
||||
@@ -39,8 +39,6 @@ let ticket = {
|
||||
acceptance_criteria: [],
|
||||
field_values: [],
|
||||
worklog_entries: [],
|
||||
created_at: '2026-09-10T12:00:00Z',
|
||||
updated_at: '2026-09-11T12:00:00Z',
|
||||
};
|
||||
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const { chromium } = playwright.default ?? playwright;
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
|
||||
const content = () => page.locator('#main-content');
|
||||
const visit = async (query = '') => {
|
||||
await page.goto(base + query);
|
||||
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||
};
|
||||
const edit = () => page.locator('#ticket-edit > summary');
|
||||
const tabTo = async (locator) => {
|
||||
await content().focus();
|
||||
for (let step = 0; step < 45; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (
|
||||
await locator.evaluate(
|
||||
(element) => element === document.activeElement,
|
||||
)
|
||||
) {
|
||||
assert.equal(
|
||||
await locator.evaluate((element) =>
|
||||
element.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail('Ticket control is unreachable by keyboard');
|
||||
};
|
||||
try {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
for (const query of ['', '?empty', '?long']) {
|
||||
await visit(query);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`Page overflow at ${width}/${theme}/${query}`,
|
||||
);
|
||||
const description = await page
|
||||
.locator('#ticket-description')
|
||||
.boundingBox();
|
||||
const metadata = await page
|
||||
.locator('#ticket-metadata')
|
||||
.boundingBox();
|
||||
if (width >= 1280) {
|
||||
assert.ok(
|
||||
metadata.x > description.x + description.width,
|
||||
'Desktop metadata must be beside the content.',
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
metadata.y > description.y + description.height,
|
||||
'Mobile metadata must be stacked.',
|
||||
);
|
||||
}
|
||||
await edit().click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).isVisible(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`Editor overflow at ${width}/${theme}/${query}`,
|
||||
);
|
||||
await edit().click();
|
||||
}
|
||||
await visit();
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan30-detail-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await visit();
|
||||
await tabTo(edit());
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
|
||||
.check();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#acceptance-criteria-11').checked,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved title draft',
|
||||
);
|
||||
await page
|
||||
.getByLabel('Acceptance criteria checklist', { exact: true })
|
||||
.fill('[ ] A draft checklist item');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('checkbox', {
|
||||
name: 'Cache requests safely',
|
||||
exact: true,
|
||||
})
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
await edit().click();
|
||||
await edit().click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved title draft',
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await content()
|
||||
.getByRole('link', { name: 'Open board', exact: true })
|
||||
.click();
|
||||
assert.match(page.url(), /\/tickets\/42/);
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await content()
|
||||
.getByRole('link', { name: 'Open board', exact: true })
|
||||
.click();
|
||||
await page.waitForURL('**/projects/7/board');
|
||||
await page.goBack();
|
||||
await edit().waitFor();
|
||||
if (!(await page.locator('#ticket-edit').getAttribute('open'))) {
|
||||
await edit().click();
|
||||
}
|
||||
if (!(await page.getByLabel('Title', { exact: true }).isVisible())) {
|
||||
await edit().click();
|
||||
}
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Unsaved title draft',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Acceptance criteria checklist', { exact: true })
|
||||
.inputValue(),
|
||||
'[ ] A draft checklist item',
|
||||
);
|
||||
await visit('?reject');
|
||||
await edit().click();
|
||||
await page.getByLabel('Title', { exact: true }).fill('Rejected draft');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save changes', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText('This title is not allowed.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Rejected draft',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Title', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await visit();
|
||||
await edit().click();
|
||||
await page
|
||||
.getByLabel('Title', { exact: true })
|
||||
.fill('Saved updated ticket title');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save changes', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Saved updated ticket title',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||
await visit('?rejectToggle');
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
|
||||
.check();
|
||||
await page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: 'Checklist status could not be saved' })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('checkbox', {
|
||||
name: 'Cache requests safely',
|
||||
exact: true,
|
||||
})
|
||||
.isChecked(),
|
||||
false,
|
||||
);
|
||||
await visit();
|
||||
const createBranch = page.getByRole('button', {
|
||||
name: 'Create branch',
|
||||
exact: true,
|
||||
});
|
||||
await tabTo(createBranch);
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await createBranch.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await createBranch.click();
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
const linkSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/links') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save link', exact: true }).click();
|
||||
assert.equal((await linkSaved).status(), 303);
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).waitFor();
|
||||
const linkRemoved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/links/3?_method=DELETE') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
assert.equal((await linkRemoved).status(), 303);
|
||||
await visit('?readonly&hideAuthor&hidePoints');
|
||||
assert.equal(await content().locator('form').count(), 0);
|
||||
assert.equal(
|
||||
await content().getByText('Sam Chen', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await content()
|
||||
.getByText('Estimated story points', { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/tickets/41');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Related infrastructure', exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||
'Related infrastructure',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Ticket detail browser checks passed: desktop/mobile, both themes, keyboard, drafts, saves, checklist rollback, links, Git dialog and direct navigation.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user