Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad | ||
|
|
002de1b293 | ||
|
|
707ebe6e48 | ||
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 |
@@ -1,18 +1,72 @@
|
|||||||
name: test_and_check_app
|
name: "Lint and Test API"
|
||||||
description: Build the test image and run all application checks
|
description: "Run Pint and the Pest test suite for the Laravel API"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Test and check app
|
- name: Setup PHP
|
||||||
shell: bash
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||||
run: |
|
with:
|
||||||
set -Eeuo pipefail
|
php-version: "8.5"
|
||||||
|
extensions: mbstring, pdo_sqlite
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
docker buildx build \
|
- name: Verify Composer lockfile
|
||||||
--file Dockerfile \
|
run: |
|
||||||
--target test \
|
if [ ! -f composer.lock ]; then
|
||||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
echo "::error::api/composer.lock not found"
|
||||||
--progress plain \
|
exit 1
|
||||||
--load \
|
fi
|
||||||
.
|
shell: bash
|
||||||
|
|
||||||
|
- 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
|
||||||
@@ -2,7 +2,13 @@ name: CI/CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -11,9 +17,19 @@ env:
|
|||||||
REGISTRY: git.twyco.de
|
REGISTRY: git.twyco.de
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test_and_check_app:
|
preconditions:
|
||||||
name: test_and_check_app
|
if: >
|
||||||
|
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Conditions met
|
||||||
|
run: |
|
||||||
|
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||||
|
|
||||||
|
test_and_check_app:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- preconditions
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
@@ -27,6 +43,7 @@ jobs:
|
|||||||
github.event_name == 'push' &&
|
github.event_name == 'push' &&
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||||
needs:
|
needs:
|
||||||
|
- preconditions
|
||||||
- test_and_check_app
|
- test_and_check_app
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+3
-2
@@ -55,8 +55,9 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
|||||||
COPY . /var/www
|
COPY . /var/www
|
||||||
|
|
||||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||||
&& npm ci \
|
&& corepack enable pnpm \
|
||||||
&& npm run build
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run build
|
||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -47,15 +47,15 @@
|
|||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
"@php artisan key:generate",
|
"@php artisan key:generate",
|
||||||
"@php artisan migrate --force",
|
"@php artisan migrate --force",
|
||||||
"npm install",
|
"pnpm install",
|
||||||
"npm run build"
|
"pnpm run build"
|
||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"@php artisan dev"
|
"@php artisan dev"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
"dev:ssr": [
|
||||||
"npm run build:ssr",
|
"pnpm run build:ssr",
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||||
],
|
],
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npm audit --audit-level=high",
|
"pnpm audit --audit-level=high",
|
||||||
"npm run lint:check",
|
"pnpm run lint:check",
|
||||||
"npm run types:check",
|
"pnpm run types:check",
|
||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const prettierOptions = {
|
|||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
|
'bootstrap/ssr/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'.github/**',
|
'.github/**',
|
||||||
|
|||||||
Generated
-7269
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,13 @@
|
|||||||
"$schema": "https://www.schemastore.org/package.json",
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"devEngines": {
|
||||||
|
"packageManager": {
|
||||||
|
"name": "pnpm",
|
||||||
|
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
|
||||||
|
"onFail": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:ssr": "vite build && vite build --ssr",
|
"build:ssr": "vite build && vite build --ssr",
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
"vue-tsc": "^2.2.4"
|
"vue-tsc": "^2.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inertiajs/core": "2.3.18",
|
||||||
"@inertiajs/vue3": "^2.3.7",
|
"@inertiajs/vue3": "^2.3.7",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
|
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
||||||
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
||||||
|
|||||||
Generated
+4832
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
minimumReleaseAge: 10080
|
||||||
|
allowBuilds:
|
||||||
|
unrs-resolver: true
|
||||||
|
vue-demi: true
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { TicketCard } from '@/components/tickets';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type {
|
||||||
|
BacklogTicketTreeEmits,
|
||||||
|
BacklogTicketTreeProps,
|
||||||
|
} from '@/types/backlog';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
const props = defineProps<BacklogTicketTreeProps>();
|
||||||
|
const emit = defineEmits<BacklogTicketTreeEmits>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const allowedTargetStatuses = (ticket: TicketModel) =>
|
||||||
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="grid min-w-0 gap-3">
|
||||||
|
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
|
||||||
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
|
<div class="flex justify-center pt-1">
|
||||||
|
<button
|
||||||
|
v-if="node.children.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
|
||||||
|
:aria-controls="`backlog-children-${node.ticket.id}`"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
collapsedTicketIds.has(node.ticket.id)
|
||||||
|
? 'backlog.tree.expand'
|
||||||
|
: 'backlog.tree.collapse',
|
||||||
|
{ key: node.ticket.ticket_key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="emit('toggle', node.ticket.id)"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
v-if="collapsedTicketIds.has(node.ticket.id)"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="bg-border mt-4 size-1.5 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="!node.matchesFilters"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-fit"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.parentContext') }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<TicketCard
|
||||||
|
:project-id="projectId"
|
||||||
|
:ticket="node.ticket"
|
||||||
|
layout="list"
|
||||||
|
:class="{ 'opacity-80': !node.matchesFilters }"
|
||||||
|
>
|
||||||
|
<template v-if="canEditTickets" #actions>
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
move.form({
|
||||||
|
project: projectId,
|
||||||
|
ticket: node.ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-slot="{ processing, errors }"
|
||||||
|
:error-bag="`backlog-ticket-${node.ticket.id}`"
|
||||||
|
:options="{ preserveScroll: true }"
|
||||||
|
:on-before="() => pendingTicketId === null"
|
||||||
|
class="grid min-w-0 gap-2"
|
||||||
|
@start="emit('moveStart', node.ticket.id)"
|
||||||
|
@finish="emit('moveFinish', node.ticket.id)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="redirect_to"
|
||||||
|
value="backlog"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="`backlog-ticket-status-${node.ticket.id}`"
|
||||||
|
class="text-caption text-muted-foreground font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('backlog.moveTicket', {
|
||||||
|
key: node.ticket.ticket_key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:id="`backlog-ticket-status-${node.ticket.id}`"
|
||||||
|
name="ticket_status_id"
|
||||||
|
:disabled="
|
||||||
|
pendingTicketId !== null ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errors.ticket_status_id || undefined
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
errors.ticket_status_id
|
||||||
|
? `backlog-ticket-status-error-${node.ticket.id}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
{{ t('board.noMoves') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="target in allowedTargetStatuses(
|
||||||
|
node.ticket,
|
||||||
|
)"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:id="`backlog-ticket-status-error-${node.ticket.id}`"
|
||||||
|
:message="errors.ticket_status_id"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
pendingTicketId !== null ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
processing
|
||||||
|
? 'board.updatingStatus'
|
||||||
|
: 'board.updateStatus',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.noMovesDescription') }}
|
||||||
|
</p>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
</TicketCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
node.children.length > 0 &&
|
||||||
|
!collapsedTicketIds.has(node.ticket.id)
|
||||||
|
"
|
||||||
|
:id="`backlog-children-${node.ticket.id}`"
|
||||||
|
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||||
|
>
|
||||||
|
<BacklogTicketTree
|
||||||
|
:nodes="node.children"
|
||||||
|
:project-id="projectId"
|
||||||
|
:can-edit-tickets="canEditTickets"
|
||||||
|
:collapsed-ticket-ids="collapsedTicketIds"
|
||||||
|
:pending-ticket-id="pendingTicketId"
|
||||||
|
@toggle="emit('toggle', $event)"
|
||||||
|
@move-start="emit('moveStart', $event)"
|
||||||
|
@move-finish="emit('moveFinish', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -1,20 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { reactive } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeListProps } from '@/types/ticket-tree';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<TicketTreeListProps>();
|
||||||
nodes: TicketTreeNodeModel[];
|
|
||||||
projectId: number;
|
const { t } = useLocalization();
|
||||||
}>();
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
|
|
||||||
|
function toggleTicket(ticketId: number): void {
|
||||||
|
if (collapsedTicketIds.has(ticketId)) {
|
||||||
|
collapsedTicketIds.delete(ticketId);
|
||||||
|
} else {
|
||||||
|
collapsedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="space-y-3">
|
<ul class="grid min-w-0 gap-3">
|
||||||
<li v-for="node in nodes" :key="node.id">
|
<li v-for="node in nodes" :key="node.id" class="min-w-0">
|
||||||
<div class="border-border rounded-xl border px-4 py-4">
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex justify-center pt-1">
|
||||||
|
<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 },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleTicket(node.id)"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
v-if="collapsedTicketIds.has(node.id)"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="bg-border mt-4 size-1.5 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
showTicket({
|
showTicket({
|
||||||
@@ -22,24 +63,31 @@ defineProps<{
|
|||||||
ticket: node.id,
|
ticket: node.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
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 }}
|
{{ node.ticket_key }}
|
||||||
</Link>
|
</Badge>
|
||||||
|
|
||||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||||
{{ node.ticket_type_name }}
|
{{ node.ticket_type_name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-foreground mt-2 text-sm">
|
<span
|
||||||
|
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||||
|
>
|
||||||
{{ node.title }}
|
{{ node.title }}
|
||||||
</div>
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="node.children.length > 0"
|
v-if="
|
||||||
class="border-border mt-3 border-l pl-4"
|
node.children.length > 0 && !collapsedTicketIds.has(node.id)
|
||||||
|
"
|
||||||
|
:id="`ticket-tree-children-${node.id}`"
|
||||||
|
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||||
>
|
>
|
||||||
<TicketTreeList
|
<TicketTreeList
|
||||||
:nodes="node.children"
|
:nodes="node.children"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from '@/types/ticket-presentation';
|
} from '@/types/ticket-presentation';
|
||||||
|
|
||||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||||
|
layout: 'card',
|
||||||
showDescription: true,
|
showDescription: true,
|
||||||
showStatus: true,
|
showStatus: true,
|
||||||
});
|
});
|
||||||
@@ -22,9 +23,17 @@ const { t } = useLocalization();
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
:class="[
|
:class="[
|
||||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||||
|
layout === 'card' ? 'shadow-sm' : '',
|
||||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||||
]"
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
layout === 'list'
|
||||||
|
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||||
@@ -34,7 +43,12 @@ const { t } = useLocalization();
|
|||||||
title: ticket.title,
|
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"
|
: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)"
|
@click="emit('open', ticket.id)"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||||
@@ -77,8 +91,17 @@ const { t } = useLocalization();
|
|||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="border-t p-4">
|
<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" />
|
<slot name="actions" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind?: 'text' | 'number' | 'textarea' | 'select';
|
||||||
|
options?: { value: string | number; label: string }[];
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
min?: number;
|
||||||
|
step?: string;
|
||||||
|
}>(),
|
||||||
|
{ kind: 'text', rows: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = defineModel<string | number | null>({ required: true });
|
||||||
|
const controlClass =
|
||||||
|
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 content-start gap-2">
|
||||||
|
<Label :for="id">{{ label }}</Label>
|
||||||
|
<select
|
||||||
|
v-if="kind === 'select'"
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:class="controlClass"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
v-else-if="kind === 'textarea'"
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:rows="rows"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:class="controlClass"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:type="kind"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:min="min"
|
||||||
|
:step="step"
|
||||||
|
:maxlength="kind === 'text' ? 255 : undefined"
|
||||||
|
:invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
:id="`${id}-hint`"
|
||||||
|
class="text-muted-foreground text-xs empty:hidden"
|
||||||
|
>
|
||||||
|
{{ hint }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { ProjectTicketCreatePageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
Pick<
|
||||||
|
ProjectTicketCreatePageProps,
|
||||||
|
| 'project'
|
||||||
|
| 'ticketTypes'
|
||||||
|
| 'ticketPriorities'
|
||||||
|
| 'users'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
> & {
|
||||||
|
errors: Record<string, string>;
|
||||||
|
disabled?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const title = defineModel<string>('title', { required: true });
|
||||||
|
const description = defineModel<string>('description', { required: true });
|
||||||
|
const type = defineModel<string | number>('type', { required: true });
|
||||||
|
const priority = defineModel<string | number>('priority', { required: true });
|
||||||
|
const assignee = defineModel<string | number>('assignee', { required: true });
|
||||||
|
const parent = defineModel<string | number>('parent', { required: true });
|
||||||
|
const points = defineModel<string | number | null>('points', {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const status = defineModel<string | number>('status');
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
);
|
||||||
|
const priorityOptions = computed(() =>
|
||||||
|
props.ticketPriorities.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const userOptions = computed(() => [
|
||||||
|
{ value: '', label: t('tickets.unassigned') },
|
||||||
|
...props.users.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
]);
|
||||||
|
const parentOptions = computed(() => [
|
||||||
|
{ value: '', label: t('ticketDetail.noParent') },
|
||||||
|
...props.parentTicketOptions.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.ticket_key} - ${item.title}`,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
const statusOptions = computed(
|
||||||
|
() =>
|
||||||
|
props.ticketStatusOptions?.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-6">
|
||||||
|
<TicketFormField
|
||||||
|
id="title"
|
||||||
|
v-model="title"
|
||||||
|
:label="t('ticketDetail.title')"
|
||||||
|
:error="errors.title"
|
||||||
|
:disabled="disabled"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="description"
|
||||||
|
v-model="description"
|
||||||
|
kind="textarea"
|
||||||
|
:label="t('ticketDetail.description')"
|
||||||
|
:error="errors.description"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="t('ticketForm.descriptionPlaceholder')"
|
||||||
|
/>
|
||||||
|
<slot name="content" />
|
||||||
|
<fieldset
|
||||||
|
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<legend class="px-2 text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.metadata') }}
|
||||||
|
</legend>
|
||||||
|
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_type_id"
|
||||||
|
v-model="type"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.type')"
|
||||||
|
:options="typeOptions"
|
||||||
|
:error="errors.ticket_type_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_priority_id"
|
||||||
|
v-model="priority"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.priority')"
|
||||||
|
:options="priorityOptions"
|
||||||
|
:error="errors.ticket_priority_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="ticketStatusOptions && status !== undefined"
|
||||||
|
id="ticket_status_id"
|
||||||
|
v-model="status"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.status')"
|
||||||
|
:options="statusOptions"
|
||||||
|
:error="errors.ticket_status_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="assignee_user_id"
|
||||||
|
v-model="assignee"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.assignee')"
|
||||||
|
:options="userOptions"
|
||||||
|
:error="errors.assignee_user_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="parent_ticket_id"
|
||||||
|
v-model="parent"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.parent')"
|
||||||
|
:options="parentOptions"
|
||||||
|
:error="errors.parent_ticket_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="project.show_story_points"
|
||||||
|
id="story_points"
|
||||||
|
v-model="points"
|
||||||
|
kind="number"
|
||||||
|
:label="t('ticketDetail.storyPoints')"
|
||||||
|
:error="errors.story_points"
|
||||||
|
:disabled="disabled"
|
||||||
|
:min="0"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
<slot name="metadata" />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
|
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
|
import type {
|
||||||
|
TicketDetailGitProps,
|
||||||
|
TicketBranchFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailGitProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const appName = String(usePage().props.name);
|
||||||
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||||
|
const visibleRepositoryBranches = computed(() =>
|
||||||
|
props.repositoryBranches.filter(
|
||||||
|
(repositoryBranch) =>
|
||||||
|
repositoryBranch.branches.length > 0 ||
|
||||||
|
repositoryBranch.error !== null ||
|
||||||
|
!repositoryBranch.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleRepositoryPullRequests = computed(() =>
|
||||||
|
props.repositoryPullRequests.filter(
|
||||||
|
(repositoryPullRequest) =>
|
||||||
|
repositoryPullRequest.pull_requests.length > 0 ||
|
||||||
|
repositoryPullRequest.error !== null ||
|
||||||
|
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||||
|
props.repositoryBranches.map(
|
||||||
|
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canCreateBranches = computed(
|
||||||
|
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
||||||
|
);
|
||||||
|
const defaultBranchRepositoryConnectionId = computed(() =>
|
||||||
|
String(
|
||||||
|
linkedRepositoryConnections.value.find(
|
||||||
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||||
|
)?.id ??
|
||||||
|
linkedRepositoryConnections.value[0]?.id ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCreateBranchDialogOpen = ref(false);
|
||||||
|
const isCheckingBranchName = ref(false);
|
||||||
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||||
|
|
||||||
|
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
|
||||||
|
branch_name: props.suggestedBranchName,
|
||||||
|
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
||||||
|
});
|
||||||
|
const branchProtection = useFormProtection(branchCreationForm, {
|
||||||
|
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBranchDialogOpen = (open: boolean): void => {
|
||||||
|
if (!open && !branchProtection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCreateBranchDialogOpen.value = open;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedBranchRepositoryConnection = computed(
|
||||||
|
(): RepositoryConnectionModel | null =>
|
||||||
|
linkedRepositoryConnections.value.find(
|
||||||
|
(repositoryConnection) =>
|
||||||
|
String(repositoryConnection.id) ===
|
||||||
|
branchCreationForm.repository_connection_id,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedBranchRepositoryHasPersonalAccess = computed(
|
||||||
|
() =>
|
||||||
|
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeBranchCreationForm = (): void => {
|
||||||
|
if (branchCreationForm.isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
branchCreationForm.clearErrors();
|
||||||
|
branchCreationForm.branch_name = props.suggestedBranchName;
|
||||||
|
branchCreationForm.repository_connection_id =
|
||||||
|
defaultBranchRepositoryConnectionId.value;
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => isCreateBranchDialogOpen.value,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
branchCreationForm.clearErrors();
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeBranchCreationForm();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => isCreateBranchDialogOpen.value,
|
||||||
|
() => branchCreationForm.repository_connection_id,
|
||||||
|
() => branchCreationForm.branch_name,
|
||||||
|
],
|
||||||
|
(
|
||||||
|
[isOpen, repositoryConnectionId, branchName],
|
||||||
|
_previousValues,
|
||||||
|
onCleanup,
|
||||||
|
) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryConnection = selectedBranchRepositoryConnection.value;
|
||||||
|
|
||||||
|
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repositoryConnection.has_personal_access) {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.connectGitFirst'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchName.trim() === '') {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchRequired'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
isCheckingBranchName.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
ProjectTicketBranchController.validate.url(
|
||||||
|
{
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
branch_name: branchName,
|
||||||
|
repository_connection_id:
|
||||||
|
repositoryConnectionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Branch validation failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value =
|
||||||
|
(await response.json()) as BranchNameValidationModel;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === 'AbortError'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchValidationFailed', {
|
||||||
|
app: appName,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abortController.abort();
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitBranchCreation = (): void => {
|
||||||
|
if (
|
||||||
|
!canCreateBranches.value ||
|
||||||
|
!selectedBranchRepositoryHasPersonalAccess.value ||
|
||||||
|
branchNameValidation.value?.is_valid !== true
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchProtection.submit(
|
||||||
|
ProjectTicketBranchController.store({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
isCreateBranchDialogOpen.value = false;
|
||||||
|
},
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-git"
|
||||||
|
aria-labelledby="ticket-git-title"
|
||||||
|
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||||
|
>
|
||||||
|
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.git') }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid min-w-0 gap-6">
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.branchesDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
:open="isCreateBranchDialogOpen"
|
||||||
|
@update:open="updateBranchDialogOpen"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="canCreateBranches"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="gap-2"
|
||||||
|
@click="isCreateBranchDialogOpen = true"
|
||||||
|
>
|
||||||
|
<GitBranchPlus class="size-4" />{{
|
||||||
|
t('ticketDetail.createBranch')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader class="space-y-3">
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('ticketDetail.createBranchTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('ticketDetail.createBranchDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="grid gap-4"
|
||||||
|
@submit.prevent="submitBranchCreation"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="branchProtection.state.value"
|
||||||
|
@retry="submitBranchCreation"
|
||||||
|
/>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="repository_connection_id">{{
|
||||||
|
t('ticketDetail.repository')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="repository_connection_id"
|
||||||
|
v-model="
|
||||||
|
branchCreationForm.repository_connection_id
|
||||||
|
"
|
||||||
|
:disabled="
|
||||||
|
branchCreationForm.processing
|
||||||
|
"
|
||||||
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="repositoryConnection in linkedRepositoryConnections"
|
||||||
|
:key="repositoryConnection.id"
|
||||||
|
:value="
|
||||||
|
String(repositoryConnection.id)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="
|
||||||
|
branchCreationForm.errors
|
||||||
|
.repository_connection_id
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="branch_name">{{
|
||||||
|
t('ticketDetail.branchName')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="branch_name"
|
||||||
|
v-model="branchCreationForm.branch_name"
|
||||||
|
:disabled="
|
||||||
|
branchCreationForm.processing ||
|
||||||
|
!selectedBranchRepositoryHasPersonalAccess
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="
|
||||||
|
branchCreationForm.errors
|
||||||
|
.branch_name
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border px-4 py-3"
|
||||||
|
:class="
|
||||||
|
branchNameValidation?.is_valid
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
||||||
|
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<CheckCircle2
|
||||||
|
v-if="
|
||||||
|
branchNameValidation?.is_valid
|
||||||
|
"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<XCircle
|
||||||
|
v-else-if="
|
||||||
|
branchNameValidation !== null
|
||||||
|
"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
isCheckingBranchName
|
||||||
|
? t(
|
||||||
|
'ticketDetail.checkingBranch',
|
||||||
|
)
|
||||||
|
: (branchNameValidation?.message ??
|
||||||
|
t(
|
||||||
|
'ticketDetail.selectRepository',
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!selectedBranchRepositoryHasPersonalAccess &&
|
||||||
|
selectedBranchRepositoryConnection !==
|
||||||
|
null
|
||||||
|
"
|
||||||
|
class="border-border rounded-xl border border-dashed px-4 py-4"
|
||||||
|
>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.branchAccess') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="updateBranchDialogOpen(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="
|
||||||
|
branchCreationForm.processing ||
|
||||||
|
isCheckingBranchName ||
|
||||||
|
branchNameValidation?.is_valid !==
|
||||||
|
true
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
t('ticketDetail.createNow')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="visibleRepositoryBranches.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noBranches')"
|
||||||
|
:description="t('tickets.noBranchesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="repositoryBranch in visibleRepositoryBranches"
|
||||||
|
:key="repositoryBranch.repository_connection.id"
|
||||||
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.base_url
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!repositoryBranch.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
class="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.inspectBranches') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button as-child variant="outline" size="sm">
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="repositoryBranch.error"
|
||||||
|
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
{{ repositoryBranch.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="branch in repositoryBranch.branches"
|
||||||
|
:key="branch.name"
|
||||||
|
variant="outline"
|
||||||
|
class="px-3 py-1"
|
||||||
|
>
|
||||||
|
{{ branch.name }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.pullRequestsDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="visibleRepositoryPullRequests.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noPullRequests')"
|
||||||
|
:description="t('tickets.noPullRequestsDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
||||||
|
:key="
|
||||||
|
repositoryPullRequest.repository_connection.id
|
||||||
|
"
|
||||||
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.base_url
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!repositoryPullRequest.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
class="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.inspectPullRequests') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button as-child variant="outline" size="sm">
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="repositoryPullRequest.error"
|
||||||
|
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
{{ repositoryPullRequest.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-3 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||||
|
:key="pullRequest.html_url"
|
||||||
|
class="border-border/70 rounded-xl border px-4 py-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge variant="outline">
|
||||||
|
#{{ pullRequest.number }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ pullRequest.status }}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="pullRequest.html_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="text-foreground hover:text-primary font-medium transition"
|
||||||
|
>
|
||||||
|
{{ pullRequest.title }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground mt-2 text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('ticketDetail.sourceBranch', {
|
||||||
|
name: pullRequest.source_branch,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
|
||||||
|
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
|
import { show } from '@/routes/projects';
|
||||||
|
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
|
||||||
|
defineProps<TicketDetailHeaderProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sections = [
|
||||||
|
'description',
|
||||||
|
'checklist',
|
||||||
|
'metadata',
|
||||||
|
'relationships',
|
||||||
|
'history',
|
||||||
|
'git',
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="flex min-w-0 flex-col gap-5">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Button as-child variant="ghost" class="max-w-full"
|
||||||
|
><Link :href="show(project.id)"
|
||||||
|
><ArrowLeft aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.backToProject')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="backlogIndex(project.id)"
|
||||||
|
><ListTree aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.openBacklog')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="boardIndex(project.id)"
|
||||||
|
><Columns3 aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.openBoard')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-primary font-mono text-sm font-semibold">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><Badge v-if="!canEditTicket" variant="outline">{{
|
||||||
|
t('ticketDetail.readOnly')
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
</h1>
|
||||||
|
<TicketMetadata :ticket="ticket" />
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
:aria-label="t('ticketDetail.sections')"
|
||||||
|
class="flex flex-wrap gap-1 border-b pb-3"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section"
|
||||||
|
:href="`#ticket-${section}`"
|
||||||
|
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>{{ t(`ticketDetail.${section}`) }}</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||||
|
defineProps<TicketDetailHistoryProps>();
|
||||||
|
const { t, date } = useLocalization();
|
||||||
|
const formatDateTime = (value: string): string =>
|
||||||
|
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-history"
|
||||||
|
aria-labelledby="ticket-history-title"
|
||||||
|
class="wrap-anywhere min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<h2 id="ticket-history-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.history') }}
|
||||||
|
</h2>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.historyDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
|
||||||
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noHistory') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-for="worklogEntry in ticket.worklog_entries"
|
||||||
|
:key="worklogEntry.id"
|
||||||
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ worklogEntry.entry_type }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-foreground text-sm font-medium">
|
||||||
|
{{
|
||||||
|
worklogEntry.actor?.name ??
|
||||||
|
t('ticketDetail.system')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-foreground mt-3 text-sm">
|
||||||
|
{{ worklogEntry.summary }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground mt-2 text-xs">
|
||||||
|
{{ formatDateTime(worklogEntry.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailMetadataProps>();
|
||||||
|
const { t, date, number } = useLocalization();
|
||||||
|
const fields = computed(() =>
|
||||||
|
props.ticketFieldDefinitions.map((definition) => {
|
||||||
|
const value = props.ticket.field_values?.find(
|
||||||
|
(field) => field.ticket_field_definition_id === definition.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: definition.id,
|
||||||
|
label: definition.label,
|
||||||
|
value:
|
||||||
|
definition.field_type === 'user'
|
||||||
|
? (value?.user_value?.name ?? t('tickets.unassigned'))
|
||||||
|
: value?.value_number != null
|
||||||
|
? number(value.value_number)
|
||||||
|
: t('ticketDetail.notSet'),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-metadata"
|
||||||
|
aria-labelledby="ticket-metadata-title"
|
||||||
|
class="min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card class="bg-muted/30">
|
||||||
|
<CardHeader
|
||||||
|
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.metadata') }}
|
||||||
|
</h2></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
|
||||||
|
<dl
|
||||||
|
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.status') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
ticket.ticket_status,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.type') }}
|
||||||
|
</dt>
|
||||||
|
<dd>{{ ticket.ticket_type.name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.priority') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
ticket.ticket_priority.name
|
||||||
|
}}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.assignee') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="ticket.assignee"
|
||||||
|
:user="ticket.assignee"
|
||||||
|
size="sm"
|
||||||
|
/>{{
|
||||||
|
ticket.assignee?.name ?? t('tickets.unassigned')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="project.show_author || canManageProject"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.author') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="ticket.author"
|
||||||
|
:user="ticket.author"
|
||||||
|
size="sm"
|
||||||
|
/>{{
|
||||||
|
ticket.author?.name ??
|
||||||
|
t('ticketDetail.noAuthor')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="project.show_story_points" class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.storyPoints') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
ticket.story_points != null
|
||||||
|
? number(ticket.story_points)
|
||||||
|
: t('ticketDetail.notSet')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="field in fields"
|
||||||
|
:key="field.id"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground">{{ field.label }}</dt>
|
||||||
|
<dd>{{ field.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div
|
||||||
|
v-if="ticket.is_blocked"
|
||||||
|
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
|
||||||
|
<p class="whitespace-pre-wrap">
|
||||||
|
{{ ticket.blocked_comment }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl
|
||||||
|
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt>{{ t('ticketDetail.created') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<time :datetime="ticket.created_at">{{
|
||||||
|
date(new Date(ticket.created_at), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt>{{ t('ticketDetail.updated') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<time :datetime="ticket.updated_at">{{
|
||||||
|
date(new Date(ticket.updated_at), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
/** Pointer capture keeps mouse, pen and touch drags on the same code path. */
|
||||||
|
export function useBoardDrag(options: {
|
||||||
|
canStart: (ticket: TicketModel) => boolean;
|
||||||
|
drop: (ticket: TicketModel, columnId: number) => void;
|
||||||
|
}) {
|
||||||
|
const ticket = shallowRef<TicketModel | null>(null);
|
||||||
|
const active = ref(false);
|
||||||
|
const x = ref(0);
|
||||||
|
const y = ref(0);
|
||||||
|
const columnId = ref<number | null>(null);
|
||||||
|
let pointerId: number | null = null;
|
||||||
|
let handle: HTMLElement | null = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const draggedTicket = computed(() => (active.value ? ticket.value : null));
|
||||||
|
|
||||||
|
function locateTarget(): void {
|
||||||
|
const target = document
|
||||||
|
.elementFromPoint(x.value, y.value)
|
||||||
|
?.closest<HTMLElement>('[data-board-drop-column]');
|
||||||
|
columnId.value = target ? Number(target.dataset.boardDropColumn) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scroll(): void {
|
||||||
|
if (!active.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = document.elementFromPoint(x.value, y.value);
|
||||||
|
const region = element?.closest<HTMLElement>(
|
||||||
|
'[data-board-drag-scroll]',
|
||||||
|
);
|
||||||
|
if (region) {
|
||||||
|
const bounds = region.getBoundingClientRect();
|
||||||
|
const left = Math.max(0, bounds.left);
|
||||||
|
const right = Math.min(window.innerWidth, bounds.right);
|
||||||
|
const delta =
|
||||||
|
x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0;
|
||||||
|
region.scrollLeft += delta;
|
||||||
|
}
|
||||||
|
if (!element?.closest('[data-board-mobile-targets]')) {
|
||||||
|
const delta =
|
||||||
|
y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0;
|
||||||
|
window.scrollBy(0, delta);
|
||||||
|
}
|
||||||
|
locateTarget();
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
const previousPointer = pointerId;
|
||||||
|
pointerId = null;
|
||||||
|
active.value = false;
|
||||||
|
ticket.value = null;
|
||||||
|
columnId.value = null;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
if (
|
||||||
|
previousPointer !== null &&
|
||||||
|
handle?.hasPointerCapture(previousPointer)
|
||||||
|
) {
|
||||||
|
handle.releasePointerCapture(previousPointer);
|
||||||
|
}
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(event: PointerEvent, source: TicketModel): void {
|
||||||
|
if (
|
||||||
|
pointerId !== null ||
|
||||||
|
!event.isPrimary ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
!options.canStart(source)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle = event.currentTarget as HTMLElement;
|
||||||
|
handle.focus({ preventScroll: true });
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
ticket.value = source;
|
||||||
|
startX = x.value = event.clientX;
|
||||||
|
startY = y.value = event.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event: PointerEvent): void {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.value = event.clientX;
|
||||||
|
y.value = event.clientY;
|
||||||
|
if (
|
||||||
|
!active.value &&
|
||||||
|
Math.hypot(x.value - startX, y.value - startY) >= 6
|
||||||
|
) {
|
||||||
|
active.value = true;
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
if (active.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
locateTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(event: PointerEvent): void {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.value = event.clientX;
|
||||||
|
y.value = event.clientY;
|
||||||
|
locateTarget();
|
||||||
|
const source = draggedTicket.value;
|
||||||
|
const target = columnId.value;
|
||||||
|
cancel();
|
||||||
|
if (source && target !== null) {
|
||||||
|
options.drop(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPointer(event: PointerEvent): void {
|
||||||
|
if (event.pointerId === pointerId) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape' && pointerId !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointermove', move, { passive: false });
|
||||||
|
document.addEventListener('pointerup', finish);
|
||||||
|
document.addEventListener('pointercancel', cancelPointer);
|
||||||
|
document.addEventListener('lostpointercapture', cancelPointer);
|
||||||
|
document.addEventListener('keydown', keydown);
|
||||||
|
window.addEventListener('blur', cancel);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancel();
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', finish);
|
||||||
|
document.removeEventListener('pointercancel', cancelPointer);
|
||||||
|
document.removeEventListener('lostpointercapture', cancelPointer);
|
||||||
|
document.removeEventListener('keydown', keydown);
|
||||||
|
window.removeEventListener('blur', cancel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { draggedTicket, columnId, x, y, start, cancel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type {
|
||||||
|
BacklogFilterState,
|
||||||
|
BacklogTicketTreeNode,
|
||||||
|
} from '@/types/backlog';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export function buildBacklogTicketTree(
|
||||||
|
tickets: TicketModel[],
|
||||||
|
): BacklogTicketTreeNode[] {
|
||||||
|
const nodesByTicketId = new Map<number, BacklogTicketTreeNode>();
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
nodesByTicketId.set(ticket.id, {
|
||||||
|
children: [],
|
||||||
|
matchesFilters: true,
|
||||||
|
ticket,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: BacklogTicketTreeNode[] = [];
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
const node = nodesByTicketId.get(ticket.id);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentNode =
|
||||||
|
ticket.parent_ticket_id === null
|
||||||
|
? undefined
|
||||||
|
: nodesByTicketId.get(ticket.parent_ticket_id);
|
||||||
|
|
||||||
|
if (parentNode) {
|
||||||
|
parentNode.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterBacklogTicketTree(
|
||||||
|
nodes: BacklogTicketTreeNode[],
|
||||||
|
filters: BacklogFilterState,
|
||||||
|
): BacklogTicketTreeNode[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
const children = filterBacklogTicketTree(node.children, filters);
|
||||||
|
const matchesFilters = ticketMatchesFilters(node.ticket, filters);
|
||||||
|
|
||||||
|
if (!matchesFilters && children.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...node,
|
||||||
|
children,
|
||||||
|
matchesFilters,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countBacklogTreeTickets(
|
||||||
|
nodes: BacklogTicketTreeNode[],
|
||||||
|
): number {
|
||||||
|
return nodes.reduce(
|
||||||
|
(count, node) => count + 1 + countBacklogTreeTickets(node.children),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandableBacklogTicketIds(
|
||||||
|
nodes: BacklogTicketTreeNode[],
|
||||||
|
): number[] {
|
||||||
|
return nodes.flatMap((node) => [
|
||||||
|
...(node.children.length > 0 ? [node.ticket.id] : []),
|
||||||
|
...expandableBacklogTicketIds(node.children),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketMatchesFilters(
|
||||||
|
ticket: TicketModel,
|
||||||
|
filters: BacklogFilterState,
|
||||||
|
): boolean {
|
||||||
|
const query = filters.query.trim().toLocaleLowerCase();
|
||||||
|
const searchableText = [
|
||||||
|
ticket.ticket_key,
|
||||||
|
ticket.title,
|
||||||
|
ticket.description,
|
||||||
|
ticket.ticket_type.name,
|
||||||
|
ticket.ticket_priority.name,
|
||||||
|
ticket.ticket_status?.name,
|
||||||
|
ticket.assignee?.name,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(' ')
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
(query === '' || searchableText.includes(query)) &&
|
||||||
|
(filters.typeId === '' || ticket.ticket_type_id === filters.typeId) &&
|
||||||
|
(filters.priorityId === '' ||
|
||||||
|
ticket.ticket_priority_id === filters.priorityId) &&
|
||||||
|
(filters.statusId === '' ||
|
||||||
|
ticket.ticket_status_id === filters.statusId) &&
|
||||||
|
(filters.assigneeId === '' ||
|
||||||
|
(filters.assigneeId === 'unassigned'
|
||||||
|
? ticket.assignee_user_id === null
|
||||||
|
: ticket.assignee_user_id === filters.assigneeId))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,8 +18,53 @@ export const en = {
|
|||||||
'board.updateStatus': 'Update status',
|
'board.updateStatus': 'Update status',
|
||||||
'board.updatingStatus': 'Updating…',
|
'board.updatingStatus': 'Updating…',
|
||||||
'board.noMoves': 'No allowed moves',
|
'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':
|
'board.noMovesDescription':
|
||||||
'This status does not allow moving anywhere else.',
|
'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.title': 'Create a project',
|
||||||
'projectCreation.description':
|
'projectCreation.description':
|
||||||
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
||||||
@@ -345,6 +390,8 @@ export const en = {
|
|||||||
'tickets.noStatus': 'No status',
|
'tickets.noStatus': 'No status',
|
||||||
'tickets.assignedTo': 'Assigned to {name}',
|
'tickets.assignedTo': 'Assigned to {name}',
|
||||||
'tickets.unassigned': 'Unassigned',
|
'tickets.unassigned': 'Unassigned',
|
||||||
|
'tickets.tree.expand': 'Expand children of {key}',
|
||||||
|
'tickets.tree.collapse': 'Collapse children of {key}',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.error': 'Something went wrong.',
|
'common.error': 'Something went wrong.',
|
||||||
@@ -353,4 +400,126 @@ export const en = {
|
|||||||
one: '{count} ticket',
|
one: '{count} ticket',
|
||||||
other: '{count} tickets',
|
other: '{count} tickets',
|
||||||
},
|
},
|
||||||
|
'ticketDetail.description': 'Description',
|
||||||
|
'ticketDetail.noDescription': 'No description has been added yet.',
|
||||||
|
'ticketDetail.checklist': 'Acceptance criteria',
|
||||||
|
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
|
||||||
|
'ticketDetail.saveChecklistFirst':
|
||||||
|
'Save checklist text changes before toggling completion directly.',
|
||||||
|
'ticketDetail.edit': 'Edit ticket',
|
||||||
|
'ticketDetail.editDescription':
|
||||||
|
'Tracked ticket changes create worklog entries automatically.',
|
||||||
|
'ticketDetail.git': 'Git',
|
||||||
|
'ticketDetail.relationships': 'Relationships',
|
||||||
|
'ticketDetail.history': 'Worklog',
|
||||||
|
'ticketDetail.metadata': 'Ticket information',
|
||||||
|
'ticketDetail.sections': 'Ticket sections',
|
||||||
|
'ticketDetail.status': 'Status',
|
||||||
|
'ticketDetail.type': 'Type',
|
||||||
|
'ticketDetail.priority': 'Priority',
|
||||||
|
'ticketDetail.assignee': 'Assignee',
|
||||||
|
'ticketDetail.author': 'Author',
|
||||||
|
'ticketDetail.noAuthor': 'No author',
|
||||||
|
'ticketDetail.storyPoints': 'Estimated story points',
|
||||||
|
'ticketDetail.notSet': 'Not set',
|
||||||
|
'ticketDetail.created': 'Created',
|
||||||
|
'ticketDetail.updated': 'Last updated',
|
||||||
|
'ticketDetail.backToProject': 'Back to project',
|
||||||
|
'ticketDetail.openBacklog': 'Open backlog',
|
||||||
|
'ticketDetail.openBoard': 'Open board',
|
||||||
|
'ticketDetail.readOnly': 'Read only',
|
||||||
|
'ticketDetail.title': 'Title',
|
||||||
|
'ticketDetail.parent': 'Parent ticket',
|
||||||
|
'ticketDetail.noParent': 'No parent ticket',
|
||||||
|
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
|
||||||
|
'ticketDetail.checklistHelp':
|
||||||
|
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
|
||||||
|
'ticketDetail.checklistPlaceholder':
|
||||||
|
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||||
|
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||||
|
'ticketDetail.blockerComment': 'Blocker comment',
|
||||||
|
'ticketDetail.blockerPlaceholder':
|
||||||
|
'Describe what currently blocks progress on this ticket.',
|
||||||
|
'ticketDetail.customFields': 'Custom fields',
|
||||||
|
'ticketDetail.customFieldsDescription':
|
||||||
|
'Project-configured fields can be tracked directly on the ticket.',
|
||||||
|
'ticketDetail.noCustomFields':
|
||||||
|
'No custom fields are configured for this project yet.',
|
||||||
|
'ticketDetail.saveChanges': 'Save changes',
|
||||||
|
'ticketDetail.branches': 'Branches',
|
||||||
|
'ticketDetail.branchesDescription':
|
||||||
|
'Matching branches are discovered from linked repositories when the branch name starts with this ticket key.',
|
||||||
|
'ticketDetail.createBranch': 'Create branch',
|
||||||
|
'ticketDetail.createBranchTitle': 'Create branch from ticket',
|
||||||
|
'ticketDetail.createBranchDescription':
|
||||||
|
'Pick a linked repository, confirm the suggested branch name, and create it directly in Gitea.',
|
||||||
|
'ticketDetail.repository': 'Repository',
|
||||||
|
'ticketDetail.branchName': 'Branch name',
|
||||||
|
'ticketDetail.branchAccess':
|
||||||
|
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||||
|
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||||
|
'ticketDetail.createNow': 'Create now',
|
||||||
|
'ticketDetail.inspectBranches':
|
||||||
|
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||||
|
'ticketDetail.pullRequests': 'Pull requests',
|
||||||
|
'ticketDetail.pullRequestsDescription':
|
||||||
|
'Matching pull requests are detected from linked repositories by checking whether the source branch starts with this ticket key.',
|
||||||
|
'ticketDetail.inspectPullRequests':
|
||||||
|
'Connect a matching Git account in your user settings to inspect pull requests for this repository.',
|
||||||
|
'ticketDetail.checkingBranch':
|
||||||
|
'Checking whether this branch name is valid and still available...',
|
||||||
|
'ticketDetail.selectRepository':
|
||||||
|
'Select a linked repository to validate the branch name.',
|
||||||
|
'ticketDetail.connectGitFirst':
|
||||||
|
'Connect a matching Git connection in your user settings first.',
|
||||||
|
'ticketDetail.branchRequired': 'Branch name is required.',
|
||||||
|
'ticketDetail.branchValidationFailed':
|
||||||
|
'{app} could not validate this branch name right now.',
|
||||||
|
'ticketDetail.checklistSaveFailed':
|
||||||
|
'Checklist status could not be saved right now.',
|
||||||
|
'ticketDetail.children': 'Child tickets',
|
||||||
|
'ticketDetail.childrenDescription':
|
||||||
|
'Parent tickets expose their full child tree here, including nested parent chains.',
|
||||||
|
'ticketDetail.noChildren':
|
||||||
|
'No child tickets are assigned to this ticket yet.',
|
||||||
|
'ticketDetail.parentDescription':
|
||||||
|
'This ticket belongs to the parent shown here.',
|
||||||
|
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||||
|
'ticketDetail.linkedTicketsDescription':
|
||||||
|
'Track cross-ticket relationships with project-defined link labels.',
|
||||||
|
'ticketDetail.linkType': 'Link type',
|
||||||
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
|
'ticketDetail.saveLink': 'Save link',
|
||||||
|
'ticketDetail.noLinks':
|
||||||
|
'No ticket links are configured for this ticket yet.',
|
||||||
|
'ticketDetail.removeLink': 'Remove',
|
||||||
|
'ticketDetail.historyDescription':
|
||||||
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
|
'ticketDetail.system': 'System',
|
||||||
|
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||||
|
'ticketForm.create': 'Create ticket',
|
||||||
|
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||||
|
'ticketForm.createDescription': 'Add a ticket to {project}.',
|
||||||
|
'ticketForm.backToBoard': 'Back to board',
|
||||||
|
'ticketForm.backToBacklog': 'Back to backlog',
|
||||||
|
'ticketForm.details': 'Ticket details',
|
||||||
|
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
|
||||||
|
'ticketForm.titlePlaceholder': 'Implement repository sync',
|
||||||
|
'ticketForm.descriptionPlaceholder':
|
||||||
|
'Describe the expected behavior and relevant context.',
|
||||||
|
'ticketForm.checklistHelp':
|
||||||
|
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
|
||||||
|
'ticketForm.checklistPreview': 'Checklist preview',
|
||||||
|
'ticketForm.metadataRequired':
|
||||||
|
'Ticket creation needs at least one type, one priority, and one status.',
|
||||||
|
'ticketForm.openSettings': 'Open ticket settings',
|
||||||
|
'ticketForm.creating': 'Creating ticket…',
|
||||||
|
'ticketForm.saving': 'Saving changes…',
|
||||||
|
'ticketForm.reset': 'Discard changes',
|
||||||
|
'ticketForm.unsaved': 'Unsaved changes',
|
||||||
|
'ticketForm.ready': 'Ready for a new ticket',
|
||||||
|
'ticketForm.saved': 'All changes saved',
|
||||||
|
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||||
|
'ticketForm.blocker': 'Blocker',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,70 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
import { TicketListItem } from '@/components/tickets';
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Columns3,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import {
|
||||||
|
buildBacklogTicketTree,
|
||||||
|
countBacklogTreeTickets,
|
||||||
|
expandableBacklogTicketIds,
|
||||||
|
filterBacklogTicketTree,
|
||||||
|
} from '@/lib/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
import type {
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
BacklogFilterState,
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
BreadcrumbItem,
|
||||||
|
ProjectBacklogPageProps,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectBacklogPageProps>();
|
const props = defineProps<ProjectBacklogPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
|
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
|
||||||
|
]);
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const filters = reactive<BacklogFilterState>({
|
||||||
{
|
assigneeId: '',
|
||||||
title: 'Projects',
|
priorityId: '',
|
||||||
href: index(),
|
query: '',
|
||||||
},
|
statusId: '',
|
||||||
{
|
typeId: '',
|
||||||
title: props.project.name,
|
});
|
||||||
href: show(props.project.id),
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
|
const pendingTicketId = ref<number | null>(null);
|
||||||
|
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} backlog`" />
|
<Head :title="t('backlog.title', { name: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||||
|
>
|
||||||
|
<div class="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"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading
|
<p class="text-caption text-muted-foreground">
|
||||||
:title="`${project.name} backlog`"
|
{{ t('tickets.count', { count: tickets.length }) }}
|
||||||
description="All newly created tickets start here before they move onto the board."
|
</p>
|
||||||
/>
|
</div>
|
||||||
|
<h1
|
||||||
|
class="text-headline-mobile md:text-headline wrap-anywhere"
|
||||||
|
>
|
||||||
|
{{ t('backlog.title', { name: project.name }) }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-caption text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('backlog.description') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -74,138 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Create ticket
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('navigation.createTicket') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
<Link :href="boardIndex(project.id)">
|
||||||
|
<Columns3 aria-hidden="true" />
|
||||||
|
{{ t('backlog.openBoard') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Button as-child variant="outline">
|
<Link :href="show(project.id)">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<ArrowLeft aria-hidden="true" />
|
||||||
|
{{ t('backlog.backToProject') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="backlog-filter-title"
|
||||||
|
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<h2 id="backlog-filter-title" class="text-title">
|
||||||
|
{{ t('backlog.filters.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('backlog.filters.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!hasActiveFilters"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{ t('backlog.filters.clear') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="status"
|
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
|
||||||
>
|
>
|
||||||
{{ status }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Backlog tickets</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Tickets remain here while their status belongs to
|
|
||||||
backlog.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<div
|
<div
|
||||||
v-if="tickets.length === 0"
|
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
|
||||||
class="text-muted-foreground p-6 text-sm"
|
|
||||||
>
|
>
|
||||||
No backlog tickets exist yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-else class="gap-column grid p-4">
|
|
||||||
<TicketListItem
|
|
||||||
v-for="ticket in 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 w-full flex-col gap-2"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="redirect_to"
|
|
||||||
value="backlog"
|
|
||||||
/>
|
|
||||||
<label
|
<label
|
||||||
:for="`backlog-ticket-status-${ticket.id}`"
|
for="backlog-query"
|
||||||
class="text-label text-muted-foreground uppercase"
|
class="text-caption font-semibold"
|
||||||
>
|
>
|
||||||
Move to status
|
{{ t('backlog.filters.search') }}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div class="relative min-w-0">
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
<Search
|
||||||
|
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="backlog-query"
|
||||||
|
v-model="filters.query"
|
||||||
|
type="search"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
:placeholder="
|
||||||
|
t('backlog.filters.searchPlaceholder')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-type"
|
||||||
|
class="text-caption font-semibold"
|
||||||
>
|
>
|
||||||
|
{{ t('backlog.filters.type') }}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
:id="`backlog-ticket-status-${ticket.id}`"
|
id="backlog-type"
|
||||||
name="ticket_status_id"
|
v-model="filters.typeId"
|
||||||
:disabled="
|
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"
|
||||||
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
|
<option value="">
|
||||||
v-if="
|
{{ t('backlog.filters.allTypes') }}
|
||||||
allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
value=""
|
|
||||||
>
|
|
||||||
No allowed moves
|
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
v-for="ticketType in ticketTypes"
|
||||||
ticket,
|
: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"
|
:key="ticketStatus.id"
|
||||||
:value="ticketStatus.id"
|
:value="ticketStatus.id"
|
||||||
>
|
>
|
||||||
{{
|
{{ ticketStatus.name }}
|
||||||
ticketStatusLabel(
|
|
||||||
ticketStatus,
|
|
||||||
'Unknown status',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<Button
|
</div>
|
||||||
type="submit"
|
|
||||||
size="sm"
|
<div class="grid min-w-0 gap-1.5">
|
||||||
:disabled="
|
<label
|
||||||
processing ||
|
for="backlog-assignee"
|
||||||
allowedTargetStatuses(ticket)
|
class="text-caption font-semibold"
|
||||||
.length === 0
|
>
|
||||||
|
{{ 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' } },
|
||||||
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Move
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('backlog.empty.action') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
v-if="
|
<div
|
||||||
allowedTargetStatuses(ticket)
|
v-else-if="visibleTicketCount === 0"
|
||||||
.length === 0
|
class="grid justify-items-start gap-3"
|
||||||
"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
This status does not allow moving
|
<FeedbackState
|
||||||
anywhere else.
|
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>
|
</p>
|
||||||
</Form>
|
</div>
|
||||||
</template>
|
<Button
|
||||||
</TicketListItem>
|
v-if="expandableTicketIds.length > 0"
|
||||||
</ul>
|
type="button"
|
||||||
</CardContent>
|
variant="outline"
|
||||||
</Card>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next';
|
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { TicketCard } from '@/components/tickets';
|
import { TicketCard } from '@/components/tickets';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useBoardDrag } from '@/composables/useBoardDrag';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
@@ -67,6 +78,140 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
(status) => status.id !== ticket.ticket_status_id,
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
|
||||||
|
const feedback = useFormProtection(moveForm, { isDirty: () => false });
|
||||||
|
const menuPending = ref<number | null>(null);
|
||||||
|
const busy = computed(() => moveForm.processing || menuPending.value !== null);
|
||||||
|
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
|
||||||
|
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
|
||||||
|
const findTicket = (id: number) =>
|
||||||
|
props.boardColumns
|
||||||
|
.flatMap((column) => column.tickets ?? [])
|
||||||
|
.find((ticket) => ticket.id === id);
|
||||||
|
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
|
||||||
|
const statusIds =
|
||||||
|
props.boardColumns
|
||||||
|
.find((column) => column.id === columnId)
|
||||||
|
?.ticket_statuses?.map((status) => status.id) ?? [];
|
||||||
|
return allowedTargetStatuses(ticket).filter((status) =>
|
||||||
|
statusIds.includes(status.id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const choiceTicket = computed(() =>
|
||||||
|
choice.value ? findTicket(choice.value.ticketId) : undefined,
|
||||||
|
);
|
||||||
|
const choiceTargets = computed(() =>
|
||||||
|
choice.value && choiceTicket.value
|
||||||
|
? targetsForColumn(choiceTicket.value, choice.value.columnId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const choiceOpen = computed({
|
||||||
|
get: () => choice.value !== null,
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
choice.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitMove(ticketId: number, statusId: number): void {
|
||||||
|
const ticket = findTicket(ticketId);
|
||||||
|
if (
|
||||||
|
busy.value ||
|
||||||
|
!props.canEditTickets ||
|
||||||
|
!ticket ||
|
||||||
|
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMove.value = { ticketId, statusId };
|
||||||
|
moveForm.ticket_status_id = statusId;
|
||||||
|
moveForm.clearErrors();
|
||||||
|
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
|
||||||
|
errorBag: `board-ticket-${ticketId}`,
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const destination = props.boardColumns.find((column) =>
|
||||||
|
column.ticket_statuses?.some(
|
||||||
|
(status) => status.id === statusId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (destination) {
|
||||||
|
selectedColumnId.value = destination.id;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
document
|
||||||
|
.getElementById(`ticket-status-${ticketId}`)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTicket(ticket: TicketModel, columnId: number): void {
|
||||||
|
const current = findTicket(ticket.id);
|
||||||
|
if (!current || busy.value || !props.canEditTickets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = targetsForColumn(current, columnId);
|
||||||
|
if (targets.length === 1) {
|
||||||
|
submitMove(current.id, targets[0].id);
|
||||||
|
} else if (targets.length > 1) {
|
||||||
|
moveForm.ticket_status_id = targets[0].id;
|
||||||
|
choice.value = { ticketId: current.id, columnId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
draggedTicket,
|
||||||
|
columnId: dropColumnId,
|
||||||
|
x: dragX,
|
||||||
|
y: dragY,
|
||||||
|
start: startDrag,
|
||||||
|
cancel: cancelDrag,
|
||||||
|
} = useBoardDrag({
|
||||||
|
canStart: (ticket) =>
|
||||||
|
props.canEditTickets &&
|
||||||
|
!busy.value &&
|
||||||
|
!choice.value &&
|
||||||
|
allowedTargetStatuses(ticket).length > 0,
|
||||||
|
drop: dropTicket,
|
||||||
|
});
|
||||||
|
const canDrop = (columnId: number) =>
|
||||||
|
!!draggedTicket.value &&
|
||||||
|
targetsForColumn(draggedTicket.value, columnId).length > 0;
|
||||||
|
const dropClasses = (columnId: number) =>
|
||||||
|
!draggedTicket.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'border-primary border-dashed': canDrop(columnId),
|
||||||
|
'ring-2 ring-primary bg-primary/10':
|
||||||
|
canDrop(columnId) && dropColumnId.value === columnId,
|
||||||
|
'opacity-40': !canDrop(columnId),
|
||||||
|
};
|
||||||
|
function confirmMove(): void {
|
||||||
|
if (
|
||||||
|
!choice.value ||
|
||||||
|
!choiceTargets.value.some(
|
||||||
|
(status) => status.id === moveForm.ticket_status_id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticketId = choice.value.ticketId;
|
||||||
|
choice.value = null;
|
||||||
|
submitMove(ticketId, moveForm.ticket_status_id);
|
||||||
|
}
|
||||||
|
function focusStatus(ticketId: number): void {
|
||||||
|
document.getElementById(`ticket-status-${ticketId}`)?.focus();
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => [props.project.id, props.boardColumns, props.canEditTickets],
|
||||||
|
() => {
|
||||||
|
cancelDrag();
|
||||||
|
choice.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -130,6 +275,13 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<FeedbackState v-if="status" state="success" :title="status" />
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
<FormFeedback
|
||||||
|
:state="feedback.state.value"
|
||||||
|
:description="moveForm.errors.ticket_status_id"
|
||||||
|
@retry="
|
||||||
|
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
|
||||||
|
"
|
||||||
|
/>
|
||||||
<FeedbackState
|
<FeedbackState
|
||||||
v-if="columns.length === 0"
|
v-if="columns.length === 0"
|
||||||
state="empty"
|
state="empty"
|
||||||
@@ -168,10 +320,18 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
>
|
>
|
||||||
{{ t('board.scrollHint') }}
|
{{ t('board.scrollHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTickets"
|
||||||
|
id="board-drag-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.dragHint') }}
|
||||||
|
</p>
|
||||||
<section
|
<section
|
||||||
:aria-label="t('board.columns')"
|
:aria-label="t('board.columns')"
|
||||||
aria-describedby="board-scroll-hint"
|
aria-describedby="board-scroll-hint"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
data-board-drag-scroll
|
||||||
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@@ -179,9 +339,11 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:id="`board-column-${column.id}`"
|
:id="`board-column-${column.id}`"
|
||||||
:key="`${project.id}-${column.id}`"
|
:key="`${project.id}-${column.id}`"
|
||||||
:aria-labelledby="`board-column-title-${column.id}`"
|
:aria-labelledby="`board-column-title-${column.id}`"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
:class="[
|
:class="[
|
||||||
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
||||||
selectedColumnId === column.id ? 'flex' : 'hidden',
|
selectedColumnId === column.id ? 'flex' : 'hidden',
|
||||||
|
dropClasses(column.id),
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
@@ -250,6 +412,10 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
v-for="ticket in group.tickets"
|
v-for="ticket in group.tickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="min-w-0"
|
class="min-w-0"
|
||||||
|
:class="{
|
||||||
|
'opacity-50':
|
||||||
|
draggedTicket?.id === ticket.id,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<TicketCard
|
<TicketCard
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
@@ -259,6 +425,36 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
<template
|
<template
|
||||||
v-if="canEditTickets"
|
v-if="canEditTickets"
|
||||||
#actions
|
#actions
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
|
:disabled="
|
||||||
|
busy ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('board.dragTicket', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
aria-describedby="board-drag-hint"
|
||||||
|
@pointerdown="
|
||||||
|
startDrag($event, ticket)
|
||||||
|
"
|
||||||
|
@click.prevent
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
><GripVertical
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ t('board.drag') }}</Button
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
v-bind="
|
v-bind="
|
||||||
@@ -272,6 +468,12 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:options="{
|
:options="{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
}"
|
}"
|
||||||
|
:on-before="
|
||||||
|
() =>
|
||||||
|
!busy && !draggedTicket
|
||||||
|
"
|
||||||
|
@start="menuPending = ticket.id"
|
||||||
|
@finish="menuPending = null"
|
||||||
class="grid min-w-0 gap-2"
|
class="grid min-w-0 gap-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -292,6 +494,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:id="`ticket-status-${ticket.id}`"
|
:id="`ticket-status-${ticket.id}`"
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -345,6 +548,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -381,6 +585,94 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
|
||||||
|
data-board-mobile-targets
|
||||||
|
>
|
||||||
|
<p class="text-caption mb-2 font-semibold">
|
||||||
|
{{ t('board.dropHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
|
||||||
|
<div
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.id"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
|
:class="dropClasses(column.id)"
|
||||||
|
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
|
||||||
|
>
|
||||||
|
{{ column.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.max(0, dragX - 80)}px`,
|
||||||
|
top: `${Math.max(0, dragY - 56)}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ draggedTicket.ticket_key }}
|
||||||
|
</div>
|
||||||
|
<p class="sr-only" aria-live="polite">
|
||||||
|
{{
|
||||||
|
draggedTicket
|
||||||
|
? t('board.dragging', { key: draggedTicket.ticket_key })
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Dialog v-model:open="choiceOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('board.chooseStatusDescription', {
|
||||||
|
key: choiceTicket?.ticket_key ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form class="grid gap-4" @submit.prevent="confirmMove">
|
||||||
|
<label
|
||||||
|
for="board-drop-status"
|
||||||
|
class="text-body font-semibold"
|
||||||
|
>{{ t('board.targetStatus') }}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="board-drop-status"
|
||||||
|
v-model="moveForm.ticket_status_id"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="target in choiceTargets"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="choiceOpen = false"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || choiceTargets.length === 0"
|
||||||
|
>{{ t('board.updateStatus') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,9 @@
|
|||||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
||||||
import { computed, toRef, unref } from 'vue';
|
import { computed, toRef, unref } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -15,8 +16,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import {
|
||||||
buildAcceptanceCriteria,
|
buildAcceptanceCriteria,
|
||||||
@@ -27,20 +26,22 @@ import { index as boardIndex } from '@/routes/projects/board';
|
|||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketCreatePageProps>();
|
const props = defineProps<ProjectTicketCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const returnHref =
|
const returnHref =
|
||||||
props.source === 'board'
|
props.source === 'board'
|
||||||
? boardIndex(props.project.id)
|
? boardIndex(props.project.id)
|
||||||
: backlogIndex(props.project.id);
|
: backlogIndex(props.project.id);
|
||||||
const returnLabel =
|
const returnLabel =
|
||||||
props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
props.source === 'board'
|
||||||
|
? t('ticketForm.backToBoard')
|
||||||
|
: t('ticketForm.backToBacklog');
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Projects',
|
title: t('navigation.projects'),
|
||||||
href: index(),
|
href: index(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,21 +49,26 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
href: show(props.project.id),
|
href: show(props.project.id),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
title:
|
||||||
|
props.source === 'board'
|
||||||
|
? t('navigation.board')
|
||||||
|
: t('navigation.backlog'),
|
||||||
href: returnHref,
|
href: returnHref,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Create ticket',
|
title: t('ticketForm.create'),
|
||||||
href: ProjectTicketController.create({
|
href: ProjectTicketController.create({
|
||||||
project: props.project.id,
|
project: props.project.id,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasTicketMetadata =
|
const hasTicketMetadata = computed(
|
||||||
|
() =>
|
||||||
props.ticketTypes.length > 0 &&
|
props.ticketTypes.length > 0 &&
|
||||||
props.ticketPriorities.length > 0 &&
|
props.ticketPriorities.length > 0 &&
|
||||||
props.ticketStatusOptions.length > 0;
|
props.ticketStatusOptions.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
const acceptanceCriteriaDraft = useRemember(
|
||||||
@@ -90,9 +96,19 @@ const form = useForm(draftKey, {
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const protection = useFormProtection(form, {
|
const isDirty = computed(
|
||||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
() => 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 acceptanceCriteriaPreviewItems = computed(() =>
|
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||||
@@ -109,6 +125,9 @@ const acceptanceCriteriaError = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
|
if (!hasTicketMetadata.value || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value,
|
acceptanceCriteriaChecklist.value,
|
||||||
);
|
);
|
||||||
@@ -117,31 +136,22 @@ const submit = (): void => {
|
|||||||
ProjectTicketController.store({ project: props.project.id }),
|
ProjectTicketController.store({ project: props.project.id }),
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Create ${project.name} ticket`" />
|
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
|
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
@@ -149,10 +159,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Badge variant="outline" class="px-3 py-1">
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading
|
<h1 class="text-headline font-semibold tracking-tight">
|
||||||
title="Create ticket"
|
{{ t('ticketForm.create') }}
|
||||||
:description="`Add a ticket to ${project.name}.`"
|
</h1>
|
||||||
/>
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t('ticketForm.createDescription', {
|
||||||
|
project: project.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
@@ -160,7 +176,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<Link :href="show(project.id)">{{
|
||||||
|
t('ticketDetail.backToProject')
|
||||||
|
}}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,9 +192,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
|
|
||||||
<Card v-if="hasTicketMetadata">
|
<Card v-if="hasTicketMetadata">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ticket details</CardTitle>
|
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The ticket key is assigned after creation.
|
{{ t('ticketForm.keyAssigned') }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -186,82 +204,44 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
:state="protection.state.value"
|
:state="protection.state.value"
|
||||||
@retry="submit"
|
@retry="submit"
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-2">
|
<TicketFormFields
|
||||||
<Label for="title">Title</Label>
|
v-model:title="form.title"
|
||||||
<Input
|
v-model:description="form.description"
|
||||||
id="title"
|
v-model:type="form.ticket_type_id"
|
||||||
v-model="form.title"
|
v-model:priority="form.ticket_priority_id"
|
||||||
:invalid="Boolean(form.errors.title)"
|
v-model:status="form.ticket_status_id"
|
||||||
aria-describedby="title-error"
|
v-model:assignee="form.assignee_user_id"
|
||||||
required
|
v-model:parent="form.parent_ticket_id"
|
||||||
|
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
|
autofocus
|
||||||
:disabled="form.processing"
|
|
||||||
placeholder="Implement repository sync"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="title-error"
|
|
||||||
:message="form.errors.title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-6">
|
<template #content>
|
||||||
<div class="grid gap-2">
|
<TicketFormField
|
||||||
<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"
|
id="acceptance_criteria_checklist"
|
||||||
v-model="
|
v-model="acceptanceCriteriaChecklist"
|
||||||
acceptanceCriteriaChecklist
|
kind="textarea"
|
||||||
|
:label="t('ticketDetail.checklistEditor')"
|
||||||
|
:hint="t('ticketForm.checklistHelp')"
|
||||||
|
:placeholder="
|
||||||
|
t('ticketDetail.checklistPlaceholder')
|
||||||
"
|
"
|
||||||
rows="6"
|
:error="acceptanceCriteriaError"
|
||||||
:disabled="form.processing"
|
: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
|
<div
|
||||||
v-if="
|
v-if="acceptanceCriteriaPreviewItems.length"
|
||||||
acceptanceCriteriaPreviewItems.length >
|
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
|
||||||
0
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border p-4 text-sm"
|
|
||||||
>
|
>
|
||||||
<p class="mb-3 font-medium">
|
<p class="mb-3 font-medium">
|
||||||
Checklist preview
|
{{ t('ticketForm.checklistPreview') }}
|
||||||
</p>
|
</p>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li
|
<li
|
||||||
@@ -270,174 +250,47 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
) in acceptanceCriteriaPreviewItems"
|
) in acceptanceCriteriaPreviewItems"
|
||||||
:key="index"
|
:key="index"
|
||||||
:style="{
|
:style="{
|
||||||
marginLeft: `${item.indent * 1.25}rem`,
|
marginInlineStart: `${item.indent * 0.75}rem`,
|
||||||
}"
|
}"
|
||||||
class="flex items-start gap-2"
|
class="flex min-w-0 items-start gap-2"
|
||||||
>
|
>
|
||||||
<span class="mt-0.5">{{
|
<span aria-hidden="true">{{
|
||||||
item.checked ? '☑' : '☐'
|
item.checked ? '☑' : '☐'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span>{{ item.text }}</span>
|
<span
|
||||||
|
class="wrap-anywhere min-w-0"
|
||||||
|
>{{ item.text }}</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</TicketFormFields>
|
||||||
|
|
||||||
<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
|
<div
|
||||||
v-if="project.show_story_points"
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||||
class="grid gap-2"
|
|
||||||
>
|
>
|
||||||
<Label for="story_points">
|
<Button type="submit" :disabled="form.processing">{{
|
||||||
Estimated story points
|
form.processing
|
||||||
</Label>
|
? t('ticketForm.creating')
|
||||||
<Input
|
: t('ticketForm.create')
|
||||||
id="story_points"
|
}}</Button>
|
||||||
v-model="form.story_points"
|
<Button
|
||||||
type="number"
|
type="button"
|
||||||
step="1"
|
variant="outline"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing || !isDirty"
|
||||||
/>
|
@click="reset"
|
||||||
<InputError
|
>{{ t('ticketForm.reset') }}</Button
|
||||||
:message="form.errors.story_points"
|
>
|
||||||
/>
|
<p
|
||||||
</div>
|
role="status"
|
||||||
</div>
|
class="text-muted-foreground text-sm"
|
||||||
</div>
|
>
|
||||||
|
{{
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
isDirty
|
||||||
<Button type="submit" :disabled="form.processing">
|
? t('ticketForm.unsaved')
|
||||||
Create ticket
|
: t('ticketForm.ready')
|
||||||
</Button>
|
}}
|
||||||
<Button as-child variant="outline">
|
</p>
|
||||||
<Link :href="returnHref">
|
|
||||||
{{ returnLabel }}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -446,13 +299,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Card v-else>
|
<Card v-else>
|
||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Ticket creation needs at least one type, one priority,
|
{{ t('ticketForm.metadataRequired') }}
|
||||||
and one status.
|
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="metadataSettings(project.id)">
|
<Link :href="metadataSettings(project.id)">
|
||||||
Open ticket settings
|
{{ t('ticketForm.openSettings') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
|
||||||
|
|
||||||
|
export type BacklogFilterState = {
|
||||||
|
assigneeId: BacklogAssigneeFilter;
|
||||||
|
priorityId: '' | number;
|
||||||
|
query: string;
|
||||||
|
statusId: '' | number;
|
||||||
|
typeId: '' | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeNode = {
|
||||||
|
children: BacklogTicketTreeNode[];
|
||||||
|
matchesFilters: boolean;
|
||||||
|
ticket: TicketModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeProps = {
|
||||||
|
canEditTickets: boolean;
|
||||||
|
collapsedTicketIds: Set<number>;
|
||||||
|
nodes: BacklogTicketTreeNode[];
|
||||||
|
pendingTicketId: number | null;
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeEmits = {
|
||||||
|
moveFinish: [ticketId: number];
|
||||||
|
moveStart: [ticketId: number];
|
||||||
|
toggle: [ticketId: number];
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './backlog';
|
||||||
export * from './navigation';
|
export * from './navigation';
|
||||||
export * from './page-props';
|
export * from './page-props';
|
||||||
|
export * from './ticket-tree';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||||
|
export type TicketDetailHeaderProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
|
>;
|
||||||
|
export type TicketDetailContentProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'canManageProject'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
| 'ticketFieldDefinitions'
|
||||||
|
| 'ticketPriorities'
|
||||||
|
| 'ticketTypes'
|
||||||
|
| 'users'
|
||||||
|
>;
|
||||||
|
export type TicketDetailMetadataProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||||
|
>;
|
||||||
|
export type TicketDetailGitProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'repositoryBranches'
|
||||||
|
| 'repositoryPullRequests'
|
||||||
|
| 'suggestedBranchName'
|
||||||
|
>;
|
||||||
|
export type TicketDetailRelationshipsProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
| 'childTicketTree'
|
||||||
|
| 'linkedTickets'
|
||||||
|
| 'ticketLinkTypes'
|
||||||
|
| 'ticketLinkOptions'
|
||||||
|
>;
|
||||||
|
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
|
||||||
|
export type TicketBranchFormData = {
|
||||||
|
branch_name: string;
|
||||||
|
repository_connection_id: string;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
export type TicketPresentationProps = {
|
export type TicketPresentationProps = {
|
||||||
|
layout?: 'card' | 'list';
|
||||||
projectId: number;
|
projectId: number;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
showStatus?: boolean;
|
showStatus?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
|
|
||||||
|
export type TicketTreeListProps = {
|
||||||
|
nodes: TicketTreeNodeModel[];
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
@@ -71,6 +71,110 @@ class ProjectBacklogTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the backlog payload supports nested tickets and their allowed status moves.
|
||||||
|
*/
|
||||||
|
public function test_backlog_includes_nested_tickets_and_allowed_status_moves(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'TREE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$backlogStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
$boardColumn = BoardColumn::factory()->for($project)->create([
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$boardStatus = TicketStatus::factory()
|
||||||
|
->for($project)
|
||||||
|
->for($boardColumn)
|
||||||
|
->create([
|
||||||
|
'name' => 'Ready',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$backlogStatus->allowedTargetStatuses()->sync([$boardStatus->id]);
|
||||||
|
|
||||||
|
$parentType = $project->ticketTypes()
|
||||||
|
->where('is_parent', true)
|
||||||
|
->firstOrFail();
|
||||||
|
$taskType = $project->ticketTypes()
|
||||||
|
->where('is_parent', false)
|
||||||
|
->firstOrFail();
|
||||||
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
|
||||||
|
$parentTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'TREE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'ticket_type_id' => $parentType->id,
|
||||||
|
'title' => 'Parent ticket',
|
||||||
|
]);
|
||||||
|
$childTicket = Ticket::factory()->create([
|
||||||
|
'parent_ticket_id' => $parentTicket->id,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'TREE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
'title' => 'Child ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->get(route('projects.backlog.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Backlog')
|
||||||
|
->where('canEditTickets', true)
|
||||||
|
->has('tickets', 2)
|
||||||
|
->where('tickets.0.id', $childTicket->id)
|
||||||
|
->where('tickets.0.parent_ticket_id', $parentTicket->id)
|
||||||
|
->where('tickets.0.ticket_status.allowed_target_statuses.0.id', $boardStatus->id)
|
||||||
|
->where('tickets.1.id', $parentTicket->id)
|
||||||
|
->where('tickets.1.parent_ticket_id', null)
|
||||||
|
->where('tickets.1.ticket_status.allowed_target_statuses.0.id', $boardStatus->id),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $parentTicket->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $childTicket->id,
|
||||||
|
'parent_ticket_id' => $parentTicket->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure projects without backlog tickets receive an empty collection.
|
||||||
|
*/
|
||||||
|
public function test_projects_without_backlog_tickets_receive_an_empty_collection(): void
|
||||||
|
{
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(route('projects.backlog.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Backlog')
|
||||||
|
->where('tickets', []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure creating tickets requires the project write permission.
|
* Ensure creating tickets requires the project write permission.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -212,6 +212,19 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'entry_type' => 'status_changed',
|
'entry_type' => 'status_changed',
|
||||||
'summary' => 'Moved from Todo to Done.',
|
'summary' => 'Moved from Todo to Done.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $ticket->worklogEntries()->count());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
|
'redirect_to' => 'board',
|
||||||
|
'ticket_status_id' => $doneStatus->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.board.index', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame($doneStatus->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
$this->assertSame(1, $ticket->worklogEntries()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1511,6 +1511,74 @@ 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
|
private function nonParentType(Project $project): TicketType
|
||||||
{
|
{
|
||||||
return $project->ticketTypes()
|
return $project->ticketTypes()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -114,6 +114,9 @@ function makePage(url) {
|
|||||||
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
if (params.has('multiple')) {
|
||||||
|
tickets[0].ticket_status.allowed_target_statuses.push(statuses[3]);
|
||||||
|
}
|
||||||
for (const column of boardColumns) {
|
for (const column of boardColumns) {
|
||||||
column.tickets = tickets.filter(
|
column.tickets = tickets.filter(
|
||||||
(item) => item.ticket_status.board_column_id === column.id,
|
(item) => item.ticket_status.board_column_id === column.id,
|
||||||
@@ -167,7 +170,7 @@ function makePage(url) {
|
|||||||
}
|
}
|
||||||
if (serve) {
|
if (serve) {
|
||||||
server.middlewares.use(async (request, response) => {
|
server.middlewares.use(async (request, response) => {
|
||||||
if (request.method === 'POST') {
|
if (request.method === 'POST' || request.method === 'PATCH') {
|
||||||
let body = '';
|
let body = '';
|
||||||
for await (const chunk of request) {
|
for await (const chunk of request) {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
@@ -175,12 +178,21 @@ if (serve) {
|
|||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
request.url,
|
request.url,
|
||||||
'/projects/7/tickets/42/status?_method=PATCH',
|
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.equal(data.redirect_to, 'board');
|
||||||
assert.ok([11, 20, 99].includes(Number(data.ticket_status_id)));
|
assert.ok([11, 20, 21, 99].includes(Number(data.ticket_status_id)));
|
||||||
const previous = new URL(request.headers.referer);
|
const previous = new URL(request.headers.referer);
|
||||||
const reject = previous.searchParams.has('reject');
|
const reject = previous.searchParams.has('reject');
|
||||||
|
if (previous.searchParams.has('slow')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
response.writeHead(303, {
|
response.writeHead(303, {
|
||||||
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ try {
|
|||||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||||
|
|
||||||
await page.goto(`${base}/projects/7/tickets/31`);
|
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('#title').fill('Updated ticket draft');
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.locator('#acceptance_criteria_checklist')
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ let ticket = {
|
|||||||
acceptance_criteria: [],
|
acceptance_criteria: [],
|
||||||
field_values: [],
|
field_values: [],
|
||||||
worklog_entries: [],
|
worklog_entries: [],
|
||||||
|
created_at: '2026-09-10T12:00:00Z',
|
||||||
|
updated_at: '2026-09-11T12:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
server.middlewares.use(async (request, response, next) => {
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
|
||||||
|
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'TicketCreate'
|
||||||
|
? TicketCreate
|
||||||
|
: name === 'Ticket'
|
||||||
|
? Ticket
|
||||||
|
: { render: () => h('h1', 'Destination reached') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
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/ticket-detail-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const user = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
id: 7,
|
||||||
|
key: 'ENG',
|
||||||
|
name: 'Engineering',
|
||||||
|
show_author: true,
|
||||||
|
show_story_points: true,
|
||||||
|
};
|
||||||
|
const repository = {
|
||||||
|
id: 9,
|
||||||
|
provider: 'gitea',
|
||||||
|
provider_label: 'Gitea',
|
||||||
|
base_url: 'https://git.example.test',
|
||||||
|
repository_full_name: 'Engineering/platform',
|
||||||
|
has_personal_access: true,
|
||||||
|
};
|
||||||
|
const parent = {
|
||||||
|
id: 40,
|
||||||
|
ticket_key: 'ENG-40',
|
||||||
|
title: 'Platform delivery',
|
||||||
|
project_id: 7,
|
||||||
|
};
|
||||||
|
const linked = {
|
||||||
|
id: 41,
|
||||||
|
ticket_key: 'ENG-41',
|
||||||
|
title: 'Related infrastructure',
|
||||||
|
project_id: 7,
|
||||||
|
};
|
||||||
|
const ticket = {
|
||||||
|
id: 42,
|
||||||
|
project_id: 7,
|
||||||
|
sequence_number: 42,
|
||||||
|
ticket_key: 'ENG-42',
|
||||||
|
title: 'Implement a resilient caching layer for the project API',
|
||||||
|
description:
|
||||||
|
'Keep every existing ticket action available.\nLongUnbrokenDescriptionThatMustWrapWithoutCausingHorizontalScrollingOnTheSmallestSupportedMobileViewport.',
|
||||||
|
acceptance_criteria: [
|
||||||
|
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
text: 'Invalidate when records change',
|
||||||
|
checked: false,
|
||||||
|
indent: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assignee_user_id: 1,
|
||||||
|
assignee: user,
|
||||||
|
author_user_id: 2,
|
||||||
|
author: { ...user, id: 2, name: 'Sam Chen' },
|
||||||
|
parent_ticket_id: 40,
|
||||||
|
parent_ticket: parent,
|
||||||
|
is_blocked: true,
|
||||||
|
blocked_comment: 'Waiting for infrastructure approval.',
|
||||||
|
story_points: 0,
|
||||||
|
ticket_type_id: 1,
|
||||||
|
ticket_type: { id: 1, name: 'Custom development task', is_parent: false },
|
||||||
|
ticket_priority_id: 2,
|
||||||
|
ticket_priority: { id: 2, name: 'Urgent project priority' },
|
||||||
|
ticket_status_id: 3,
|
||||||
|
ticket_status: {
|
||||||
|
id: 3,
|
||||||
|
name: 'In progress',
|
||||||
|
board_column: { id: 1, name: 'Delivery' },
|
||||||
|
},
|
||||||
|
field_values: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
ticket_field_definition_id: 1,
|
||||||
|
value_number: 0,
|
||||||
|
value_user_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
ticket_field_definition_id: 2,
|
||||||
|
value_number: null,
|
||||||
|
value_user_id: 1,
|
||||||
|
user_value: user,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
worklog_entries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
actor: user,
|
||||||
|
entry_type: 'created',
|
||||||
|
summary: 'Ticket created in the project backlog.',
|
||||||
|
created_at: '2026-09-11T12:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: '2026-09-10T12:00:00Z',
|
||||||
|
updated_at: '2026-09-11T12:00:00Z',
|
||||||
|
};
|
||||||
|
const fieldDefinitions = [
|
||||||
|
{ id: 1, label: 'Impact score', field_type: 'number' },
|
||||||
|
{ id: 2, label: 'Reviewer', field_type: 'user' },
|
||||||
|
];
|
||||||
|
let savedForm = {};
|
||||||
|
function makePage(url) {
|
||||||
|
const parsed = new URL(url, 'http://localhost');
|
||||||
|
const params = parsed.searchParams;
|
||||||
|
const creating = parsed.pathname.endsWith('/create');
|
||||||
|
const detail =
|
||||||
|
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
|
||||||
|
const data = structuredClone(ticket);
|
||||||
|
if (parsed.pathname.endsWith('/41')) {
|
||||||
|
Object.assign(data, linked, {
|
||||||
|
parent_ticket_id: null,
|
||||||
|
parent_ticket: null,
|
||||||
|
title: 'Related infrastructure',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (params.has('saved')) {
|
||||||
|
Object.assign(data, savedForm, { title: params.get('saved') });
|
||||||
|
}
|
||||||
|
if (params.has('long')) {
|
||||||
|
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
|
||||||
|
}
|
||||||
|
if (params.has('parent')) {
|
||||||
|
data.ticket_type.is_parent = true;
|
||||||
|
}
|
||||||
|
if (params.has('empty')) {
|
||||||
|
Object.assign(data, {
|
||||||
|
description: null,
|
||||||
|
acceptance_criteria: [],
|
||||||
|
assignee: null,
|
||||||
|
assignee_user_id: null,
|
||||||
|
author: null,
|
||||||
|
author_user_id: null,
|
||||||
|
parent_ticket: null,
|
||||||
|
parent_ticket_id: null,
|
||||||
|
is_blocked: false,
|
||||||
|
blocked_comment: null,
|
||||||
|
story_points: null,
|
||||||
|
ticket_status: null,
|
||||||
|
field_values: [],
|
||||||
|
worklog_entries: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const noAccess = params.has('noAccess');
|
||||||
|
const repo = { ...repository, has_personal_access: !noAccess };
|
||||||
|
const repositoryError = params.has('gitError')
|
||||||
|
? 'Repository temporarily unavailable.'
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
component: creating
|
||||||
|
? 'TicketCreate'
|
||||||
|
: detail
|
||||||
|
? 'Ticket'
|
||||||
|
: 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: params.has('rejected')
|
||||||
|
? {
|
||||||
|
title: 'This title is not allowed.',
|
||||||
|
...(params.has('allErrors')
|
||||||
|
? {
|
||||||
|
description: 'Invalid description.',
|
||||||
|
ticket_type_id: 'Invalid type.',
|
||||||
|
ticket_priority_id: 'Invalid priority.',
|
||||||
|
ticket_status_id: 'Invalid status.',
|
||||||
|
assignee_user_id: 'Invalid assignee.',
|
||||||
|
author_user_id: 'Invalid author.',
|
||||||
|
parent_ticket_id: 'Invalid parent.',
|
||||||
|
story_points: 'Invalid points.',
|
||||||
|
'acceptance_criteria.0.text':
|
||||||
|
'Invalid checklist.',
|
||||||
|
blocked_comment: 'Invalid blocker.',
|
||||||
|
'field_values.0.value_number':
|
||||||
|
'Invalid number.',
|
||||||
|
'field_values.1.value_user_id':
|
||||||
|
'Invalid reviewer.',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
canManage: false,
|
||||||
|
canCreateTickets: !params.has('readonly'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
show_author: !params.has('hideAuthor'),
|
||||||
|
show_story_points: !params.has('hidePoints'),
|
||||||
|
},
|
||||||
|
ticket: data,
|
||||||
|
canEditTicket: !params.has('readonly'),
|
||||||
|
canManageProject: params.has('admin'),
|
||||||
|
status: params.has('saved') ? 'Ticket updated.' : null,
|
||||||
|
users: [user, ticket.author],
|
||||||
|
ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
|
||||||
|
source: params.get('source') === 'board' ? 'board' : 'backlog',
|
||||||
|
defaultTicketStatusId: 3,
|
||||||
|
ticketStatusOptions: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'In progress',
|
||||||
|
board_column: { name: 'Delivery' },
|
||||||
|
},
|
||||||
|
{ id: 4, name: 'Backlog', board_column: null },
|
||||||
|
],
|
||||||
|
ticketPriorities: [data.ticket_priority],
|
||||||
|
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
|
||||||
|
parentTicketOptions: [parent],
|
||||||
|
childTicketTree: params.has('parent')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...linked,
|
||||||
|
ticket_type: { is_parent: false },
|
||||||
|
children: [],
|
||||||
|
children_count: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
linkedTickets: params.has('empty')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
ticket_link_type: { id: 1, label: 'Blocks' },
|
||||||
|
linked_ticket: linked,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ticketLinkOptions: [linked],
|
||||||
|
ticketLinkTypes: [
|
||||||
|
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
||||||
|
],
|
||||||
|
repositoryBranches: params.has('empty')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
repository_connection: repo,
|
||||||
|
branches: [{ name: 'feature/ENG-42-cache' }],
|
||||||
|
error: repositoryError,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
repositoryPullRequests: params.has('empty')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
repository_connection: repo,
|
||||||
|
pull_requests: [
|
||||||
|
{
|
||||||
|
number: 12,
|
||||||
|
status: 'open',
|
||||||
|
title: 'Cache implementation',
|
||||||
|
source_branch: 'feature/ENG-42-cache',
|
||||||
|
html_url:
|
||||||
|
'https://git.example.test/Engineering/platform/pulls/12',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: repositoryError,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
suggestedBranchName: `${data.ticket_key.toLowerCase()}-cache`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response) => {
|
||||||
|
if (request.url.includes('/branches/validate')) {
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
is_valid: true,
|
||||||
|
message: 'Branch name is available.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const previous = new URL(request.headers.referer);
|
||||||
|
const params = previous.searchParams;
|
||||||
|
if (params.has('serverError')) {
|
||||||
|
response.writeHead(503);
|
||||||
|
response.end('Simulated failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!request.headers['x-inertia']) {
|
||||||
|
assert.equal(request.method, 'PATCH');
|
||||||
|
assert.equal(request.url, '/projects/7/tickets/42');
|
||||||
|
assert.equal(
|
||||||
|
data.title,
|
||||||
|
ticket.title,
|
||||||
|
'Checklist toggle must not save the title draft.',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
data.acceptance_criteria[0].checked,
|
||||||
|
data.acceptance_criteria[1].checked,
|
||||||
|
'Toggling a parent must update its descendants.',
|
||||||
|
);
|
||||||
|
response.writeHead(params.has('rejectToggle') ? 422 : 200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
response.end('{}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url.includes('/branches')) {
|
||||||
|
assert.equal(data.branch_name, 'eng-42-cache');
|
||||||
|
assert.equal(Number(data.repository_connection_id), 9);
|
||||||
|
} else if (request.url.includes('/links')) {
|
||||||
|
assert.ok(
|
||||||
|
request.url.endsWith('/links/3?_method=DELETE') ||
|
||||||
|
Number(data.linked_ticket_id) === 41,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert.ok(
|
||||||
|
(request.method === 'POST' || 'field_values' in data) &&
|
||||||
|
'acceptance_criteria' in data &&
|
||||||
|
'parent_ticket_id' in data,
|
||||||
|
);
|
||||||
|
if (params.has('reject')) {
|
||||||
|
params.set('rejected', '1');
|
||||||
|
} else {
|
||||||
|
savedForm = data;
|
||||||
|
params.set('saved', data.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.writeHead(303, {
|
||||||
|
Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = makePage(request.url);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.writeHead(200, {
|
||||||
|
'X-Inertia': 'true',
|
||||||
|
'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/ticket-detail-preview.mjs"></script></body></html>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Ticket detail fixture ready at http://127.0.0.1:4185/projects/7/tickets/42',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { default: Ticket } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/Ticket.vue',
|
||||||
|
);
|
||||||
|
const { default: TicketCreate } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/TicketCreate.vue',
|
||||||
|
);
|
||||||
|
const render = async (query = '', creating = false) => {
|
||||||
|
const result = await createInertiaApp({
|
||||||
|
page: makePage(
|
||||||
|
`/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
|
||||||
|
),
|
||||||
|
resolve: () => (creating ? TicketCreate : Ticket),
|
||||||
|
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 html = await render();
|
||||||
|
for (const text of [
|
||||||
|
ticket.title,
|
||||||
|
'Keep every existing ticket action available.',
|
||||||
|
'Cache requests safely',
|
||||||
|
'Invalidate when records change',
|
||||||
|
'Urgent project priority',
|
||||||
|
'Delivery / In progress',
|
||||||
|
'Alex Morgan',
|
||||||
|
'Sam Chen',
|
||||||
|
'Impact score',
|
||||||
|
'Reviewer',
|
||||||
|
'Waiting for infrastructure approval.',
|
||||||
|
'feature/ENG-42-cache',
|
||||||
|
'Cache implementation',
|
||||||
|
'Ticket created in the project backlog.',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
html.includes(text),
|
||||||
|
`Missing ticket information: ${text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const section of [
|
||||||
|
'description',
|
||||||
|
'checklist',
|
||||||
|
'metadata',
|
||||||
|
'relationships',
|
||||||
|
'history',
|
||||||
|
'git',
|
||||||
|
]) {
|
||||||
|
assert.ok(html.includes(`href="#ticket-${section}"`));
|
||||||
|
assert.ok(html.includes(`id="ticket-${section}"`));
|
||||||
|
}
|
||||||
|
for (const route of [
|
||||||
|
'/projects/7',
|
||||||
|
'/projects/7/backlog',
|
||||||
|
'/projects/7/board',
|
||||||
|
'/projects/7/tickets/40',
|
||||||
|
'/projects/7/tickets/41',
|
||||||
|
]) {
|
||||||
|
assert.ok(html.includes(`href="${route}"`));
|
||||||
|
}
|
||||||
|
assert.match(html, /<details[^>]+id="ticket-edit"/);
|
||||||
|
for (const field of [
|
||||||
|
'title',
|
||||||
|
'ticket_type_id',
|
||||||
|
'ticket_priority_id',
|
||||||
|
'assignee_user_id',
|
||||||
|
'author_user_id',
|
||||||
|
'story_points',
|
||||||
|
'parent_ticket_id',
|
||||||
|
'description',
|
||||||
|
'acceptance_criteria_checklist',
|
||||||
|
'is_blocked',
|
||||||
|
'blocked_comment',
|
||||||
|
'field-1',
|
||||||
|
'field-2',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
html.includes(`id="${field}"`),
|
||||||
|
`Missing editor: ${field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(html, /Create branch/);
|
||||||
|
assert.match(html, /Save link/);
|
||||||
|
assert.match(html, /Remove/);
|
||||||
|
const readOnly = await render('?readonly');
|
||||||
|
assert.doesNotMatch(
|
||||||
|
readOnly,
|
||||||
|
/<form|<details|Create branch|Save changes|Save link/,
|
||||||
|
);
|
||||||
|
assert.match(readOnly, /type="checkbox" disabled/);
|
||||||
|
assert.match(readOnly, /Read only/);
|
||||||
|
const hidden = await render('?readonly&hideAuthor&hidePoints');
|
||||||
|
assert.doesNotMatch(hidden, /Sam Chen|Estimated story points/);
|
||||||
|
assert.match(await render('?readonly&hideAuthor&admin'), /Sam Chen/);
|
||||||
|
const empty = await render('?empty');
|
||||||
|
for (const text of [
|
||||||
|
'No description has been added yet.',
|
||||||
|
'No acceptance criteria have been added yet.',
|
||||||
|
'No status',
|
||||||
|
'Unassigned',
|
||||||
|
'No author',
|
||||||
|
'Not set',
|
||||||
|
'No matching branches',
|
||||||
|
'No matching pull requests',
|
||||||
|
'No worklog entries exist',
|
||||||
|
]) {
|
||||||
|
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
||||||
|
}
|
||||||
|
assert.match(await render('?parent'), /Child tickets/);
|
||||||
|
assert.match(await render('?noAccess'), /Open Git connections/);
|
||||||
|
assert.match(
|
||||||
|
await render('?gitError'),
|
||||||
|
/Repository temporarily unavailable/,
|
||||||
|
);
|
||||||
|
const create = await render('?source=board', true);
|
||||||
|
assert.match(create, /Create ticket/);
|
||||||
|
assert.match(create, /Delivery \/ In progress/);
|
||||||
|
assert.match(create, /Back to board/);
|
||||||
|
assert.match(create, /id="ticket_status_id"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
create,
|
||||||
|
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await render('?hidePoints', true),
|
||||||
|
/id="story_points"/,
|
||||||
|
);
|
||||||
|
const unavailable = await render('?noMetadata', true);
|
||||||
|
assert.doesNotMatch(unavailable, /<form/);
|
||||||
|
assert.match(unavailable, /Ticket creation needs at least one type/);
|
||||||
|
for (const creating of [false, true]) {
|
||||||
|
const invalid = await render('?rejected&allErrors', creating);
|
||||||
|
for (const id of [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'ticket_type_id',
|
||||||
|
'ticket_priority_id',
|
||||||
|
'assignee_user_id',
|
||||||
|
'parent_ticket_id',
|
||||||
|
'story_points',
|
||||||
|
'acceptance_criteria_checklist',
|
||||||
|
]) {
|
||||||
|
assert.ok(invalid.includes(`id="${id}-error"`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Ticket detail behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const base = 'http://127.0.0.1:4185/projects/7/tickets';
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const field = (label) => page.getByLabel(label, { exact: true });
|
||||||
|
const form = () => page.locator('form').first();
|
||||||
|
const state = (value) =>
|
||||||
|
form().locator(`[data-form-state="${value}"]`).waitFor();
|
||||||
|
const button = (label) =>
|
||||||
|
form().getByRole('button', { name: label, exact: true });
|
||||||
|
async function visit(create, query = '') {
|
||||||
|
await page.goto(`${base}/${create ? 'create' : '42'}${query}`);
|
||||||
|
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||||
|
if (!create) {
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fill(create) {
|
||||||
|
await field('Title').fill('A complete ticket');
|
||||||
|
await field('Description').fill('Description with context.');
|
||||||
|
await field('Assignee').selectOption('2');
|
||||||
|
await field('Parent ticket').selectOption('40');
|
||||||
|
await field('Estimated story points').fill('0');
|
||||||
|
await field('Acceptance criteria checklist').fill(
|
||||||
|
'[ ] Main criterion\n [x] Nested criterion',
|
||||||
|
);
|
||||||
|
if (create) {
|
||||||
|
await field('Status').selectOption('4');
|
||||||
|
} else {
|
||||||
|
await field('Author').selectOption('1');
|
||||||
|
await field('Blocker comment').fill('Waiting for review');
|
||||||
|
await field('Impact score').fill('2.5');
|
||||||
|
await field('Reviewer').selectOption('2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function submit(create) {
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(r) =>
|
||||||
|
r.method() === (create ? 'POST' : 'PATCH') &&
|
||||||
|
r.url().split('?')[0] === (create ? base : `${base}/42`),
|
||||||
|
);
|
||||||
|
await button(create ? 'Create ticket' : 'Save changes').click();
|
||||||
|
return (await request).postDataJSON();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const create of [true, false]) {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
await visit(create, '?admin');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((el) => el.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`Overflow ${create}/${width}/${theme}`,
|
||||||
|
);
|
||||||
|
await field('Title').focus();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await field('Description').evaluate(
|
||||||
|
(el) =>
|
||||||
|
el === document.activeElement &&
|
||||||
|
el.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await fill(create);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
page.once('dialog', (dialog) => dialog.dismiss());
|
||||||
|
await button('Discard changes').click();
|
||||||
|
assert.equal(
|
||||||
|
await field('Title').inputValue(),
|
||||||
|
'A complete ticket',
|
||||||
|
);
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await button('Discard changes').click();
|
||||||
|
assert.equal(
|
||||||
|
await button('Discard changes').isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await field('Title').inputValue(),
|
||||||
|
create
|
||||||
|
? ''
|
||||||
|
: 'Implement a resilient caching layer for the project API',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(create, '?admin&reject&allErrors');
|
||||||
|
await fill(create);
|
||||||
|
const data = await submit(create);
|
||||||
|
assert.equal(data.title, 'A complete ticket');
|
||||||
|
assert.equal(data.description, 'Description with context.');
|
||||||
|
assert.equal(Number(data.ticket_type_id), 1);
|
||||||
|
assert.equal(Number(data.ticket_priority_id), 2);
|
||||||
|
assert.equal(Number(data.assignee_user_id), 2);
|
||||||
|
assert.equal(Number(data.parent_ticket_id), 40);
|
||||||
|
assert.equal(Number(data.story_points), 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
data.acceptance_criteria.map(({ text, checked, indent }) => ({
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
indent,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ text: 'Main criterion', checked: false, indent: 0 },
|
||||||
|
{ text: 'Nested criterion', checked: true, indent: 1 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (create) {
|
||||||
|
assert.equal(Number(data.ticket_status_id), 4);
|
||||||
|
assert.equal('author_user_id' in data, false);
|
||||||
|
} else {
|
||||||
|
assert.equal(Number(data.author_user_id), 1);
|
||||||
|
assert.equal(data.is_blocked, true);
|
||||||
|
assert.equal(data.blocked_comment, 'Waiting for review');
|
||||||
|
assert.equal(Number(data.field_values[0].value_number), 2.5);
|
||||||
|
assert.equal(Number(data.field_values[1].value_user_id), 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
data.acceptance_criteria.map((item) => item.id),
|
||||||
|
[10, 11],
|
||||||
|
);
|
||||||
|
assert.equal('ticket_status_id' in data, false);
|
||||||
|
}
|
||||||
|
await state('validation');
|
||||||
|
const fields = [
|
||||||
|
'Title',
|
||||||
|
'Description',
|
||||||
|
'Type',
|
||||||
|
'Priority',
|
||||||
|
'Assignee',
|
||||||
|
'Parent ticket',
|
||||||
|
'Estimated story points',
|
||||||
|
'Acceptance criteria checklist',
|
||||||
|
...(create
|
||||||
|
? ['Status']
|
||||||
|
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
|
||||||
|
];
|
||||||
|
for (const label of fields) {
|
||||||
|
const control = field(label);
|
||||||
|
assert.equal(
|
||||||
|
await control.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
const errorId = (await control.getAttribute('aria-describedby'))
|
||||||
|
.split(' ')
|
||||||
|
.at(-1);
|
||||||
|
assert.ok(
|
||||||
|
(await page.locator(`[id="${errorId}"]`).innerText()).length,
|
||||||
|
`Missing associated error: ${label}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(await field('Title').inputValue(), 'A complete ticket');
|
||||||
|
await page.route(create ? `${base}` : `${base}/42`, (route) =>
|
||||||
|
route.abort('failed'),
|
||||||
|
);
|
||||||
|
await button(create ? 'Create ticket' : 'Save changes').click();
|
||||||
|
await state('network');
|
||||||
|
assert.equal(
|
||||||
|
await field('Description').inputValue(),
|
||||||
|
'Description with context.',
|
||||||
|
);
|
||||||
|
await page.unrouteAll();
|
||||||
|
await visit(create, '?admin&serverError');
|
||||||
|
await fill(create);
|
||||||
|
await submit(create);
|
||||||
|
await state('server');
|
||||||
|
assert.equal(await field('Title').inputValue(), 'A complete ticket');
|
||||||
|
let pending;
|
||||||
|
let requests = 0;
|
||||||
|
await page.route(create ? `${base}` : `${base}/42`, (route) => {
|
||||||
|
requests++;
|
||||||
|
pending = route;
|
||||||
|
});
|
||||||
|
await button('Try again').click();
|
||||||
|
await state('loading');
|
||||||
|
assert.equal(await field('Title').isDisabled(), true);
|
||||||
|
assert.equal(
|
||||||
|
await button(
|
||||||
|
create ? 'Creating ticket…' : 'Saving changes…',
|
||||||
|
).isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await form().evaluate((el) =>
|
||||||
|
el.dispatchEvent(
|
||||||
|
new Event('submit', { bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(requests, 1);
|
||||||
|
await pending.abort();
|
||||||
|
await state('network');
|
||||||
|
await page.unrouteAll();
|
||||||
|
await visit(create, '?admin');
|
||||||
|
await fill(create);
|
||||||
|
page.once('dialog', (dialog) => dialog.dismiss());
|
||||||
|
await page
|
||||||
|
.locator('#main-content')
|
||||||
|
.getByRole('link', {
|
||||||
|
name: create ? 'Back to backlog' : 'Open backlog',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
assert.ok(page.url().includes('/tickets/'));
|
||||||
|
const successData = await submit(create);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: successData.title, exact: true })
|
||||||
|
.waitFor();
|
||||||
|
if (create) {
|
||||||
|
await page.goBack();
|
||||||
|
await field('Title').waitFor();
|
||||||
|
assert.equal(await field('Title').inputValue(), '');
|
||||||
|
assert.equal(
|
||||||
|
await field('Acceptance criteria checklist').inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await form()
|
||||||
|
.getByText('All changes saved', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await field('Title').fill('Another draft');
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await button('Discard changes').click();
|
||||||
|
assert.equal(
|
||||||
|
await field('Title').inputValue(),
|
||||||
|
'A complete ticket',
|
||||||
|
);
|
||||||
|
assert.equal(await field('Impact score').inputValue(), '2.5');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(false, '?hideAuthor&hidePoints');
|
||||||
|
assert.equal(await field('Author').count(), 0);
|
||||||
|
assert.equal(await field('Estimated story points').count(), 0);
|
||||||
|
await visit(false);
|
||||||
|
assert.equal(await field('Author').isDisabled(), true);
|
||||||
|
await visit(true, '?hidePoints');
|
||||||
|
assert.equal(await field('Estimated story points').count(), 0);
|
||||||
|
await visit(true, '?noMetadata');
|
||||||
|
assert.equal(await page.locator('form').count(), 0);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user