Compare commits
10
Commits
v0.2.0
...
b76b4adaec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,45 +1,93 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { reactive } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeListProps } from '@/types/ticket-tree';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<TicketTreeListProps>();
|
||||||
nodes: TicketTreeNodeModel[];
|
|
||||||
projectId: number;
|
const { t } = useLocalization();
|
||||||
}>();
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
|
|
||||||
|
function toggleTicket(ticketId: number): void {
|
||||||
|
if (collapsedTicketIds.has(ticketId)) {
|
||||||
|
collapsedTicketIds.delete(ticketId);
|
||||||
|
} else {
|
||||||
|
collapsedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="space-y-3">
|
<ul class="grid min-w-0 gap-3">
|
||||||
<li v-for="node in nodes" :key="node.id">
|
<li v-for="node in nodes" :key="node.id" class="min-w-0">
|
||||||
<div class="border-border rounded-xl border px-4 py-4">
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex justify-center pt-1">
|
||||||
<Link
|
<button
|
||||||
:href="
|
v-if="node.children.length > 0"
|
||||||
showTicket({
|
type="button"
|
||||||
project: projectId,
|
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
ticket: node.id,
|
:aria-expanded="!collapsedTicketIds.has(node.id)"
|
||||||
})
|
:aria-controls="`ticket-tree-children-${node.id}`"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
collapsedTicketIds.has(node.id)
|
||||||
|
? 'tickets.tree.expand'
|
||||||
|
: 'tickets.tree.collapse',
|
||||||
|
{ key: node.ticket_key },
|
||||||
|
)
|
||||||
"
|
"
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
@click="toggleTicket(node.id)"
|
||||||
>
|
>
|
||||||
{{ node.ticket_key }}
|
<ChevronRight
|
||||||
</Link>
|
v-if="collapsedTicketIds.has(node.id)"
|
||||||
|
class="size-4"
|
||||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
aria-hidden="true"
|
||||||
{{ node.ticket_type_name }}
|
/>
|
||||||
</Badge>
|
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="bg-border mt-4 size-1.5 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-foreground mt-2 text-sm">
|
<Link
|
||||||
{{ node.title }}
|
:href="
|
||||||
</div>
|
showTicket({
|
||||||
|
project: projectId,
|
||||||
|
ticket: node.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ node.ticket_key }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||||
|
{{ node.ticket_type_name }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||||
|
>
|
||||||
|
{{ node.title }}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="node.children.length > 0"
|
v-if="
|
||||||
class="border-border mt-3 border-l pl-4"
|
node.children.length > 0 && !collapsedTicketIds.has(node.id)
|
||||||
|
"
|
||||||
|
:id="`ticket-tree-children-${node.id}`"
|
||||||
|
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||||
>
|
>
|
||||||
<TicketTreeList
|
<TicketTreeList
|
||||||
:nodes="node.children"
|
:nodes="node.children"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from '@/types/ticket-presentation';
|
} from '@/types/ticket-presentation';
|
||||||
|
|
||||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||||
|
layout: 'card',
|
||||||
showDescription: true,
|
showDescription: true,
|
||||||
showStatus: true,
|
showStatus: true,
|
||||||
});
|
});
|
||||||
@@ -22,63 +23,85 @@ const { t } = useLocalization();
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
:class="[
|
:class="[
|
||||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||||
|
layout === 'card' ? 'shadow-sm' : '',
|
||||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Link
|
<div
|
||||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
:class="
|
||||||
:aria-label="
|
layout === 'list'
|
||||||
t('tickets.open', {
|
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
|
||||||
key: ticket.ticket_key,
|
: ''
|
||||||
title: ticket.title,
|
|
||||||
})
|
|
||||||
"
|
"
|
||||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
|
||||||
@click="emit('open', ticket.id)"
|
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
<Link
|
||||||
<Badge
|
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||||
variant="outline"
|
:aria-label="
|
||||||
class="wrap-anywhere max-w-full whitespace-normal"
|
t('tickets.open', {
|
||||||
>
|
key: ticket.ticket_key,
|
||||||
{{ ticket.ticket_key }}
|
title: ticket.title,
|
||||||
</Badge>
|
|
||||||
<ArrowUpRight
|
|
||||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3
|
|
||||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
|
||||||
>
|
|
||||||
{{ ticket.title }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="showDescription && ticket.description"
|
|
||||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
|
||||||
>
|
|
||||||
{{ ticket.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
|
||||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('tickets.blockerComment', {
|
|
||||||
comment: ticket.blocked_comment,
|
|
||||||
})
|
})
|
||||||
}}
|
"
|
||||||
</p>
|
:class="[
|
||||||
</Link>
|
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
|
||||||
|
layout === 'list'
|
||||||
|
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
|
||||||
|
: 'rounded-t-md',
|
||||||
|
]"
|
||||||
|
@click="emit('open', ticket.id)"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_key }}
|
||||||
|
</Badge>
|
||||||
|
<ArrowUpRight
|
||||||
|
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="border-t p-4">
|
<h3
|
||||||
<slot name="actions" />
|
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="showDescription && ticket.description"
|
||||||
|
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
||||||
|
>
|
||||||
|
{{ ticket.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||||
|
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('tickets.blockerComment', {
|
||||||
|
comment: ticket.blocked_comment,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="$slots.actions"
|
||||||
|
:class="[
|
||||||
|
'border-t p-4',
|
||||||
|
layout === 'list'
|
||||||
|
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,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.',
|
||||||
|
|||||||
@@ -1,70 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
import { TicketListItem } from '@/components/tickets';
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Columns3,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import {
|
||||||
|
buildBacklogTicketTree,
|
||||||
|
countBacklogTreeTickets,
|
||||||
|
expandableBacklogTicketIds,
|
||||||
|
filterBacklogTicketTree,
|
||||||
|
} from '@/lib/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
import type {
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
BacklogFilterState,
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
BreadcrumbItem,
|
||||||
|
ProjectBacklogPageProps,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectBacklogPageProps>();
|
const props = defineProps<ProjectBacklogPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
|
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
|
||||||
|
]);
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const filters = reactive<BacklogFilterState>({
|
||||||
{
|
assigneeId: '',
|
||||||
title: 'Projects',
|
priorityId: '',
|
||||||
href: index(),
|
query: '',
|
||||||
},
|
statusId: '',
|
||||||
{
|
typeId: '',
|
||||||
title: props.project.name,
|
});
|
||||||
href: show(props.project.id),
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
},
|
const pendingTicketId = ref<number | null>(null);
|
||||||
{
|
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
||||||
title: 'Backlog',
|
const filteredTicketTree = computed(() =>
|
||||||
href: backlogIndex(props.project.id),
|
filterBacklogTicketTree(ticketTree.value, filters),
|
||||||
},
|
);
|
||||||
];
|
const visibleTicketCount = computed(() =>
|
||||||
|
countBacklogTreeTickets(filteredTicketTree.value),
|
||||||
|
);
|
||||||
|
const expandableTicketIds = computed(() =>
|
||||||
|
expandableBacklogTicketIds(filteredTicketTree.value),
|
||||||
|
);
|
||||||
|
const hasActiveFilters = computed(
|
||||||
|
() =>
|
||||||
|
filters.query.trim() !== '' ||
|
||||||
|
filters.typeId !== '' ||
|
||||||
|
filters.priorityId !== '' ||
|
||||||
|
filters.statusId !== '' ||
|
||||||
|
filters.assigneeId !== '',
|
||||||
|
);
|
||||||
|
const allVisibleBranchesCollapsed = computed(
|
||||||
|
() =>
|
||||||
|
expandableTicketIds.value.length > 0 &&
|
||||||
|
expandableTicketIds.value.every((ticketId) =>
|
||||||
|
collapsedTicketIds.has(ticketId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
function clearFilters(): void {
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
filters.query = '';
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
filters.typeId = '';
|
||||||
);
|
filters.priorityId = '';
|
||||||
};
|
filters.statusId = '';
|
||||||
|
filters.assigneeId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTicket(ticketId: number): void {
|
||||||
|
if (collapsedTicketIds.has(ticketId)) {
|
||||||
|
collapsedTicketIds.delete(ticketId);
|
||||||
|
} else {
|
||||||
|
collapsedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllBranches(): void {
|
||||||
|
if (allVisibleBranchesCollapsed.value) {
|
||||||
|
collapsedTicketIds.clear();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ticketId of expandableTicketIds.value) {
|
||||||
|
collapsedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMove(ticketId: number): void {
|
||||||
|
if (pendingTicketId.value === ticketId) {
|
||||||
|
pendingTicketId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.project.id,
|
||||||
|
() => {
|
||||||
|
clearFilters();
|
||||||
|
collapsedTicketIds.clear();
|
||||||
|
pendingTicketId.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} backlog`" />
|
<Head :title="t('backlog.title', { name: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
{{ project.key }}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
<Heading
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
:title="`${project.name} backlog`"
|
>
|
||||||
description="All newly created tickets start here before they move onto the board."
|
{{ project.key }}
|
||||||
/>
|
</Badge>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('tickets.count', { count: tickets.length }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="text-headline-mobile md:text-headline wrap-anywhere"
|
||||||
|
>
|
||||||
|
{{ t('backlog.title', { name: project.name }) }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-caption text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('backlog.description') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -74,138 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Create ticket
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('navigation.createTicket') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
<Link :href="boardIndex(project.id)">
|
||||||
|
<Columns3 aria-hidden="true" />
|
||||||
|
{{ t('backlog.openBoard') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Button as-child variant="outline">
|
<Link :href="show(project.id)">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<ArrowLeft aria-hidden="true" />
|
||||||
|
{{ t('backlog.backToProject') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="backlog-filter-title"
|
||||||
|
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<h2 id="backlog-filter-title" class="text-title">
|
||||||
|
{{ t('backlog.filters.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('backlog.filters.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!hasActiveFilters"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{ t('backlog.filters.clear') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="backlog-query"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.search') }}
|
||||||
|
</label>
|
||||||
|
<div class="relative min-w-0">
|
||||||
|
<Search
|
||||||
|
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="backlog-query"
|
||||||
|
v-model="filters.query"
|
||||||
|
type="search"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
:placeholder="
|
||||||
|
t('backlog.filters.searchPlaceholder')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-type"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.type') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-type"
|
||||||
|
v-model="filters.typeId"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allTypes') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="ticketType in ticketTypes"
|
||||||
|
:key="ticketType.id"
|
||||||
|
:value="ticketType.id"
|
||||||
|
>
|
||||||
|
{{ ticketType.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-priority"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.priority') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-priority"
|
||||||
|
v-model="filters.priorityId"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allPriorities') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="priority in ticketPriorities"
|
||||||
|
:key="priority.id"
|
||||||
|
:value="priority.id"
|
||||||
|
>
|
||||||
|
{{ priority.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-status"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.status') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-status"
|
||||||
|
v-model="filters.statusId"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allStatuses') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="ticketStatus in backlogTicketStatuses"
|
||||||
|
:key="ticketStatus.id"
|
||||||
|
:value="ticketStatus.id"
|
||||||
|
>
|
||||||
|
{{ ticketStatus.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-assignee"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.assignee') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-assignee"
|
||||||
|
v-model="filters.assigneeId"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('backlog.filters.allAssignees') }}
|
||||||
|
</option>
|
||||||
|
<option value="unassigned">
|
||||||
|
{{ t('tickets.unassigned') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="tickets.length === 0"
|
||||||
|
class="grid justify-items-start gap-3"
|
||||||
|
>
|
||||||
|
<FeedbackState
|
||||||
|
state="empty"
|
||||||
|
:title="t('backlog.empty.title')"
|
||||||
|
:description="t('backlog.empty.description')"
|
||||||
|
/>
|
||||||
|
<Button v-if="canCreateTickets" as-child size="sm">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
createTicket(
|
||||||
|
{ project: project.id },
|
||||||
|
{ query: { source: 'backlog' } },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('backlog.empty.action') }}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="status"
|
v-else-if="visibleTicketCount === 0"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
class="grid justify-items-start gap-3"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<FeedbackState
|
||||||
|
state="empty"
|
||||||
|
:title="t('backlog.filters.empty.title')"
|
||||||
|
:description="t('backlog.filters.empty.description')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{ t('backlog.filters.clear') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
<section
|
||||||
<CardHeader class="border-b">
|
v-else
|
||||||
<CardTitle>Backlog tickets</CardTitle>
|
aria-labelledby="backlog-results-title"
|
||||||
<CardDescription>
|
class="grid min-w-0 gap-3"
|
||||||
Tickets remain here while their status belongs to
|
>
|
||||||
backlog.
|
<div
|
||||||
</CardDescription>
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
</CardHeader>
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
<CardContent class="p-0">
|
<h2 id="backlog-results-title" class="text-title">
|
||||||
<div
|
{{ t('backlog.results.title') }}
|
||||||
v-if="tickets.length === 0"
|
</h2>
|
||||||
class="text-muted-foreground p-6 text-sm"
|
<p class="text-caption text-muted-foreground">
|
||||||
>
|
{{
|
||||||
No backlog tickets exist yet.
|
t('backlog.results.count', {
|
||||||
|
count: visibleTicketCount,
|
||||||
|
total: tickets.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="expandableTicketIds.length > 0"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="toggleAllBranches"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown
|
||||||
|
v-if="allVisibleBranchesCollapsed"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronsDownUp v-else aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
allVisibleBranchesCollapsed
|
||||||
|
? 'backlog.tree.expandAll'
|
||||||
|
: 'backlog.tree.collapseAll',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul v-else class="gap-column grid p-4">
|
<BacklogTicketTree
|
||||||
<TicketListItem
|
:nodes="filteredTicketTree"
|
||||||
v-for="ticket in tickets"
|
:project-id="project.id"
|
||||||
:key="ticket.id"
|
:can-edit-tickets="canEditTickets"
|
||||||
:project-id="project.id"
|
:collapsed-ticket-ids="collapsedTicketIds"
|
||||||
:ticket="ticket"
|
:pending-ticket-id="pendingTicketId"
|
||||||
>
|
@toggle="toggleTicket"
|
||||||
<template v-if="canEditTickets" #actions>
|
@move-start="pendingTicketId = $event"
|
||||||
<Form
|
@move-finish="finishMove"
|
||||||
v-bind="
|
/>
|
||||||
ProjectBoardController.move.form({
|
</section>
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="flex w-full flex-col gap-2"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="redirect_to"
|
|
||||||
value="backlog"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
:for="`backlog-ticket-status-${ticket.id}`"
|
|
||||||
class="text-label text-muted-foreground uppercase"
|
|
||||||
>
|
|
||||||
Move to status
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
:id="`backlog-ticket-status-${ticket.id}`"
|
|
||||||
name="ticket_status_id"
|
|
||||||
:disabled="
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length === 0 || processing
|
|
||||||
"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-if="
|
|
||||||
allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
value=""
|
|
||||||
>
|
|
||||||
No allowed moves
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
)"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="ticketStatus.id"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(
|
|
||||||
ticketStatus,
|
|
||||||
'Unknown status',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
:disabled="
|
|
||||||
processing ||
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length === 0
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length === 0
|
|
||||||
"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>
|
|
||||||
This status does not allow moving
|
|
||||||
anywhere else.
|
|
||||||
</p>
|
|
||||||
</Form>
|
|
||||||
</template>
|
|
||||||
</TicketListItem>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next';
|
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { TicketCard } from '@/components/tickets';
|
import { TicketCard } from '@/components/tickets';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useBoardDrag } from '@/composables/useBoardDrag';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
@@ -67,6 +78,140 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
(status) => status.id !== ticket.ticket_status_id,
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
|
||||||
|
const feedback = useFormProtection(moveForm, { isDirty: () => false });
|
||||||
|
const menuPending = ref<number | null>(null);
|
||||||
|
const busy = computed(() => moveForm.processing || menuPending.value !== null);
|
||||||
|
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
|
||||||
|
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
|
||||||
|
const findTicket = (id: number) =>
|
||||||
|
props.boardColumns
|
||||||
|
.flatMap((column) => column.tickets ?? [])
|
||||||
|
.find((ticket) => ticket.id === id);
|
||||||
|
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
|
||||||
|
const statusIds =
|
||||||
|
props.boardColumns
|
||||||
|
.find((column) => column.id === columnId)
|
||||||
|
?.ticket_statuses?.map((status) => status.id) ?? [];
|
||||||
|
return allowedTargetStatuses(ticket).filter((status) =>
|
||||||
|
statusIds.includes(status.id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const choiceTicket = computed(() =>
|
||||||
|
choice.value ? findTicket(choice.value.ticketId) : undefined,
|
||||||
|
);
|
||||||
|
const choiceTargets = computed(() =>
|
||||||
|
choice.value && choiceTicket.value
|
||||||
|
? targetsForColumn(choiceTicket.value, choice.value.columnId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const choiceOpen = computed({
|
||||||
|
get: () => choice.value !== null,
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
choice.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitMove(ticketId: number, statusId: number): void {
|
||||||
|
const ticket = findTicket(ticketId);
|
||||||
|
if (
|
||||||
|
busy.value ||
|
||||||
|
!props.canEditTickets ||
|
||||||
|
!ticket ||
|
||||||
|
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMove.value = { ticketId, statusId };
|
||||||
|
moveForm.ticket_status_id = statusId;
|
||||||
|
moveForm.clearErrors();
|
||||||
|
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
|
||||||
|
errorBag: `board-ticket-${ticketId}`,
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const destination = props.boardColumns.find((column) =>
|
||||||
|
column.ticket_statuses?.some(
|
||||||
|
(status) => status.id === statusId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (destination) {
|
||||||
|
selectedColumnId.value = destination.id;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
document
|
||||||
|
.getElementById(`ticket-status-${ticketId}`)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTicket(ticket: TicketModel, columnId: number): void {
|
||||||
|
const current = findTicket(ticket.id);
|
||||||
|
if (!current || busy.value || !props.canEditTickets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = targetsForColumn(current, columnId);
|
||||||
|
if (targets.length === 1) {
|
||||||
|
submitMove(current.id, targets[0].id);
|
||||||
|
} else if (targets.length > 1) {
|
||||||
|
moveForm.ticket_status_id = targets[0].id;
|
||||||
|
choice.value = { ticketId: current.id, columnId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
draggedTicket,
|
||||||
|
columnId: dropColumnId,
|
||||||
|
x: dragX,
|
||||||
|
y: dragY,
|
||||||
|
start: startDrag,
|
||||||
|
cancel: cancelDrag,
|
||||||
|
} = useBoardDrag({
|
||||||
|
canStart: (ticket) =>
|
||||||
|
props.canEditTickets &&
|
||||||
|
!busy.value &&
|
||||||
|
!choice.value &&
|
||||||
|
allowedTargetStatuses(ticket).length > 0,
|
||||||
|
drop: dropTicket,
|
||||||
|
});
|
||||||
|
const canDrop = (columnId: number) =>
|
||||||
|
!!draggedTicket.value &&
|
||||||
|
targetsForColumn(draggedTicket.value, columnId).length > 0;
|
||||||
|
const dropClasses = (columnId: number) =>
|
||||||
|
!draggedTicket.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'border-primary border-dashed': canDrop(columnId),
|
||||||
|
'ring-2 ring-primary bg-primary/10':
|
||||||
|
canDrop(columnId) && dropColumnId.value === columnId,
|
||||||
|
'opacity-40': !canDrop(columnId),
|
||||||
|
};
|
||||||
|
function confirmMove(): void {
|
||||||
|
if (
|
||||||
|
!choice.value ||
|
||||||
|
!choiceTargets.value.some(
|
||||||
|
(status) => status.id === moveForm.ticket_status_id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticketId = choice.value.ticketId;
|
||||||
|
choice.value = null;
|
||||||
|
submitMove(ticketId, moveForm.ticket_status_id);
|
||||||
|
}
|
||||||
|
function focusStatus(ticketId: number): void {
|
||||||
|
document.getElementById(`ticket-status-${ticketId}`)?.focus();
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => [props.project.id, props.boardColumns, props.canEditTickets],
|
||||||
|
() => {
|
||||||
|
cancelDrag();
|
||||||
|
choice.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -130,6 +275,13 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<FeedbackState v-if="status" state="success" :title="status" />
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
<FormFeedback
|
||||||
|
:state="feedback.state.value"
|
||||||
|
:description="moveForm.errors.ticket_status_id"
|
||||||
|
@retry="
|
||||||
|
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
|
||||||
|
"
|
||||||
|
/>
|
||||||
<FeedbackState
|
<FeedbackState
|
||||||
v-if="columns.length === 0"
|
v-if="columns.length === 0"
|
||||||
state="empty"
|
state="empty"
|
||||||
@@ -168,10 +320,18 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
>
|
>
|
||||||
{{ t('board.scrollHint') }}
|
{{ t('board.scrollHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTickets"
|
||||||
|
id="board-drag-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.dragHint') }}
|
||||||
|
</p>
|
||||||
<section
|
<section
|
||||||
:aria-label="t('board.columns')"
|
:aria-label="t('board.columns')"
|
||||||
aria-describedby="board-scroll-hint"
|
aria-describedby="board-scroll-hint"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
data-board-drag-scroll
|
||||||
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@@ -179,9 +339,11 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:id="`board-column-${column.id}`"
|
:id="`board-column-${column.id}`"
|
||||||
:key="`${project.id}-${column.id}`"
|
:key="`${project.id}-${column.id}`"
|
||||||
:aria-labelledby="`board-column-title-${column.id}`"
|
:aria-labelledby="`board-column-title-${column.id}`"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
:class="[
|
:class="[
|
||||||
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
||||||
selectedColumnId === column.id ? 'flex' : 'hidden',
|
selectedColumnId === column.id ? 'flex' : 'hidden',
|
||||||
|
dropClasses(column.id),
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
@@ -250,6 +412,10 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
v-for="ticket in group.tickets"
|
v-for="ticket in group.tickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="min-w-0"
|
class="min-w-0"
|
||||||
|
:class="{
|
||||||
|
'opacity-50':
|
||||||
|
draggedTicket?.id === ticket.id,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<TicketCard
|
<TicketCard
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
@@ -260,6 +426,36 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
v-if="canEditTickets"
|
v-if="canEditTickets"
|
||||||
#actions
|
#actions
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
|
:disabled="
|
||||||
|
busy ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('board.dragTicket', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
aria-describedby="board-drag-hint"
|
||||||
|
@pointerdown="
|
||||||
|
startDrag($event, ticket)
|
||||||
|
"
|
||||||
|
@click.prevent
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
><GripVertical
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ t('board.drag') }}</Button
|
||||||
|
>
|
||||||
<Form
|
<Form
|
||||||
v-bind="
|
v-bind="
|
||||||
move.form({
|
move.form({
|
||||||
@@ -272,6 +468,12 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:options="{
|
:options="{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
}"
|
}"
|
||||||
|
:on-before="
|
||||||
|
() =>
|
||||||
|
!busy && !draggedTicket
|
||||||
|
"
|
||||||
|
@start="menuPending = ticket.id"
|
||||||
|
@finish="menuPending = null"
|
||||||
class="grid min-w-0 gap-2"
|
class="grid min-w-0 gap-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -292,6 +494,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:id="`ticket-status-${ticket.id}`"
|
:id="`ticket-status-${ticket.id}`"
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -345,6 +548,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -381,6 +585,94 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
|
||||||
|
data-board-mobile-targets
|
||||||
|
>
|
||||||
|
<p class="text-caption mb-2 font-semibold">
|
||||||
|
{{ t('board.dropHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
|
||||||
|
<div
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.id"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
|
:class="dropClasses(column.id)"
|
||||||
|
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
|
||||||
|
>
|
||||||
|
{{ column.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.max(0, dragX - 80)}px`,
|
||||||
|
top: `${Math.max(0, dragY - 56)}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ draggedTicket.ticket_key }}
|
||||||
|
</div>
|
||||||
|
<p class="sr-only" aria-live="polite">
|
||||||
|
{{
|
||||||
|
draggedTicket
|
||||||
|
? t('board.dragging', { key: draggedTicket.ticket_key })
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Dialog v-model:open="choiceOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('board.chooseStatusDescription', {
|
||||||
|
key: choiceTicket?.ticket_key ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form class="grid gap-4" @submit.prevent="confirmMove">
|
||||||
|
<label
|
||||||
|
for="board-drop-status"
|
||||||
|
class="text-body font-semibold"
|
||||||
|
>{{ t('board.targetStatus') }}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="board-drop-status"
|
||||||
|
v-model="moveForm.ticket_status_id"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="target in choiceTargets"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="choiceOpen = false"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || choiceTargets.length === 0"
|
||||||
|
>{{ t('board.updateStatus') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}`}`,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user