Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d | ||
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad | ||
|
|
002de1b293 | ||
|
|
707ebe6e48 | ||
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 |
@@ -33,9 +33,6 @@
|
|||||||
vendor
|
vendor
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.env.example
|
|
||||||
.env.mariadb
|
|
||||||
.env.mariadb.example
|
|
||||||
.env.production
|
.env.production
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
MARIADB_DATABASE=kanbangit
|
|
||||||
MARIADB_USER=kanbangit
|
|
||||||
MARIADB_PASSWORD=
|
|
||||||
MARIADB_ROOT_PASSWORD=
|
|
||||||
@@ -2,7 +2,13 @@ name: CI/CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -11,9 +17,19 @@ env:
|
|||||||
REGISTRY: git.twyco.de
|
REGISTRY: git.twyco.de
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test_and_check_app:
|
preconditions:
|
||||||
name: test_and_check_app
|
if: >
|
||||||
|
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Conditions met
|
||||||
|
run: |
|
||||||
|
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||||
|
|
||||||
|
test_and_check_app:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- preconditions
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
@@ -27,6 +43,7 @@ jobs:
|
|||||||
github.event_name == 'push' &&
|
github.event_name == 'push' &&
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||||
needs:
|
needs:
|
||||||
|
- preconditions
|
||||||
- test_and_check_app
|
- test_and_check_app
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+5
-3
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
|||||||
COPY . /var/www
|
COPY . /var/www
|
||||||
|
|
||||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||||
&& npm ci \
|
&& corepack enable pnpm \
|
||||||
&& npm run build
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run build
|
||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
|
|
||||||
RUN touch .env \
|
RUN cp .env.example .env \
|
||||||
|
&& php artisan key:generate\
|
||||||
&& composer audit --locked --no-interaction \
|
&& composer audit --locked --no-interaction \
|
||||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->dontFlash(['api_token']);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
+6
-6
@@ -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
|
||||||
@@ -1,39 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
import { useAppearance } from '@/composables/useAppearance';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = computed(() => [
|
||||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
{
|
||||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
value: 'light' as const,
|
||||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
Icon: Sun,
|
||||||
] as const;
|
label: t('appearance.light'),
|
||||||
|
description: t('appearance.lightDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark' as const,
|
||||||
|
Icon: Moon,
|
||||||
|
label: t('appearance.dark'),
|
||||||
|
description: t('appearance.darkDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'system' as const,
|
||||||
|
Icon: Monitor,
|
||||||
|
label: t('appearance.system'),
|
||||||
|
description: t('appearance.systemDescription'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<fieldset class="min-w-0">
|
||||||
role="group"
|
<legend class="sr-only">{{ t('appearance.label') }}</legend>
|
||||||
:aria-label="t('appearance.label')"
|
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
<label
|
||||||
>
|
v-for="{ value, Icon, label, description } in tabs"
|
||||||
<button
|
:key="value"
|
||||||
v-for="{ value, Icon, label } in tabs"
|
class="relative min-w-0"
|
||||||
:key="value"
|
>
|
||||||
type="button"
|
<input
|
||||||
:aria-pressed="appearance === value"
|
type="radio"
|
||||||
@click="updateAppearance(value)"
|
name="appearance"
|
||||||
:class="[
|
:value="value"
|
||||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
:checked="appearance === value"
|
||||||
appearance === value
|
:aria-describedby="`appearance-${value}-description`"
|
||||||
? 'bg-primary text-primary-foreground'
|
class="peer sr-only"
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
@change="updateAppearance(value)"
|
||||||
]"
|
/>
|
||||||
>
|
<span
|
||||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
|
||||||
<span>{{ t(label) }}</span>
|
>
|
||||||
</button>
|
<span class="flex items-center justify-between gap-3">
|
||||||
</div>
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="Icon"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="appearance === value"
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">{{ label }}</span>
|
||||||
|
<span
|
||||||
|
:id="`appearance-${value}-description`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { TicketCard } from '@/components/tickets';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type {
|
||||||
|
BacklogTicketTreeEmits,
|
||||||
|
BacklogTicketTreeProps,
|
||||||
|
} from '@/types/backlog';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
const props = defineProps<BacklogTicketTreeProps>();
|
||||||
|
const emit = defineEmits<BacklogTicketTreeEmits>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const allowedTargetStatuses = (ticket: TicketModel) =>
|
||||||
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="grid min-w-0 gap-3">
|
||||||
|
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
|
||||||
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
|
<div class="flex justify-center pt-1">
|
||||||
|
<button
|
||||||
|
v-if="node.children.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
|
||||||
|
:aria-controls="`backlog-children-${node.ticket.id}`"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
collapsedTicketIds.has(node.ticket.id)
|
||||||
|
? 'backlog.tree.expand'
|
||||||
|
: 'backlog.tree.collapse',
|
||||||
|
{ key: node.ticket.ticket_key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="emit('toggle', node.ticket.id)"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
v-if="collapsedTicketIds.has(node.ticket.id)"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="bg-border mt-4 size-1.5 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="!node.matchesFilters"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-fit"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.parentContext') }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<TicketCard
|
||||||
|
:project-id="projectId"
|
||||||
|
:ticket="node.ticket"
|
||||||
|
layout="list"
|
||||||
|
:class="{ 'opacity-80': !node.matchesFilters }"
|
||||||
|
>
|
||||||
|
<template v-if="canEditTickets" #actions>
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
move.form({
|
||||||
|
project: projectId,
|
||||||
|
ticket: node.ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-slot="{ processing, errors }"
|
||||||
|
:error-bag="`backlog-ticket-${node.ticket.id}`"
|
||||||
|
:options="{ preserveScroll: true }"
|
||||||
|
:on-before="() => pendingTicketId === null"
|
||||||
|
class="grid min-w-0 gap-2"
|
||||||
|
@start="emit('moveStart', node.ticket.id)"
|
||||||
|
@finish="emit('moveFinish', node.ticket.id)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="redirect_to"
|
||||||
|
value="backlog"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="`backlog-ticket-status-${node.ticket.id}`"
|
||||||
|
class="text-caption text-muted-foreground font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('backlog.moveTicket', {
|
||||||
|
key: node.ticket.ticket_key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:id="`backlog-ticket-status-${node.ticket.id}`"
|
||||||
|
name="ticket_status_id"
|
||||||
|
:disabled="
|
||||||
|
pendingTicketId !== null ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errors.ticket_status_id || undefined
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
errors.ticket_status_id
|
||||||
|
? `backlog-ticket-status-error-${node.ticket.id}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
{{ t('board.noMoves') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="target in allowedTargetStatuses(
|
||||||
|
node.ticket,
|
||||||
|
)"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:id="`backlog-ticket-status-error-${node.ticket.id}`"
|
||||||
|
:message="errors.ticket_status_id"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
pendingTicketId !== null ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
processing
|
||||||
|
? 'board.updatingStatus'
|
||||||
|
: 'board.updateStatus',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(node.ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.noMovesDescription') }}
|
||||||
|
</p>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
</TicketCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
node.children.length > 0 &&
|
||||||
|
!collapsedTicketIds.has(node.ticket.id)
|
||||||
|
"
|
||||||
|
:id="`backlog-children-${node.ticket.id}`"
|
||||||
|
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
|
||||||
|
>
|
||||||
|
<BacklogTicketTree
|
||||||
|
:nodes="node.children"
|
||||||
|
:project-id="projectId"
|
||||||
|
:can-edit-tickets="canEditTickets"
|
||||||
|
:collapsed-ticket-ids="collapsedTicketIds"
|
||||||
|
:pending-ticket-id="pendingTicketId"
|
||||||
|
@toggle="emit('toggle', $event)"
|
||||||
|
@move-start="emit('moveStart', $event)"
|
||||||
|
@move-finish="emit('moveFinish', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
import { Form } from '@inertiajs/vue3';
|
import { Form } from '@inertiajs/vue3';
|
||||||
import { useTemplateRef } from 'vue';
|
import { useTemplateRef } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -17,97 +17,104 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const passwordInput = useTemplateRef('passwordInput');
|
const passwordInput = useTemplateRef('passwordInput');
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<Card
|
||||||
<Heading
|
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
variant="small"
|
>
|
||||||
title="Delete account"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
description="Delete your account and all of its resources"
|
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
|
||||||
/>
|
<p class="text-muted-foreground">
|
||||||
<div
|
{{ t('profile.delete.description') }}
|
||||||
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
|
</p>
|
||||||
>
|
</CardHeader>
|
||||||
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
|
<CardContent class="p-5 sm:p-6">
|
||||||
<p class="font-medium">Warning</p>
|
<div
|
||||||
<p class="text-sm">
|
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
|
||||||
Please proceed with caution, this cannot be undone.
|
>
|
||||||
</p>
|
<div class="gap-stack grid">
|
||||||
</div>
|
<p class="text-destructive font-semibold">
|
||||||
<Dialog>
|
{{ t('profile.delete.warning') }}
|
||||||
<DialogTrigger as-child>
|
</p>
|
||||||
<Button variant="destructive" data-test="delete-user-button"
|
<p class="text-body text-muted-foreground">
|
||||||
>Delete account</Button
|
{{ t('profile.delete.warningDescription') }}
|
||||||
>
|
</p>
|
||||||
</DialogTrigger>
|
</div>
|
||||||
<DialogContent>
|
<Dialog>
|
||||||
<Form
|
<DialogTrigger as-child>
|
||||||
v-bind="ProfileController.destroy.form()"
|
<Button
|
||||||
reset-on-success
|
variant="destructive"
|
||||||
@error="() => passwordInput?.focus()"
|
class="justify-self-start"
|
||||||
:options="{
|
data-test="delete-user-button"
|
||||||
preserveScroll: true,
|
>{{ t('profile.delete.action') }}</Button
|
||||||
}"
|
>
|
||||||
class="space-y-6"
|
</DialogTrigger>
|
||||||
v-slot="{ errors, processing, reset, clearErrors }"
|
<DialogContent>
|
||||||
>
|
<Form
|
||||||
<DialogHeader class="space-y-3">
|
v-bind="ProfileController.destroy.form()"
|
||||||
<DialogTitle
|
reset-on-success
|
||||||
>Are you sure you want to delete your
|
@error="() => passwordInput?.focus()"
|
||||||
account?</DialogTitle
|
:options="{
|
||||||
>
|
preserveScroll: true,
|
||||||
<DialogDescription>
|
}"
|
||||||
Once your account is deleted, all of its
|
class="space-y-6"
|
||||||
resources and data will also be permanently
|
v-slot="{ errors, processing, reset, clearErrors }"
|
||||||
deleted. Please enter your password to confirm
|
>
|
||||||
you would like to permanently delete your
|
<DialogHeader class="space-y-3">
|
||||||
account.
|
<DialogTitle>{{
|
||||||
</DialogDescription>
|
t('profile.delete.confirmTitle')
|
||||||
</DialogHeader>
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('profile.delete.confirmDescription') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="password" class="sr-only"
|
<Label for="password" class="sr-only">{{
|
||||||
>Password</Label
|
t('profile.delete.password')
|
||||||
>
|
}}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
ref="passwordInput"
|
ref="passwordInput"
|
||||||
placeholder="Password"
|
:placeholder="t('profile.delete.password')"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.password" />
|
<InputError :message="errors.password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-2">
|
||||||
|
<DialogClose as-child>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
clearErrors();
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
<DialogFooter class="gap-2">
|
|
||||||
<DialogClose as-child>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
type="submit"
|
||||||
@click="
|
variant="destructive"
|
||||||
() => {
|
:disabled="processing"
|
||||||
clearErrors();
|
data-test="confirm-delete-user-button"
|
||||||
reset();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
Cancel
|
{{ t('profile.delete.confirmAction') }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
<Button
|
</DialogContent>
|
||||||
type="submit"
|
</Dialog>
|
||||||
variant="destructive"
|
</div>
|
||||||
:disabled="processing"
|
</CardContent>
|
||||||
data-test="confirm-delete-user-button"
|
</Card>
|
||||||
>
|
|
||||||
Delete account
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,123 +1,169 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
import { ref } from 'vue';
|
||||||
import AlertError from '@/components/AlertError.vue';
|
import AlertError from '@/components/AlertError.vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||||
|
|
||||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
const { t } = useLocalization();
|
||||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
const {
|
||||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
recoveryCodesList,
|
||||||
|
fetchRecoveryCodes,
|
||||||
|
clearRecoveryCodes,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
} = useTwoFactorAuth();
|
||||||
|
const visible = ref(false);
|
||||||
|
const showRegenerate = ref(false);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
|
||||||
const toggleRecoveryCodesVisibility = async () => {
|
function hide(): void {
|
||||||
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
|
visible.value = false;
|
||||||
await fetchRecoveryCodes();
|
clearRecoveryCodes();
|
||||||
|
}
|
||||||
|
function toggle(): void {
|
||||||
|
if (visible.value) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
visible.value = true;
|
||||||
|
void fetchRecoveryCodes();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
|
function closeRegenerate(open: boolean): void {
|
||||||
|
if (!form.processing) {
|
||||||
if (isRecoveryCodesVisible.value) {
|
showRegenerate.value = open;
|
||||||
await nextTick();
|
protection.clearFeedback();
|
||||||
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
function regenerate(): void {
|
||||||
onMounted(async () => {
|
protection.submit(regenerateRecoveryCodes(), {
|
||||||
if (!recoveryCodesList.value.length) {
|
preserveScroll: true,
|
||||||
await fetchRecoveryCodes();
|
onBefore: hide,
|
||||||
}
|
onSuccess: () => {
|
||||||
});
|
showRegenerate.value = false;
|
||||||
|
visible.value = true;
|
||||||
|
return fetchRecoveryCodes();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="w-full">
|
<section
|
||||||
<CardHeader>
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
<CardTitle class="flex gap-3">
|
aria-labelledby="recovery-title"
|
||||||
<LockKeyhole class="size-4" />2FA recovery codes
|
>
|
||||||
</CardTitle>
|
<div class="gap-stack grid">
|
||||||
<CardDescription>
|
<h3
|
||||||
Recovery codes let you regain access if you lose your 2FA
|
id="recovery-title"
|
||||||
device. Store them in a secure password manager.
|
class="flex items-center gap-2 font-semibold"
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
>
|
||||||
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
|
<LockKeyhole class="text-primary size-4" />{{
|
||||||
<component
|
t('security.recoveryTitle')
|
||||||
:is="isRecoveryCodesVisible ? EyeOff : Eye"
|
}}
|
||||||
class="size-4"
|
</h3>
|
||||||
/>
|
<p class="text-body text-muted-foreground">
|
||||||
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
|
{{ t('security.recoveryDescription') }}
|
||||||
codes
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
<Form
|
<Button
|
||||||
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
|
variant="outline"
|
||||||
v-bind="regenerateRecoveryCodes.form()"
|
:aria-expanded="visible"
|
||||||
method="post"
|
aria-controls="recovery-codes"
|
||||||
:options="{ preserveScroll: true }"
|
:disabled="form.processing"
|
||||||
@success="fetchRecoveryCodes"
|
@click="toggle"
|
||||||
#default="{ processing }"
|
><component :is="visible ? EyeOff : Eye" />{{
|
||||||
|
t(
|
||||||
|
visible
|
||||||
|
? 'security.hideRecovery'
|
||||||
|
: 'security.viewRecovery',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="visible && recoveryCodesList.length"
|
||||||
|
variant="secondary"
|
||||||
|
@click="showRegenerate = true"
|
||||||
|
><RefreshCw />{{ t('security.regenerate') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
|
||||||
|
<p
|
||||||
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
class="bg-muted animate-pulse rounded-lg p-4"
|
||||||
|
>
|
||||||
|
{{ t('security.loadingRecovery') }}
|
||||||
|
</p>
|
||||||
|
<template v-else-if="errors.length">
|
||||||
|
<AlertError :errors="errors" />
|
||||||
|
<Button
|
||||||
|
class="justify-self-start"
|
||||||
|
variant="outline"
|
||||||
|
@click="fetchRecoveryCodes"
|
||||||
|
>{{ t('common.retry') }}</Button
|
||||||
>
|
>
|
||||||
<Button
|
</template>
|
||||||
variant="secondary"
|
<template v-else>
|
||||||
type="submit"
|
<ul
|
||||||
:disabled="processing"
|
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
|
||||||
>
|
:aria-label="t('security.recoveryTitle')"
|
||||||
<RefreshCw /> Regenerate codes
|
>
|
||||||
</Button>
|
<li v-for="code in recoveryCodesList" :key="code">
|
||||||
</Form>
|
{{ code }}
|
||||||
</div>
|
</li>
|
||||||
<div
|
</ul>
|
||||||
:class="[
|
<p class="text-caption text-muted-foreground">
|
||||||
'relative overflow-hidden transition-all duration-300',
|
{{ t('security.recoveryOnce') }}
|
||||||
isRecoveryCodesVisible
|
</p>
|
||||||
? 'h-auto opacity-100'
|
</template>
|
||||||
: 'h-0 opacity-0',
|
</div>
|
||||||
]"
|
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
>
|
>
|
||||||
<div v-if="errors?.length" class="mt-6">
|
<DialogHeader>
|
||||||
<AlertError :errors="errors" />
|
<DialogTitle>{{
|
||||||
</div>
|
t('security.regenerateTitle')
|
||||||
<div v-else class="mt-3 space-y-3">
|
}}</DialogTitle>
|
||||||
<div
|
<DialogDescription>{{
|
||||||
ref="recoveryCodeSectionRef"
|
t('security.regenerateDescription')
|
||||||
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
|
@retry="regenerate"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="closeRegenerate(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
>
|
>
|
||||||
<div v-if="!recoveryCodesList.length" class="space-y-2">
|
<Button :loading="form.processing" @click="regenerate">{{
|
||||||
<div
|
t('security.regenerate')
|
||||||
v-for="n in 8"
|
}}</Button>
|
||||||
:key="n"
|
</DialogFooter>
|
||||||
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
|
</DialogContent>
|
||||||
></div>
|
</Dialog>
|
||||||
</div>
|
</section>
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
v-for="(code, index) in recoveryCodesList"
|
|
||||||
:key="index"
|
|
||||||
>
|
|
||||||
{{ code }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted-foreground select-none text-xs">
|
|
||||||
Each recovery code can be used once to access your
|
|
||||||
account and will be removed after use. If you need more,
|
|
||||||
click
|
|
||||||
<span class="font-bold">Regenerate codes</span> above.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,297 +1,261 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { useClipboard } from '@vueuse/core';
|
|
||||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||||
import AlertError from '@/components/AlertError.vue';
|
import AlertError from '@/components/AlertError.vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
InputOTP,
|
import { Label } from '@/components/ui/label';
|
||||||
InputOTPGroup,
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
InputOTPSlot,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
} from '@/components/ui/input-otp';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
import { confirm } from '@/routes/two-factor';
|
import { confirm } from '@/routes/two-factor';
|
||||||
import type { TwoFactorConfigContent } from '@/types';
|
|
||||||
|
|
||||||
type Props = {
|
const props = defineProps<{
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
};
|
}>();
|
||||||
|
|
||||||
const { resolvedAppearance } = useAppearance();
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
const isOpen = defineModel<boolean>('isOpen');
|
const isOpen = defineModel<boolean>('isOpen');
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const {
|
||||||
|
qrCodeSvg,
|
||||||
|
manualSetupKey,
|
||||||
|
hasSetupData,
|
||||||
|
clearSetupData,
|
||||||
|
fetchSetupData,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
} = useTwoFactorAuth();
|
||||||
|
const form = useForm({ code: '' }).dontRemember('code');
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
|
const verification = ref(false);
|
||||||
|
const copied = ref(false);
|
||||||
|
const copyError = ref(false);
|
||||||
|
const codeInput = useTemplateRef('codeInput');
|
||||||
|
const title = computed(() =>
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.setupEnabled'
|
||||||
|
: verification.value
|
||||||
|
? 'security.verifyTitle'
|
||||||
|
: 'security.setupTitle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const description = computed(() =>
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.setupEnabledDescription'
|
||||||
|
: verification.value
|
||||||
|
? 'security.verifyDescription'
|
||||||
|
: 'security.setupDescription',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const { copy, copied } = useClipboard();
|
function close(open: boolean): void {
|
||||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
if (!form.processing) {
|
||||||
useTwoFactorAuth();
|
isOpen.value = open;
|
||||||
|
|
||||||
const showVerificationStep = ref(false);
|
|
||||||
const code = ref<string>('');
|
|
||||||
|
|
||||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
|
||||||
|
|
||||||
const modalConfig = computed<TwoFactorConfigContent>(() => {
|
|
||||||
if (props.twoFactorEnabled) {
|
|
||||||
return {
|
|
||||||
title: 'Two-factor authentication enabled',
|
|
||||||
description:
|
|
||||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
|
||||||
buttonText: 'Close',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (showVerificationStep.value) {
|
async function next(): Promise<void> {
|
||||||
return {
|
if (props.twoFactorEnabled || !props.requiresConfirmation) {
|
||||||
title: 'Verify authentication code',
|
close(false);
|
||||||
description: 'Enter the 6-digit code from your authenticator app',
|
|
||||||
buttonText: 'Continue',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: 'Enable two-factor authentication',
|
|
||||||
description:
|
|
||||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
|
||||||
buttonText: 'Continue',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleModalNextStep = () => {
|
|
||||||
if (props.requiresConfirmation) {
|
|
||||||
showVerificationStep.value = true;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
pinInputContainerRef.value?.querySelector('input')?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
verification.value = true;
|
||||||
clearSetupData();
|
await nextTick();
|
||||||
isOpen.value = false;
|
codeInput.value?.$el?.focus();
|
||||||
};
|
}
|
||||||
|
function submit(): void {
|
||||||
const resetModalState = () => {
|
protection.submit(confirm(), {
|
||||||
if (props.twoFactorEnabled) {
|
preserveScroll: true,
|
||||||
clearSetupData();
|
errorBag: 'confirmTwoFactorAuthentication',
|
||||||
|
onSuccess: () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
const key = manualSetupKey.value;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
copyError.value = false;
|
||||||
showVerificationStep.value = false;
|
try {
|
||||||
code.value = '';
|
await navigator.clipboard.writeText(key);
|
||||||
};
|
if (isOpen.value && manualSetupKey.value === key) {
|
||||||
|
copied.value = true;
|
||||||
watch(
|
|
||||||
() => isOpen.value,
|
|
||||||
async (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
resetModalState();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
if (!qrCodeSvg.value) {
|
if (isOpen.value) {
|
||||||
await fetchSetupData();
|
copyError.value = true;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
}
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
verification.value = false;
|
||||||
|
copied.value = false;
|
||||||
|
copyError.value = false;
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
clearSetupData();
|
||||||
|
if (open) {
|
||||||
|
void fetchSetupData();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="isOpen" @update:open="isOpen = $event">
|
<Dialog :open="isOpen" @update:open="close">
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent
|
||||||
<DialogHeader class="flex items-center justify-center">
|
class="sm:max-w-md"
|
||||||
<div
|
:show-close-button="!form.processing"
|
||||||
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<ScanLine class="text-primary size-8" />
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ description }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<template v-if="!verification">
|
||||||
|
<p
|
||||||
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
class="bg-muted animate-pulse rounded-lg p-6 text-center"
|
||||||
>
|
>
|
||||||
|
{{ t('security.loadingSetup') }}
|
||||||
|
</p>
|
||||||
|
<div v-else-if="errors.length" class="gap-stack grid">
|
||||||
|
<AlertError :errors="errors" />
|
||||||
|
<Button variant="outline" @click="fetchSetupData">{{
|
||||||
|
t('common.retry')
|
||||||
|
}}</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
|
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
|
||||||
>
|
v-html="qrCodeSvg"
|
||||||
<div
|
/>
|
||||||
class="absolute inset-0 grid grid-cols-5 opacity-50"
|
<div class="grid min-w-0 gap-2">
|
||||||
>
|
<Label for="setup-key">{{
|
||||||
<div
|
t('security.manualKey')
|
||||||
v-for="i in 5"
|
}}</Label>
|
||||||
:key="`col-${i}`"
|
<div class="flex min-w-0 gap-2">
|
||||||
class="border-border border-r last:border-r-0"
|
<Input
|
||||||
|
id="setup-key"
|
||||||
|
:model-value="manualSetupKey"
|
||||||
|
readonly
|
||||||
|
class="min-w-0 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
copied
|
||||||
|
? 'security.copied'
|
||||||
|
: 'security.copyKey',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="copyKey"
|
||||||
|
><Check v-if="copied" /><Copy v-else
|
||||||
|
/></Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<p
|
||||||
class="absolute inset-0 grid grid-rows-5 opacity-50"
|
v-if="copied"
|
||||||
|
role="status"
|
||||||
|
class="text-caption text-success-foreground"
|
||||||
>
|
>
|
||||||
<div
|
{{ t('security.copied') }}
|
||||||
v-for="i in 5"
|
</p>
|
||||||
:key="`row-${i}`"
|
<InputError
|
||||||
class="border-border border-b last:border-b-0"
|
v-if="copyError"
|
||||||
/>
|
:message="t('security.copyError')"
|
||||||
</div>
|
|
||||||
<ScanLine
|
|
||||||
class="text-foreground relative z-20 size-6"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
|
<p
|
||||||
<DialogDescription class="text-center">
|
v-if="requiresConfirmation && !twoFactorEnabled"
|
||||||
{{ modalConfig.description }}
|
class="text-caption text-muted-foreground"
|
||||||
</DialogDescription>
|
>
|
||||||
</DialogHeader>
|
{{ t('security.setupPendingDescription') }}
|
||||||
|
</p>
|
||||||
<div
|
<DialogFooter>
|
||||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
<Button variant="outline" @click="close(false)">{{
|
||||||
>
|
t('common.close')
|
||||||
<template v-if="!showVerificationStep">
|
}}</Button>
|
||||||
<AlertError v-if="errors?.length" :errors="errors" />
|
<Button v-if="hasSetupData" @click="next">{{
|
||||||
<template v-else>
|
t(
|
||||||
<div
|
twoFactorEnabled || !requiresConfirmation
|
||||||
class="relative mx-auto flex max-w-md items-center overflow-hidden"
|
? 'common.done'
|
||||||
>
|
: 'security.continue',
|
||||||
<div
|
)
|
||||||
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
|
}}</Button>
|
||||||
>
|
</DialogFooter>
|
||||||
<div
|
</template>
|
||||||
v-if="!qrCodeSvg"
|
<form v-else class="gap-column grid" @submit.prevent="submit">
|
||||||
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
|
<AuthFormFeedback
|
||||||
>
|
:state="protection.state.value"
|
||||||
<Spinner class="size-6" />
|
:response-status="protection.responseStatus.value"
|
||||||
</div>
|
@retry="submit"
|
||||||
<div
|
/>
|
||||||
v-else
|
<div class="grid gap-2">
|
||||||
class="relative z-10 overflow-hidden border p-5"
|
<Label for="authentication-code">{{
|
||||||
>
|
t('security.authenticationCode')
|
||||||
<div
|
}}</Label>
|
||||||
v-html="qrCodeSvg"
|
<Input
|
||||||
class="flex aspect-square size-full items-center justify-center"
|
id="authentication-code"
|
||||||
:style="{
|
ref="codeInput"
|
||||||
filter:
|
v-model="form.code"
|
||||||
resolvedAppearance === 'dark'
|
name="code"
|
||||||
? 'invert(1) brightness(1.5)'
|
type="text"
|
||||||
: undefined,
|
inputmode="numeric"
|
||||||
}"
|
autocomplete="one-time-code"
|
||||||
/>
|
pattern="[0-9]{6}"
|
||||||
</div>
|
maxlength="6"
|
||||||
</div>
|
required
|
||||||
</div>
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="!!form.errors.code"
|
||||||
<div class="flex w-full items-center space-x-5">
|
aria-describedby="authentication-code-error"
|
||||||
<Button class="w-full" @click="handleModalNextStep">
|
class="font-mono text-lg tracking-widest"
|
||||||
{{ modalConfig.buttonText }}
|
/>
|
||||||
</Button>
|
<InputError
|
||||||
</div>
|
id="authentication-code-error"
|
||||||
|
:message="form.errors.code"
|
||||||
<div
|
/>
|
||||||
class="relative flex w-full items-center justify-center"
|
</div>
|
||||||
>
|
<DialogFooter>
|
||||||
<div
|
<Button
|
||||||
class="bg-border absolute inset-0 top-1/2 h-px w-full"
|
type="button"
|
||||||
/>
|
variant="outline"
|
||||||
<span class="bg-card relative px-2 py-1"
|
:disabled="form.processing"
|
||||||
>or, enter the code manually</span
|
@click="
|
||||||
>
|
verification = false;
|
||||||
</div>
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
<div
|
"
|
||||||
class="flex w-full items-center justify-center space-x-2"
|
>{{ t('common.back') }}</Button
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!manualSetupKey"
|
|
||||||
class="bg-muted flex h-full w-full items-center justify-center p-3"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readonly
|
|
||||||
:value="manualSetupKey"
|
|
||||||
class="bg-background text-foreground h-full w-full p-3"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="copy(manualSetupKey || '')"
|
|
||||||
class="border-border hover:bg-muted relative block h-auto border-l px-3"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
v-if="copied"
|
|
||||||
class="w-4 text-green-500"
|
|
||||||
/>
|
|
||||||
<Copy v-else class="w-4" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<Form
|
|
||||||
v-bind="confirm.form()"
|
|
||||||
error-bag="confirmTwoFactorAuthentication"
|
|
||||||
reset-on-error
|
|
||||||
@finish="code = ''"
|
|
||||||
@success="isOpen = false"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="code" :value="code" />
|
<Button
|
||||||
<div
|
type="submit"
|
||||||
ref="pinInputContainerRef"
|
:disabled="form.code.length !== 6"
|
||||||
class="relative w-full space-y-3"
|
:loading="form.processing"
|
||||||
>
|
>{{ t('security.confirm') }}</Button
|
||||||
<div
|
>
|
||||||
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
|
</DialogFooter>
|
||||||
>
|
</form>
|
||||||
<InputOTP
|
|
||||||
id="otp"
|
|
||||||
v-model="code"
|
|
||||||
:maxlength="6"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot
|
|
||||||
v-for="index in 6"
|
|
||||||
:key="index"
|
|
||||||
:index="index - 1"
|
|
||||||
/>
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
<InputError :message="errors?.code" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full items-center space-x-5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
class="w-auto flex-1"
|
|
||||||
@click="showVerificationStep = false"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
class="w-auto flex-1"
|
|
||||||
:disabled="processing || code.length < 6"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroyPriority,
|
||||||
|
destroyType,
|
||||||
|
storePriority,
|
||||||
|
storeType,
|
||||||
|
updatePriority,
|
||||||
|
updateType,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
|
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
|
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
target: MetadataEditorTarget | null;
|
||||||
|
types: TicketTypeModel[];
|
||||||
|
priorities: TicketPriorityModel[];
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
position: 1,
|
||||||
|
is_parent: false,
|
||||||
|
replacement_ticket_type_id: '',
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deletionError = ref<string | null>(null);
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const entries = computed(() =>
|
||||||
|
props.target?.kind === 'type' ? props.types : props.priorities,
|
||||||
|
);
|
||||||
|
const replacements = computed(() =>
|
||||||
|
props.types.filter((type) => type.id !== props.target?.entry?.id),
|
||||||
|
);
|
||||||
|
const needsReplacement = computed(
|
||||||
|
() =>
|
||||||
|
deleting.value &&
|
||||||
|
props.target?.kind === 'type' &&
|
||||||
|
(props.target.entry?.tickets_count ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const title = computed(() => {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (target.action === 'create') {
|
||||||
|
return t(
|
||||||
|
target.kind === 'type'
|
||||||
|
? 'metadata.addType'
|
||||||
|
: 'metadata.addPriority',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
target.action === 'edit'
|
||||||
|
? 'metadata.editEntry'
|
||||||
|
: 'metadata.deleteEntry',
|
||||||
|
{ name: target.entry.name },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.defaults({
|
||||||
|
name: target.entry?.name ?? '',
|
||||||
|
position: target.entry?.position ?? 1,
|
||||||
|
is_parent: target.entry?.is_parent ?? false,
|
||||||
|
replacement_ticket_type_id: '',
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
deletionError.value = null;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
form.processing ||
|
||||||
|
(deleting.value && entries.value.length <= 1)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
target.kind === 'type'
|
||||||
|
? target.action === 'create'
|
||||||
|
? storeType(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updateType([props.projectId, target.entry.id])
|
||||||
|
: destroyType([props.projectId, target.entry.id])
|
||||||
|
: target.action === 'create'
|
||||||
|
? storePriority(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updatePriority([props.projectId, target.entry.id])
|
||||||
|
: destroyPriority([props.projectId, target.entry.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
target.action === 'delete'
|
||||||
|
? target.kind === 'type'
|
||||||
|
? {
|
||||||
|
replacement_ticket_type_id:
|
||||||
|
data.replacement_ticket_type_id || null,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
: target.action === 'edit'
|
||||||
|
? { name: data.name, position: data.position }
|
||||||
|
: target.kind === 'type'
|
||||||
|
? { name: data.name, is_parent: data.is_parent }
|
||||||
|
: { name: data.name },
|
||||||
|
);
|
||||||
|
deletionError.value = null;
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: (page) => {
|
||||||
|
if (page.props.error) {
|
||||||
|
deletionError.value =
|
||||||
|
typeof page.props.status === 'string'
|
||||||
|
? page.props.status
|
||||||
|
: t('common.error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t(
|
||||||
|
deleting
|
||||||
|
? target?.kind === 'type'
|
||||||
|
? 'metadata.deleteTypeHelp'
|
||||||
|
: 'metadata.deletePriorityHelp'
|
||||||
|
: 'metadata.editHelp',
|
||||||
|
)
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="deletionError ? 'idle' : protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="deletionError"
|
||||||
|
role="alert"
|
||||||
|
class="text-destructive text-sm"
|
||||||
|
>
|
||||||
|
{{ deletionError }}
|
||||||
|
</p>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<template v-if="!deleting">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="metadata-name">{{
|
||||||
|
t('metadata.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="metadata-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="metadata-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="metadata-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="target?.action === 'edit'"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label for="metadata-position">{{
|
||||||
|
t('workflow.position')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="metadata-position"
|
||||||
|
v-model="form.position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="entries.length"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!form.errors.position"
|
||||||
|
aria-describedby="metadata-position-help metadata-position-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="metadata-position-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('metadata.positionHelp', {
|
||||||
|
count: entries.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="metadata-position-error"
|
||||||
|
:message="form.errors.position"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="target?.kind === 'type'" class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
v-if="target.action === 'create'"
|
||||||
|
for="metadata-parent"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="metadata-parent"
|
||||||
|
v-model="form.is_parent"
|
||||||
|
name="is_parent"
|
||||||
|
:invalid="!!form.errors.is_parent"
|
||||||
|
aria-describedby="metadata-parent-help metadata-parent-error"
|
||||||
|
/>{{ t('metadata.allowChildren') }}
|
||||||
|
</Label>
|
||||||
|
<p v-else class="text-sm font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
form.is_parent
|
||||||
|
? 'metadata.parentType'
|
||||||
|
: 'metadata.standardType',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="metadata-parent-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.parentFixed') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="metadata-parent-error"
|
||||||
|
:message="form.errors.is_parent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="needsReplacement" class="grid gap-2">
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
t('metadata.replacementHelp', {
|
||||||
|
count: target?.entry?.tickets_count ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Label for="metadata-replacement">{{
|
||||||
|
t('metadata.replacement')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="metadata-replacement"
|
||||||
|
v-model="form.replacement_ticket_type_id"
|
||||||
|
name="replacement_ticket_type_id"
|
||||||
|
required
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.replacement_ticket_type_id
|
||||||
|
"
|
||||||
|
aria-describedby="metadata-replacement-help metadata-replacement-error"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('metadata.chooseType') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="entry in replacements"
|
||||||
|
:key="entry.id"
|
||||||
|
:value="entry.id"
|
||||||
|
>
|
||||||
|
{{ entry.name
|
||||||
|
}}{{
|
||||||
|
entry.is_parent
|
||||||
|
? ` (${t('metadata.parentType')})`
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p
|
||||||
|
id="metadata-replacement-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.parentReplacement') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="metadata-replacement-error"
|
||||||
|
:message="form.errors.replacement_ticket_type_id"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="deleting && entries.length <= 1"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('workflow.confirmDelete')
|
||||||
|
: target?.action === 'create'
|
||||||
|
? title
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type {
|
||||||
|
MetadataEditorTarget,
|
||||||
|
MetadataEntry,
|
||||||
|
MetadataKind,
|
||||||
|
} from '@/types/project-metadata';
|
||||||
|
|
||||||
|
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
|
||||||
|
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||||
|
:aria-labelledby="`metadata-${kind}`"
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 :id="`metadata-${kind}`" class="font-semibold">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.types'
|
||||||
|
: 'metadata.priorities',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{{ entries.length }}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
@click="$emit('edit', { kind, action: 'create' })"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.addType'
|
||||||
|
: 'metadata.addPriority',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.typesDescription'
|
||||||
|
: 'metadata.prioritiesDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<ol
|
||||||
|
class="divide-y"
|
||||||
|
:aria-label="
|
||||||
|
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
|
||||||
|
:data-metadata-entry="`${kind}-${entry.id}`"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.positionValue', {
|
||||||
|
position: entry.position,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>{{ entry.position }}</span
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 basis-28 gap-1">
|
||||||
|
<p class="break-words font-medium">{{ entry.name }}</p>
|
||||||
|
<div
|
||||||
|
v-if="kind === 'type'"
|
||||||
|
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
t(
|
||||||
|
entry.is_parent
|
||||||
|
? 'metadata.parentType'
|
||||||
|
: 'metadata.standardType',
|
||||||
|
)
|
||||||
|
}}</Badge>
|
||||||
|
<span>{{
|
||||||
|
t('metadata.assignedTickets', {
|
||||||
|
count: entry.tickets_count ?? 0,
|
||||||
|
})
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.editEntry', { name: entry.name })
|
||||||
|
"
|
||||||
|
@click="$emit('edit', { kind, action: 'edit', entry })"
|
||||||
|
><Pencil class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive"
|
||||||
|
:disabled="entries.length <= 1"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.deleteEntry', { name: entry.name })
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
entries.length <= 1
|
||||||
|
? `metadata-${kind}-protection`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
$emit('edit', { kind, action: 'delete', entry })
|
||||||
|
"
|
||||||
|
><Trash2 class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p
|
||||||
|
v-if="entries.length <= 1"
|
||||||
|
:id="`metadata-${kind}-protection`"
|
||||||
|
class="text-caption text-muted-foreground flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.lastType'
|
||||||
|
: 'metadata.lastPriority',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { Layers3, RotateCcw } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
areas: string[];
|
areas: string[];
|
||||||
projectId: number;
|
projectId: number;
|
||||||
templateState?: ProjectTemplateState | null;
|
templateState?: ProjectTemplateState | null;
|
||||||
}>();
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
areas: string[];
|
areas: string[];
|
||||||
status_replacements: Record<string, number | undefined>;
|
status_replacements: Record<string, number | undefined>;
|
||||||
@@ -28,51 +34,82 @@ const form = useForm<{
|
|||||||
areas: [],
|
areas: [],
|
||||||
status_replacements: {},
|
status_replacements: {},
|
||||||
});
|
});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
const activeArea = computed(() => form.areas[0]);
|
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
|
||||||
|
|
||||||
const inheritedAreas = computed(
|
const inheritedAreas = computed(
|
||||||
() =>
|
() =>
|
||||||
props.templateState?.inheritedAreas.filter((area) =>
|
props.templateState?.inheritedAreas.filter((area) =>
|
||||||
props.areas.includes(area.value),
|
props.areas.includes(area.value),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overriddenAreas = computed(
|
const overriddenAreas = computed(
|
||||||
() =>
|
() =>
|
||||||
props.templateState?.overriddenAreas.filter((area) =>
|
props.templateState?.overriddenAreas.filter((area) =>
|
||||||
props.areas.includes(area.value),
|
props.areas.includes(area.value),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
const statusRequirement = computed(() =>
|
||||||
const statusRequirement = computed(
|
pendingArea.value?.value === 'ticket_statuses'
|
||||||
() => props.templateState?.resetRequirements?.ticket_statuses,
|
? props.templateState?.resetRequirements?.ticket_statuses
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
const canReset = computed(
|
||||||
const canReset = (area: ProjectTemplateStateArea): boolean => {
|
() =>
|
||||||
if (area.value !== 'ticket_statuses') {
|
statusRequirement.value?.removedStatuses.every((status) =>
|
||||||
return true;
|
statusRequirement.value?.replacementOptions.some(
|
||||||
}
|
(option) =>
|
||||||
|
option.id === form.status_replacements[String(status.id)],
|
||||||
return (
|
),
|
||||||
statusRequirement.value?.removedStatuses.every(
|
) ?? true,
|
||||||
(status) =>
|
);
|
||||||
form.status_replacements[String(status.id)] !== undefined,
|
const areaKeys = {
|
||||||
) ?? true
|
project_settings: 'inheritance.area.projectSettings',
|
||||||
);
|
board_columns: 'inheritance.area.boardColumns',
|
||||||
};
|
ticket_statuses: 'inheritance.area.ticketStatuses',
|
||||||
|
status_transitions: 'inheritance.area.statusTransitions',
|
||||||
const resetArea = (area: ProjectTemplateStateArea): void => {
|
ticket_types: 'inheritance.area.ticketTypes',
|
||||||
if (!canReset(area)) {
|
ticket_priorities: 'inheritance.area.ticketPriorities',
|
||||||
|
ticket_fields: 'inheritance.area.ticketFields',
|
||||||
|
ticket_link_types: 'inheritance.area.ticketLinkTypes',
|
||||||
|
repository_connections: 'inheritance.area.repositoryConnections',
|
||||||
|
notification_actions: 'inheritance.area.notificationActions',
|
||||||
|
} as const;
|
||||||
|
function areaLabel(area: ProjectTemplateStateArea): string {
|
||||||
|
const key = areaKeys[area.value as keyof typeof areaKeys];
|
||||||
|
return key ? t(key) : area.label;
|
||||||
|
}
|
||||||
|
function openReset(area: ProjectTemplateStateArea): void {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
pendingArea.value = area;
|
||||||
|
}
|
||||||
|
function closeReset(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
pendingArea.value = null;
|
||||||
form.areas = [area.value];
|
form.resetAndClearErrors();
|
||||||
form.post(ProjectController.resetTemplate.url(props.projectId), {
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
function resetArea(): void {
|
||||||
|
if (
|
||||||
|
!pendingArea.value ||
|
||||||
|
!canReset.value ||
|
||||||
|
!overriddenAreas.value.some(
|
||||||
|
(area) => area.value === pendingArea.value?.value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.areas = [pendingArea.value.value];
|
||||||
|
protection.submit(resetTemplate(props.projectId), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => form.reset(),
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
pendingArea.value = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
|||||||
templateState?.inheritsFromTemplate &&
|
templateState?.inheritsFromTemplate &&
|
||||||
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
||||||
"
|
"
|
||||||
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
<CardTitle>
|
<div class="flex items-center gap-2">
|
||||||
Template inheritance: {{ templateState.templateName }}
|
<Layers3
|
||||||
</CardTitle>
|
class="text-primary size-5 shrink-0"
|
||||||
<CardDescription>
|
aria-hidden="true"
|
||||||
Every setting area inherits independently. Local changes stay
|
/>
|
||||||
unchanged when the template is updated until that area is reset.
|
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
|
||||||
</CardDescription>
|
</div>
|
||||||
|
<p class="text-muted-foreground break-words">
|
||||||
|
{{
|
||||||
|
t('inheritance.description', {
|
||||||
|
template: templateState.templateName,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<div class="flex flex-wrap gap-2">
|
<p class="text-caption text-muted-foreground">
|
||||||
<Badge
|
{{ t('inheritance.help') }}
|
||||||
v-for="area in inheritedAreas"
|
</p>
|
||||||
:key="`inherited-${area.value}`"
|
<ul
|
||||||
variant="outline"
|
class="grid min-w-0 divide-y"
|
||||||
>
|
:aria-label="t('inheritance.areas')"
|
||||||
{{ area.label }} · inherited
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="area in overriddenAreas"
|
|
||||||
:key="`overridden-${area.value}`"
|
|
||||||
class="space-y-3 rounded-lg border p-4"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<li
|
||||||
<Badge>{{ area.label }} · template overridden</Badge>
|
v-for="area in inheritedAreas"
|
||||||
<Button
|
:key="area.value"
|
||||||
type="button"
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="form.processing || !canReset(area)"
|
|
||||||
@click="resetArea(area)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
activeArea === area.value && form.processing
|
|
||||||
? 'Resetting…'
|
|
||||||
: 'Reset to template'
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="area.value === 'ticket_statuses' && statusRequirement"
|
|
||||||
class="space-y-3"
|
|
||||||
>
|
>
|
||||||
<p class="text-muted-foreground text-sm">
|
<span class="min-w-0 break-words font-medium">{{
|
||||||
Choose where tickets in local statuses should move
|
areaLabel(area)
|
||||||
before those statuses are removed.
|
}}</span>
|
||||||
</p>
|
<Badge
|
||||||
<div
|
variant="outline"
|
||||||
v-for="status in statusRequirement.removedStatuses"
|
class="border-primary/25 text-primary"
|
||||||
:key="status.id"
|
>{{ t('projectSettings.inherited') }}</Badge
|
||||||
class="grid gap-2 sm:grid-cols-2 sm:items-center"
|
|
||||||
>
|
>
|
||||||
<Label :for="`replacement-${status.id}`">
|
</li>
|
||||||
{{ status.name }} ({{ status.ticketCount }} tickets)
|
<li
|
||||||
</Label>
|
v-for="area in overriddenAreas"
|
||||||
<div class="grid gap-1">
|
:key="area.value"
|
||||||
<select
|
class="gap-stack grid min-w-0 py-3"
|
||||||
:id="`replacement-${status.id}`"
|
>
|
||||||
v-model="
|
<div
|
||||||
form.status_replacements[String(status.id)]
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
|
||||||
"
|
>
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
|
<span class="min-w-0 break-words font-medium">{{
|
||||||
>
|
areaLabel(area)
|
||||||
<option :value="undefined" disabled>
|
}}</span>
|
||||||
Select replacement status
|
<Badge variant="secondary">{{
|
||||||
</option>
|
t('projectSettings.override')
|
||||||
<option
|
}}</Badge>
|
||||||
v-for="option in statusRequirement.replacementOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:value="option.id"
|
|
||||||
>
|
|
||||||
{{ option.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
form.errors[
|
|
||||||
`status_replacements.${status.id}`
|
|
||||||
]
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
|
||||||
<InputError :message="form.errors.areas" />
|
>
|
||||||
</div>
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.overrideHelp') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-label="
|
||||||
|
t('inheritance.resetArea', {
|
||||||
|
area: areaLabel(area),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openReset(area)"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />{{
|
||||||
|
t('inheritance.reset')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<Dialog :open="pendingArea !== null" @update:open="closeReset">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('inheritance.confirmTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('inheritance.confirmDescription', {
|
||||||
|
area: pendingArea ? areaLabel(pendingArea) : '',
|
||||||
|
template: templateState.templateName,
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="resetArea"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
form.processing
|
||||||
|
? t('inheritance.resetting')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="resetArea"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.areas ?? form.errors['areas.0']"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('inheritance.confirmTitle') }}
|
||||||
|
</legend>
|
||||||
|
<div
|
||||||
|
v-if="statusRequirement"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.statusHelp') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="status in statusRequirement.removedStatuses"
|
||||||
|
:key="status.id"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label :for="'replacement-' + status.id">{{
|
||||||
|
t('inheritance.statusLabel', {
|
||||||
|
status: status.name,
|
||||||
|
count: status.ticketCount,
|
||||||
|
})
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
:id="'replacement-' + status.id"
|
||||||
|
v-model="
|
||||||
|
form.status_replacements[
|
||||||
|
String(status.id)
|
||||||
|
]
|
||||||
|
"
|
||||||
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors[
|
||||||
|
`status_replacements.${status.id}`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
'replacement-error-' + status.id
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option :value="undefined" disabled>
|
||||||
|
{{ t('inheritance.selectStatus') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="option in statusRequirement.replacementOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:value="option.id"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:id="'replacement-error-' + status.id"
|
||||||
|
:message="
|
||||||
|
form.errors[
|
||||||
|
`status_replacements.${status.id}`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('inheritance.resetWarning') }}
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="closeReset(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" :disabled="!canReset">{{
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'inheritance.resetting'
|
||||||
|
: 'inheritance.reset',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
|
|
||||||
|
const props = defineProps<{ project: ProjectModel }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
show_author: props.project.show_author,
|
||||||
|
show_story_points: props.project.show_story_points,
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
watch(
|
||||||
|
() => [props.project.show_author, props.project.show_story_points],
|
||||||
|
() => {
|
||||||
|
form.defaults({
|
||||||
|
show_author: props.project.show_author,
|
||||||
|
show_story_points: props.project.show_story_points,
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
function submit(): void {
|
||||||
|
protection.submit(update(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => form.defaults(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('metadata.displayDescription') }}
|
||||||
|
</p>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<fieldset :disabled="form.processing" class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="option in ['show_author', 'show_story_points'] as const"
|
||||||
|
:key="option"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
:for="option"
|
||||||
|
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:id="option"
|
||||||
|
v-model="form[option]"
|
||||||
|
:name="option"
|
||||||
|
:invalid="!!form.errors[option]"
|
||||||
|
:aria-describedby="`${option}-help ${option}-error`"
|
||||||
|
/>
|
||||||
|
<span class="grid gap-1">
|
||||||
|
<span>{{
|
||||||
|
t(
|
||||||
|
option === 'show_author'
|
||||||
|
? 'metadata.author'
|
||||||
|
: 'metadata.storyPoints',
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
:id="`${option}-help`"
|
||||||
|
class="text-muted-foreground text-sm font-normal"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
option === 'show_author'
|
||||||
|
? 'metadata.authorHelp'
|
||||||
|
: 'metadata.storyPointsHelp',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<InputError
|
||||||
|
:id="`${option}-error`"
|
||||||
|
:message="form.errors[option]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="w-full sm:w-fit"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{ t('common.save') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
destroyStatus,
|
||||||
|
store,
|
||||||
|
storeStatus,
|
||||||
|
update,
|
||||||
|
updateStatus,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
columnCount: number;
|
||||||
|
target: WorkflowEditorTarget | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({ name: '', position: 1 });
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.target?.kind === 'column') {
|
||||||
|
return t(
|
||||||
|
props.target.action === 'create'
|
||||||
|
? 'workflow.addColumn'
|
||||||
|
: props.target.action === 'edit'
|
||||||
|
? 'workflow.editColumn'
|
||||||
|
: 'workflow.deleteColumn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
props.target?.action === 'create'
|
||||||
|
? 'workflow.addStatus'
|
||||||
|
: props.target?.action === 'edit'
|
||||||
|
? 'workflow.editStatus'
|
||||||
|
: 'workflow.deleteStatus',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const description = computed(() => {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (target.kind === 'column') {
|
||||||
|
return t(
|
||||||
|
target.action === 'delete'
|
||||||
|
? 'workflow.deleteColumnDescription'
|
||||||
|
: target.action === 'create'
|
||||||
|
? 'workflow.addColumnDescription'
|
||||||
|
: 'workflow.editColumnDescription',
|
||||||
|
{ name: target.column?.name ?? '' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
target.action === 'delete'
|
||||||
|
? 'workflow.deleteStatusDescription'
|
||||||
|
: 'workflow.statusContext',
|
||||||
|
{
|
||||||
|
name: target.status?.name ?? '',
|
||||||
|
context: target.column?.name ?? t('workflow.backlog'),
|
||||||
|
replacement: target.replacement?.name ?? '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const otherErrors = computed(() =>
|
||||||
|
Object.entries(form.errors)
|
||||||
|
.filter(([key]) => !['name', 'position'].includes(key))
|
||||||
|
.map(([, message]) => message),
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.defaults({
|
||||||
|
name:
|
||||||
|
target.kind === 'column'
|
||||||
|
? (target.column?.name ?? '')
|
||||||
|
: (target.status?.name ?? ''),
|
||||||
|
position: target.column?.position ?? 1,
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
form.processing ||
|
||||||
|
(target.kind === 'status' &&
|
||||||
|
target.action === 'delete' &&
|
||||||
|
!target.replacement)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
target.kind === 'column'
|
||||||
|
? target.action === 'create'
|
||||||
|
? store(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? update([props.projectId, target.column.id])
|
||||||
|
: destroy([props.projectId, target.column.id])
|
||||||
|
: target.action === 'create'
|
||||||
|
? storeStatus(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updateStatus([props.projectId, target.status.id])
|
||||||
|
: destroyStatus([props.projectId, target.status.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
target.action === 'delete'
|
||||||
|
? {}
|
||||||
|
: target.kind === 'column' && target.action === 'edit'
|
||||||
|
? data
|
||||||
|
: target.kind === 'status' && target.action === 'create'
|
||||||
|
? {
|
||||||
|
name: data.name,
|
||||||
|
board_column_id: target.column?.id ?? null,
|
||||||
|
}
|
||||||
|
: { name: data.name },
|
||||||
|
);
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription class="break-words">{{
|
||||||
|
description
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<InputError
|
||||||
|
v-for="message in otherErrors"
|
||||||
|
:key="message"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
v-if="!deleting"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="workflow-name">{{
|
||||||
|
t(
|
||||||
|
target?.kind === 'column'
|
||||||
|
? 'workflow.columnName'
|
||||||
|
: 'workflow.statusName',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="workflow-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
:aria-describedby="
|
||||||
|
form.errors.name
|
||||||
|
? 'workflow-name-error'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="workflow-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
target?.kind === 'column' &&
|
||||||
|
target.action === 'edit'
|
||||||
|
"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label for="workflow-position">{{
|
||||||
|
t('workflow.position')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="workflow-position"
|
||||||
|
v-model="form.position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="columnCount"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!form.errors.position"
|
||||||
|
aria-describedby="workflow-position-help workflow-position-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="workflow-position-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('workflow.positionHelp', {
|
||||||
|
count: columnCount,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="workflow-position-error"
|
||||||
|
:message="form.errors.position"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('workflow.confirmDelete')
|
||||||
|
: target?.action === 'create'
|
||||||
|
? title
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
column: BoardColumnModel | null;
|
||||||
|
statuses: TicketStatusModel[];
|
||||||
|
}>();
|
||||||
|
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-3">
|
||||||
|
<ul
|
||||||
|
class="divide-y"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.statusesIn', {
|
||||||
|
context: column?.name ?? t('workflow.backlog'),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
|
||||||
|
>{{ status.name }}</span
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.renameStatus', { name: status.name })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
$emit('edit', {
|
||||||
|
kind: 'status',
|
||||||
|
action: 'edit',
|
||||||
|
column,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
><Pencil class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive"
|
||||||
|
:disabled="statuses.length <= 1"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.removeStatus', { name: status.name })
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
statuses.length <= 1
|
||||||
|
? `status-protection-${column?.id ?? 'backlog'}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
$emit('edit', {
|
||||||
|
kind: 'status',
|
||||||
|
action: 'delete',
|
||||||
|
column,
|
||||||
|
status,
|
||||||
|
replacement: statuses.find(
|
||||||
|
(item) => item.id !== status.id,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
><Trash2 class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-if="statuses.length <= 1"
|
||||||
|
:id="`status-protection-${column?.id ?? 'backlog'}`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('workflow.lastStatus') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-fit"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.addStatusTo', {
|
||||||
|
context: column?.name ?? t('workflow.backlog'),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="$emit('edit', { kind: 'status', action: 'create', column })"
|
||||||
|
><Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('workflow.addStatus')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowRightLeft } from 'lucide-vue-next';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
statuses: TicketStatusModel[];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sourceId = ref(props.statuses[0]?.id ?? null);
|
||||||
|
const source = computed(() =>
|
||||||
|
props.statuses.find((status) => status.id === sourceId.value),
|
||||||
|
);
|
||||||
|
const targets = computed(() =>
|
||||||
|
props.statuses.filter((status) => status.id !== sourceId.value),
|
||||||
|
);
|
||||||
|
const savedTargets = computed(
|
||||||
|
() =>
|
||||||
|
source.value?.allowed_target_statuses
|
||||||
|
?.map((status) => status.id)
|
||||||
|
.sort((a, b) => a - b) ?? [],
|
||||||
|
);
|
||||||
|
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const saved = ref(false);
|
||||||
|
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
saved.value = false;
|
||||||
|
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.statuses,
|
||||||
|
() => {
|
||||||
|
if (!source.value) {
|
||||||
|
sourceId.value = props.statuses[0]?.id ?? null;
|
||||||
|
reset();
|
||||||
|
} else if (!form.isDirty && !form.processing) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function selectSource(event: Event): void {
|
||||||
|
const select = event.target as HTMLSelectElement;
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
|
select.value = String(sourceId.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceId.value = Number(select.value);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTarget(id: number, event: Event): void {
|
||||||
|
saved.value = false;
|
||||||
|
const checked = (event.target as HTMLInputElement).checked;
|
||||||
|
form.allowed_ticket_status_ids = checked
|
||||||
|
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
|
||||||
|
: form.allowed_ticket_status_ids.filter((target) => target !== id);
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (!source.value || !form.isDirty || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
updateStatusTransitions([props.projectId, source.value.id]),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
saved.value = true;
|
||||||
|
form.defaults({
|
||||||
|
allowed_ticket_status_ids: [...savedTargets.value],
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||||
|
aria-labelledby="workflow-transitions"
|
||||||
|
>
|
||||||
|
<header class="grid gap-3 border-b p-5 sm:p-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ArrowRightLeft
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h3 id="workflow-transitions" class="font-semibold">
|
||||||
|
{{ t('workflow.transitions') }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('workflow.transitionsDescription') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
||||||
|
<p v-if="!source" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('workflow.noStatuses') }}
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="workflow-source">{{
|
||||||
|
t('workflow.sourceStatus')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="workflow-source"
|
||||||
|
:value="sourceId"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||||
|
@change="selectSource"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
:value="status.id"
|
||||||
|
>
|
||||||
|
{{ ticketStatusLabel(status, '') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('workflow.directionHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-3"
|
||||||
|
aria-describedby="workflow-target-help workflow-transition-errors"
|
||||||
|
:aria-invalid="hasErrors"
|
||||||
|
>
|
||||||
|
<legend class="mb-3 break-words font-medium">
|
||||||
|
{{
|
||||||
|
t('workflow.targetsFrom', { name: source.name })
|
||||||
|
}}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
id="workflow-target-help"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('workflow.targetHelp') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="targets.length === 0"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('workflow.noTargets') }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="target in targets"
|
||||||
|
:key="target.id"
|
||||||
|
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
|
||||||
|
:class="
|
||||||
|
form.allowed_ticket_status_ids.includes(
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
? 'border-primary/40 bg-accent/40'
|
||||||
|
: 'border-input'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="allowed_ticket_status_ids[]"
|
||||||
|
:value="target.id"
|
||||||
|
:checked="
|
||||||
|
form.allowed_ticket_status_ids.includes(
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="accent-primary size-4 shrink-0"
|
||||||
|
@change="toggleTarget(target.id, $event)"
|
||||||
|
/>
|
||||||
|
<span class="grid min-w-0 gap-1">
|
||||||
|
<span
|
||||||
|
class="break-words text-sm font-medium"
|
||||||
|
>{{
|
||||||
|
ticketStatusLabel(target, '')
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
form.allowed_ticket_status_ids.includes(
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
? 'workflow.allowed'
|
||||||
|
: 'workflow.notAllowed',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
targets.length > 0 &&
|
||||||
|
form.allowed_ticket_status_ids.length === 0
|
||||||
|
"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('workflow.noMovesHelp') }}
|
||||||
|
</p>
|
||||||
|
<div id="workflow-transition-errors" role="alert">
|
||||||
|
<InputError
|
||||||
|
v-for="(message, key) in form.errors"
|
||||||
|
:key="key"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
:state="
|
||||||
|
!saved && protection.state.value === 'success'
|
||||||
|
? 'idle'
|
||||||
|
: protection.state.value
|
||||||
|
"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
:variant="form.isDirty ? 'warning' : 'secondary'"
|
||||||
|
role="status"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
form.isDirty
|
||||||
|
? 'workflow.unsavedMoves'
|
||||||
|
: 'workflow.savedMoves',
|
||||||
|
{ count: savedTargets.length },
|
||||||
|
)
|
||||||
|
}}</Badge
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!form.isDirty || form.processing"
|
||||||
|
@click="reset"
|
||||||
|
>{{ t('workflow.resetMoves') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!form.isDirty"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{ t('workflow.saveMoves') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
IndentDecrease,
|
||||||
|
IndentIncrease,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, ref, useId, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
canIndentAcceptanceCriterion,
|
||||||
|
indentAcceptanceCriterion,
|
||||||
|
removeAcceptanceCriterion,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: AcceptanceCriteriaModel;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
persistToggles?: boolean;
|
||||||
|
allowEditing?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
dirty?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
persistToggles: false,
|
||||||
|
allowEditing: true,
|
||||||
|
saving: undefined,
|
||||||
|
dirty: false,
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [items: AcceptanceCriteriaModel];
|
||||||
|
toggle: [index: number, checked: boolean];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const id = useId();
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editing = ref(!props.persistToggles);
|
||||||
|
const completed = computed(
|
||||||
|
() => props.modelValue.filter((item) => item.checked).length,
|
||||||
|
);
|
||||||
|
const locked = computed(() => props.readonly || props.disabled);
|
||||||
|
const update = (items: AcceptanceCriteriaModel): void => {
|
||||||
|
if (!locked.value) {
|
||||||
|
emit('update:modelValue', items);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusRow = async (index: number): Promise<void> => {
|
||||||
|
await nextTick();
|
||||||
|
const inputs =
|
||||||
|
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
|
||||||
|
if (inputs?.[index]) {
|
||||||
|
inputs[index].focus();
|
||||||
|
} else {
|
||||||
|
root.value
|
||||||
|
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const add = (): void => {
|
||||||
|
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
|
||||||
|
void focusRow(props.modelValue.length);
|
||||||
|
};
|
||||||
|
const remove = (index: number): void => {
|
||||||
|
update(removeAcceptanceCriterion(props.modelValue, index));
|
||||||
|
void focusRow(Math.max(0, index - 1));
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
root.value
|
||||||
|
?.querySelectorAll<HTMLTextAreaElement>('textarea')
|
||||||
|
.forEach((input) => {
|
||||||
|
input.setCustomValidity(
|
||||||
|
input.value.trim() === '' ? t('criteria.required') : '',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const changeText = (index: number, event: Event): void => {
|
||||||
|
const text = (event.target as HTMLTextAreaElement).value;
|
||||||
|
update(
|
||||||
|
props.modelValue.map((item, position) =>
|
||||||
|
position === index ? { ...item, text } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const toggle = (index: number, event: Event): void => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const checked = target.checked;
|
||||||
|
target.checked = props.modelValue[index].checked;
|
||||||
|
if (locked.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.persistToggles && !editing.value && !props.dirty) {
|
||||||
|
emit('toggle', index, checked);
|
||||||
|
} else {
|
||||||
|
update(
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
checked,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeydown = (index: number, event: KeyboardEvent): void => {
|
||||||
|
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
event.key === 'ArrowRight' ? 1 : -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="root"
|
||||||
|
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:aria-labelledby="`${id}-title`"
|
||||||
|
:aria-busy="disabled"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3
|
||||||
|
:id="`${id}-title`"
|
||||||
|
class="flex items-center gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<ClipboardCheck
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('criteria.title') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-muted-foreground text-sm" aria-live="polite">{{
|
||||||
|
t('criteria.progress', { completed, total: modelValue.length })
|
||||||
|
}}</span>
|
||||||
|
<Button
|
||||||
|
v-if="persistToggles && allowEditing && !readonly"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
(editing &&
|
||||||
|
modelValue.some((item) => item.text.trim() === ''))
|
||||||
|
"
|
||||||
|
:aria-pressed="editing"
|
||||||
|
@click="editing = !editing"
|
||||||
|
>
|
||||||
|
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('criteria.empty') }}
|
||||||
|
</p>
|
||||||
|
<ol v-else class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in modelValue"
|
||||||
|
:key="item.id ?? `new-${index}`"
|
||||||
|
:data-indent="item.indent"
|
||||||
|
class="border-border min-w-0"
|
||||||
|
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
|
||||||
|
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<label
|
||||||
|
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-60': locked }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="item.checked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="locked"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.complete', {
|
||||||
|
number: index + 1,
|
||||||
|
text: item.text,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-input accent-primary size-5 rounded"
|
||||||
|
@change="toggle(index, $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<textarea
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
:value="item.text"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.item', { number: index + 1 })
|
||||||
|
"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
required
|
||||||
|
rows="2"
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
|
||||||
|
@input="changeText(index, $event)"
|
||||||
|
@keydown="onKeydown(index, $event)"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground line-through':
|
||||||
|
item.checked,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
class="flex flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled || item.indent === 0"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.outdent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentDecrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!canIndentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.indent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentIncrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.remove', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="remove(index)"
|
||||||
|
><Trash2 aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<template v-if="editing && !readonly">
|
||||||
|
<Button
|
||||||
|
data-add-criterion
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="add"
|
||||||
|
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
|
||||||
|
>
|
||||||
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||||
|
{{ t('criteria.hint') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p
|
||||||
|
v-if="persistToggles && dirty"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.unsaved') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="saving ?? disabled"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.saving') }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" role="alert" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from '@/types/ticket-presentation';
|
} from '@/types/ticket-presentation';
|
||||||
|
|
||||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||||
|
layout: 'card',
|
||||||
showDescription: true,
|
showDescription: true,
|
||||||
showStatus: true,
|
showStatus: true,
|
||||||
});
|
});
|
||||||
@@ -22,63 +23,85 @@ const { t } = useLocalization();
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
:class="[
|
:class="[
|
||||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||||
|
layout === 'card' ? 'shadow-sm' : '',
|
||||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Link
|
<div
|
||||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
:class="
|
||||||
:aria-label="
|
layout === 'list'
|
||||||
t('tickets.open', {
|
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
|
||||||
key: ticket.ticket_key,
|
: ''
|
||||||
title: ticket.title,
|
|
||||||
})
|
|
||||||
"
|
"
|
||||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
|
||||||
@click="emit('open', ticket.id)"
|
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
<Link
|
||||||
<Badge
|
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||||
variant="outline"
|
:aria-label="
|
||||||
class="wrap-anywhere max-w-full whitespace-normal"
|
t('tickets.open', {
|
||||||
>
|
key: ticket.ticket_key,
|
||||||
{{ ticket.ticket_key }}
|
title: ticket.title,
|
||||||
</Badge>
|
|
||||||
<ArrowUpRight
|
|
||||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3
|
|
||||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
|
||||||
>
|
|
||||||
{{ ticket.title }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="showDescription && ticket.description"
|
|
||||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
|
||||||
>
|
|
||||||
{{ ticket.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
|
||||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('tickets.blockerComment', {
|
|
||||||
comment: ticket.blocked_comment,
|
|
||||||
})
|
})
|
||||||
}}
|
"
|
||||||
</p>
|
:class="[
|
||||||
</Link>
|
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
|
||||||
|
layout === 'list'
|
||||||
|
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
|
||||||
|
: 'rounded-t-md',
|
||||||
|
]"
|
||||||
|
@click="emit('open', ticket.id)"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_key }}
|
||||||
|
</Badge>
|
||||||
|
<ArrowUpRight
|
||||||
|
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="border-t p-4">
|
<h3
|
||||||
<slot name="actions" />
|
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="showDescription && ticket.description"
|
||||||
|
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
||||||
|
>
|
||||||
|
{{ ticket.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||||
|
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('tickets.blockerComment', {
|
||||||
|
comment: ticket.blocked_comment,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="$slots.actions"
|
||||||
|
:class="[
|
||||||
|
'border-t p-4',
|
||||||
|
layout === 'list'
|
||||||
|
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind?: 'text' | 'number' | 'textarea' | 'select';
|
||||||
|
options?: { value: string | number; label: string }[];
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
min?: number;
|
||||||
|
step?: string;
|
||||||
|
}>(),
|
||||||
|
{ kind: 'text', rows: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = defineModel<string | number | null>({ required: true });
|
||||||
|
const controlClass =
|
||||||
|
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 content-start gap-2">
|
||||||
|
<Label :for="id">{{ label }}</Label>
|
||||||
|
<select
|
||||||
|
v-if="kind === 'select'"
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:class="controlClass"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
v-else-if="kind === 'textarea'"
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:rows="rows"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:class="controlClass"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:type="kind"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:min="min"
|
||||||
|
:step="step"
|
||||||
|
:maxlength="kind === 'text' ? 255 : undefined"
|
||||||
|
:invalid="Boolean(error)"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
:id="`${id}-hint`"
|
||||||
|
class="text-muted-foreground text-xs empty:hidden"
|
||||||
|
>
|
||||||
|
{{ hint }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { ProjectTicketCreatePageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
Pick<
|
||||||
|
ProjectTicketCreatePageProps,
|
||||||
|
| 'project'
|
||||||
|
| 'ticketTypes'
|
||||||
|
| 'ticketPriorities'
|
||||||
|
| 'users'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
> & {
|
||||||
|
errors: Record<string, string>;
|
||||||
|
disabled?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const title = defineModel<string>('title', { required: true });
|
||||||
|
const description = defineModel<string>('description', { required: true });
|
||||||
|
const type = defineModel<string | number>('type', { required: true });
|
||||||
|
const priority = defineModel<string | number>('priority', { required: true });
|
||||||
|
const assignee = defineModel<string | number>('assignee', { required: true });
|
||||||
|
const parent = defineModel<string | number>('parent', { required: true });
|
||||||
|
const points = defineModel<string | number | null>('points', {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const status = defineModel<string | number>('status');
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
);
|
||||||
|
const priorityOptions = computed(() =>
|
||||||
|
props.ticketPriorities.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const userOptions = computed(() => [
|
||||||
|
{ value: '', label: t('tickets.unassigned') },
|
||||||
|
...props.users.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
]);
|
||||||
|
const parentOptions = computed(() => [
|
||||||
|
{ value: '', label: t('ticketDetail.noParent') },
|
||||||
|
...props.parentTicketOptions.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.ticket_key} - ${item.title}`,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
const statusOptions = computed(
|
||||||
|
() =>
|
||||||
|
props.ticketStatusOptions?.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-6">
|
||||||
|
<TicketFormField
|
||||||
|
id="title"
|
||||||
|
v-model="title"
|
||||||
|
:label="t('ticketDetail.title')"
|
||||||
|
:error="errors.title"
|
||||||
|
:disabled="disabled"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="description"
|
||||||
|
v-model="description"
|
||||||
|
kind="textarea"
|
||||||
|
:label="t('ticketDetail.description')"
|
||||||
|
:error="errors.description"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="t('ticketForm.descriptionPlaceholder')"
|
||||||
|
/>
|
||||||
|
<slot name="content" />
|
||||||
|
<fieldset
|
||||||
|
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<legend class="px-2 text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.metadata') }}
|
||||||
|
</legend>
|
||||||
|
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_type_id"
|
||||||
|
v-model="type"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.type')"
|
||||||
|
:options="typeOptions"
|
||||||
|
:error="errors.ticket_type_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_priority_id"
|
||||||
|
v-model="priority"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.priority')"
|
||||||
|
:options="priorityOptions"
|
||||||
|
:error="errors.ticket_priority_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="ticketStatusOptions && status !== undefined"
|
||||||
|
id="ticket_status_id"
|
||||||
|
v-model="status"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.status')"
|
||||||
|
:options="statusOptions"
|
||||||
|
:error="errors.ticket_status_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="assignee_user_id"
|
||||||
|
v-model="assignee"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.assignee')"
|
||||||
|
:options="userOptions"
|
||||||
|
:error="errors.assignee_user_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="parent_ticket_id"
|
||||||
|
v-model="parent"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.parent')"
|
||||||
|
:options="parentOptions"
|
||||||
|
:error="errors.parent_ticket_id"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="project.show_story_points"
|
||||||
|
id="story_points"
|
||||||
|
v-model="points"
|
||||||
|
kind="number"
|
||||||
|
:label="t('ticketDetail.storyPoints')"
|
||||||
|
:error="errors.story_points"
|
||||||
|
:disabled="disabled"
|
||||||
|
:min="0"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
<slot name="metadata" />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
CircleAlert,
|
||||||
|
GitBranchPlus,
|
||||||
|
LoaderCircle,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
store,
|
||||||
|
validate as validateBranch,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||||
|
import type {
|
||||||
|
TicketBranchCreateDialogProps,
|
||||||
|
TicketBranchFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketBranchCreateDialogProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const appName = String(usePage().props.name);
|
||||||
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const isCheckingBranchName = ref(false);
|
||||||
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||||
|
|
||||||
|
const defaultRepositoryConnectionId = computed(() =>
|
||||||
|
String(
|
||||||
|
props.repositoryConnections.find(
|
||||||
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||||
|
)?.id ??
|
||||||
|
props.repositoryConnections[0]?.id ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<TicketBranchFormData>(draftKey, {
|
||||||
|
branch_name: props.suggestedBranchName,
|
||||||
|
repository_connection_id: defaultRepositoryConnectionId.value,
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => isOpen.value && form.isDirty,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRepositoryConnection = computed(
|
||||||
|
() =>
|
||||||
|
props.repositoryConnections.find(
|
||||||
|
(repositoryConnection) =>
|
||||||
|
String(repositoryConnection.id) ===
|
||||||
|
form.repository_connection_id,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
const selectedRepositoryHasPersonalAccess = computed(
|
||||||
|
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
|
||||||
|
);
|
||||||
|
const validationStateClasses = computed(() => {
|
||||||
|
if (isCheckingBranchName.value) {
|
||||||
|
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchNameValidation.value?.is_valid) {
|
||||||
|
return 'border-success/40 bg-success/10 text-success-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchNameValidation.value !== null) {
|
||||||
|
return 'border-warning/40 bg-warning/10 text-warning-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-border bg-muted/30 text-muted-foreground';
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateOpen(open: boolean): void {
|
||||||
|
if (!open && !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeForm(): void {
|
||||||
|
if (form.isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
form.branch_name = props.suggestedBranchName;
|
||||||
|
form.repository_connection_id = defaultRepositoryConnectionId.value;
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[isOpen, () => form.repository_connection_id, () => form.branch_name],
|
||||||
|
(
|
||||||
|
[open, repositoryConnectionId, branchName],
|
||||||
|
_previousValues,
|
||||||
|
onCleanup,
|
||||||
|
) => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryConnection = selectedRepositoryConnection.value;
|
||||||
|
|
||||||
|
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repositoryConnection.has_personal_access) {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.connectGitFirst'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchName.trim() === '') {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchRequired'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = true;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
validateBranch.url(
|
||||||
|
{
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
branch_name: branchName,
|
||||||
|
repository_connection_id:
|
||||||
|
repositoryConnectionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Branch validation failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value =
|
||||||
|
(await response.json()) as BranchNameValidationModel;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === 'AbortError'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchValidationFailed', {
|
||||||
|
app: appName,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abortController.abort();
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (
|
||||||
|
!selectedRepositoryHasPersonalAccess.value ||
|
||||||
|
branchNameValidation.value?.is_valid !== true
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
store({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
},
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="updateOpen">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full gap-2 sm:w-auto"
|
||||||
|
@click="isOpen = true"
|
||||||
|
>
|
||||||
|
<GitBranchPlus class="size-4" aria-hidden="true" />
|
||||||
|
{{ t('ticketDetail.createBranch') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader class="space-y-3">
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('ticketDetail.createBranchTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('ticketDetail.createBranchDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form class="grid gap-4" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
protection.state.value === 'loading'
|
||||||
|
? t('ticketDetail.creatingBranch')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="repository_connection_id">{{
|
||||||
|
t('ticketDetail.repository')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="repository_connection_id"
|
||||||
|
v-model="form.repository_connection_id"
|
||||||
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="repositoryConnection in repositoryConnections"
|
||||||
|
:key="repositoryConnection.id"
|
||||||
|
:value="String(repositoryConnection.id)"
|
||||||
|
>
|
||||||
|
{{ repositoryConnection.repository_full_name
|
||||||
|
}}{{
|
||||||
|
repositoryConnection.has_personal_access
|
||||||
|
? ''
|
||||||
|
: ` — ${t('ticketDetail.personalAccessRequired')}`
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.repository_connection_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="branch_name">{{
|
||||||
|
t('ticketDetail.branchName')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="branch_name"
|
||||||
|
v-model="form.branch_name"
|
||||||
|
:disabled="!selectedRepositoryHasPersonalAccess"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.branch_name" />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
:aria-busy="isCheckingBranchName || undefined"
|
||||||
|
class="rounded-lg border px-4 py-3 transition-colors"
|
||||||
|
:class="validationStateClasses"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<LoaderCircle
|
||||||
|
v-if="isCheckingBranchName"
|
||||||
|
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<CheckCircle2
|
||||||
|
v-else-if="branchNameValidation?.is_valid"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<CircleAlert
|
||||||
|
v-else-if="branchNameValidation !== null"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
isCheckingBranchName
|
||||||
|
? t('ticketDetail.checkingBranch')
|
||||||
|
: (branchNameValidation?.message ??
|
||||||
|
t('ticketDetail.selectRepository'))
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!selectedRepositoryHasPersonalAccess &&
|
||||||
|
selectedRepositoryConnection !== null
|
||||||
|
"
|
||||||
|
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<p class="font-semibold">
|
||||||
|
{{ t('ticketDetail.gitAccessRequired') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.branchAccess') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="updateOpen(false)"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="
|
||||||
|
isCheckingBranchName ||
|
||||||
|
branchNameValidation?.is_valid !== true
|
||||||
|
"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('ticketDetail.creatingBranch')"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.createNow') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDetailBlockerProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const savedFormData = () => ({
|
||||||
|
acceptance_criteria: props.ticket.acceptance_criteria,
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id,
|
||||||
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
|
description: props.ticket.description,
|
||||||
|
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
||||||
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||||
|
value_number: fieldValue.value_number,
|
||||||
|
value_user_id: fieldValue.value_user_id,
|
||||||
|
})),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm(
|
||||||
|
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
|
||||||
|
savedFormData(),
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const isBlocked = computed(() => Boolean(form.is_blocked));
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (!props.canEditTicket || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
ProjectTicketController.update({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
Object.assign(form, savedFormData());
|
||||||
|
form.defaults();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-blocker"
|
||||||
|
aria-labelledby="ticket-blocker-title"
|
||||||
|
class="min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card :class="{ 'border-warning/40': isBlocked }">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.blocker') }}
|
||||||
|
</h2>
|
||||||
|
<Badge :variant="isBlocked ? 'warning' : 'outline'">
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t('ticketDetail.blockerActive')
|
||||||
|
: t('ticketDetail.blockerInactive')
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{{ t('ticketDetail.blockerDescription') }}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent v-if="canEditTicket">
|
||||||
|
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="is_blocked"
|
||||||
|
v-model="form.is_blocked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||||
|
aria-describedby="ticket-blocker-state-description is_blocked-error"
|
||||||
|
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<Label for="is_blocked">
|
||||||
|
{{ t('ticketDetail.blocked') }}
|
||||||
|
</Label>
|
||||||
|
<p
|
||||||
|
id="ticket-blocker-state-description"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t(
|
||||||
|
'ticketDetail.blockerActiveDescription',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'ticketDetail.blockerInactiveDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="is_blocked-error"
|
||||||
|
:message="form.errors.is_blocked"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TicketFormField
|
||||||
|
v-if="isBlocked"
|
||||||
|
id="blocked_comment"
|
||||||
|
v-model="form.blocked_comment"
|
||||||
|
kind="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:label="t('ticketDetail.blockerComment')"
|
||||||
|
:hint="t('ticketDetail.blockerCommentHelp')"
|
||||||
|
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||||
|
:error="form.errors.blocked_comment"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!form.isDirty || form.processing"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.resetBlocker') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('ticketDetail.savingBlocker')"
|
||||||
|
:disabled="!form.isDirty"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t('ticketDetail.saveBlocker')
|
||||||
|
: t('ticketDetail.unblockTicket')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardContent v-else>
|
||||||
|
<p
|
||||||
|
class="whitespace-pre-wrap text-sm leading-6"
|
||||||
|
:class="{ 'text-muted-foreground': !isBlocked }"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? ticket.blocked_comment
|
||||||
|
: t('ticketDetail.blockerInactiveDescription')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
toRef,
|
||||||
|
unref,
|
||||||
|
useTemplateRef,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import {
|
||||||
|
normalizeAcceptanceCriteria,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailContentProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const editor = useTemplateRef<HTMLDetailsElement>('editor');
|
||||||
|
async function editParent(): Promise<void> {
|
||||||
|
if (!props.canEditTicket || !editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.value.open = true;
|
||||||
|
await nextTick();
|
||||||
|
editor.value
|
||||||
|
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
defineExpose({ editParent });
|
||||||
|
const canEditAuthor = computed(
|
||||||
|
() => props.canEditTicket && props.canManageProject,
|
||||||
|
);
|
||||||
|
const canViewAuthor = computed(
|
||||||
|
() => props.project.show_author || props.canManageProject,
|
||||||
|
);
|
||||||
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||||
|
const acceptanceCriteriaDraft = useRemember(
|
||||||
|
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
|
||||||
|
`${draftKey}:criteria`,
|
||||||
|
);
|
||||||
|
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
|
||||||
|
const savedAcceptanceCriteria = ref(
|
||||||
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||||
|
);
|
||||||
|
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||||
|
const acceptanceCriteriaToggleError = ref('');
|
||||||
|
|
||||||
|
const savedFormData = () => ({
|
||||||
|
acceptance_criteria: normalizeAcceptanceCriteria(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
),
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||||
|
author_user_id: props.ticket.author_user_id ?? '',
|
||||||
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
|
description: props.ticket.description ?? '',
|
||||||
|
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
||||||
|
const currentFieldValue = props.ticket.field_values?.find(
|
||||||
|
(fieldValue) =>
|
||||||
|
fieldValue.ticket_field_definition_id ===
|
||||||
|
ticketFieldDefinition.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticket_field_definition_id: ticketFieldDefinition.id,
|
||||||
|
value_number: currentFieldValue?.value_number ?? '',
|
||||||
|
value_user_id: currentFieldValue?.value_user_id ?? '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm(draftKey, savedFormData());
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
|
||||||
|
([isBlocked, blockedComment]) => {
|
||||||
|
form.is_blocked = isBlocked;
|
||||||
|
form.blocked_comment = blockedComment ?? '';
|
||||||
|
form.defaults('is_blocked', isBlocked);
|
||||||
|
form.defaults('blocked_comment', blockedComment ?? '');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||||
|
JSON.stringify(savedAcceptanceCriteria.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
props.canEditTicket &&
|
||||||
|
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
||||||
|
const reset = (): void => {
|
||||||
|
if (
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value ||
|
||||||
|
!protection.confirmLeave()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
);
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptanceCriteriaError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.acceptance_criteria ??
|
||||||
|
Object.entries(form.errors).find(([key]) =>
|
||||||
|
key.startsWith('acceptance_criteria.'),
|
||||||
|
)?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSavedTicketPayload = (
|
||||||
|
acceptanceCriteria = savedAcceptanceCriteria.value,
|
||||||
|
) => ({
|
||||||
|
acceptance_criteria: acceptanceCriteria,
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id,
|
||||||
|
author_user_id: props.ticket.author_user_id,
|
||||||
|
blocked_comment: props.ticket.blocked_comment,
|
||||||
|
description: props.ticket.description,
|
||||||
|
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
||||||
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||||
|
value_number: fieldValue.value_number,
|
||||||
|
value_user_id: fieldValue.value_user_id,
|
||||||
|
})),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistAcceptanceCriteriaToggle = async (
|
||||||
|
index: number,
|
||||||
|
checked: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (
|
||||||
|
!props.canEditTicket ||
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value ||
|
||||||
|
hasUnsavedAcceptanceCriteriaDraft.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
index,
|
||||||
|
checked,
|
||||||
|
);
|
||||||
|
const csrfToken = document
|
||||||
|
.querySelector('meta[name="csrf-token"]')
|
||||||
|
?.getAttribute('content');
|
||||||
|
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value = true;
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
ProjectTicketController.update.url({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken ?? '',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(
|
||||||
|
buildSavedTicketPayload(nextAcceptanceCriteria),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Checklist toggle update failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
||||||
|
|
||||||
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
|
nextAcceptanceCriteria,
|
||||||
|
);
|
||||||
|
form.acceptance_criteria = nextAcceptanceCriteria;
|
||||||
|
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||||
|
} catch (_error) {
|
||||||
|
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
||||||
|
} finally {
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (
|
||||||
|
!props.canEditTicket ||
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.acceptance_criteria = acceptanceCriteriaItems.value;
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
ProjectTicketController.update({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||||
|
props.ticket.acceptance_criteria,
|
||||||
|
);
|
||||||
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
);
|
||||||
|
Object.assign(form, savedFormData());
|
||||||
|
form.defaults();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
|
<section
|
||||||
|
id="ticket-description"
|
||||||
|
aria-labelledby="ticket-description-title"
|
||||||
|
class="scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
><h2
|
||||||
|
id="ticket-description-title"
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.description') }}
|
||||||
|
</h2></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<p
|
||||||
|
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground': !ticket.description,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticket.description ||
|
||||||
|
t('ticketDetail.noDescription')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<div id="ticket-checklist" class="scroll-mt-6">
|
||||||
|
<AcceptanceCriteriaChecklist
|
||||||
|
:model-value="savedAcceptanceCriteria"
|
||||||
|
:readonly="!canEditTicket"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
|
hasUnsavedAcceptanceCriteriaDraft
|
||||||
|
"
|
||||||
|
:saving="
|
||||||
|
form.processing || isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
:allow-editing="false"
|
||||||
|
persist-toggles
|
||||||
|
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
||||||
|
:error="acceptanceCriteriaToggleError"
|
||||||
|
@toggle="persistAcceptanceCriteriaToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<details
|
||||||
|
v-if="canEditTicket"
|
||||||
|
id="ticket-edit"
|
||||||
|
ref="editor"
|
||||||
|
class="bg-card group rounded-xl border shadow-sm"
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.edit') }}
|
||||||
|
</summary>
|
||||||
|
<div class="border-t p-4 sm:p-6">
|
||||||
|
<p class="text-muted-foreground mb-6 text-sm">
|
||||||
|
{{ t('ticketDetail.editDescription') }}
|
||||||
|
</p>
|
||||||
|
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<TicketFormFields
|
||||||
|
v-model:title="form.title"
|
||||||
|
v-model:description="form.description"
|
||||||
|
v-model:type="form.ticket_type_id"
|
||||||
|
v-model:priority="form.ticket_priority_id"
|
||||||
|
v-model:assignee="form.assignee_user_id"
|
||||||
|
v-model:parent="form.parent_ticket_id"
|
||||||
|
v-model:points="form.story_points"
|
||||||
|
:project="project"
|
||||||
|
:ticket-types="ticketTypes"
|
||||||
|
:ticket-priorities="ticketPriorities"
|
||||||
|
:users="users"
|
||||||
|
:parent-ticket-options="parentTicketOptions"
|
||||||
|
:errors="form.errors"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<AcceptanceCriteriaChecklist
|
||||||
|
v-model="acceptanceCriteriaItems"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
:error="acceptanceCriteriaError"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #metadata>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="canViewAuthor"
|
||||||
|
id="author_user_id"
|
||||||
|
v-model="form.author_user_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.author')"
|
||||||
|
:hint="
|
||||||
|
!canEditAuthor
|
||||||
|
? t('ticketForm.authorRestricted')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t('ticketDetail.noAuthor'),
|
||||||
|
},
|
||||||
|
...users.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="form.errors.author_user_id"
|
||||||
|
:disabled="!canEditAuthor || form.processing"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TicketFormFields>
|
||||||
|
<fieldset
|
||||||
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<legend class="px-2 text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.customFields') }}
|
||||||
|
</legend>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.customFieldsDescription') }}
|
||||||
|
</p>
|
||||||
|
<InputError :message="form.errors.field_values" />
|
||||||
|
<p
|
||||||
|
v-if="!ticketFieldDefinitions.length"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noCustomFields') }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||||
|
<template
|
||||||
|
v-for="(
|
||||||
|
definition, index
|
||||||
|
) in ticketFieldDefinitions"
|
||||||
|
:key="definition.id"
|
||||||
|
>
|
||||||
|
<TicketFormField
|
||||||
|
v-if="definition.field_type === 'user'"
|
||||||
|
:id="`field-${definition.id}`"
|
||||||
|
v-model="
|
||||||
|
form.field_values[index].value_user_id
|
||||||
|
"
|
||||||
|
kind="select"
|
||||||
|
:label="definition.label"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t('tickets.unassigned'),
|
||||||
|
},
|
||||||
|
...users.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.value_user_id`
|
||||||
|
] ??
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.ticket_field_definition_id`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
v-else
|
||||||
|
:id="`field-${definition.id}`"
|
||||||
|
v-model="
|
||||||
|
form.field_values[index].value_number
|
||||||
|
"
|
||||||
|
kind="number"
|
||||||
|
step="0.1"
|
||||||
|
:label="definition.label"
|
||||||
|
:error="
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.value_number`
|
||||||
|
] ??
|
||||||
|
form.errors[
|
||||||
|
`field_values.${index}.ticket_field_definition_id`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
form.processing
|
||||||
|
? t('ticketForm.saving')
|
||||||
|
: t('ticketDetail.saveChanges')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
|
!isDirty
|
||||||
|
"
|
||||||
|
@click="reset"
|
||||||
|
>{{ t('ticketForm.reset') }}</Button
|
||||||
|
>
|
||||||
|
<p role="status" class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
isDirty
|
||||||
|
? t('ticketForm.unsaved')
|
||||||
|
: t('ticketForm.saved')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
|
||||||
|
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketDetailGitProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
defineProps<TicketDetailGitProps>();
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-git"
|
||||||
|
aria-labelledby="ticket-git-title"
|
||||||
|
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||||
|
>
|
||||||
|
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.git') }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-6">
|
||||||
|
<TicketGitBranches
|
||||||
|
:ticket="ticket"
|
||||||
|
:project="project"
|
||||||
|
:can-edit-ticket="canEditTicket"
|
||||||
|
:repository-branches="repositoryBranches"
|
||||||
|
:suggested-branch-name="suggestedBranchName"
|
||||||
|
/>
|
||||||
|
<TicketGitPullRequests
|
||||||
|
:repository-pull-requests="repositoryPullRequests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
|
||||||
|
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
|
import { show } from '@/routes/projects';
|
||||||
|
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
|
||||||
|
defineProps<TicketDetailHeaderProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sections = [
|
||||||
|
'description',
|
||||||
|
'checklist',
|
||||||
|
'metadata',
|
||||||
|
'blocker',
|
||||||
|
'relationships',
|
||||||
|
'history',
|
||||||
|
'git',
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="flex min-w-0 flex-col gap-5">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Button as-child variant="ghost" class="max-w-full"
|
||||||
|
><Link :href="show(project.id)"
|
||||||
|
><ArrowLeft aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.backToProject')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="backlogIndex(project.id)"
|
||||||
|
><ListTree aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.openBacklog')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="boardIndex(project.id)"
|
||||||
|
><Columns3 aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.openBoard')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-primary font-mono text-sm font-semibold">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><Badge v-if="!canEditTicket" variant="outline">{{
|
||||||
|
t('ticketDetail.readOnly')
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
</h1>
|
||||||
|
<TicketMetadata :ticket="ticket" />
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
:aria-label="t('ticketDetail.sections')"
|
||||||
|
class="flex flex-wrap gap-1 border-b pb-3"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section"
|
||||||
|
:href="`#ticket-${section}`"
|
||||||
|
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>{{ t(`ticketDetail.${section}`) }}</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
|
||||||
|
defineProps<TicketDetailHistoryProps>();
|
||||||
|
const { t, date } = useLocalization();
|
||||||
|
const formatDateTime = (value: string): string =>
|
||||||
|
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
||||||
|
created: 'ticketDetail.historyType.created',
|
||||||
|
link_removed: 'ticketDetail.historyType.linkRemoved',
|
||||||
|
link_updated: 'ticketDetail.historyType.linkUpdated',
|
||||||
|
status_changed: 'ticketDetail.historyType.statusChanged',
|
||||||
|
updated: 'ticketDetail.historyType.updated',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-history"
|
||||||
|
aria-labelledby="ticket-history-title"
|
||||||
|
class="wrap-anywhere min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<h2 id="ticket-history-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.history') }}
|
||||||
|
</h2>
|
||||||
|
<CardDescription>{{
|
||||||
|
t('ticketDetail.historyDescription')
|
||||||
|
}}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
|
||||||
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noHistory') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol v-else class="relative grid gap-0" role="list">
|
||||||
|
<li
|
||||||
|
v-for="worklogEntry in ticket.worklog_entries"
|
||||||
|
:key="worklogEntry.id"
|
||||||
|
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="worklogEntry.actor"
|
||||||
|
:user="worklogEntry.actor"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<article
|
||||||
|
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-foreground text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
worklogEntry.actor?.name ??
|
||||||
|
t('ticketDetail.system')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
entryTypeLabels[
|
||||||
|
worklogEntry.entry_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
:datetime="worklogEntry.created_at"
|
||||||
|
class="text-muted-foreground shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatDateTime(worklogEntry.created_at)
|
||||||
|
}}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="text-foreground mt-3 text-sm leading-6">
|
||||||
|
{{ worklogEntry.summary }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
|
||||||
|
const props = defineProps<TicketDetailMetadataProps>();
|
||||||
|
const { t, date, number } = useLocalization();
|
||||||
|
const fields = computed(() =>
|
||||||
|
props.ticketFieldDefinitions.map((definition) => {
|
||||||
|
const value = props.ticket.field_values?.find(
|
||||||
|
(field) => field.ticket_field_definition_id === definition.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: definition.id,
|
||||||
|
label: definition.label,
|
||||||
|
value:
|
||||||
|
definition.field_type === 'user'
|
||||||
|
? (value?.user_value?.name ?? t('tickets.unassigned'))
|
||||||
|
: value?.value_number != null
|
||||||
|
? number(value.value_number)
|
||||||
|
: t('ticketDetail.notSet'),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-metadata"
|
||||||
|
aria-labelledby="ticket-metadata-title"
|
||||||
|
class="min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card class="bg-muted/30">
|
||||||
|
<CardHeader
|
||||||
|
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.metadata') }}
|
||||||
|
</h2></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
|
||||||
|
<dl
|
||||||
|
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.status') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
ticket.ticket_status,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.type') }}
|
||||||
|
</dt>
|
||||||
|
<dd>{{ ticket.ticket_type.name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.priority') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
ticket.ticket_priority.name
|
||||||
|
}}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.assignee') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="ticket.assignee"
|
||||||
|
:user="ticket.assignee"
|
||||||
|
size="sm"
|
||||||
|
/>{{
|
||||||
|
ticket.assignee?.name ?? t('tickets.unassigned')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="project.show_author || canManageProject"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.author') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="ticket.author"
|
||||||
|
:user="ticket.author"
|
||||||
|
size="sm"
|
||||||
|
/>{{
|
||||||
|
ticket.author?.name ??
|
||||||
|
t('ticketDetail.noAuthor')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="project.show_story_points" class="space-y-2">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('ticketDetail.storyPoints') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
ticket.story_points != null
|
||||||
|
? number(ticket.story_points)
|
||||||
|
: t('ticketDetail.notSet')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="field in fields"
|
||||||
|
:key="field.id"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground">{{ field.label }}</dt>
|
||||||
|
<dd>{{ field.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div
|
||||||
|
v-if="ticket.is_blocked"
|
||||||
|
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
|
||||||
|
<p class="whitespace-pre-wrap">
|
||||||
|
{{ ticket.blocked_comment }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl
|
||||||
|
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt>{{ t('ticketDetail.created') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<time :datetime="ticket.created_at">{{
|
||||||
|
date(new Date(ticket.created_at), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt>{{ t('ticketDetail.updated') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<time :datetime="ticket.updated_at">{{
|
||||||
|
date(new Date(ticket.updated_at), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, useTemplateRef } from 'vue';
|
||||||
|
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
|
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
|
defineEmits<{ editParent: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
|
||||||
|
const currentParentTicket = computed(
|
||||||
|
() =>
|
||||||
|
props.ticket.parent_ticket ??
|
||||||
|
props.parentTicketOptions.find(
|
||||||
|
(parent) => parent.id === props.ticket.parent_ticket_id,
|
||||||
|
) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const canAddLink = computed(
|
||||||
|
() =>
|
||||||
|
props.canEditTicket &&
|
||||||
|
props.ticketLinkTypes.length > 0 &&
|
||||||
|
props.ticketLinkOptions.length > 0,
|
||||||
|
);
|
||||||
|
const form = useForm({
|
||||||
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
linked_ticket_id: '',
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
|
});
|
||||||
|
const selectedType = computed(() =>
|
||||||
|
props.ticketLinkTypes.find(
|
||||||
|
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedTicket = computed(() =>
|
||||||
|
props.ticketLinkOptions.find(
|
||||||
|
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function focusLinks(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
linksHeading.value?.focus();
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
if (!canAddLink.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
store({ project: props.project.id, ticket: props.ticket.id }),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.linked_ticket_id = '';
|
||||||
|
form.clearErrors();
|
||||||
|
void focusLinks();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-relationships"
|
||||||
|
aria-labelledby="ticket-relationships-title"
|
||||||
|
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||||
|
>
|
||||||
|
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.relationships') }}
|
||||||
|
</h2>
|
||||||
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
|
<GitFork class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('ticketRelationships.hierarchy')
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
v-if="canEditTicket"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('editParent')"
|
||||||
|
>{{ t('ticketRelationships.editParent') }}</Button
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<h4
|
||||||
|
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.parent') }}
|
||||||
|
</h4>
|
||||||
|
<Link
|
||||||
|
v-if="currentParentTicket"
|
||||||
|
:href="
|
||||||
|
showTicket({
|
||||||
|
project: project.id,
|
||||||
|
ticket: currentParentTicket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-primary font-mono text-xs font-semibold"
|
||||||
|
>{{ currentParentTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="group-hover:text-primary text-sm font-medium"
|
||||||
|
>{{ currentParentTicket.title }}</span
|
||||||
|
>
|
||||||
|
</Link>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noParent') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{{ t('ticketRelationships.parentHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="ticket.ticket_type?.is_parent"
|
||||||
|
class="grid min-w-0 gap-3 border-t pt-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.children') }}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
childTicketTree.length
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('ticketRelationships.childrenHelp') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="childTicketTree.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noChildren') }}
|
||||||
|
</p>
|
||||||
|
<TicketTreeList
|
||||||
|
v-else
|
||||||
|
:nodes="childTicketTree"
|
||||||
|
:project-id="project.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<h3
|
||||||
|
ref="linksHeading"
|
||||||
|
tabindex="-1"
|
||||||
|
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<Link2 class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.linkedTickets')
|
||||||
|
}}<Badge variant="secondary">{{
|
||||||
|
linkedTickets.length
|
||||||
|
}}</Badge>
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.linkedTicketsDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<p
|
||||||
|
v-if="linkedTickets.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noLinks') }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
|
<TicketRelationshipLink
|
||||||
|
v-for="link in linkedTickets"
|
||||||
|
:key="link.id"
|
||||||
|
:link="link"
|
||||||
|
:project-id="project.id"
|
||||||
|
:ticket-id="ticket.id"
|
||||||
|
:ticket-key="ticket.ticket_key"
|
||||||
|
:can-edit="canEditTicket"
|
||||||
|
@removed="focusLinks"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<form
|
||||||
|
v-if="canAddLink"
|
||||||
|
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<h4 class="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketRelationships.addLink')
|
||||||
|
}}
|
||||||
|
</h4>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||||
|
>
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_link_type_id"
|
||||||
|
v-model="form.ticket_link_type_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkType')"
|
||||||
|
:options="
|
||||||
|
ticketLinkTypes.map((type) => ({
|
||||||
|
value: type.id,
|
||||||
|
label: type.label,
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
:error="form.errors.ticket_link_type_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="linked_ticket_id"
|
||||||
|
v-model="form.linked_ticket_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkedTicket')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t(
|
||||||
|
'ticketRelationships.selectTicket',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...ticketLinkOptions.map((ticket) => ({
|
||||||
|
value: ticket.id,
|
||||||
|
label: `${ticket.ticket_key} — ${ticket.title}`,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="form.errors.linked_ticket_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<p
|
||||||
|
v-if="selectedType && selectedTicket"
|
||||||
|
aria-live="polite"
|
||||||
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><span>{{ selectedType.label }}</span
|
||||||
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
|
class="font-mono text-xs"
|
||||||
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('ticketRelationships.directionHelp') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing || !selectedTicket"
|
||||||
|
class="sm:justify-self-start"
|
||||||
|
>{{
|
||||||
|
form.processing
|
||||||
|
? t('forms.saving')
|
||||||
|
: t('ticketDetail.saveLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<p
|
||||||
|
v-else-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
ticketLinkTypes.length === 0
|
||||||
|
? 'ticketRelationships.noTypes'
|
||||||
|
: 'ticketRelationships.noTargets',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ExternalLink, GitBranch } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
|
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketGitBranchesProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const visibleRepositories = computed(() =>
|
||||||
|
props.repositoryBranches.filter(
|
||||||
|
(repositoryBranch) =>
|
||||||
|
repositoryBranch.branches.length > 0 ||
|
||||||
|
repositoryBranch.error !== null ||
|
||||||
|
!repositoryBranch.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||||
|
props.repositoryBranches.map(
|
||||||
|
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const canCreateBranch = computed(
|
||||||
|
() => props.canEditTicket && repositoryConnections.value.length > 0,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<GitBranch
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('ticketDetail.branches') }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>{ t('ticketDetail.branchesDescription') }}</CardDescription
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TicketBranchCreateDialog
|
||||||
|
v-if="canCreateBranch"
|
||||||
|
:ticket="ticket"
|
||||||
|
:project="project"
|
||||||
|
:repository-connections="repositoryConnections"
|
||||||
|
:suggested-branch-name="suggestedBranchName"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="grid gap-4 pt-6">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="repositoryBranches.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.noLinkedRepositories')"
|
||||||
|
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="visibleRepositories.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noBranches')"
|
||||||
|
:description="t('tickets.noBranchesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="repositoryBranch in visibleRepositories"
|
||||||
|
v-else
|
||||||
|
:key="repositoryBranch.repository_connection.id"
|
||||||
|
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<header class="grid min-w-0 gap-2">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_url
|
||||||
|
"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketDetail.openRepositoryExternal', {
|
||||||
|
repository:
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_full_name,
|
||||||
|
provider:
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.provider_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="wrap-anywhere">{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ repositoryBranch.repository_connection.base_url }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-if="
|
||||||
|
!repositoryBranch.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.gitAccessRequired')"
|
||||||
|
:description="t('ticketDetail.inspectBranches')"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="repositoryBranch.error"
|
||||||
|
state="error"
|
||||||
|
:title="t('ticketDetail.branchesUnavailable')"
|
||||||
|
:description="repositoryBranch.error"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||||
|
<li
|
||||||
|
v-for="branch in repositoryBranch.branches"
|
||||||
|
:key="branch.name"
|
||||||
|
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<GitBranch
|
||||||
|
class="text-muted-foreground size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="wrap-anywhere font-mono text-xs">{{
|
||||||
|
branch.name
|
||||||
|
}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketGitPullRequestsProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const visibleRepositories = computed(() =>
|
||||||
|
props.repositoryPullRequests.filter(
|
||||||
|
(repositoryPullRequest) =>
|
||||||
|
repositoryPullRequest.pull_requests.length > 0 ||
|
||||||
|
repositoryPullRequest.error !== null ||
|
||||||
|
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<GitPullRequest
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('ticketDetail.pullRequests') }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="grid gap-4 pt-6">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="repositoryPullRequests.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.noLinkedRepositories')"
|
||||||
|
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="visibleRepositories.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noPullRequests')"
|
||||||
|
:description="t('tickets.noPullRequestsDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="repositoryPullRequest in visibleRepositories"
|
||||||
|
v-else
|
||||||
|
:key="repositoryPullRequest.repository_connection.id"
|
||||||
|
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<header class="grid min-w-0 gap-2">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.repository_url
|
||||||
|
"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketDetail.openRepositoryExternal', {
|
||||||
|
repository:
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.repository_full_name,
|
||||||
|
provider:
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.provider_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="wrap-anywhere">{{
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest.repository_connection.base_url
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-if="
|
||||||
|
!repositoryPullRequest.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.gitAccessRequired')"
|
||||||
|
:description="t('ticketDetail.inspectPullRequests')"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="repositoryPullRequest.error"
|
||||||
|
state="error"
|
||||||
|
:title="t('ticketDetail.pullRequestsUnavailable')"
|
||||||
|
:description="repositoryPullRequest.error"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
|
<li
|
||||||
|
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||||
|
:key="pullRequest.html_url"
|
||||||
|
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
#{{ pullRequest.number }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ pullRequest.status }}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="pullRequest.html_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketDetail.openPullRequestExternal', {
|
||||||
|
number: pullRequest.number,
|
||||||
|
provider:
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.provider_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="wrap-anywhere">{{
|
||||||
|
pullRequest.title
|
||||||
|
}}</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
t('ticketDetail.sourceBranch', {
|
||||||
|
name: pullRequest.source_branch,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show } from '@/routes/projects/tickets';
|
||||||
|
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
link: TicketLinkModel;
|
||||||
|
projectId: number;
|
||||||
|
ticketId: number;
|
||||||
|
ticketKey: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
function remove(): void {
|
||||||
|
if (!props.canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
destroy({
|
||||||
|
project: props.projectId,
|
||||||
|
ticket: props.ticketId,
|
||||||
|
ticketLink: props.link.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => emit('removed'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="grid min-w-0 gap-3 rounded-xl border p-4"
|
||||||
|
:data-ticket-link="link.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-muted-foreground max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
v-if="outgoing"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/><ArrowDownLeft
|
||||||
|
v-else
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
outgoing
|
||||||
|
? 'ticketRelationships.outgoing'
|
||||||
|
: 'ticketRelationships.incoming',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
|
ticketKey
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
link.ticket_link_type.label
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
show({ project: projectId, ticket: link.linked_ticket.id })
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="text-primary font-mono text-xs font-semibold">{{
|
||||||
|
link.linked_ticket.ticket_key
|
||||||
|
}}</span>
|
||||||
|
<span class="wrap-anywhere text-sm font-medium">{{
|
||||||
|
link.linked_ticket.title
|
||||||
|
}}</span>
|
||||||
|
</Link>
|
||||||
|
<form v-if="canEdit" @submit.prevent="remove">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-muted-foreground hover:text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketRelationships.removeLink', {
|
||||||
|
key: link.linked_ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:disabled="form.processing"
|
||||||
|
><Unlink class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.removeLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
v-if="canEdit"
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="remove"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,120 +1,107 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue';
|
import { computed, onScopeDispose, ref } from 'vue';
|
||||||
import { computed, ref } from 'vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||||
|
|
||||||
export type UseTwoFactorAuthReturn = {
|
/** Keep secrets within the consuming component and discard late responses after hiding them. */
|
||||||
qrCodeSvg: Ref<string | null>;
|
export function useTwoFactorAuth() {
|
||||||
manualSetupKey: Ref<string | null>;
|
const { t } = useLocalization();
|
||||||
recoveryCodesList: Ref<string[]>;
|
const errors = ref<string[]>([]);
|
||||||
errors: Ref<string[]>;
|
const manualSetupKey = ref<string | null>(null);
|
||||||
hasSetupData: ComputedRef<boolean>;
|
const qrCodeSvg = ref<string | null>(null);
|
||||||
clearSetupData: () => void;
|
const recoveryCodesList = ref<string[]>([]);
|
||||||
clearErrors: () => void;
|
const loading = ref(false);
|
||||||
clearTwoFactorAuthData: () => void;
|
let pending: AbortController | null = null;
|
||||||
fetchQrCode: () => Promise<void>;
|
|
||||||
fetchSetupKey: () => Promise<void>;
|
|
||||||
fetchSetupData: () => Promise<void>;
|
|
||||||
fetchRecoveryCodes: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
if (!response.ok) {
|
});
|
||||||
throw new Error(`Failed to fetch: ${response.status}`);
|
if (!response.ok || response.redirected) {
|
||||||
|
throw new Error(String(response.status));
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
function clearErrors(): void {
|
||||||
};
|
errors.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
const errors = ref<string[]>([]);
|
function clearTwoFactorAuthData(): void {
|
||||||
const manualSetupKey = ref<string | null>(null);
|
pending?.abort();
|
||||||
const qrCodeSvg = ref<string | null>(null);
|
pending = null;
|
||||||
const recoveryCodesList = ref<string[]>([]);
|
loading.value = false;
|
||||||
|
|
||||||
const hasSetupData = computed<boolean>(
|
|
||||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
|
||||||
const fetchQrCode = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
|
||||||
qrCode.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
qrCodeSvg.value = svg;
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch QR code');
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSetupKey = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
|
||||||
secretKey.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
manualSetupKey.value = key;
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch a setup key');
|
|
||||||
manualSetupKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSetupData = (): void => {
|
|
||||||
manualSetupKey.value = null;
|
manualSetupKey.value = null;
|
||||||
qrCodeSvg.value = null;
|
qrCodeSvg.value = null;
|
||||||
clearErrors();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearErrors = (): void => {
|
|
||||||
errors.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTwoFactorAuthData = (): void => {
|
|
||||||
clearSetupData();
|
|
||||||
clearErrors();
|
|
||||||
recoveryCodesList.value = [];
|
recoveryCodesList.value = [];
|
||||||
};
|
clearErrors();
|
||||||
|
}
|
||||||
|
|
||||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
async function load(kind: 'setup' | 'recovery'): Promise<void> {
|
||||||
|
clearTwoFactorAuthData();
|
||||||
|
const request = new AbortController();
|
||||||
|
pending = request;
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
clearErrors();
|
if (kind === 'setup') {
|
||||||
recoveryCodesList.value = await fetchJson<string[]>(
|
const [qr, key] = await Promise.all([
|
||||||
recoveryCodes.url(),
|
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
|
||||||
);
|
fetchJson<{ secretKey: string }>(
|
||||||
} catch {
|
secretKey.url(),
|
||||||
errors.value.push('Failed to fetch recovery codes');
|
request.signal,
|
||||||
recoveryCodesList.value = [];
|
),
|
||||||
|
]);
|
||||||
|
if (pending !== request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qrCodeSvg.value = qr.svg;
|
||||||
|
manualSetupKey.value = key.secretKey;
|
||||||
|
} else {
|
||||||
|
const codes = await fetchJson<string[]>(
|
||||||
|
recoveryCodes.url(),
|
||||||
|
request.signal,
|
||||||
|
);
|
||||||
|
if (pending === request) {
|
||||||
|
recoveryCodesList.value = codes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (pending === request && !request.signal.aborted) {
|
||||||
|
const status = error instanceof Error ? error.message : '';
|
||||||
|
errors.value = [
|
||||||
|
t(
|
||||||
|
status === '423' || status === '401' || status === '419'
|
||||||
|
? 'security.confirmAgain'
|
||||||
|
: kind === 'setup'
|
||||||
|
? 'security.setupError'
|
||||||
|
: 'security.recoveryError',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pending === request) {
|
||||||
|
pending = null;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const fetchSetupData = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
clearErrors();
|
|
||||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
|
||||||
} catch {
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
manualSetupKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
onScopeDispose(clearTwoFactorAuthData);
|
||||||
return {
|
return {
|
||||||
qrCodeSvg,
|
qrCodeSvg,
|
||||||
manualSetupKey,
|
manualSetupKey,
|
||||||
recoveryCodesList,
|
recoveryCodesList,
|
||||||
errors,
|
errors,
|
||||||
hasSetupData,
|
loading,
|
||||||
clearSetupData,
|
hasSetupData: computed(() =>
|
||||||
clearErrors,
|
Boolean(qrCodeSvg.value && manualSetupKey.value),
|
||||||
|
),
|
||||||
|
clearSetupData: clearTwoFactorAuthData,
|
||||||
|
clearRecoveryCodes: clearTwoFactorAuthData,
|
||||||
clearTwoFactorAuthData,
|
clearTwoFactorAuthData,
|
||||||
fetchQrCode,
|
clearErrors,
|
||||||
fetchSetupKey,
|
fetchSetupData: () => load('setup'),
|
||||||
fetchSetupData,
|
fetchRecoveryCodes: () => load('recovery'),
|
||||||
fetchRecoveryCodes,
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { Settings2 } from 'lucide-vue-next';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { toUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
defineProps<{ projectName: string; projectKey: string }>();
|
||||||
|
const { projectSettingsItems } = useAppNavigation();
|
||||||
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
|
||||||
|
>
|
||||||
|
<header class="gap-stack grid min-w-0">
|
||||||
|
<div class="text-primary flex items-center gap-2">
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<p class="text-label text-muted-foreground break-all uppercase">
|
||||||
|
{{ projectKey }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
|
{{ t('navigation.projectSettings') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground break-words">
|
||||||
|
{{ t('projectSettings.description', { project: projectName }) }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||||
|
:aria-label="t('navigation.projectSettings')"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-for="item in projectSettingsItems"
|
||||||
|
:key="toUrl(item.href)"
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentUrl(item.href) ? 'page' : undefined
|
||||||
|
"
|
||||||
|
:class="[
|
||||||
|
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||||
|
isCurrentUrl(item.href)
|
||||||
|
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
|
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
Palette,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
|||||||
{
|
{
|
||||||
title: t('navigation.profile'),
|
title: t('navigation.profile'),
|
||||||
href: editProfile(),
|
href: editProfile(),
|
||||||
|
icon: UserRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.security'),
|
title: t('navigation.security'),
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.gitConnections'),
|
title: t('navigation.gitConnections'),
|
||||||
href: editGitConnections(),
|
href: editGitConnections(),
|
||||||
|
icon: GitBranch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.applications'),
|
title: t('navigation.applications'),
|
||||||
href: editApplications(),
|
href: editApplications(),
|
||||||
|
icon: KeyRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.appearance'),
|
title: t('navigation.appearance'),
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
|
icon: Palette,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-6">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||||
:title="t('navigation.settings')"
|
>
|
||||||
:description="t('navigation.settingsDescription')"
|
<header class="gap-stack grid min-w-0">
|
||||||
/>
|
<div class="text-primary flex items-center gap-2">
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
|
{{ t('settings.eyebrow') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
|
{{ t('navigation.settings') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('navigation.settingsDescription') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
<div
|
||||||
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
|
>
|
||||||
<nav
|
<nav
|
||||||
class="flex gap-1 overflow-x-auto lg:flex-col"
|
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||||
:aria-label="t('navigation.settings')"
|
:aria-label="t('navigation.settings')"
|
||||||
>
|
>
|
||||||
<Button
|
<Link
|
||||||
v-for="item in sidebarNavItems"
|
v-for="item in sidebarNavItems"
|
||||||
:key="toUrl(item.href)"
|
:key="toUrl(item.href)"
|
||||||
variant="ghost"
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentOrParentUrl(item.href) ? 'page' : undefined
|
||||||
|
"
|
||||||
:class="[
|
:class="[
|
||||||
'shrink-0 justify-start lg:w-full',
|
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
isCurrentOrParentUrl(item.href)
|
||||||
|
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||||
]"
|
]"
|
||||||
as-child
|
|
||||||
>
|
>
|
||||||
<Link
|
<component
|
||||||
:href="item.href"
|
:is="item.icon"
|
||||||
:aria-current="
|
class="size-4 shrink-0"
|
||||||
isCurrentOrParentUrl(item.href)
|
aria-hidden="true"
|
||||||
? 'page'
|
/>
|
||||||
: undefined
|
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||||
"
|
</Link>
|
||||||
>
|
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
|
||||||
{{ item.title }}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Separator class="lg:hidden" />
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
|
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
|
||||||
<div class="min-w-0 flex-1 md:max-w-2xl">
|
|
||||||
<section class="max-w-xl space-y-12">
|
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,76 @@ import type {
|
|||||||
|
|
||||||
const MAX_INDENT_LEVEL = 8;
|
const MAX_INDENT_LEVEL = 8;
|
||||||
|
|
||||||
|
export const canIndentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): boolean => {
|
||||||
|
const item = items[index];
|
||||||
|
const previous = items[index - 1];
|
||||||
|
if (!item || !previous || item.indent > previous.indent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
|
||||||
|
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptanceCriteriaSubtreeEnd = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number => {
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < items.length && items[end].indent > items[index].indent) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const indentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (
|
||||||
|
!items[index] ||
|
||||||
|
(direction === 1
|
||||||
|
? !canIndentAcceptanceCriterion(items, index)
|
||||||
|
: items[index].indent === 0)
|
||||||
|
) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.map((item, position) =>
|
||||||
|
position >= index && position < end
|
||||||
|
? { ...item, indent: item.indent + direction }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove only the selected criterion, promoting its children without losing them. */
|
||||||
|
export const removeAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (!items[index]) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.flatMap((item, position) =>
|
||||||
|
position === index
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
indent:
|
||||||
|
position > index && position < end
|
||||||
|
? item.indent - 1
|
||||||
|
: item.indent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AcceptanceCriteriaEditorState = {
|
export type AcceptanceCriteriaEditorState = {
|
||||||
checklist: string;
|
checklist: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,178 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'workflow.title': 'Board workflow',
|
||||||
|
'workflow.pageTitle': '{project} · Board workflow',
|
||||||
|
'workflow.description':
|
||||||
|
'Organize your board into columns and give each stage the statuses your team needs.',
|
||||||
|
'workflow.updated': 'Workflow updated.',
|
||||||
|
'workflow.backlog': 'Backlog',
|
||||||
|
'workflow.offBoard': 'Off the board',
|
||||||
|
'workflow.backlogDescription':
|
||||||
|
'Your intake queue. Tickets with these statuses stay outside the board columns.',
|
||||||
|
'workflow.columns': 'Board columns',
|
||||||
|
'workflow.columnsDescription':
|
||||||
|
'Columns appear from left to right in this order. Edit a column to change its name or position.',
|
||||||
|
'workflow.emptyColumns': 'No board columns yet',
|
||||||
|
'workflow.emptyColumnsDescription':
|
||||||
|
'Add your first column to start organizing tickets on the board.',
|
||||||
|
'workflow.addColumn': 'Add column',
|
||||||
|
'workflow.editColumn': 'Edit column',
|
||||||
|
'workflow.deleteColumn': 'Delete column?',
|
||||||
|
'workflow.addColumnDescription':
|
||||||
|
'The column is added at the end of the board with a first status of the same name.',
|
||||||
|
'workflow.editColumnDescription':
|
||||||
|
'Rename this column or move it to a different position. Its statuses keep their names.',
|
||||||
|
'workflow.deleteColumnDescription':
|
||||||
|
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
|
||||||
|
'workflow.addStatus': 'Add status',
|
||||||
|
'workflow.editStatus': 'Rename status',
|
||||||
|
'workflow.deleteStatus': 'Delete status?',
|
||||||
|
'workflow.statusContext': 'This status belongs to {context}.',
|
||||||
|
'workflow.deleteStatusDescription':
|
||||||
|
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
|
||||||
|
'workflow.columnName': 'Column name',
|
||||||
|
'workflow.statusName': 'Status name',
|
||||||
|
'workflow.position': 'Position',
|
||||||
|
'workflow.positionValue': 'Position {position}',
|
||||||
|
'workflow.positionHelp':
|
||||||
|
'Choose a position from 1 to {count}. Other columns shift automatically.',
|
||||||
|
'workflow.statusesIn': 'Statuses in {context}',
|
||||||
|
'workflow.renameStatus': 'Rename status {name}',
|
||||||
|
'workflow.removeStatus': 'Delete status {name}',
|
||||||
|
'workflow.changeColumn': 'Edit column {name}',
|
||||||
|
'workflow.removeColumn': 'Delete column {name}',
|
||||||
|
'workflow.addStatusTo': 'Add status to {context}',
|
||||||
|
'workflow.lastStatus':
|
||||||
|
'Keep at least one status in this group. The last status cannot be deleted.',
|
||||||
|
'workflow.transitions': 'Status moves',
|
||||||
|
'workflow.transitionsDescription':
|
||||||
|
'Choose exactly where each status is allowed to move next.',
|
||||||
|
'workflow.sourceStatus': 'From status',
|
||||||
|
'workflow.directionHelp':
|
||||||
|
'Rules apply in one direction. Select another source to configure the return move.',
|
||||||
|
'workflow.targetsFrom': 'Allowed destinations from {name}',
|
||||||
|
'workflow.targetHelp':
|
||||||
|
'Checked destinations are allowed. Unchecked destinations are forbidden. Save to apply these rules to ticket moves and board drop targets.',
|
||||||
|
'workflow.allowed': 'Allowed',
|
||||||
|
'workflow.notAllowed': 'Not allowed',
|
||||||
|
'workflow.noMovesHelp':
|
||||||
|
'With no destinations selected, tickets cannot leave this status after you save.',
|
||||||
|
'workflow.noStatuses':
|
||||||
|
'Add a status to configure its allowed destinations.',
|
||||||
|
'workflow.unsavedMoves': 'Unsaved changes',
|
||||||
|
'workflow.savedMoves': 'Saved destinations: {count}',
|
||||||
|
'workflow.resetMoves': 'Reset changes',
|
||||||
|
'workflow.saveMoves': 'Save moves',
|
||||||
|
'workflow.noTargets': 'No other statuses exist yet.',
|
||||||
|
'workflow.confirmDelete': 'Delete',
|
||||||
|
'security.title': 'Security settings',
|
||||||
|
'security.passwordTitle': 'Update password',
|
||||||
|
'security.passwordDescription':
|
||||||
|
'Use a long, unique password to keep your account secure.',
|
||||||
|
'security.currentPassword': 'Current password',
|
||||||
|
'security.newPassword': 'New password',
|
||||||
|
'security.confirmPassword': 'Confirm password',
|
||||||
|
'security.savePassword': 'Save password',
|
||||||
|
'security.savingPassword': 'Saving password…',
|
||||||
|
'security.passwordSaved': 'Password saved.',
|
||||||
|
'security.twoFactorTitle': 'Two-factor authentication',
|
||||||
|
'security.twoFactorDescription':
|
||||||
|
'Add a second layer of protection with a code from your authenticator app.',
|
||||||
|
'security.enabledDescription':
|
||||||
|
'Your authenticator app provides a six-digit code when you sign in. Keep your recovery codes somewhere safe.',
|
||||||
|
'security.enabled': 'Enabled',
|
||||||
|
'security.disabled': 'Disabled',
|
||||||
|
'security.pending': 'Awaiting confirmation',
|
||||||
|
'security.enable': 'Enable 2FA',
|
||||||
|
'security.disable': 'Disable 2FA',
|
||||||
|
'security.continueSetup': 'Continue setup',
|
||||||
|
'security.disableTitle': 'Disable two-factor authentication?',
|
||||||
|
'security.disableDescription':
|
||||||
|
'You will no longer need an authenticator code to sign in. Your existing recovery codes will be removed.',
|
||||||
|
'security.setupTitle': 'Set up your authenticator',
|
||||||
|
'security.setupDescription':
|
||||||
|
'Scan this QR code with a TOTP authenticator app, or enter the setup key manually.',
|
||||||
|
'security.setupEnabled': 'Two-factor authentication enabled',
|
||||||
|
'security.setupEnabledDescription':
|
||||||
|
'Finish adding this account to your authenticator app and save your recovery codes.',
|
||||||
|
'security.verifyTitle': 'Verify authentication code',
|
||||||
|
'security.verifyDescription':
|
||||||
|
'Enter the six-digit code from your authenticator app to finish enabling two-factor authentication.',
|
||||||
|
'security.authenticationCode': 'Authentication code',
|
||||||
|
'security.manualKey': 'Manual setup key',
|
||||||
|
'security.copyKey': 'Copy key',
|
||||||
|
'security.copied': 'Copied',
|
||||||
|
'security.copyError':
|
||||||
|
'Copying failed. Select the key and copy it manually.',
|
||||||
|
'security.confirm': 'Confirm',
|
||||||
|
'security.continue': 'Continue',
|
||||||
|
'security.loadingSetup': 'Loading authenticator setup…',
|
||||||
|
'security.setupPendingDescription':
|
||||||
|
'Protection starts after you confirm a code. Closing this dialog keeps setup unfinished; you can continue from this page.',
|
||||||
|
'security.confirmAgain':
|
||||||
|
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
|
||||||
|
'security.setupError':
|
||||||
|
'Authenticator setup could not be loaded. Please try again.',
|
||||||
|
'security.recoveryError':
|
||||||
|
'Recovery codes could not be loaded. Please try again.',
|
||||||
|
'security.recoveryTitle': '2FA recovery codes',
|
||||||
|
'security.recoveryDescription':
|
||||||
|
'Recovery codes let you regain access if you lose your authenticator. Store them in a secure password manager.',
|
||||||
|
'security.hideRecovery': 'Hide recovery codes',
|
||||||
|
'security.viewRecovery': 'View recovery codes',
|
||||||
|
'security.loadingRecovery': 'Loading recovery codes…',
|
||||||
|
'security.regenerate': 'Regenerate codes',
|
||||||
|
'security.recoveryOnce':
|
||||||
|
'Each recovery code can be used once. Hiding this section removes the codes from this page.',
|
||||||
|
'security.regenerateTitle': 'Regenerate recovery codes?',
|
||||||
|
'security.regenerateDescription':
|
||||||
|
'All previous recovery codes will stop working. Save the new codes in a secure place.',
|
||||||
|
'applications.title': 'Applications',
|
||||||
|
'applications.description':
|
||||||
|
'Create API keys for tools that use KanbanGit with your account permissions. Delete an application to revoke its access.',
|
||||||
|
'applications.saveKey': 'Save this API key now.',
|
||||||
|
'applications.keyOnce':
|
||||||
|
'This key is shown only after creation. It will not be shown again after you leave this page.',
|
||||||
|
'applications.apiKey': 'API key',
|
||||||
|
'applications.create': 'Create application',
|
||||||
|
'applications.nameDescription':
|
||||||
|
'Name the tool that will receive an API key.',
|
||||||
|
'applications.name': 'Application name',
|
||||||
|
'applications.namePlaceholder': 'For example, Claude Desktop',
|
||||||
|
'applications.creating': 'Creating application…',
|
||||||
|
'applications.saved': 'Saved applications',
|
||||||
|
'applications.hiddenKeys':
|
||||||
|
'Existing keys stay hidden. Create a new application when another key is needed.',
|
||||||
|
'applications.empty': 'No applications have been created yet.',
|
||||||
|
'applications.created': 'Created:',
|
||||||
|
'applications.lastUsed': 'Last used:',
|
||||||
|
'applications.never': 'Never',
|
||||||
|
'applications.delete': 'Delete',
|
||||||
|
'applications.deleteNamed': 'Delete {name}',
|
||||||
|
'applications.deleteTitle': 'Delete application?',
|
||||||
|
'applications.deleteDescription':
|
||||||
|
'{name} will lose access immediately. This cannot be undone.',
|
||||||
|
'common.back': 'Back',
|
||||||
|
'common.done': 'Done',
|
||||||
|
|
||||||
|
'criteria.title': 'Acceptance criteria',
|
||||||
|
'criteria.progress': '{completed} of {total} complete',
|
||||||
|
'criteria.empty': 'No acceptance criteria yet.',
|
||||||
|
'criteria.required': 'Enter a criterion or remove this row.',
|
||||||
|
'criteria.edit': 'Edit checklist',
|
||||||
|
'criteria.preview': 'Preview checklist',
|
||||||
|
'criteria.item': 'Criterion {number}',
|
||||||
|
'criteria.complete': 'Complete criterion {number}: {text}',
|
||||||
|
'criteria.indent': 'Indent criterion {number}',
|
||||||
|
'criteria.outdent': 'Outdent criterion {number}',
|
||||||
|
'criteria.remove': 'Remove criterion {number}',
|
||||||
|
'criteria.add': 'Add criterion',
|
||||||
|
'criteria.hint':
|
||||||
|
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
|
||||||
|
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
|
||||||
|
'criteria.saving': 'Saving checklist…',
|
||||||
|
'criteria.saveError':
|
||||||
|
'Checklist status could not be saved. Please try again.',
|
||||||
|
|
||||||
'board.title': '{name} board',
|
'board.title': '{name} board',
|
||||||
'board.description':
|
'board.description':
|
||||||
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
||||||
@@ -18,8 +192,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.',
|
||||||
@@ -295,6 +514,7 @@ export const en = {
|
|||||||
'navigation.appearance': 'Appearance',
|
'navigation.appearance': 'Appearance',
|
||||||
'navigation.settingsDescription':
|
'navigation.settingsDescription':
|
||||||
'Manage your profile, security, and Git integration settings.',
|
'Manage your profile, security, and Git integration settings.',
|
||||||
|
'settings.eyebrow': 'Account settings',
|
||||||
'navigation.createTicket': 'New ticket',
|
'navigation.createTicket': 'New ticket',
|
||||||
'navigation.createProject': 'New project',
|
'navigation.createProject': 'New project',
|
||||||
'navigation.open': 'Open navigation',
|
'navigation.open': 'Open navigation',
|
||||||
@@ -309,8 +529,58 @@ export const en = {
|
|||||||
'navigation.breadcrumbs': 'Breadcrumbs',
|
'navigation.breadcrumbs': 'Breadcrumbs',
|
||||||
'appearance.label': 'Color theme',
|
'appearance.label': 'Color theme',
|
||||||
'appearance.light': 'Light',
|
'appearance.light': 'Light',
|
||||||
|
'appearance.lightDescription':
|
||||||
|
'Use a bright workspace with dark text and clear borders.',
|
||||||
'appearance.dark': 'Dark',
|
'appearance.dark': 'Dark',
|
||||||
|
'appearance.darkDescription':
|
||||||
|
'Reduce glare with a darker workspace and balanced contrast.',
|
||||||
'appearance.system': 'System',
|
'appearance.system': 'System',
|
||||||
|
'appearance.systemDescription':
|
||||||
|
'Follow the light or dark preference set on this device.',
|
||||||
|
'appearance.title': 'Appearance',
|
||||||
|
'appearance.description': 'Choose how KanbanGit looks on this device.',
|
||||||
|
'appearance.persistenceHint':
|
||||||
|
'Your choice is saved on this device and restored when you open KanbanGit again.',
|
||||||
|
'profile.title': 'Profile',
|
||||||
|
'profile.description':
|
||||||
|
'Keep your personal details and profile picture up to date.',
|
||||||
|
'profile.information': 'Profile information',
|
||||||
|
'profile.picture': 'Profile picture',
|
||||||
|
'profile.pictureDescription':
|
||||||
|
'Help teammates recognize you throughout the workspace.',
|
||||||
|
'profile.picturePreview': 'Profile picture preview',
|
||||||
|
'profile.choosePicture': 'Choose picture',
|
||||||
|
'profile.changePicture': 'Choose another',
|
||||||
|
'profile.discardPicture': 'Discard selection',
|
||||||
|
'profile.removePicture': 'Remove picture',
|
||||||
|
'profile.removingPicture': 'Removing picture…',
|
||||||
|
'profile.selectedPicture': 'Selected: {name}',
|
||||||
|
'profile.pictureHelp':
|
||||||
|
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
|
||||||
|
'profile.name': 'Name',
|
||||||
|
'profile.namePlaceholder': 'Full name',
|
||||||
|
'profile.email': 'Email address',
|
||||||
|
'profile.emailPlaceholder': 'Email address',
|
||||||
|
'profile.emailUnverified': 'Your email address is unverified.',
|
||||||
|
'profile.resendVerification': 'Resend the verification email',
|
||||||
|
'profile.verificationSent':
|
||||||
|
'A new verification link has been sent to your email address.',
|
||||||
|
'profile.save': 'Save profile',
|
||||||
|
'profile.saving': 'Saving profile…',
|
||||||
|
'profile.unsaved': 'You have unsaved profile changes.',
|
||||||
|
'profile.delete.title': 'Delete account',
|
||||||
|
'profile.delete.description':
|
||||||
|
'Permanently delete your account and all of its resources.',
|
||||||
|
'profile.delete.warning': 'This action cannot be undone',
|
||||||
|
'profile.delete.warningDescription':
|
||||||
|
'Your account and associated data will be permanently removed.',
|
||||||
|
'profile.delete.action': 'Delete account',
|
||||||
|
'profile.delete.confirmTitle':
|
||||||
|
'Are you sure you want to delete your account?',
|
||||||
|
'profile.delete.confirmDescription':
|
||||||
|
'Enter your password to confirm that you want to permanently delete your account and its data.',
|
||||||
|
'profile.delete.password': 'Password',
|
||||||
|
'profile.delete.confirmAction': 'Permanently delete account',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.close': 'Close',
|
'common.close': 'Close',
|
||||||
'common.retry': 'Try again',
|
'common.retry': 'Try again',
|
||||||
@@ -345,6 +615,8 @@ export const en = {
|
|||||||
'tickets.noStatus': 'No status',
|
'tickets.noStatus': 'No status',
|
||||||
'tickets.assignedTo': 'Assigned to {name}',
|
'tickets.assignedTo': 'Assigned to {name}',
|
||||||
'tickets.unassigned': 'Unassigned',
|
'tickets.unassigned': 'Unassigned',
|
||||||
|
'tickets.tree.expand': 'Expand children of {key}',
|
||||||
|
'tickets.tree.collapse': 'Collapse children of {key}',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.error': 'Something went wrong.',
|
'common.error': 'Something went wrong.',
|
||||||
@@ -353,4 +625,339 @@ export const en = {
|
|||||||
one: '{count} ticket',
|
one: '{count} ticket',
|
||||||
other: '{count} tickets',
|
other: '{count} tickets',
|
||||||
},
|
},
|
||||||
|
'ticketDetail.description': 'Description',
|
||||||
|
'ticketDetail.noDescription': 'No description has been added yet.',
|
||||||
|
'ticketDetail.checklist': 'Acceptance criteria',
|
||||||
|
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
|
||||||
|
'ticketDetail.saveChecklistFirst':
|
||||||
|
'Save checklist text changes before toggling completion directly.',
|
||||||
|
'ticketDetail.edit': 'Edit ticket',
|
||||||
|
'ticketDetail.editDescription':
|
||||||
|
'Tracked ticket changes create worklog entries automatically.',
|
||||||
|
'ticketDetail.git': 'Git',
|
||||||
|
'ticketDetail.relationships': 'Relationships',
|
||||||
|
'ticketDetail.history': 'Worklog',
|
||||||
|
'ticketDetail.metadata': 'Ticket information',
|
||||||
|
'ticketDetail.sections': 'Ticket sections',
|
||||||
|
'ticketDetail.status': 'Status',
|
||||||
|
'ticketDetail.type': 'Type',
|
||||||
|
'ticketDetail.priority': 'Priority',
|
||||||
|
'ticketDetail.assignee': 'Assignee',
|
||||||
|
'ticketDetail.author': 'Author',
|
||||||
|
'ticketDetail.noAuthor': 'No author',
|
||||||
|
'ticketDetail.storyPoints': 'Estimated story points',
|
||||||
|
'ticketDetail.notSet': 'Not set',
|
||||||
|
'ticketDetail.created': 'Created',
|
||||||
|
'ticketDetail.updated': 'Last updated',
|
||||||
|
'ticketDetail.backToProject': 'Back to project',
|
||||||
|
'ticketDetail.openBacklog': 'Open backlog',
|
||||||
|
'ticketDetail.openBoard': 'Open board',
|
||||||
|
'ticketDetail.readOnly': 'Read only',
|
||||||
|
'ticketDetail.title': 'Title',
|
||||||
|
'ticketDetail.parent': 'Parent ticket',
|
||||||
|
'ticketDetail.noParent': 'No parent ticket',
|
||||||
|
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
|
||||||
|
'ticketDetail.checklistHelp':
|
||||||
|
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
|
||||||
|
'ticketDetail.checklistPlaceholder':
|
||||||
|
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||||
|
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||||
|
'ticketDetail.blocker': 'Blocker',
|
||||||
|
'ticketDetail.blockerDescription':
|
||||||
|
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
|
||||||
|
'ticketDetail.blockerActive': 'Blocked',
|
||||||
|
'ticketDetail.blockerInactive': 'Not blocked',
|
||||||
|
'ticketDetail.blockerActiveDescription':
|
||||||
|
'A blocker reason is required while this ticket is blocked.',
|
||||||
|
'ticketDetail.blockerInactiveDescription':
|
||||||
|
'This ticket currently has no active blocker.',
|
||||||
|
'ticketDetail.blockerComment': 'Blocker comment',
|
||||||
|
'ticketDetail.blockerCommentHelp':
|
||||||
|
'Explain the concrete dependency or decision preventing progress.',
|
||||||
|
'ticketDetail.blockerPlaceholder':
|
||||||
|
'Describe what currently blocks progress on this ticket.',
|
||||||
|
'ticketDetail.saveBlocker': 'Save blocker',
|
||||||
|
'ticketDetail.unblockTicket': 'Unblock ticket',
|
||||||
|
'ticketDetail.resetBlocker': 'Discard blocker changes',
|
||||||
|
'ticketDetail.savingBlocker': 'Saving blocker…',
|
||||||
|
'ticketDetail.customFields': 'Custom fields',
|
||||||
|
'ticketDetail.customFieldsDescription':
|
||||||
|
'Project-configured fields can be tracked directly on the ticket.',
|
||||||
|
'ticketDetail.noCustomFields':
|
||||||
|
'No custom fields are configured for this project yet.',
|
||||||
|
'ticketDetail.saveChanges': 'Save changes',
|
||||||
|
'ticketDetail.branches': 'Branches',
|
||||||
|
'ticketDetail.branchesDescription':
|
||||||
|
'Matching branches are discovered from linked repositories when the branch name starts with this ticket key.',
|
||||||
|
'ticketDetail.createBranch': 'Create branch',
|
||||||
|
'ticketDetail.createBranchTitle': 'Create branch from ticket',
|
||||||
|
'ticketDetail.createBranchDescription':
|
||||||
|
'Pick a linked repository, confirm the suggested branch name, and create it directly in Gitea.',
|
||||||
|
'ticketDetail.repository': 'Repository',
|
||||||
|
'ticketDetail.branchName': 'Branch name',
|
||||||
|
'ticketDetail.branchAccess':
|
||||||
|
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||||
|
'ticketDetail.gitAccessRequired': 'Personal Git access required',
|
||||||
|
'ticketDetail.personalAccessRequired': 'personal Git access required',
|
||||||
|
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||||
|
'ticketDetail.createNow': 'Create now',
|
||||||
|
'ticketDetail.creatingBranch': 'Creating branch…',
|
||||||
|
'ticketDetail.noLinkedRepositories': 'No linked repositories',
|
||||||
|
'ticketDetail.noLinkedRepositoriesDescription':
|
||||||
|
'A project administrator needs to link a repository before Git activity can appear here.',
|
||||||
|
'ticketDetail.branchesUnavailable': 'Branches unavailable',
|
||||||
|
'ticketDetail.pullRequestsUnavailable': 'Pull requests unavailable',
|
||||||
|
'ticketDetail.openRepositoryExternal':
|
||||||
|
'Open {repository} on {provider} (opens in a new tab)',
|
||||||
|
'ticketDetail.openPullRequestExternal':
|
||||||
|
'Open pull request #{number} on {provider} (opens in a new tab)',
|
||||||
|
'ticketDetail.inspectBranches':
|
||||||
|
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||||
|
'ticketDetail.pullRequests': 'Pull requests',
|
||||||
|
'ticketDetail.pullRequestsDescription':
|
||||||
|
'Matching pull requests are detected from linked repositories by checking whether the source branch starts with this ticket key.',
|
||||||
|
'ticketDetail.inspectPullRequests':
|
||||||
|
'Connect a matching Git account in your user settings to inspect pull requests for this repository.',
|
||||||
|
'ticketDetail.checkingBranch':
|
||||||
|
'Checking whether this branch name is valid and still available...',
|
||||||
|
'ticketDetail.selectRepository':
|
||||||
|
'Select a linked repository to validate the branch name.',
|
||||||
|
'ticketDetail.connectGitFirst':
|
||||||
|
'Connect a matching Git connection in your user settings first.',
|
||||||
|
'ticketDetail.branchRequired': 'Branch name is required.',
|
||||||
|
'ticketDetail.branchValidationFailed':
|
||||||
|
'{app} could not validate this branch name right now.',
|
||||||
|
'ticketDetail.checklistSaveFailed':
|
||||||
|
'Checklist status could not be saved right now.',
|
||||||
|
'ticketDetail.children': 'Child tickets',
|
||||||
|
'ticketDetail.childrenDescription':
|
||||||
|
'Parent tickets expose their full child tree here, including nested parent chains.',
|
||||||
|
'ticketDetail.noChildren':
|
||||||
|
'No child tickets are assigned to this ticket yet.',
|
||||||
|
'ticketDetail.parentDescription':
|
||||||
|
'This ticket belongs to the parent shown here.',
|
||||||
|
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||||
|
'ticketDetail.linkedTicketsDescription':
|
||||||
|
'Track cross-ticket relationships with project-defined link labels.',
|
||||||
|
'ticketDetail.linkType': 'Link type',
|
||||||
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
|
'ticketDetail.saveLink': 'Save link',
|
||||||
|
'ticketDetail.noLinks':
|
||||||
|
'No ticket links are configured for this ticket yet.',
|
||||||
|
'ticketDetail.removeLink': 'Remove',
|
||||||
|
'ticketRelationships.hierarchy': 'Hierarchy',
|
||||||
|
'ticketRelationships.editParent': 'Change parent',
|
||||||
|
'ticketRelationships.parentHelp':
|
||||||
|
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
|
||||||
|
'ticketRelationships.childrenHelp':
|
||||||
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
|
'ticketRelationships.directionHelp':
|
||||||
|
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
||||||
|
'ticketRelationships.outgoing': 'Outgoing',
|
||||||
|
'ticketRelationships.incoming': 'Incoming',
|
||||||
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
|
'ticketRelationships.noTypes':
|
||||||
|
'No link types are configured for this project.',
|
||||||
|
'ticketRelationships.noTargets':
|
||||||
|
'No other tickets are available to link in this project.',
|
||||||
|
'ticketDetail.historyDescription':
|
||||||
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
|
'ticketDetail.system': 'System',
|
||||||
|
'ticketDetail.historyType.created': 'Ticket created',
|
||||||
|
'ticketDetail.historyType.updated': 'Ticket updated',
|
||||||
|
'ticketDetail.historyType.statusChanged': 'Status changed',
|
||||||
|
'ticketDetail.historyType.linkUpdated': 'Link added',
|
||||||
|
'ticketDetail.historyType.linkRemoved': 'Link removed',
|
||||||
|
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||||
|
'ticketForm.create': 'Create ticket',
|
||||||
|
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||||
|
'ticketForm.createDescription': 'Add a ticket to {project}.',
|
||||||
|
'ticketForm.backToBoard': 'Back to board',
|
||||||
|
'ticketForm.backToBacklog': 'Back to backlog',
|
||||||
|
'ticketForm.details': 'Ticket details',
|
||||||
|
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
|
||||||
|
'ticketForm.titlePlaceholder': 'Implement repository sync',
|
||||||
|
'ticketForm.descriptionPlaceholder':
|
||||||
|
'Describe the expected behavior and relevant context.',
|
||||||
|
'ticketForm.checklistHelp':
|
||||||
|
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
|
||||||
|
'ticketForm.checklistPreview': 'Checklist preview',
|
||||||
|
'ticketForm.metadataRequired':
|
||||||
|
'Ticket creation needs at least one type, one priority, and one status.',
|
||||||
|
'ticketForm.openSettings': 'Open ticket settings',
|
||||||
|
'ticketForm.creating': 'Creating ticket…',
|
||||||
|
'ticketForm.saving': 'Saving changes…',
|
||||||
|
'ticketForm.reset': 'Discard changes',
|
||||||
|
'ticketForm.unsaved': 'Unsaved changes',
|
||||||
|
'ticketForm.ready': 'Ready for a new ticket',
|
||||||
|
'ticketForm.saved': 'All changes saved',
|
||||||
|
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||||
|
'ticketForm.blocker': 'Blocker',
|
||||||
|
'projectSettings.pageTitle': '{project} general settings',
|
||||||
|
'projectSettings.description':
|
||||||
|
'Manage settings and template inheritance for {project}.',
|
||||||
|
'projectSettings.generalDescription':
|
||||||
|
'Review project identity, setting sources, and administrative actions.',
|
||||||
|
'projectSettings.identity': 'Project identity',
|
||||||
|
'projectSettings.identityDescription':
|
||||||
|
'The name and key belong to this project and are never inherited from a template.',
|
||||||
|
'projectSettings.name': 'Project name',
|
||||||
|
'projectSettings.key': 'Project key',
|
||||||
|
'projectSettings.local': 'Local',
|
||||||
|
'projectSettings.inherited': 'Inherited',
|
||||||
|
'projectSettings.override': 'Local override',
|
||||||
|
'projectSettings.display': 'Ticket display',
|
||||||
|
'projectSettings.showAuthor': 'Show author',
|
||||||
|
'projectSettings.showStoryPoints': 'Show story points',
|
||||||
|
'projectSettings.enabled': 'Enabled',
|
||||||
|
'projectSettings.disabled': 'Disabled',
|
||||||
|
'projectSettings.displayDescription':
|
||||||
|
'Manage these display options in Ticket settings.',
|
||||||
|
'projectSettings.template': 'Template baseline',
|
||||||
|
'projectSettings.templateDescription':
|
||||||
|
'Manage the linked template or reset individual areas using the inheritance panel.',
|
||||||
|
'projectSettings.currentTemplate': 'Current linked template',
|
||||||
|
'projectSettings.noTemplate': 'No linked template',
|
||||||
|
'projectSettings.linked': 'Template inheritance active',
|
||||||
|
'projectSettings.standalone': 'Standalone',
|
||||||
|
'projectSettings.standaloneDescription':
|
||||||
|
'This project manages its settings locally. Published template updates are not applied.',
|
||||||
|
'projectSettings.noAlternatives': 'No other published templates',
|
||||||
|
'projectSettings.noAlternativesDescription':
|
||||||
|
'A different published template is required to switch this project.',
|
||||||
|
'projectSettings.switch': 'Switch template',
|
||||||
|
'projectSettings.switching': 'Switching template…',
|
||||||
|
'projectSettings.switchWarning':
|
||||||
|
'Switching templates can replace or remove project configuration in copied areas, including workflows, ticket metadata, repositories, and notifications.',
|
||||||
|
'projectSettings.publishedTemplate': 'Published template',
|
||||||
|
'projectSettings.selectTemplate': 'Select a template',
|
||||||
|
'projectSettings.confirmation': 'Confirmation key',
|
||||||
|
'projectSettings.confirmPlaceholder': 'Type {key} exactly',
|
||||||
|
'projectSettings.confirmHelp':
|
||||||
|
'Enter the exact project key {key} to confirm this action. The key is case sensitive.',
|
||||||
|
'projectSettings.danger': 'Danger zone',
|
||||||
|
'projectSettings.deleteDescription':
|
||||||
|
'Permanently delete this project and all of its tickets, board columns, ticket metadata, repository connections, and project permissions.',
|
||||||
|
'projectSettings.irreversible': 'This action cannot be undone.',
|
||||||
|
'projectSettings.delete': 'Delete project',
|
||||||
|
'projectSettings.deleting': 'Deleting project…',
|
||||||
|
'projectSettings.updated': 'Project settings updated.',
|
||||||
|
'inheritance.title': 'Template inheritance',
|
||||||
|
'inheritance.description': 'Settings linked to {template}.',
|
||||||
|
'inheritance.help':
|
||||||
|
'Each area inherits independently. Settings outside the template are managed locally.',
|
||||||
|
'inheritance.areas': 'Setting sources',
|
||||||
|
'inheritance.overrideHelp':
|
||||||
|
'Local changes stay in place when the template is updated.',
|
||||||
|
'inheritance.reset': 'Reset to template',
|
||||||
|
'inheritance.resetArea': 'Reset {area} to template',
|
||||||
|
'inheritance.resetting': 'Resetting…',
|
||||||
|
'inheritance.confirmTitle': 'Reset this setting area?',
|
||||||
|
'inheritance.confirmDescription':
|
||||||
|
'Replace local changes in {area} with the current baseline from {template}. Other setting areas keep their current values.',
|
||||||
|
'inheritance.resetWarning':
|
||||||
|
'Local configuration in this area may be replaced or removed.',
|
||||||
|
'inheritance.statusHelp':
|
||||||
|
'Choose where tickets in local statuses should move before those statuses are removed.',
|
||||||
|
'inheritance.statusLabel': '{status} ({count} tickets)',
|
||||||
|
'inheritance.selectStatus': 'Select replacement status',
|
||||||
|
'inheritance.area.projectSettings': 'Project settings',
|
||||||
|
'inheritance.area.boardColumns': 'Board columns',
|
||||||
|
'inheritance.area.ticketStatuses': 'Ticket statuses',
|
||||||
|
'inheritance.area.statusTransitions': 'Status transitions',
|
||||||
|
'inheritance.area.ticketTypes': 'Ticket types',
|
||||||
|
'inheritance.area.ticketPriorities': 'Ticket priorities',
|
||||||
|
'inheritance.area.ticketFields': 'Custom ticket fields',
|
||||||
|
'inheritance.area.ticketLinkTypes': 'Ticket link types',
|
||||||
|
'inheritance.area.repositoryConnections': 'Repository connections',
|
||||||
|
'inheritance.area.notificationActions': 'Notification actions',
|
||||||
|
'gitConnections.description':
|
||||||
|
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
|
||||||
|
'gitConnections.privacy':
|
||||||
|
'Connections belong to your account. Saved tokens stay hidden. Without a matching connection, only repository names are available.',
|
||||||
|
'gitConnections.add': 'Add connection',
|
||||||
|
'gitConnections.editAction': 'Edit',
|
||||||
|
'gitConnections.edit': 'Edit connection',
|
||||||
|
'gitConnections.remove': 'Remove',
|
||||||
|
'gitConnections.removeTitle': 'Remove connection',
|
||||||
|
'gitConnections.saved': 'Saved connections',
|
||||||
|
'gitConnections.empty': 'No Git connections yet',
|
||||||
|
'gitConnections.emptyDescription':
|
||||||
|
'Add a Gitea connection to access your repositories and their Git activity.',
|
||||||
|
'gitConnections.unnamedAccount': 'Unnamed remote account',
|
||||||
|
'gitConnections.remoteId': 'Remote user ID: {id}',
|
||||||
|
'gitConnections.editAccount': 'Edit {account} at {server}',
|
||||||
|
'gitConnections.removeAccount': 'Remove {account} at {server}',
|
||||||
|
'gitConnections.addDescription':
|
||||||
|
'Connect a Gitea account using its server URL and a personal application token.',
|
||||||
|
'gitConnections.editDescription':
|
||||||
|
'Update the server URL and enter a fresh token to verify this connection.',
|
||||||
|
'gitConnections.removeDescription':
|
||||||
|
'Remove this personal connection? Repository links remain, but Git activity will require another matching connection.',
|
||||||
|
'gitConnections.gitea': 'Gitea',
|
||||||
|
'gitConnections.baseUrl': 'Gitea base URL',
|
||||||
|
'gitConnections.token': 'Application token',
|
||||||
|
'gitConnections.newToken': 'New application token',
|
||||||
|
'gitConnections.tokenPlaceholder': 'Paste your Gitea application token',
|
||||||
|
'gitConnections.tokenHelp':
|
||||||
|
'Your token is verified with Gitea before saving. It is never shown in the saved connection.',
|
||||||
|
'gitConnections.verifying': 'Verifying connection…',
|
||||||
|
'gitConnections.save': 'Save changes',
|
||||||
|
'gitConnections.removing': 'Removing connection…',
|
||||||
|
'gitConnections.forbidden': 'Connection access denied',
|
||||||
|
'gitConnections.forbiddenDescription':
|
||||||
|
'You can only manage connections belonging to your account. Close this dialog and refresh the page.',
|
||||||
|
'gitConnections.added': 'Git connection added.',
|
||||||
|
'gitConnections.updated': 'Git connection updated.',
|
||||||
|
'gitConnections.removed': 'Git connection removed.',
|
||||||
|
'metadata.title': 'Ticket settings',
|
||||||
|
'metadata.pageTitle': '{project} ticket settings',
|
||||||
|
'metadata.description':
|
||||||
|
'Manage ticket types, priorities, and the information shown on your tickets.',
|
||||||
|
'metadata.updated': 'Ticket settings updated.',
|
||||||
|
'metadata.types': 'Ticket types',
|
||||||
|
'metadata.priorities': 'Priorities',
|
||||||
|
'metadata.addType': 'Add type',
|
||||||
|
'metadata.addPriority': 'Add priority',
|
||||||
|
'metadata.typesDescription':
|
||||||
|
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
|
||||||
|
'metadata.prioritiesDescription':
|
||||||
|
'Define and order project-specific priorities. The last priority must be kept.',
|
||||||
|
'metadata.parentType': 'Parent type',
|
||||||
|
'metadata.standardType': 'Standard type',
|
||||||
|
'metadata.assignedTickets': 'Assigned tickets: {count}',
|
||||||
|
'metadata.editEntry': 'Edit {name}',
|
||||||
|
'metadata.deleteEntry': 'Delete {name}',
|
||||||
|
'metadata.lastType':
|
||||||
|
'The last ticket type cannot be deleted. Add another type first.',
|
||||||
|
'metadata.lastPriority':
|
||||||
|
'The last priority cannot be deleted. Add another priority first.',
|
||||||
|
'metadata.display': 'Display options',
|
||||||
|
'metadata.displayDescription':
|
||||||
|
'Choose which optional information is available on this project’s tickets.',
|
||||||
|
'metadata.author': 'Show ticket author',
|
||||||
|
'metadata.authorHelp': 'Display the author on ticket details.',
|
||||||
|
'metadata.storyPoints': 'Enable story points',
|
||||||
|
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
|
||||||
|
'metadata.name': 'Name',
|
||||||
|
'metadata.positionHelp':
|
||||||
|
'Choose a position from 1 to {count}. Other entries shift automatically.',
|
||||||
|
'metadata.allowChildren': 'Allow child tickets',
|
||||||
|
'metadata.parentFixed':
|
||||||
|
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
|
||||||
|
'metadata.editHelp':
|
||||||
|
'Choose a project-specific name. Changes apply to tickets using this entry.',
|
||||||
|
'metadata.deleteTypeHelp':
|
||||||
|
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
|
||||||
|
'metadata.deletePriorityHelp':
|
||||||
|
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
|
||||||
|
'metadata.replacementHelp':
|
||||||
|
'Move {count} assigned tickets to another type before deleting this one.',
|
||||||
|
'metadata.replacement': 'Replacement ticket type',
|
||||||
|
'metadata.chooseType': 'Choose a type',
|
||||||
|
'metadata.parentReplacement':
|
||||||
|
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,70 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
import { TicketListItem } from '@/components/tickets';
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Columns3,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import {
|
||||||
|
buildBacklogTicketTree,
|
||||||
|
countBacklogTreeTickets,
|
||||||
|
expandableBacklogTicketIds,
|
||||||
|
filterBacklogTicketTree,
|
||||||
|
} from '@/lib/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
import type {
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
BacklogFilterState,
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
BreadcrumbItem,
|
||||||
|
ProjectBacklogPageProps,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectBacklogPageProps>();
|
const props = defineProps<ProjectBacklogPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
|
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
|
||||||
|
]);
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const filters = reactive<BacklogFilterState>({
|
||||||
{
|
assigneeId: '',
|
||||||
title: 'Projects',
|
priorityId: '',
|
||||||
href: index(),
|
query: '',
|
||||||
},
|
statusId: '',
|
||||||
{
|
typeId: '',
|
||||||
title: props.project.name,
|
});
|
||||||
href: show(props.project.id),
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
},
|
const pendingTicketId = ref<number | null>(null);
|
||||||
{
|
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
||||||
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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, toRef, unref } from 'vue';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -15,32 +16,30 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
buildAcceptanceCriteria,
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
parseChecklistLines,
|
|
||||||
} from '@/lib/acceptance-criteria';
|
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketCreatePageProps>();
|
const props = defineProps<ProjectTicketCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const returnHref =
|
const returnHref =
|
||||||
props.source === 'board'
|
props.source === 'board'
|
||||||
? boardIndex(props.project.id)
|
? boardIndex(props.project.id)
|
||||||
: backlogIndex(props.project.id);
|
: backlogIndex(props.project.id);
|
||||||
const returnLabel =
|
const returnLabel =
|
||||||
props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
props.source === 'board'
|
||||||
|
? t('ticketForm.backToBoard')
|
||||||
|
: t('ticketForm.backToBacklog');
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Projects',
|
title: t('navigation.projects'),
|
||||||
href: index(),
|
href: index(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,36 +47,31 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
href: show(props.project.id),
|
href: show(props.project.id),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
title:
|
||||||
|
props.source === 'board'
|
||||||
|
? t('navigation.board')
|
||||||
|
: t('navigation.backlog'),
|
||||||
href: returnHref,
|
href: returnHref,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Create ticket',
|
title: t('ticketForm.create'),
|
||||||
href: ProjectTicketController.create({
|
href: ProjectTicketController.create({
|
||||||
project: props.project.id,
|
project: props.project.id,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasTicketMetadata =
|
const hasTicketMetadata = computed(
|
||||||
props.ticketTypes.length > 0 &&
|
() =>
|
||||||
props.ticketPriorities.length > 0 &&
|
props.ticketTypes.length > 0 &&
|
||||||
props.ticketStatusOptions.length > 0;
|
props.ticketPriorities.length > 0 &&
|
||||||
|
props.ticketStatusOptions.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
|
||||||
{ checklist: '' },
|
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
|
||||||
`${draftKey}:checklist`,
|
acceptance_criteria: [] as AcceptanceCriteriaModel,
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm(draftKey, {
|
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
),
|
|
||||||
assignee_user_id: '',
|
assignee_user_id: '',
|
||||||
description: '',
|
description: '',
|
||||||
parent_ticket_id: '',
|
parent_ticket_id: '',
|
||||||
@@ -90,13 +84,16 @@ const form = useForm(draftKey, {
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const protection = useFormProtection(form, {
|
const isDirty = computed(() => form.isDirty);
|
||||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
const protection = useFormProtection(form);
|
||||||
});
|
const reset = (): void => {
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
return;
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
}
|
||||||
);
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
const acceptanceCriteriaError = computed(() => {
|
const acceptanceCriteriaError = computed(() => {
|
||||||
return (
|
return (
|
||||||
@@ -109,39 +106,35 @@ const acceptanceCriteriaError = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
if (!hasTicketMetadata.value || form.processing) {
|
||||||
acceptanceCriteriaChecklist.value,
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
protection.submit(
|
protection.submit(
|
||||||
ProjectTicketController.store({ project: props.project.id }),
|
ProjectTicketController.store({ project: props.project.id }),
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
// The source page is unmounted before this callback, so its storage watcher has stopped.
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
`${draftKey}:generation`,
|
||||||
|
String(draftGeneration.value + 1),
|
||||||
|
);
|
||||||
|
draftGeneration.value++;
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|
||||||
if (
|
|
||||||
ticketStatus.board_column === null ||
|
|
||||||
ticketStatus.board_column === undefined
|
|
||||||
) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Create ${project.name} ticket`" />
|
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
|
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
@@ -149,10 +142,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Badge variant="outline" class="px-3 py-1">
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading
|
<h1 class="text-headline font-semibold tracking-tight">
|
||||||
title="Create ticket"
|
{{ t('ticketForm.create') }}
|
||||||
:description="`Add a ticket to ${project.name}.`"
|
</h1>
|
||||||
/>
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t('ticketForm.createDescription', {
|
||||||
|
project: project.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
@@ -160,7 +159,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<Link :href="show(project.id)">{{
|
||||||
|
t('ticketDetail.backToProject')
|
||||||
|
}}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,9 +175,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
|
|
||||||
<Card v-if="hasTicketMetadata">
|
<Card v-if="hasTicketMetadata">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ticket details</CardTitle>
|
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The ticket key is assigned after creation.
|
{{ t('ticketForm.keyAssigned') }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -186,258 +187,58 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
:state="protection.state.value"
|
:state="protection.state.value"
|
||||||
@retry="submit"
|
@retry="submit"
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-2">
|
<TicketFormFields
|
||||||
<Label for="title">Title</Label>
|
v-model:title="form.title"
|
||||||
<Input
|
v-model:description="form.description"
|
||||||
id="title"
|
v-model:type="form.ticket_type_id"
|
||||||
v-model="form.title"
|
v-model:priority="form.ticket_priority_id"
|
||||||
:invalid="Boolean(form.errors.title)"
|
v-model:status="form.ticket_status_id"
|
||||||
aria-describedby="title-error"
|
v-model:assignee="form.assignee_user_id"
|
||||||
required
|
v-model:parent="form.parent_ticket_id"
|
||||||
autofocus
|
v-model:points="form.story_points"
|
||||||
:disabled="form.processing"
|
:project="project"
|
||||||
placeholder="Implement repository sync"
|
:ticket-types="ticketTypes"
|
||||||
/>
|
:ticket-priorities="ticketPriorities"
|
||||||
<InputError
|
:ticket-status-options="ticketStatusOptions"
|
||||||
id="title-error"
|
:users="users"
|
||||||
:message="form.errors.title"
|
:parent-ticket-options="parentTicketOptions"
|
||||||
/>
|
:errors="form.errors"
|
||||||
</div>
|
:disabled="form.processing"
|
||||||
|
autofocus
|
||||||
<div
|
|
||||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-6">
|
<template #content>
|
||||||
<div class="grid gap-2">
|
<AcceptanceCriteriaChecklist
|
||||||
<Label for="description">Description</Label>
|
v-model="form.acceptance_criteria"
|
||||||
<textarea
|
:disabled="form.processing"
|
||||||
id="description"
|
:error="acceptanceCriteriaError"
|
||||||
v-model="form.description"
|
/>
|
||||||
rows="4"
|
</template>
|
||||||
:disabled="form.processing"
|
</TicketFormFields>
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
<div
|
||||||
placeholder="Describe the expected behavior and relevant context."
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||||
/>
|
>
|
||||||
<InputError
|
<Button type="submit" :disabled="form.processing">{{
|
||||||
:message="form.errors.description"
|
form.processing
|
||||||
/>
|
? t('ticketForm.creating')
|
||||||
</div>
|
: t('ticketForm.create')
|
||||||
|
}}</Button>
|
||||||
<div class="grid gap-4">
|
<Button
|
||||||
<div class="grid gap-2">
|
type="button"
|
||||||
<Label
|
variant="outline"
|
||||||
for="acceptance_criteria_checklist"
|
:disabled="form.processing || !isDirty"
|
||||||
>
|
@click="reset"
|
||||||
Acceptance criteria checklist
|
>{{ t('ticketForm.reset') }}</Button
|
||||||
</Label>
|
>
|
||||||
<textarea
|
<p
|
||||||
id="acceptance_criteria_checklist"
|
role="status"
|
||||||
v-model="
|
class="text-muted-foreground text-sm"
|
||||||
acceptanceCriteriaChecklist
|
>
|
||||||
"
|
{{
|
||||||
rows="6"
|
isDirty
|
||||||
:disabled="form.processing"
|
? t('ticketForm.unsaved')
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
: t('ticketForm.ready')
|
||||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
}}
|
||||||
/>
|
</p>
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Use <code>[ ]</code> /
|
|
||||||
<code>[x]</code> for checklist
|
|
||||||
items. Indent with two spaces for
|
|
||||||
nested steps.
|
|
||||||
</p>
|
|
||||||
<InputError
|
|
||||||
:message="acceptanceCriteriaError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
acceptanceCriteriaPreviewItems.length >
|
|
||||||
0
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border p-4 text-sm"
|
|
||||||
>
|
|
||||||
<p class="mb-3 font-medium">
|
|
||||||
Checklist preview
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="(
|
|
||||||
item, index
|
|
||||||
) in acceptanceCriteriaPreviewItems"
|
|
||||||
:key="index"
|
|
||||||
:style="{
|
|
||||||
marginLeft: `${item.indent * 1.25}rem`,
|
|
||||||
}"
|
|
||||||
class="flex items-start gap-2"
|
|
||||||
>
|
|
||||||
<span class="mt-0.5">{{
|
|
||||||
item.checked ? '☑' : '☐'
|
|
||||||
}}</span>
|
|
||||||
<span>{{ item.text }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid h-fit gap-6">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_type_id">Type</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_type_id"
|
|
||||||
v-model="form.ticket_type_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketType in ticketTypes"
|
|
||||||
:key="ticketType.id"
|
|
||||||
:value="String(ticketType.id)"
|
|
||||||
>
|
|
||||||
{{ ticketType.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.ticket_type_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_priority_id">
|
|
||||||
Priority
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_priority_id"
|
|
||||||
v-model="form.ticket_priority_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketPriority in ticketPriorities"
|
|
||||||
:key="ticketPriority.id"
|
|
||||||
:value="String(ticketPriority.id)"
|
|
||||||
>
|
|
||||||
{{ ticketPriority.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
form.errors.ticket_priority_id
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_status_id">Status</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_status_id"
|
|
||||||
v-model="form.ticket_status_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketStatus in ticketStatusOptions"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="String(ticketStatus.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(ticketStatus)
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.ticket_status_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="assignee_user_id">
|
|
||||||
Assignee
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="assignee_user_id"
|
|
||||||
v-model="form.assignee_user_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="String(user.id)"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.assignee_user_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="parent_ticket_id">
|
|
||||||
Parent ticket
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="parent_ticket_id"
|
|
||||||
v-model="form.parent_ticket_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
No parent ticket
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="parentTicketOption in parentTicketOptions"
|
|
||||||
:key="parentTicketOption.id"
|
|
||||||
:value="
|
|
||||||
String(parentTicketOption.id)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.parent_ticket_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="project.show_story_points"
|
|
||||||
class="grid gap-2"
|
|
||||||
>
|
|
||||||
<Label for="story_points">
|
|
||||||
Estimated story points
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="story_points"
|
|
||||||
v-model="form.story_points"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
:disabled="form.processing"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.story_points"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="form.processing">
|
|
||||||
Create ticket
|
|
||||||
</Button>
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="returnHref">
|
|
||||||
{{ returnLabel }}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -446,13 +247,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Card v-else>
|
<Card v-else>
|
||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Ticket creation needs at least one type, one priority,
|
{{ t('ticketForm.metadataRequired') }}
|
||||||
and one status.
|
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="metadataSettings(project.id)">
|
<Link :href="metadataSettings(project.id)">
|
||||||
Open ticket settings
|
{{ t('ticketForm.openSettings') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,674 +1,209 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
|
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
|
||||||
|
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
|
||||||
|
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
Card,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||||
import {
|
import { index, show } from '@/routes/projects';
|
||||||
board as boardSettings,
|
import { board } from '@/routes/projects/settings';
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||||
|
|
||||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const target = ref<WorkflowEditorTarget | null>(null);
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'Projects',
|
{ title: t('navigation.projects'), href: index() },
|
||||||
href: index(),
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
},
|
{ title: t('workflow.title'), href: board(props.project.id) },
|
||||||
{
|
]);
|
||||||
title: props.project.name,
|
|
||||||
href: show(props.project.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Board settings',
|
|
||||||
href: boardSettings(props.project.id),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|
||||||
if (
|
|
||||||
ticketStatus.board_column === null ||
|
|
||||||
ticketStatus.board_column === undefined
|
|
||||||
) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const transitionTargetOptions = (
|
|
||||||
sourceStatus: TicketStatusModel,
|
|
||||||
): TicketStatusModel[] => {
|
|
||||||
return props.ticketStatuses.filter(
|
|
||||||
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusAllowsTarget = (
|
|
||||||
sourceStatus: TicketStatusModel,
|
|
||||||
targetStatus: TicketStatusModel,
|
|
||||||
): boolean => {
|
|
||||||
return (
|
|
||||||
sourceStatus.allowed_target_statuses?.some(
|
|
||||||
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
|
|
||||||
) ?? false
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} board settings`" />
|
<Head :title="t('workflow.pageTitle', { project: project.name })" />
|
||||||
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<header class="grid gap-3">
|
||||||
<div
|
<h2 class="text-title">{{ t('workflow.title') }}</h2>
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
<p class="text-muted-foreground">
|
||||||
>
|
{{ t('workflow.description') }}
|
||||||
<div class="space-y-3">
|
</p>
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
</header>
|
||||||
{{ project.key }}
|
<FeedbackState
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="`${project.name} board settings`"
|
|
||||||
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button
|
|
||||||
v-if="$page.props.auth.permissions.globalAdmin"
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link :href="generalSettings(project.id)">
|
|
||||||
General settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
state="success"
|
||||||
>
|
:title="t('workflow.updated')"
|
||||||
{{ status }}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TemplateInheritancePanel
|
<TemplateInheritancePanel
|
||||||
|
:project-id="project.id"
|
||||||
|
:template-state="templateState"
|
||||||
:areas="[
|
:areas="[
|
||||||
'board_columns',
|
'board_columns',
|
||||||
'ticket_statuses',
|
'ticket_statuses',
|
||||||
'status_transitions',
|
'status_transitions',
|
||||||
]"
|
]"
|
||||||
:project-id="project.id"
|
|
||||||
:template-state="templateState"
|
|
||||||
/>
|
/>
|
||||||
|
<Card
|
||||||
<Card>
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle>Workflow model</CardTitle>
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
<CardDescription>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
Backlog is always the intake queue; board columns group
|
<Inbox
|
||||||
the statuses that appear on the board.
|
class="text-primary size-5 shrink-0"
|
||||||
</CardDescription>
|
aria-hidden="true"
|
||||||
</CardHeader>
|
/>
|
||||||
|
<h3 class="font-semibold">
|
||||||
<CardContent class="flex flex-wrap gap-3">
|
{{ t('workflow.backlog') }}
|
||||||
<Badge class="px-3 py-1">Backlog</Badge>
|
</h3>
|
||||||
<Badge
|
<Badge variant="secondary">{{
|
||||||
v-for="ticketStatus in backlogTicketStatuses"
|
t('workflow.offBoard')
|
||||||
:key="ticketStatus.id"
|
}}</Badge>
|
||||||
variant="outline"
|
</div>
|
||||||
class="px-3 py-1"
|
|
||||||
>
|
|
||||||
{{ ticketStatus.name }}
|
|
||||||
</Badge>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Tickets move between statuses; backlog statuses are not
|
{{ t('workflow.backlogDescription') }}
|
||||||
included in a board column.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Status moves</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Choose exactly where each status is allowed to move
|
|
||||||
next.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6"
|
||||||
<CardContent class="p-0">
|
><WorkflowStatusList
|
||||||
<div class="overflow-x-auto">
|
:column="null"
|
||||||
<table
|
:statuses="backlogTicketStatuses"
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
@edit="target = $event"
|
||||||
>
|
/></CardContent>
|
||||||
<thead
|
</Card>
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
<section
|
||||||
>
|
class="grid min-w-0 gap-4"
|
||||||
<tr>
|
aria-labelledby="workflow-columns"
|
||||||
<th class="px-4 py-3 font-medium">
|
>
|
||||||
Source status
|
<header
|
||||||
</th>
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
<th class="px-4 py-3 font-medium">
|
>
|
||||||
Allowed move targets
|
<div class="flex items-center gap-2">
|
||||||
</th>
|
<Columns3
|
||||||
<th class="px-4 py-3 font-medium">Save</th>
|
class="text-primary size-5"
|
||||||
</tr>
|
aria-hidden="true"
|
||||||
</thead>
|
/>
|
||||||
<tbody class="divide-border divide-y">
|
<h3 id="workflow-columns" class="font-semibold">
|
||||||
<tr
|
{{ t('workflow.columns') }}
|
||||||
v-for="sourceStatus in ticketStatuses"
|
</h3>
|
||||||
:key="sourceStatus.id"
|
<Badge variant="secondary">{{
|
||||||
class="bg-background align-top"
|
boardColumns.length
|
||||||
>
|
}}</Badge>
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(sourceStatus)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Form
|
|
||||||
:id="`status-transitions-${sourceStatus.id}`"
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.updateStatusTransitions.form(
|
|
||||||
[
|
|
||||||
project.id,
|
|
||||||
sourceStatus.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-3"
|
|
||||||
v-slot="{ errors }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
transitionTargetOptions(
|
|
||||||
sourceStatus,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
No other statuses exist yet.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="targetStatus in transitionTargetOptions(
|
|
||||||
sourceStatus,
|
|
||||||
)"
|
|
||||||
:key="targetStatus.id"
|
|
||||||
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="allowed_ticket_status_ids[]"
|
|
||||||
:value="targetStatus.id"
|
|
||||||
:checked="
|
|
||||||
statusAllowsTarget(
|
|
||||||
sourceStatus,
|
|
||||||
targetStatus,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span>{{
|
|
||||||
ticketStatusLabel(
|
|
||||||
targetStatus,
|
|
||||||
)
|
|
||||||
}}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.allowed_ticket_status_ids
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:form="`status-transitions-${sourceStatus.id}`"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button
|
||||||
</Card>
|
type="button"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
<Card>
|
@click="target = { kind: 'column', action: 'create' }"
|
||||||
<CardHeader>
|
><Plus class="size-4" aria-hidden="true" />{{
|
||||||
<CardTitle>Backlog statuses</CardTitle>
|
t('workflow.addColumn')
|
||||||
<CardDescription>
|
}}</Button
|
||||||
New backlog tickets choose one of these statuses.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="ticketStatus in backlogTicketStatuses"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
class="border-border rounded-lg border p-4"
|
|
||||||
>
|
>
|
||||||
<Form
|
</header>
|
||||||
v-bind="
|
<p class="text-muted-foreground text-sm">
|
||||||
ProjectBoardSettingsController.updateStatus.form(
|
{{ t('workflow.columnsDescription') }}
|
||||||
[project.id, ticketStatus.id],
|
</p>
|
||||||
)
|
<FeedbackState
|
||||||
"
|
v-if="boardColumns.length === 0"
|
||||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
state="empty"
|
||||||
v-slot="{ errors, processing }"
|
:title="t('workflow.emptyColumns')"
|
||||||
|
:description="t('workflow.emptyColumnsDescription')"
|
||||||
|
/>
|
||||||
|
<ol
|
||||||
|
v-else
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-label="t('workflow.columns')"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="column in boardColumns"
|
||||||
|
:key="column.id"
|
||||||
|
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||||
|
:data-column-id="column.id"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<span
|
||||||
<Label
|
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||||
:for="`backlog-status-${ticketStatus.id}`"
|
:aria-label="
|
||||||
>
|
t('workflow.positionValue', {
|
||||||
Status name
|
position: column.position,
|
||||||
</Label>
|
})
|
||||||
<Input
|
|
||||||
:id="`backlog-status-${ticketStatus.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="ticketStatus.name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.destroyStatus.form(
|
|
||||||
[project.id, ticketStatus.id],
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
v-slot="{ errors, processing }"
|
>{{ column.position }}</span
|
||||||
>
|
>
|
||||||
<InputError
|
<h4
|
||||||
:message="errors.ticket_status_id"
|
class="min-w-0 flex-1 basis-24 break-words font-semibold"
|
||||||
class="mb-2"
|
>
|
||||||
/>
|
{{ column.name }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
:disabled="
|
size="icon"
|
||||||
processing ||
|
:aria-label="
|
||||||
backlogTicketStatuses.length <= 1
|
t('workflow.changeColumn', {
|
||||||
|
name: column.name,
|
||||||
|
})
|
||||||
"
|
"
|
||||||
>
|
@click="
|
||||||
Delete
|
target = {
|
||||||
</Button>
|
kind: 'column',
|
||||||
</Form>
|
action: 'edit',
|
||||||
</div>
|
column,
|
||||||
</div>
|
}
|
||||||
|
"
|
||||||
<Form
|
><Pencil class="size-4" aria-hidden="true"
|
||||||
v-bind="
|
/></Button>
|
||||||
ProjectBoardSettingsController.storeStatus.form(
|
<Button
|
||||||
project.id,
|
type="button"
|
||||||
)
|
variant="ghost"
|
||||||
"
|
size="icon"
|
||||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
class="text-destructive"
|
||||||
v-slot="{ errors, processing }"
|
:aria-label="
|
||||||
>
|
t('workflow.removeColumn', {
|
||||||
<div class="grid gap-2">
|
name: column.name,
|
||||||
<Label for="backlog-status-name">
|
})
|
||||||
New backlog status
|
"
|
||||||
</Label>
|
@click="
|
||||||
<Input
|
target = {
|
||||||
id="backlog-status-name"
|
kind: 'column',
|
||||||
name="name"
|
action: 'delete',
|
||||||
placeholder="Ready"
|
column,
|
||||||
required
|
}
|
||||||
|
"
|
||||||
|
><Trash2 class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
<WorkflowStatusList
|
||||||
|
:column="column"
|
||||||
|
:statuses="column.ticket_statuses ?? []"
|
||||||
|
@edit="target = $event"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
<div class="flex items-end">
|
</ol>
|
||||||
<Button type="submit" :disabled="processing">
|
</section>
|
||||||
Add status
|
<WorkflowTransitionMatrix
|
||||||
</Button>
|
:project-id="project.id"
|
||||||
</div>
|
:statuses="ticketStatuses"
|
||||||
</Form>
|
/>
|
||||||
</CardContent>
|
<WorkflowEditorDialog
|
||||||
</Card>
|
:project-id="project.id"
|
||||||
|
:column-count="boardColumns.length"
|
||||||
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
:target="target"
|
||||||
<Card>
|
@close="target = null"
|
||||||
<CardHeader>
|
/>
|
||||||
<CardTitle>Add column</CardTitle>
|
</SettingsLayout>
|
||||||
<CardDescription>
|
|
||||||
New columns extend the project-specific workflow and
|
|
||||||
are inserted at the end unless you specify a
|
|
||||||
position later.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.store.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="space-y-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Column name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="In Review"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add column
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Board columns</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rename columns or change the numeric position to
|
|
||||||
reorder the board from left to right.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="boardColumns.length === 0"
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
No board columns exist yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="column in boardColumns"
|
|
||||||
:key="column.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.update.form([
|
|
||||||
project.id,
|
|
||||||
column.id,
|
|
||||||
])
|
|
||||||
"
|
|
||||||
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label :for="`column-name-${column.id}`">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`column-name-${column.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="column.name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`column-position-${column.id}`"
|
|
||||||
>
|
|
||||||
Position
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`column-position-${column.id}`"
|
|
||||||
name="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="boardColumns.length"
|
|
||||||
:default-value="column.position"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.position" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.destroy.form(
|
|
||||||
[project.id, column.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-border mt-4 border-t pt-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Ticket statuses
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Moving into this column requires a
|
|
||||||
specific status.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="ticketStatus in column.ticket_statuses ??
|
|
||||||
[]"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
class="border-border rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.updateStatus.form(
|
|
||||||
[
|
|
||||||
project.id,
|
|
||||||
ticketStatus.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`column-status-${ticketStatus.id}`"
|
|
||||||
>
|
|
||||||
Status name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`column-status-${ticketStatus.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="
|
|
||||||
ticketStatus.name
|
|
||||||
"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.destroyStatus.form(
|
|
||||||
[
|
|
||||||
project.id,
|
|
||||||
ticketStatus.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.ticket_status_id
|
|
||||||
"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="
|
|
||||||
processing ||
|
|
||||||
(column.ticket_statuses
|
|
||||||
?.length ?? 0) <= 1
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.storeStatus.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="board_column_id"
|
|
||||||
:value="column.id"
|
|
||||||
/>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`new-column-status-${column.id}`"
|
|
||||||
>
|
|
||||||
New status
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`new-column-status-${column.id}`"
|
|
||||||
name="name"
|
|
||||||
placeholder="Ready for QA"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Add status
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,281 +1,316 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { Layers3, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
switchTemplate,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import {
|
import { general } from '@/routes/projects/settings';
|
||||||
board as boardSettings,
|
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: index(),
|
{ title: t('navigation.general'), href: general(props.project.id) },
|
||||||
},
|
]);
|
||||||
{
|
const switchTemplateForm = useForm<{
|
||||||
title: props.project.name,
|
confirmation_key: string;
|
||||||
href: show(props.project.id),
|
source_template_id: number | undefined;
|
||||||
},
|
}>({
|
||||||
{
|
|
||||||
title: 'General settings',
|
|
||||||
href: generalSettings(props.project.id),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const switchTemplateForm = useForm({
|
|
||||||
confirmation_key: '',
|
confirmation_key: '',
|
||||||
source_template_id: props.sourceTemplates.find(
|
source_template_id: undefined,
|
||||||
(template) => template.id !== props.project.project_template_id,
|
|
||||||
)?.id,
|
|
||||||
});
|
});
|
||||||
|
const deleteForm = useForm({ confirmation_key: '' });
|
||||||
const deleteForm = useForm({
|
const switchProtection = useFormProtection(switchTemplateForm);
|
||||||
confirmation_key: '',
|
const deleteProtection = useFormProtection(deleteForm);
|
||||||
});
|
const processing = computed(
|
||||||
|
() => switchTemplateForm.processing || deleteForm.processing,
|
||||||
|
);
|
||||||
const availableSwitchTemplates = computed(() =>
|
const availableSwitchTemplates = computed(() =>
|
||||||
props.sourceTemplates.filter(
|
props.sourceTemplates.filter(
|
||||||
(template) => template.id !== props.project.project_template_id,
|
(template) => template.id !== props.project.project_template_id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTemplate = computed(() => props.project.project_template ?? null);
|
const currentTemplate = computed(() => props.project.project_template ?? null);
|
||||||
|
|
||||||
const isSwitchConfirmationValid = computed(
|
|
||||||
() => switchTemplateForm.confirmation_key === props.project.key,
|
|
||||||
);
|
|
||||||
|
|
||||||
const canSwitchTemplate = computed(
|
const canSwitchTemplate = computed(
|
||||||
() =>
|
() =>
|
||||||
isSwitchConfirmationValid.value &&
|
switchTemplateForm.confirmation_key === props.project.key &&
|
||||||
switchTemplateForm.source_template_id !== undefined,
|
availableSwitchTemplates.value.some(
|
||||||
|
(template) => template.id === switchTemplateForm.source_template_id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDeleteConfirmationValid = computed(
|
const isDeleteConfirmationValid = computed(
|
||||||
() => deleteForm.confirmation_key === props.project.key,
|
() => deleteForm.confirmation_key === props.project.key,
|
||||||
);
|
);
|
||||||
|
const inheritanceAreas = computed(() =>
|
||||||
|
[
|
||||||
|
...(props.templateState?.inheritedAreas ?? []),
|
||||||
|
...(props.templateState?.overriddenAreas ?? []),
|
||||||
|
].map((area) => area.value),
|
||||||
|
);
|
||||||
|
const settingsSource = computed(() => {
|
||||||
|
if (!props.templateState?.inheritsFromTemplate) {
|
||||||
|
return 'projectSettings.local';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.templateState.inheritedAreas.some(
|
||||||
|
(area) => area.value === 'project_settings',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'projectSettings.inherited';
|
||||||
|
}
|
||||||
|
return props.templateState.overriddenAreas.some(
|
||||||
|
(area) => area.value === 'project_settings',
|
||||||
|
)
|
||||||
|
? 'projectSettings.override'
|
||||||
|
: 'projectSettings.local';
|
||||||
|
});
|
||||||
|
|
||||||
const submitSwitchTemplate = (): void => {
|
function submitSwitchTemplate(): void {
|
||||||
if (!canSwitchTemplate.value) {
|
if (!canSwitchTemplate.value || processing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
switchProtection.submit(switchTemplate(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switchTemplateForm.post(
|
function submitDelete(): void {
|
||||||
ProjectController.switchTemplate.url(props.project.id),
|
if (!isDeleteConfirmationValid.value || processing.value) {
|
||||||
{
|
|
||||||
preserveScroll: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitDelete = (): void => {
|
|
||||||
if (!isDeleteConfirmationValid.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
deleteProtection.submit(destroy(props.project.id), {
|
||||||
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} general settings`" />
|
<Head
|
||||||
|
:title="t('projectSettings.pageTitle', { project: project.name })"
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
/>
|
||||||
<div
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
<FeedbackState
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
|
||||||
{{ project.key }}
|
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="`${project.name} general settings`"
|
|
||||||
description="Review the core project identity and manage irreversible administrative actions."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="boardSettings(project.id)">
|
|
||||||
Board settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
state="success"
|
||||||
|
:title="t('projectSettings.updated')"
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
</div>
|
<h2 class="text-title">{{ t('navigation.general') }}</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<Card>
|
{{ t('projectSettings.generalDescription') }}
|
||||||
<CardHeader>
|
</p>
|
||||||
<CardTitle>Project identity</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
These values define the project across tickets, boards,
|
|
||||||
and repository matching.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<h3 class="font-semibold">
|
||||||
<p
|
{{ t('projectSettings.identity') }}
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
</h3>
|
||||||
>
|
<Badge variant="secondary">{{
|
||||||
Project name
|
t('projectSettings.local')
|
||||||
</p>
|
}}</Badge>
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
|
||||||
{{ project.name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<div
|
||||||
<p
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.name') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words font-semibold">
|
||||||
|
{{ project.name }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.key') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-all font-semibold">
|
||||||
|
{{ project.key }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.identityDescription') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 border-t pt-4"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ t('projectSettings.display') }}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
:variant="
|
||||||
|
settingsSource === 'projectSettings.inherited'
|
||||||
|
? 'outline'
|
||||||
|
: 'secondary'
|
||||||
|
"
|
||||||
|
>{{ t(settingsSource) }}</Badge
|
||||||
>
|
>
|
||||||
Project key
|
|
||||||
</p>
|
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
|
||||||
{{ project.key }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="grid gap-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.showAuthor') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
project.show_author
|
||||||
|
? 'projectSettings.enabled'
|
||||||
|
: 'projectSettings.disabled',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.showStoryPoints') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
project.show_story_points
|
||||||
|
? 'projectSettings.enabled'
|
||||||
|
: 'projectSettings.disabled',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.displayDescription') }}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<TemplateInheritancePanel
|
||||||
<Card>
|
:project-id="project.id"
|
||||||
<CardHeader>
|
:areas="inheritanceAreas"
|
||||||
<CardTitle>Template baseline</CardTitle>
|
:template-state="templateState"
|
||||||
<CardDescription>
|
/>
|
||||||
Switch this project to another published template. Use
|
<Card
|
||||||
the inheritance panels to reset individual settings to
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
the currently linked template.
|
>
|
||||||
</CardDescription>
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
</CardHeader>
|
<div class="flex items-center gap-2">
|
||||||
|
<Layers3
|
||||||
<CardContent class="space-y-6">
|
class="text-primary size-5"
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
aria-hidden="true"
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
/>
|
||||||
<p
|
<h2 class="text-title">
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
{{ t('projectSettings.template') }}
|
||||||
>
|
</h2>
|
||||||
Current linked template
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-foreground mt-2 text-sm font-semibold"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
currentTemplate?.name ??
|
|
||||||
'No linked template'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{
|
|
||||||
templateState?.inheritsFromTemplate
|
|
||||||
? 'Inherited and overridden areas stay visible after switching.'
|
|
||||||
: 'This project currently behaves like a standalone baseline.'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
|
||||||
>
|
|
||||||
Warning
|
|
||||||
</p>
|
|
||||||
<p class="text-foreground mt-2 text-sm font-medium">
|
|
||||||
Switching templates can replace inherited board
|
|
||||||
workflow, ticket metadata, repositories, and
|
|
||||||
notifications.
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
Confirm each action with the exact project key
|
|
||||||
to avoid accidental overwrites.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<div class="grid gap-6">
|
{{ t('projectSettings.templateDescription') }}
|
||||||
<form
|
</p>
|
||||||
class="space-y-4 rounded-xl border p-4"
|
</CardHeader>
|
||||||
@submit.prevent="submitSwitchTemplate"
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
|
<div
|
||||||
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.currentTemplate') }}
|
||||||
|
</p>
|
||||||
|
<p class="break-words font-semibold">
|
||||||
|
{{
|
||||||
|
currentTemplate?.name ??
|
||||||
|
t('projectSettings.noTemplate')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" class="justify-self-start">{{
|
||||||
|
t(
|
||||||
|
templateState?.inheritsFromTemplate
|
||||||
|
? 'projectSettings.linked'
|
||||||
|
: 'projectSettings.standalone',
|
||||||
|
)
|
||||||
|
}}</Badge>
|
||||||
|
<p
|
||||||
|
v-if="!templateState?.inheritsFromTemplate"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div class="space-y-1">
|
{{ t('projectSettings.standaloneDescription') }}
|
||||||
<h3 class="text-sm font-semibold">
|
</p>
|
||||||
Switch to another template
|
</div>
|
||||||
</h3>
|
<FeedbackState
|
||||||
<p class="text-muted-foreground text-sm">
|
v-if="availableSwitchTemplates.length === 0"
|
||||||
Applying a different template replaces the
|
state="empty"
|
||||||
copied areas with that template's current
|
:title="t('projectSettings.noAlternatives')"
|
||||||
baseline.
|
:description="
|
||||||
</p>
|
t('projectSettings.noAlternativesDescription')
|
||||||
</div>
|
"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="switchTemplateForm.processing"
|
||||||
|
@submit.prevent="submitSwitchTemplate"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="switchProtection.state.value"
|
||||||
|
:title="
|
||||||
|
switchTemplateForm.processing
|
||||||
|
? t('projectSettings.switching')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submitSwitchTemplate"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
<legend class="mb-3 font-semibold">
|
||||||
|
{{ t('projectSettings.switch') }}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
{{ t('projectSettings.switchWarning') }}
|
||||||
|
</p>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="source_template_id"
|
<Label for="source_template_id">{{
|
||||||
>Published template</Label
|
t('projectSettings.publishedTemplate')
|
||||||
>
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="
|
v-model="
|
||||||
switchTemplateForm.source_template_id
|
switchTemplateForm.source_template_id
|
||||||
"
|
"
|
||||||
:disabled="
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||||
switchTemplateForm.processing ||
|
:aria-invalid="
|
||||||
availableSwitchTemplates.length === 0
|
!!switchTemplateForm.errors
|
||||||
|
.source_template_id
|
||||||
"
|
"
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
aria-describedby="source-template-error"
|
||||||
>
|
>
|
||||||
<option :value="undefined" disabled>
|
<option :value="undefined" disabled>
|
||||||
Select a template
|
{{
|
||||||
|
t('projectSettings.selectTemplate')
|
||||||
|
}}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="template in availableSwitchTemplates"
|
v-for="template in availableSwitchTemplates"
|
||||||
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-template-error"
|
||||||
:message="
|
:message="
|
||||||
switchTemplateForm.errors
|
switchTemplateForm.errors
|
||||||
.source_template_id
|
.source_template_id
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="switch_confirmation_key"
|
<Label for="switch_confirmation_key">{{
|
||||||
>Confirmation key</Label
|
t('projectSettings.confirmation')
|
||||||
>
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="switch_confirmation_key"
|
id="switch_confirmation_key"
|
||||||
v-model="
|
v-model="
|
||||||
switchTemplateForm.confirmation_key
|
switchTemplateForm.confirmation_key
|
||||||
"
|
"
|
||||||
:disabled="switchTemplateForm.processing"
|
autocomplete="off"
|
||||||
placeholder="Type the project key exactly"
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'projectSettings.confirmPlaceholder',
|
||||||
|
{ key: project.key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!switchTemplateForm.errors
|
||||||
|
.confirmation_key
|
||||||
|
"
|
||||||
|
aria-describedby="switch-confirm-help switch-confirm-error"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
id="switch-confirm-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectSettings.confirmHelp', {
|
||||||
|
key: project.key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="switch-confirm-error"
|
||||||
:message="
|
:message="
|
||||||
switchTemplateForm.errors
|
switchTemplateForm.errors
|
||||||
.confirmation_key
|
.confirmation_key
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Existing project-specific configuration in
|
|
||||||
copied areas may be replaced or removed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="
|
|
||||||
switchTemplateForm.processing ||
|
|
||||||
!canSwitchTemplate
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Switch template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-red-900 dark:text-red-100">
|
|
||||||
Danger zone
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription
|
|
||||||
class="text-red-800/80 dark:text-red-200/80"
|
|
||||||
>
|
|
||||||
Deleting this project is permanent. All tickets, board
|
|
||||||
columns, ticket metadata, repository connections, and
|
|
||||||
project permissions will be removed.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-5">
|
|
||||||
<div
|
|
||||||
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
|
|
||||||
>
|
|
||||||
<p class="text-foreground text-sm font-medium">
|
|
||||||
Confirm by typing the exact project key:
|
|
||||||
<span class="font-semibold">{{ project.key }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="submitDelete">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="confirmation_key">
|
|
||||||
Confirmation key
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="confirmation_key"
|
|
||||||
v-model="deleteForm.confirmation_key"
|
|
||||||
:disabled="deleteForm.processing"
|
|
||||||
placeholder="Type the project key exactly"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="deleteForm.errors.confirmation_key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="outline"
|
||||||
:disabled="
|
class="justify-self-start"
|
||||||
deleteForm.processing ||
|
:disabled="!canSwitchTemplate"
|
||||||
!isDeleteConfirmationValid
|
>{{
|
||||||
"
|
t(
|
||||||
|
switchTemplateForm.processing
|
||||||
|
? 'projectSettings.switching'
|
||||||
|
: 'projectSettings.switch',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Delete project
|
</fieldset>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
<Card
|
||||||
|
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
|
>
|
||||||
|
<CardHeader
|
||||||
|
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Trash2
|
||||||
|
class="text-destructive size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2 class="text-title">
|
||||||
|
{{ t('projectSettings.danger') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.deleteDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="deleteForm.processing"
|
||||||
|
@submit.prevent="submitDelete"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="deleteProtection.state.value"
|
||||||
|
:title="
|
||||||
|
deleteForm.processing
|
||||||
|
? t('projectSettings.deleting')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submitDelete"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('projectSettings.delete') }}
|
||||||
|
</legend>
|
||||||
|
<p class="text-caption font-medium">
|
||||||
|
{{ t('projectSettings.irreversible') }}
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="confirmation_key">{{
|
||||||
|
t('projectSettings.confirmation')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmation_key"
|
||||||
|
v-model="deleteForm.confirmation_key"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'projectSettings.confirmPlaceholder',
|
||||||
|
{ key: project.key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!deleteForm.errors.confirmation_key
|
||||||
|
"
|
||||||
|
aria-describedby="delete-confirm-help delete-confirm-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="delete-confirm-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectSettings.confirmHelp', {
|
||||||
|
key: project.key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="delete-confirm-error"
|
||||||
|
:message="
|
||||||
|
deleteForm.errors.confirmation_key
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
class="justify-self-start"
|
||||||
|
:disabled="!isDeleteConfirmationValid"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
deleteForm.processing
|
||||||
|
? 'projectSettings.deleting'
|
||||||
|
: 'projectSettings.delete',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/appearance/index';
|
import { edit } from '@/routes/appearance/index';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{
|
||||||
title: 'Appearance settings',
|
title: t('navigation.appearance'),
|
||||||
href: edit(),
|
href: edit(),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Appearance settings" />
|
<Head :title="t('appearance.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Appearance settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="space-y-6">
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
<Heading
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
variant="small"
|
<h2 class="text-title">{{ t('appearance.title') }}</h2>
|
||||||
title="Appearance settings"
|
<p class="text-muted-foreground">
|
||||||
description="Update your account's appearance settings"
|
{{ t('appearance.description') }}
|
||||||
/>
|
</p>
|
||||||
<AppearanceTabs />
|
</CardHeader>
|
||||||
</div>
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||||
|
<AppearanceTabs />
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('appearance.persistenceHint') }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,198 +1,318 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
destroy,
|
||||||
CardContent,
|
store,
|
||||||
CardDescription,
|
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||||
CardHeader,
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
CardTitle,
|
import InputError from '@/components/InputError.vue';
|
||||||
} from '@/components/ui/card';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit as editApplications } from '@/routes/applications/index';
|
import { edit } from '@/routes/applications/index';
|
||||||
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||||
|
|
||||||
defineProps<SettingsApplicationsPageProps>();
|
const props = defineProps<SettingsApplicationsPageProps>();
|
||||||
|
const { t, locale } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.applications'), href: edit() },
|
||||||
|
]);
|
||||||
|
const form = useForm({ name: '' });
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const deleteForm = useForm({});
|
||||||
|
const deleteProtection = useFormProtection(deleteForm);
|
||||||
|
const selected = ref<
|
||||||
|
SettingsApplicationsPageProps['applications'][number] | null
|
||||||
|
>(null);
|
||||||
|
const copied = ref(false);
|
||||||
|
const copyError = ref(false);
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
function formatDate(value: string | null): string {
|
||||||
{
|
if (!value) {
|
||||||
title: 'Applications',
|
return t('applications.never');
|
||||||
href: editApplications(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatDate = (value: string | null): string => {
|
|
||||||
if (value === null) {
|
|
||||||
return 'Never';
|
|
||||||
}
|
}
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
};
|
}
|
||||||
|
function createApplication(): void {
|
||||||
|
protection.submit(store(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function closeDelete(open: boolean): void {
|
||||||
|
if (!open && !deleteForm.processing) {
|
||||||
|
selected.value = null;
|
||||||
|
deleteProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deleteApplication(): void {
|
||||||
|
if (!selected.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteProtection.submit(destroy(selected.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
selected.value = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
const key = props.applicationKey;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyError.value = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
if (props.applicationKey === key) {
|
||||||
|
copied.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (props.applicationKey === key) {
|
||||||
|
copyError.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => props.applicationKey,
|
||||||
|
() => {
|
||||||
|
copied.value = false;
|
||||||
|
copyError.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Applications" />
|
<Head :title="t('applications.title')" />
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="gap-stack grid">
|
||||||
<div class="space-y-2">
|
<h2 class="text-title">{{ t('applications.title') }}</h2>
|
||||||
<Heading
|
<p class="text-muted-foreground">
|
||||||
variant="small"
|
{{ t('applications.description') }}
|
||||||
title="Applications"
|
</p>
|
||||||
description="Create API keys for tools that should use KanbanGit with your account permissions."
|
</div>
|
||||||
/>
|
<p
|
||||||
<p class="text-muted-foreground text-sm">
|
v-if="status"
|
||||||
Keys are shown once. Delete an application when a tool
|
role="status"
|
||||||
should lose access.
|
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
|
||||||
</p>
|
>
|
||||||
</div>
|
{{ status }}
|
||||||
|
</p>
|
||||||
<div
|
<section
|
||||||
v-if="status"
|
v-if="applicationKey"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
|
||||||
>
|
aria-labelledby="application-key-title"
|
||||||
{{ status }}
|
>
|
||||||
</div>
|
<h3 id="application-key-title" class="font-semibold">
|
||||||
|
{{ t('applications.saveKey') }}
|
||||||
<div
|
</h3>
|
||||||
v-if="applicationKey"
|
<p class="text-body text-muted-foreground">
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
|
{{ t('applications.keyOnce') }}
|
||||||
>
|
</p>
|
||||||
<p class="font-medium">Save this API key now.</p>
|
<Label for="application-key" class="sr-only">{{
|
||||||
<p class="mt-1">
|
t('applications.apiKey')
|
||||||
It will not be shown again after you leave this page.
|
}}</Label>
|
||||||
</p>
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
<Input
|
<Input
|
||||||
class="mt-3 font-mono text-xs"
|
id="application-key"
|
||||||
:model-value="applicationKey"
|
:model-value="applicationKey"
|
||||||
readonly
|
readonly
|
||||||
|
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
|
||||||
/>
|
/>
|
||||||
|
<Button variant="outline" @click="copyKey"
|
||||||
|
><Check v-if="copied" /><Copy v-else />{{
|
||||||
|
t(copied ? 'security.copied' : 'security.copyKey')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
v-if="copied"
|
||||||
<Card>
|
role="status"
|
||||||
<CardHeader>
|
class="text-caption text-success-foreground"
|
||||||
<CardTitle>Create application</CardTitle>
|
>
|
||||||
<CardDescription>
|
{{ t('security.copied') }}
|
||||||
Name the tool that will receive an API key.
|
</p>
|
||||||
</CardDescription>
|
<InputError
|
||||||
</CardHeader>
|
v-if="copyError"
|
||||||
|
:message="t('security.copyError')"
|
||||||
<CardContent>
|
/>
|
||||||
<Form
|
</section>
|
||||||
v-bind="UserApplicationController.store.form()"
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
class="space-y-4"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
v-slot="{ errors, processing }"
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
|
<KeyRound class="text-primary size-5" />{{
|
||||||
|
t('applications.create')
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('applications.nameDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="createApplication"
|
||||||
|
>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
|
:loading-title="t('applications.creating')"
|
||||||
|
@retry="createApplication"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="application-name">{{
|
||||||
|
t('applications.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="application-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="t('applications.namePlaceholder')"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="application-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="application-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="justify-self-start"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('applications.creating')"
|
||||||
|
>{{ t('applications.create') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('applications.hiddenKeys') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="gap-stack grid p-5 sm:p-6">
|
||||||
|
<p
|
||||||
|
v-if="!applications.length"
|
||||||
|
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
|
||||||
|
>
|
||||||
|
{{ t('applications.empty') }}
|
||||||
|
</p>
|
||||||
|
<article
|
||||||
|
v-for="application in applications"
|
||||||
|
:key="application.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1 basis-40">
|
||||||
|
<h4 class="break-words font-semibold">
|
||||||
|
{{ application.name }}
|
||||||
|
</h4>
|
||||||
|
<dl
|
||||||
|
class="gap-stack text-caption text-muted-foreground grid"
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<div>
|
||||||
<Label for="name">Application name</Label>
|
<dt class="inline font-medium">
|
||||||
<Input
|
{{ t('applications.created') }}
|
||||||
id="name"
|
</dt>
|
||||||
name="name"
|
<dd class="inline">
|
||||||
type="text"
|
{{ formatDate(application.created_at) }}
|
||||||
required
|
</dd>
|
||||||
autofocus
|
|
||||||
placeholder="Claude Desktop"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<Button type="submit" :disabled="processing">
|
<dt class="inline font-medium">
|
||||||
Create application
|
{{ t('applications.lastUsed') }}
|
||||||
</Button>
|
</dt>
|
||||||
</Form>
|
<dd class="inline">
|
||||||
</CardContent>
|
{{
|
||||||
</Card>
|
formatDate(application.last_used_at)
|
||||||
|
}}
|
||||||
<Card class="gap-0 overflow-hidden">
|
</dd>
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Saved applications</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Existing keys stay hidden. Create a new
|
|
||||||
application when another key is needed.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="applications.length === 0"
|
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
||||||
>
|
|
||||||
No applications have been created yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="application in applications"
|
|
||||||
:key="application.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-start justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge>API key</Badge>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{ application.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Created:
|
|
||||||
{{
|
|
||||||
formatDate(
|
|
||||||
application.created_at,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Last used:
|
|
||||||
{{
|
|
||||||
formatDate(
|
|
||||||
application.last_used_at,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
UserApplicationController.destroy.form(
|
|
||||||
application.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
</div>
|
class="text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('applications.deleteNamed', {
|
||||||
|
name: application.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="selected = application"
|
||||||
|
><Trash2 />{{ t('applications.delete') }}</Button
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Dialog :open="!!selected" @update:open="closeDelete">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!deleteForm.processing"
|
||||||
|
@escape-key-down="
|
||||||
|
deleteForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
@interact-outside="
|
||||||
|
deleteForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('applications.deleteTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('applications.deleteDescription', {
|
||||||
|
name: selected?.name ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="deleteProtection.state.value"
|
||||||
|
:response-status="deleteProtection.responseStatus.value"
|
||||||
|
@retry="deleteApplication"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="deleteForm.processing"
|
||||||
|
@click="closeDelete(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
:loading="deleteForm.processing"
|
||||||
|
@click="deleteApplication"
|
||||||
|
>{{ t('applications.delete') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,267 +1,493 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, usePage } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
GitBranch,
|
||||||
|
LockKeyhole,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
store,
|
||||||
|
update,
|
||||||
|
} from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
import { edit } from '@/routes/git-connections/index';
|
||||||
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
||||||
|
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
||||||
|
|
||||||
defineProps<SettingsGitConnectionsPageProps>();
|
const props = defineProps<SettingsGitConnectionsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.gitConnections'), href: edit() },
|
||||||
|
]);
|
||||||
|
const mode = ref<'create' | 'edit' | 'remove'>('create');
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const selectedConnection = ref<UserGitConnectionModel | null>(null);
|
||||||
|
// Keep provider credentials out of Inertia's remembered browser history.
|
||||||
|
const connectionForm = useForm({
|
||||||
|
provider: 'gitea',
|
||||||
|
base_url: '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
const connectionProtection = useFormProtection(connectionForm);
|
||||||
|
const removalForm = useForm({});
|
||||||
|
const removalProtection = useFormProtection(removalForm);
|
||||||
|
const processing = computed(
|
||||||
|
() => connectionForm.processing || removalForm.processing,
|
||||||
|
);
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
t(
|
||||||
|
mode.value === 'create'
|
||||||
|
? 'gitConnections.add'
|
||||||
|
: mode.value === 'edit'
|
||||||
|
? 'gitConnections.edit'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const successTitle = computed(() =>
|
||||||
|
props.status === 'Git connection was added.'
|
||||||
|
? t('gitConnections.added')
|
||||||
|
: props.status === 'Git connection was updated.'
|
||||||
|
? t('gitConnections.updated')
|
||||||
|
: t('gitConnections.removed'),
|
||||||
|
);
|
||||||
|
|
||||||
const page = usePage();
|
function resetForms(): void {
|
||||||
const appName = String(page.props.name);
|
connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
|
||||||
|
connectionForm.resetAndClearErrors();
|
||||||
|
connectionProtection.clearFeedback();
|
||||||
|
removalForm.resetAndClearErrors();
|
||||||
|
removalProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
function openDialog(
|
||||||
{
|
nextMode: typeof mode.value,
|
||||||
title: 'Git connections',
|
connection: UserGitConnectionModel | null = null,
|
||||||
href: editGitConnections(),
|
): void {
|
||||||
},
|
resetForms();
|
||||||
];
|
mode.value = nextMode;
|
||||||
|
selectedConnection.value = connection;
|
||||||
|
connectionForm.defaults({
|
||||||
|
provider: connection?.provider ?? 'gitea',
|
||||||
|
base_url: connection?.base_url ?? '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
connectionForm.reset();
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(open: boolean): void {
|
||||||
|
if (open || processing.value || !connectionProtection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogOpen.value = false;
|
||||||
|
resetForms();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): void {
|
||||||
|
resetForms();
|
||||||
|
dialogOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConnection(): void {
|
||||||
|
const target =
|
||||||
|
mode.value === 'edit' && selectedConnection.value
|
||||||
|
? update(selectedConnection.value.id)
|
||||||
|
: store();
|
||||||
|
connectionProtection.submit(target, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConnection(): void {
|
||||||
|
if (!selectedConnection.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removalProtection.submit(destroy(selectedConnection.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Git connections" />
|
<Head :title="t('navigation.gitConnections')" />
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col gap-6">
|
<Card
|
||||||
<div class="space-y-2">
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
<Heading
|
>
|
||||||
variant="small"
|
<CardHeader
|
||||||
title="Git connections"
|
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
|
||||||
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access."
|
|
||||||
/>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Each user needs their own application key. Without a
|
|
||||||
matching personal connection, {{ appName }} can only
|
|
||||||
show repository names and will link you back to this
|
|
||||||
page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
|
||||||
>
|
>
|
||||||
{{ status }}
|
<div
|
||||||
</div>
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
<div class="gap-stack grid min-w-0">
|
||||||
<Card>
|
<h2 class="text-title">
|
||||||
<CardHeader>
|
{{ t('navigation.gitConnections') }}
|
||||||
<CardTitle>Add Gitea connection</CardTitle>
|
</h2>
|
||||||
<CardDescription>
|
<p class="text-muted-foreground">
|
||||||
Connect a Gitea account by storing the instance
|
{{ t('gitConnections.description') }}
|
||||||
base URL and a personal application token.
|
</p>
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<Button
|
||||||
|
type="button"
|
||||||
<CardContent>
|
class="shrink-0 self-start"
|
||||||
<Form
|
@click="openDialog('create')"
|
||||||
v-bind="GitConnectionController.store.form()"
|
>
|
||||||
class="space-y-4"
|
<Plus aria-hidden="true" />{{
|
||||||
v-slot="{ errors, processing }"
|
t('gitConnections.add')
|
||||||
>
|
}}
|
||||||
<input
|
</Button>
|
||||||
name="provider"
|
</div>
|
||||||
type="hidden"
|
<div
|
||||||
value="gitea"
|
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
|
||||||
/>
|
>
|
||||||
|
<LockKeyhole
|
||||||
<div class="grid gap-2">
|
class="text-primary mt-0.5 size-4 shrink-0"
|
||||||
<Label for="base_url">Gitea base URL</Label>
|
aria-hidden="true"
|
||||||
<Input
|
/>
|
||||||
id="base_url"
|
<p>{{ t('gitConnections.privacy') }}</p>
|
||||||
name="base_url"
|
</div>
|
||||||
type="url"
|
</CardHeader>
|
||||||
required
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
autofocus
|
<FeedbackState
|
||||||
placeholder="https://git.example.com"
|
v-if="status"
|
||||||
/>
|
state="success"
|
||||||
<InputError :message="errors.base_url" />
|
:title="successTitle"
|
||||||
</div>
|
/>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="grid gap-2">
|
<h3 class="font-semibold">
|
||||||
<Label for="api_token"
|
{{ t('gitConnections.saved') }}
|
||||||
>Application token</Label
|
</h3>
|
||||||
>
|
<Badge variant="secondary">{{
|
||||||
<Input
|
gitConnections.length
|
||||||
id="api_token"
|
}}</Badge>
|
||||||
name="api_token"
|
</div>
|
||||||
type="password"
|
<FeedbackState
|
||||||
required
|
v-if="gitConnections.length === 0"
|
||||||
placeholder="Paste your Gitea application key"
|
state="empty"
|
||||||
/>
|
:title="t('gitConnections.empty')"
|
||||||
<InputError :message="errors.api_token" />
|
:description="t('gitConnections.emptyDescription')"
|
||||||
</div>
|
/>
|
||||||
|
<ul
|
||||||
<Button type="submit" :disabled="processing">
|
v-else
|
||||||
Add connection
|
class="gap-column grid min-w-0"
|
||||||
</Button>
|
:aria-label="t('gitConnections.saved')"
|
||||||
</Form>
|
>
|
||||||
</CardContent>
|
<li
|
||||||
</Card>
|
v-for="connection in gitConnections"
|
||||||
|
:key="connection.id"
|
||||||
<Card class="gap-0 overflow-hidden">
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
<CardHeader class="border-b">
|
>
|
||||||
<CardTitle>Saved connections</CardTitle>
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
<CardDescription>
|
|
||||||
Tokens stay hidden after save. Updating a
|
|
||||||
connection requires entering a fresh token.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="gitConnections.length === 0"
|
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
||||||
>
|
|
||||||
No Git connections have been added yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="gitConnection in gitConnections"
|
|
||||||
:key="gitConnection.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex flex-wrap items-start justify-between gap-3"
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
>
|
>
|
||||||
<div class="space-y-1">
|
<GitBranch
|
||||||
<div
|
class="size-5"
|
||||||
class="flex flex-wrap items-center gap-2"
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="min-w-0 break-all font-semibold"
|
||||||
>
|
>
|
||||||
<Badge>{{
|
{{ connection.account_login }}
|
||||||
gitConnection.provider_label
|
</h4>
|
||||||
}}</Badge>
|
<Badge variant="secondary">{{
|
||||||
<span class="text-sm font-medium">
|
connection.provider_label
|
||||||
{{
|
}}</Badge>
|
||||||
gitConnection.account_login
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
gitConnection.account_name ??
|
|
||||||
'Unnamed remote account'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{{ gitConnection.base_url }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
<p class="text-muted-foreground text-xs">
|
class="text-caption text-muted-foreground break-words"
|
||||||
Remote user ID:
|
>
|
||||||
{{ gitConnection.provider_user_id }}
|
{{
|
||||||
|
connection.account_name ??
|
||||||
|
t('gitConnections.unnamedAccount')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="text-caption text-muted-foreground flex min-w-0 items-start gap-2"
|
||||||
|
>
|
||||||
|
<Server
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">{{
|
||||||
|
connection.base_url
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-all"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('gitConnections.remoteId', {
|
||||||
|
id: connection.provider_user_id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
GitConnectionController.update.form(
|
|
||||||
gitConnection.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="provider"
|
|
||||||
type="hidden"
|
|
||||||
:value="gitConnection.provider"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`base-url-${gitConnection.id}`"
|
|
||||||
>
|
|
||||||
Gitea base URL
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`base-url-${gitConnection.id}`"
|
|
||||||
name="base_url"
|
|
||||||
type="url"
|
|
||||||
:default-value="
|
|
||||||
gitConnection.base_url
|
|
||||||
"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.base_url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`token-${gitConnection.id}`"
|
|
||||||
>
|
|
||||||
New application token
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`token-${gitConnection.id}`"
|
|
||||||
name="api_token"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="Paste a new token to verify and save"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.api_token"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-between gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
GitConnectionController.destroy.form(
|
|
||||||
gitConnection.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Remove connection
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div
|
||||||
</Card>
|
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
|
||||||
</div>
|
>
|
||||||
</div>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t('gitConnections.editAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('edit', connection)"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" />{{
|
||||||
|
t('gitConnections.editAction')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="
|
||||||
|
t('gitConnections.removeAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('remove', connection)"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />{{
|
||||||
|
t('gitConnections.remove')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Dialog :open="dialogOpen" @update:open="closeDialog">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!processing"
|
||||||
|
@escape-key-down="processing && $event.preventDefault()"
|
||||||
|
@interact-outside="processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ dialogTitle }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t(
|
||||||
|
mode === 'remove'
|
||||||
|
? 'gitConnections.removeDescription'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'gitConnections.editDescription'
|
||||||
|
: 'gitConnections.addDescription',
|
||||||
|
)
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
v-if="mode !== 'remove'"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="connectionForm.processing"
|
||||||
|
@submit.prevent="saveConnection"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="connectionProtection.state.value"
|
||||||
|
:title="
|
||||||
|
connectionProtection.responseStatus.value ===
|
||||||
|
403
|
||||||
|
? t('gitConnections.forbidden')
|
||||||
|
: connectionForm.processing
|
||||||
|
? t('gitConnections.verifying')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
connectionProtection.responseStatus.value ===
|
||||||
|
403
|
||||||
|
? t('gitConnections.forbiddenDescription')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="
|
||||||
|
connectionProtection.responseStatus.value !==
|
||||||
|
403
|
||||||
|
"
|
||||||
|
@retry="saveConnection"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="connectionForm.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">{{ dialogTitle }}</legend>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="justify-self-start"
|
||||||
|
>{{ t('gitConnections.gitea') }}</Badge
|
||||||
|
>
|
||||||
|
<InputError
|
||||||
|
:message="connectionForm.errors.provider"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="git-base-url">{{
|
||||||
|
t('gitConnections.baseUrl')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="git-base-url"
|
||||||
|
v-model="connectionForm.base_url"
|
||||||
|
name="base_url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://git.example.com"
|
||||||
|
autocomplete="url"
|
||||||
|
:aria-invalid="
|
||||||
|
!!connectionForm.errors.base_url
|
||||||
|
"
|
||||||
|
aria-describedby="git-base-url-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="git-base-url-error"
|
||||||
|
:message="connectionForm.errors.base_url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="git-token">{{
|
||||||
|
t(
|
||||||
|
mode === 'edit'
|
||||||
|
? 'gitConnections.newToken'
|
||||||
|
: 'gitConnections.token',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="git-token"
|
||||||
|
v-model="connectionForm.api_token"
|
||||||
|
name="api_token"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t('gitConnections.tokenPlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!connectionForm.errors.api_token
|
||||||
|
"
|
||||||
|
aria-describedby="git-token-help git-token-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="git-token-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('gitConnections.tokenHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="git-token-error"
|
||||||
|
:message="connectionForm.errors.api_token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="closeDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit">{{
|
||||||
|
t(
|
||||||
|
connectionForm.processing
|
||||||
|
? 'gitConnections.verifying'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'gitConnections.save'
|
||||||
|
: 'gitConnections.add',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="removalForm.processing"
|
||||||
|
@submit.prevent="removeConnection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-destructive/8 border-destructive/25 text-body grid min-w-0 gap-2 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="break-all font-semibold">
|
||||||
|
{{ selectedConnection?.account_login }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground break-all">
|
||||||
|
{{ selectedConnection?.base_url }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
:state="removalProtection.state.value"
|
||||||
|
:title="
|
||||||
|
removalProtection.responseStatus.value === 403
|
||||||
|
? t('gitConnections.forbidden')
|
||||||
|
: removalForm.processing
|
||||||
|
? t('gitConnections.removing')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
removalProtection.responseStatus.value === 403
|
||||||
|
? t('gitConnections.forbiddenDescription')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="
|
||||||
|
removalProtection.responseStatus.value !== 403
|
||||||
|
"
|
||||||
|
@retry="removeConnection"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="removalForm.processing"
|
||||||
|
@click="closeDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="removalForm.processing"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
removalForm.processing
|
||||||
|
? 'gitConnections.removing'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,209 +1,385 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { Trash2 } from 'lucide-vue-next';
|
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import DeleteUser from '@/components/DeleteUser.vue';
|
import DeleteUser from '@/components/DeleteUser.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
defineProps<SettingsProfilePageProps>();
|
defineProps<SettingsProfilePageProps>();
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
|
||||||
title: 'Profile settings',
|
|
||||||
href: edit(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user);
|
||||||
const avatarPreview = ref<string | null>(null);
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{
|
||||||
|
title: t('navigation.profile'),
|
||||||
|
href: edit(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const profileForm = useForm('SettingsProfile', {
|
||||||
|
name: user.value.name,
|
||||||
|
email: user.value.email,
|
||||||
|
avatar: null as File | null,
|
||||||
|
});
|
||||||
|
const profileProtection = useFormProtection(profileForm);
|
||||||
const avatarDeleteForm = useForm({});
|
const avatarDeleteForm = useForm({});
|
||||||
|
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
|
||||||
|
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
|
||||||
|
const avatarPreview = ref<string | null>(null);
|
||||||
const displayedAvatar = computed(
|
const displayedAvatar = computed(
|
||||||
() => avatarPreview.value ?? user.value.avatar,
|
() => avatarPreview.value ?? user.value.avatar,
|
||||||
);
|
);
|
||||||
|
const displayedUser = computed<UserModel>(() => ({
|
||||||
|
...user.value,
|
||||||
|
avatar: displayedAvatar.value,
|
||||||
|
}));
|
||||||
const canRemoveAvatar = computed(
|
const canRemoveAvatar = computed(
|
||||||
() => user.value.avatar !== null && !user.value.avatar_is_generated,
|
() =>
|
||||||
|
avatarPreview.value === null &&
|
||||||
|
user.value.avatar !== null &&
|
||||||
|
!user.value.avatar_is_generated,
|
||||||
);
|
);
|
||||||
|
const profileUpdateForm = ProfileController.update.form();
|
||||||
|
const profileUpdateTarget = {
|
||||||
|
url: profileUpdateForm.action,
|
||||||
|
method: profileUpdateForm.method,
|
||||||
|
};
|
||||||
|
|
||||||
const clearAvatarPreview = (): void => {
|
function clearAvatarPreview(): void {
|
||||||
if (avatarPreview.value) {
|
if (avatarPreview.value) {
|
||||||
URL.revokeObjectURL(avatarPreview.value);
|
URL.revokeObjectURL(avatarPreview.value);
|
||||||
avatarPreview.value = null;
|
avatarPreview.value = null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleAvatarChange = (event: Event) => {
|
function clearAvatarSelection(): void {
|
||||||
|
clearAvatarPreview();
|
||||||
|
profileForm.avatar = null;
|
||||||
|
profileForm.clearErrors('avatar');
|
||||||
|
profileProtection.clearFeedback();
|
||||||
|
if (avatarInput.value) {
|
||||||
|
avatarInput.value.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarChange(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0] ?? null;
|
||||||
|
|
||||||
clearAvatarPreview();
|
clearAvatarPreview();
|
||||||
|
profileForm.avatar = file;
|
||||||
|
profileForm.clearErrors('avatar');
|
||||||
|
profileProtection.clearFeedback();
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
avatarPreview.value = URL.createObjectURL(file);
|
avatarPreview.value = URL.createObjectURL(file);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const removeAvatar = (): void => {
|
function submitProfile(): void {
|
||||||
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
|
profileProtection.submit(profileUpdateTarget, {
|
||||||
|
forceFormData: true,
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: clearAvatarPreview,
|
onSuccess: () => {
|
||||||
|
profileForm.defaults({
|
||||||
|
name: profileForm.name,
|
||||||
|
email: profileForm.email,
|
||||||
|
avatar: null,
|
||||||
|
});
|
||||||
|
clearAvatarSelection();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
function removeAvatar(): void {
|
||||||
clearAvatarPreview();
|
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
|
||||||
});
|
preserveScroll: true,
|
||||||
|
onSuccess: clearAvatarSelection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(clearAvatarPreview);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Profile settings" />
|
<Head :title="t('profile.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Profile settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col space-y-6">
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
<Heading
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
variant="small"
|
<h2 class="text-title">{{ t('profile.title') }}</h2>
|
||||||
title="Profile information"
|
<p class="text-muted-foreground">
|
||||||
description="Update your name, email address, and profile picture"
|
{{ t('profile.description') }}
|
||||||
/>
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<Form
|
<CardContent class="p-5 sm:p-6">
|
||||||
v-bind="ProfileController.update.form()"
|
<form
|
||||||
class="space-y-6"
|
class="gap-column grid min-w-0"
|
||||||
v-slot="{
|
:aria-busy="profileForm.processing"
|
||||||
errors,
|
@submit.prevent="submitProfile"
|
||||||
processing,
|
>
|
||||||
progress,
|
<FormFeedback
|
||||||
recentlySuccessful,
|
:state="profileProtection.state.value"
|
||||||
}"
|
:title="
|
||||||
>
|
profileForm.processing
|
||||||
<div class="grid gap-3">
|
? t('profile.saving')
|
||||||
<Label for="avatar">Profile picture</Label>
|
: undefined
|
||||||
<div class="flex items-center gap-4">
|
"
|
||||||
<UserAvatar class="size-16" />
|
@retry="submitProfile"
|
||||||
<div class="grid flex-1 gap-2">
|
/>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<Input
|
<fieldset
|
||||||
id="avatar"
|
class="gap-column grid min-w-0"
|
||||||
name="avatar"
|
:disabled="profileForm.processing"
|
||||||
type="file"
|
>
|
||||||
accept="image/png,image/jpeg,image/webp"
|
<legend class="sr-only">
|
||||||
@change="handleAvatarChange"
|
{{ t('profile.information') }}
|
||||||
/>
|
</legend>
|
||||||
<Button
|
|
||||||
v-if="canRemoveAvatar"
|
<section
|
||||||
type="button"
|
aria-labelledby="profile-picture-title"
|
||||||
variant="outline"
|
class="gap-column grid min-w-0 border-b pb-6"
|
||||||
:disabled="avatarDeleteForm.processing"
|
>
|
||||||
data-test="delete-profile-picture-button"
|
<div class="gap-stack grid">
|
||||||
@click="removeAvatar"
|
<h3
|
||||||
|
id="profile-picture-title"
|
||||||
|
class="font-semibold"
|
||||||
>
|
>
|
||||||
<Trash2 class="size-4" />
|
{{ t('profile.picture') }}
|
||||||
Remove
|
</h3>
|
||||||
</Button>
|
<p
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.pictureDescription') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
PNG, JPG, or WebP up to 2 MB.
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="displayedUser"
|
||||||
|
size="xl"
|
||||||
|
class="ring-border shrink-0 ring-1"
|
||||||
|
:label="t('profile.picturePreview')"
|
||||||
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<label
|
||||||
|
for="avatar"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Camera
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
avatarPreview
|
||||||
|
? t(
|
||||||
|
'profile.changePicture',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'profile.choosePicture',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="avatarPreview"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
@click="clearAvatarSelection"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
t('profile.discardPicture')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="canRemoveAvatar"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="
|
||||||
|
avatarDeleteForm.processing
|
||||||
|
"
|
||||||
|
data-test="delete-profile-picture-button"
|
||||||
|
@click="removeAvatar"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />
|
||||||
|
{{ t('profile.removePicture') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="avatar"
|
||||||
|
ref="avatarInput"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
class="sr-only"
|
||||||
|
aria-describedby="avatar-help avatar-error"
|
||||||
|
:aria-invalid="
|
||||||
|
!!profileForm.errors.avatar
|
||||||
|
"
|
||||||
|
@change="handleAvatarChange"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="profileForm.avatar"
|
||||||
|
class="text-caption min-w-0 break-all font-medium"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('profile.selectedPicture', {
|
||||||
|
name: profileForm.avatar
|
||||||
|
.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="avatar-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.pictureHelp') }}
|
||||||
|
</p>
|
||||||
|
<progress
|
||||||
|
v-if="profileForm.progress"
|
||||||
|
class="h-1 w-full overflow-hidden rounded-full"
|
||||||
|
:value="
|
||||||
|
profileForm.progress.percentage
|
||||||
|
"
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
profileForm.progress.percentage
|
||||||
|
}}%
|
||||||
|
</progress>
|
||||||
|
<InputError
|
||||||
|
id="avatar-error"
|
||||||
|
:message="profileForm.errors.avatar"
|
||||||
|
/>
|
||||||
|
<FormFeedback
|
||||||
|
:state="
|
||||||
|
avatarDeleteProtection.state
|
||||||
|
.value
|
||||||
|
"
|
||||||
|
:title="
|
||||||
|
avatarDeleteForm.processing
|
||||||
|
? t(
|
||||||
|
'profile.removingPicture',
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="removeAvatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">{{
|
||||||
|
t('profile.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="profileForm.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
:placeholder="t('profile.namePlaceholder')"
|
||||||
|
:aria-invalid="!!profileForm.errors.name"
|
||||||
|
aria-describedby="name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="name-error"
|
||||||
|
:message="profileForm.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="email">{{
|
||||||
|
t('profile.email')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
v-model="profileForm.email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
:placeholder="t('profile.emailPlaceholder')"
|
||||||
|
:aria-invalid="!!profileForm.errors.email"
|
||||||
|
aria-describedby="email-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="email-error"
|
||||||
|
:message="profileForm.errors.email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
mustVerifyEmail && !user.email_verified_at
|
||||||
|
"
|
||||||
|
class="bg-muted/55 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="text-body text-muted-foreground">
|
||||||
|
{{ t('profile.emailUnverified') }}
|
||||||
|
<Link
|
||||||
|
:href="send()"
|
||||||
|
as="button"
|
||||||
|
class="text-primary font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{{ t('profile.resendVerification') }}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="status === 'verification-link-sent'"
|
||||||
|
role="status"
|
||||||
|
class="text-success-foreground mt-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ t('profile.verificationSent') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
v-if="progress"
|
|
||||||
class="h-1 w-full overflow-hidden rounded-full"
|
|
||||||
:value="progress.percentage"
|
|
||||||
max="100"
|
|
||||||
>
|
|
||||||
{{ progress.percentage }}%
|
|
||||||
</progress>
|
|
||||||
<InputError class="mt-1" :message="errors.avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Label for="name">Name</Label>
|
<Button
|
||||||
<Input
|
type="submit"
|
||||||
id="name"
|
:disabled="profileForm.processing"
|
||||||
class="mt-1 block w-full"
|
data-test="update-profile-button"
|
||||||
name="name"
|
>
|
||||||
:default-value="user.name"
|
{{
|
||||||
required
|
profileForm.processing
|
||||||
autocomplete="name"
|
? t('profile.saving')
|
||||||
placeholder="Full name"
|
: t('profile.save')
|
||||||
/>
|
}}
|
||||||
<InputError class="mt-2" :message="errors.name" />
|
</Button>
|
||||||
</div>
|
<p
|
||||||
|
v-if="profileForm.isDirty"
|
||||||
<div class="grid gap-2">
|
class="text-caption text-muted-foreground"
|
||||||
<Label for="email">Email address</Label>
|
>
|
||||||
<Input
|
{{ t('profile.unsaved') }}
|
||||||
id="email"
|
</p>
|
||||||
type="email"
|
</div>
|
||||||
class="mt-1 block w-full"
|
</fieldset>
|
||||||
name="email"
|
</form>
|
||||||
:default-value="user.email"
|
</CardContent>
|
||||||
required
|
</Card>
|
||||||
autocomplete="username"
|
|
||||||
placeholder="Email address"
|
|
||||||
/>
|
|
||||||
<InputError class="mt-2" :message="errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="mustVerifyEmail && !user.email_verified_at">
|
|
||||||
<p class="text-muted-foreground -mt-4 text-sm">
|
|
||||||
Your email address is unverified.
|
|
||||||
<Link
|
|
||||||
:href="send()"
|
|
||||||
as="button"
|
|
||||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
|
||||||
>
|
|
||||||
Click here to resend the verification email.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status === 'verification-link-sent'"
|
|
||||||
class="mt-2 text-sm font-medium text-green-600"
|
|
||||||
>
|
|
||||||
A new verification link has been sent to your email
|
|
||||||
address.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="update-profile-button"
|
|
||||||
>Save</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition ease-in-out"
|
|
||||||
enter-from-class="opacity-0"
|
|
||||||
leave-active-class="transition ease-in-out"
|
|
||||||
leave-to-class="opacity-0"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-show="recentlySuccessful"
|
|
||||||
class="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Saved.
|
|
||||||
</p>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DeleteUser />
|
<DeleteUser />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
|||||||
@@ -1,222 +1,340 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
import { ShieldCheck } from 'lucide-vue-next';
|
||||||
import { onUnmounted, ref } from 'vue';
|
import { computed, onUnmounted, ref } from 'vue';
|
||||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
||||||
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/security/index';
|
import { edit } from '@/routes/security/index';
|
||||||
import { disable, enable } from '@/routes/two-factor';
|
import { disable, enable } from '@/routes/two-factor';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
const props = withDefaults(
|
||||||
canManageTwoFactor?: boolean;
|
defineProps<{
|
||||||
mustChangePassword?: boolean;
|
canManageTwoFactor?: boolean;
|
||||||
requiresConfirmation?: boolean;
|
requiresConfirmation?: boolean;
|
||||||
status?: string | null;
|
twoFactorEnabled?: boolean;
|
||||||
twoFactorEnabled?: boolean;
|
}>(),
|
||||||
};
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
canManageTwoFactor: false,
|
|
||||||
mustChangePassword: false,
|
|
||||||
requiresConfirmation: false,
|
|
||||||
status: null,
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
|
||||||
{
|
{
|
||||||
title: 'Security settings',
|
canManageTwoFactor: false,
|
||||||
href: edit(),
|
requiresConfirmation: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.security'), href: edit() },
|
||||||
|
]);
|
||||||
|
const passwordForm = useForm({
|
||||||
|
current_password: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
}).dontRemember('current_password', 'password', 'password_confirmation');
|
||||||
|
const passwordProtection = useFormProtection(passwordForm, {
|
||||||
|
isDirty: () => false,
|
||||||
|
});
|
||||||
|
const twoFactorForm = useForm({});
|
||||||
|
const twoFactorProtection = useFormProtection(twoFactorForm);
|
||||||
|
const showSetupModal = ref(false);
|
||||||
|
const showDisableDialog = ref(false);
|
||||||
|
const setupStarted = ref(false);
|
||||||
|
|
||||||
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
|
function savePassword(): void {
|
||||||
const showSetupModal = ref<boolean>(false);
|
passwordProtection.submit(update(), {
|
||||||
|
preserveScroll: true,
|
||||||
onUnmounted(() => clearTwoFactorAuthData());
|
onFinish: () => passwordForm.reset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startSetup(): void {
|
||||||
|
if (setupStarted.value) {
|
||||||
|
showSetupModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
twoFactorProtection.submit(enable(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
setupStarted.value = true;
|
||||||
|
showSetupModal.value = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function disableTwoFactor(): void {
|
||||||
|
twoFactorProtection.submit(disable(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
showDisableDialog.value = false;
|
||||||
|
setupStarted.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateDisableDialog(open: boolean): void {
|
||||||
|
if (!twoFactorForm.processing) {
|
||||||
|
showDisableDialog.value = open;
|
||||||
|
twoFactorProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUnmounted(() => passwordForm.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Security settings" />
|
<Head :title="t('security.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Security settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
v-if="mustChangePassword || status"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
<h2 class="text-title">
|
||||||
>
|
{{ t('security.passwordTitle') }}
|
||||||
{{
|
</h2>
|
||||||
mustChangePassword
|
<p class="text-muted-foreground">
|
||||||
? 'You must change your password before continuing anywhere else in the app.'
|
{{ t('security.passwordDescription') }}
|
||||||
: status
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<Heading
|
|
||||||
variant="small"
|
|
||||||
title="Update password"
|
|
||||||
description="Ensure your account is using a long, random password to stay secure"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="SecurityController.update.form()"
|
|
||||||
:options="{
|
|
||||||
preserveScroll: true,
|
|
||||||
}"
|
|
||||||
reset-on-success
|
|
||||||
:reset-on-error="[
|
|
||||||
'password',
|
|
||||||
'password_confirmation',
|
|
||||||
'current_password',
|
|
||||||
]"
|
|
||||||
class="space-y-6"
|
|
||||||
v-slot="{ errors, processing, recentlySuccessful }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="current_password">Current password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="current_password"
|
|
||||||
name="current_password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
autocomplete="current-password"
|
|
||||||
placeholder="Current password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.current_password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password">New password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="New password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password_confirmation"
|
|
||||||
>Confirm password</Label
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password_confirmation" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="update-password-button"
|
|
||||||
>
|
|
||||||
Save password
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition ease-in-out"
|
|
||||||
enter-from-class="opacity-0"
|
|
||||||
leave-active-class="transition ease-in-out"
|
|
||||||
leave-to-class="opacity-0"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-show="recentlySuccessful"
|
|
||||||
class="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Saved.
|
|
||||||
</p>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="canManageTwoFactor" class="space-y-6">
|
|
||||||
<Heading
|
|
||||||
variant="small"
|
|
||||||
title="Two-factor authentication"
|
|
||||||
description="Manage your two-factor authentication settings"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!twoFactorEnabled"
|
|
||||||
class="flex flex-col items-start justify-start space-y-4"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
When you enable two-factor authentication, you will be
|
|
||||||
prompted for a secure pin during login. This pin can be
|
|
||||||
retrieved from a TOTP-supported application on your
|
|
||||||
phone.
|
|
||||||
</p>
|
</p>
|
||||||
|
</CardHeader>
|
||||||
<div>
|
<CardContent class="p-5 sm:p-6">
|
||||||
<Button
|
<form
|
||||||
v-if="hasSetupData"
|
class="gap-column grid"
|
||||||
@click="showSetupModal = true"
|
:aria-busy="passwordForm.processing"
|
||||||
|
@submit.prevent="savePassword"
|
||||||
|
>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="passwordProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
passwordProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
:loading-title="t('security.savingPassword')"
|
||||||
|
:success-title="t('security.passwordSaved')"
|
||||||
|
@retry="savePassword"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
>
|
>
|
||||||
<ShieldCheck />Continue setup
|
<legend class="sr-only">
|
||||||
</Button>
|
{{ t('security.passwordTitle') }}
|
||||||
<Form
|
</legend>
|
||||||
v-else
|
<div class="grid gap-2">
|
||||||
v-bind="enable.form()"
|
<Label for="current_password">{{
|
||||||
@success="showSetupModal = true"
|
t('security.currentPassword')
|
||||||
#default="{ processing }"
|
}}</Label>
|
||||||
>
|
<PasswordInput
|
||||||
<Button type="submit" :disabled="processing">
|
id="current_password"
|
||||||
Enable 2FA
|
v-model="passwordForm.current_password"
|
||||||
</Button>
|
name="current_password"
|
||||||
</Form>
|
required
|
||||||
</div>
|
autocomplete="current-password"
|
||||||
</div>
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
<div
|
!!passwordForm.errors.current_password
|
||||||
v-else
|
"
|
||||||
class="flex flex-col items-start justify-start space-y-4"
|
aria-describedby="current-password-error"
|
||||||
>
|
/>
|
||||||
<p class="text-muted-foreground text-sm">
|
<InputError
|
||||||
You will be prompted for a secure, random pin during
|
id="current-password-error"
|
||||||
login, which you can retrieve from the TOTP-supported
|
:message="
|
||||||
application on your phone.
|
passwordForm.errors.current_password
|
||||||
</p>
|
"
|
||||||
|
/>
|
||||||
<div class="relative inline">
|
</div>
|
||||||
<Form v-bind="disable.form()" #default="{ processing }">
|
<div class="grid gap-2">
|
||||||
|
<Label for="password">{{
|
||||||
|
t('security.newPassword')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
v-model="passwordForm.password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors.password
|
||||||
|
"
|
||||||
|
aria-describedby="new-password-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="new-password-error"
|
||||||
|
:message="passwordForm.errors.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="password_confirmation">{{
|
||||||
|
t('security.confirmPassword')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password_confirmation"
|
||||||
|
v-model="passwordForm.password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors
|
||||||
|
.password_confirmation
|
||||||
|
"
|
||||||
|
aria-describedby="password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="password-confirmation-error"
|
||||||
|
:message="
|
||||||
|
passwordForm.errors
|
||||||
|
.password_confirmation
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="processing"
|
class="justify-self-start"
|
||||||
|
:loading="passwordForm.processing"
|
||||||
|
:loading-label="t('security.savingPassword')"
|
||||||
|
data-test="update-password-button"
|
||||||
|
>{{ t('security.savePassword') }}</Button
|
||||||
>
|
>
|
||||||
Disable 2FA
|
</fieldset>
|
||||||
</Button>
|
</form>
|
||||||
</Form>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-if="canManageTwoFactor"
|
||||||
|
class="gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
|
>
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<h2 class="text-title flex items-center gap-2">
|
||||||
|
<ShieldCheck class="text-primary size-5" />{{
|
||||||
|
t('security.twoFactorTitle')
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
class="bg-muted rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
role="status"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.enabled'
|
||||||
|
: setupStarted
|
||||||
|
? 'security.pending'
|
||||||
|
: 'security.disabled',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<TwoFactorRecoveryCodes />
|
{{
|
||||||
</div>
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
<TwoFactorSetupModal
|
? 'security.enabledDescription'
|
||||||
v-model:isOpen="showSetupModal"
|
: 'security.twoFactorDescription',
|
||||||
:requiresConfirmation="requiresConfirmation"
|
)
|
||||||
:twoFactorEnabled="twoFactorEnabled"
|
}}
|
||||||
/>
|
</p>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||||
|
<AuthFormFeedback
|
||||||
|
v-if="!showDisableDialog"
|
||||||
|
:state="twoFactorProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
twoFactorProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
@retry="
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? (showDisableDialog = true)
|
||||||
|
: startSetup()
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!twoFactorEnabled"
|
||||||
|
class="justify-self-start"
|
||||||
|
:loading="twoFactorForm.processing"
|
||||||
|
@click="startSetup"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
setupStarted
|
||||||
|
? 'security.continueSetup'
|
||||||
|
: 'security.enable',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<template v-else>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="text-destructive justify-self-start"
|
||||||
|
@click="showDisableDialog = true"
|
||||||
|
>{{ t('security.disable') }}</Button
|
||||||
|
>
|
||||||
|
<TwoFactorRecoveryCodes />
|
||||||
|
</template>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<TwoFactorSetupModal
|
||||||
|
v-if="canManageTwoFactor"
|
||||||
|
v-model:is-open="showSetupModal"
|
||||||
|
:requires-confirmation="requiresConfirmation"
|
||||||
|
:two-factor-enabled="twoFactorEnabled"
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
:open="showDisableDialog"
|
||||||
|
@update:open="updateDisableDialog"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!twoFactorForm.processing"
|
||||||
|
@escape-key-down="
|
||||||
|
twoFactorForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
@interact-outside="
|
||||||
|
twoFactorForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('security.disableTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('security.disableDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="twoFactorProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
twoFactorProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
@retry="disableTwoFactor"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="twoFactorForm.processing"
|
||||||
|
@click="updateDisableDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
:loading="twoFactorForm.processing"
|
||||||
|
@click="disableTwoFactor"
|
||||||
|
>{{ t('security.disable') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
|
||||||
|
|
||||||
|
export type BacklogFilterState = {
|
||||||
|
assigneeId: BacklogAssigneeFilter;
|
||||||
|
priorityId: '' | number;
|
||||||
|
query: string;
|
||||||
|
statusId: '' | number;
|
||||||
|
typeId: '' | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeNode = {
|
||||||
|
children: BacklogTicketTreeNode[];
|
||||||
|
matchesFilters: boolean;
|
||||||
|
ticket: TicketModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeProps = {
|
||||||
|
canEditTickets: boolean;
|
||||||
|
collapsedTicketIds: Set<number>;
|
||||||
|
nodes: BacklogTicketTreeNode[];
|
||||||
|
pendingTicketId: number | null;
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BacklogTicketTreeEmits = {
|
||||||
|
moveFinish: [ticketId: number];
|
||||||
|
moveStart: [ticketId: number];
|
||||||
|
toggle: [ticketId: number];
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './backlog';
|
||||||
export * from './navigation';
|
export * from './navigation';
|
||||||
export * from './page-props';
|
export * from './page-props';
|
||||||
|
export * from './ticket-tree';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
|
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
|
|
||||||
|
export type MetadataKind = 'type' | 'priority';
|
||||||
|
export type MetadataEntry = TicketPriorityModel &
|
||||||
|
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
|
||||||
|
export type MetadataEditorTarget = { kind: MetadataKind } & (
|
||||||
|
| { action: 'create'; entry?: never }
|
||||||
|
| { action: 'edit' | 'delete'; entry: MetadataEntry }
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
|
export type WorkflowEditorTarget =
|
||||||
|
| { kind: 'column'; action: 'create'; column?: never }
|
||||||
|
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
|
||||||
|
| {
|
||||||
|
kind: 'status';
|
||||||
|
action: 'create';
|
||||||
|
column: BoardColumnModel | null;
|
||||||
|
status?: never;
|
||||||
|
replacement?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'status';
|
||||||
|
action: 'edit' | 'delete';
|
||||||
|
column: BoardColumnModel | null;
|
||||||
|
status: TicketStatusModel;
|
||||||
|
replacement?: TicketStatusModel;
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
|
export type TicketDetailHeaderProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
|
>;
|
||||||
|
export type TicketDetailContentProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'canManageProject'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
| 'ticketFieldDefinitions'
|
||||||
|
| 'ticketPriorities'
|
||||||
|
| 'ticketTypes'
|
||||||
|
| 'users'
|
||||||
|
>;
|
||||||
|
export type TicketDetailBlockerProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
|
>;
|
||||||
|
export type TicketDetailMetadataProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||||
|
>;
|
||||||
|
export type TicketDetailGitProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'repositoryBranches'
|
||||||
|
| 'repositoryPullRequests'
|
||||||
|
| 'suggestedBranchName'
|
||||||
|
>;
|
||||||
|
export type TicketGitBranchesProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'repositoryBranches'
|
||||||
|
| 'suggestedBranchName'
|
||||||
|
>;
|
||||||
|
export type TicketGitPullRequestsProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'repositoryPullRequests'
|
||||||
|
>;
|
||||||
|
export type TicketBranchCreateDialogProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'suggestedBranchName'
|
||||||
|
> & {
|
||||||
|
repositoryConnections: RepositoryConnectionModel[];
|
||||||
|
};
|
||||||
|
export type TicketDetailRelationshipsProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'parentTicketOptions'
|
||||||
|
| 'childTicketTree'
|
||||||
|
| 'linkedTickets'
|
||||||
|
| 'ticketLinkTypes'
|
||||||
|
| 'ticketLinkOptions'
|
||||||
|
>;
|
||||||
|
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
|
||||||
|
export type TicketBranchFormData = {
|
||||||
|
branch_name: string;
|
||||||
|
repository_connection_id: string;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
export type TicketPresentationProps = {
|
export type TicketPresentationProps = {
|
||||||
|
layout?: 'card' | 'list';
|
||||||
projectId: number;
|
projectId: number;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
showStatus?: boolean;
|
showStatus?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
|
|
||||||
|
export type TicketTreeListProps = {
|
||||||
|
nodes: TicketTreeNodeModel[];
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AcceptanceCriteriaChecklistTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Acceptance criteria behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,110 @@ class ProjectBacklogTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the backlog payload supports nested tickets and their allowed status moves.
|
||||||
|
*/
|
||||||
|
public function test_backlog_includes_nested_tickets_and_allowed_status_moves(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'TREE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$backlogStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
$boardColumn = BoardColumn::factory()->for($project)->create([
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$boardStatus = TicketStatus::factory()
|
||||||
|
->for($project)
|
||||||
|
->for($boardColumn)
|
||||||
|
->create([
|
||||||
|
'name' => 'Ready',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$backlogStatus->allowedTargetStatuses()->sync([$boardStatus->id]);
|
||||||
|
|
||||||
|
$parentType = $project->ticketTypes()
|
||||||
|
->where('is_parent', true)
|
||||||
|
->firstOrFail();
|
||||||
|
$taskType = $project->ticketTypes()
|
||||||
|
->where('is_parent', false)
|
||||||
|
->firstOrFail();
|
||||||
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
|
||||||
|
$parentTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'TREE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'ticket_type_id' => $parentType->id,
|
||||||
|
'title' => 'Parent ticket',
|
||||||
|
]);
|
||||||
|
$childTicket = Ticket::factory()->create([
|
||||||
|
'parent_ticket_id' => $parentTicket->id,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'TREE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
'title' => 'Child ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->get(route('projects.backlog.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Backlog')
|
||||||
|
->where('canEditTickets', true)
|
||||||
|
->has('tickets', 2)
|
||||||
|
->where('tickets.0.id', $childTicket->id)
|
||||||
|
->where('tickets.0.parent_ticket_id', $parentTicket->id)
|
||||||
|
->where('tickets.0.ticket_status.allowed_target_statuses.0.id', $boardStatus->id)
|
||||||
|
->where('tickets.1.id', $parentTicket->id)
|
||||||
|
->where('tickets.1.parent_ticket_id', null)
|
||||||
|
->where('tickets.1.ticket_status.allowed_target_statuses.0.id', $boardStatus->id),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $parentTicket->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $childTicket->id,
|
||||||
|
'parent_ticket_id' => $parentTicket->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure projects without backlog tickets receive an empty collection.
|
||||||
|
*/
|
||||||
|
public function test_projects_without_backlog_tickets_receive_an_empty_collection(): void
|
||||||
|
{
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(route('projects.backlog.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Backlog')
|
||||||
|
->where('tickets', []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure creating tickets requires the project write permission.
|
* Ensure creating tickets requires the project write permission.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -212,6 +212,19 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'entry_type' => 'status_changed',
|
'entry_type' => 'status_changed',
|
||||||
'summary' => 'Moved from Todo to Done.',
|
'summary' => 'Moved from Todo to Done.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $ticket->worklogEntries()->count());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
|
'redirect_to' => 'board',
|
||||||
|
'ticket_status_id' => $doneStatus->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.board.index', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame($doneStatus->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
$this->assertSame(1, $ticket->worklogEntries()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -298,6 +298,79 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure saved outgoing targets reach both consumers without changing the reverse direction.
|
||||||
|
*/
|
||||||
|
public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create(['key' => 'CORE']);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$sourceStatus = TicketStatus::factory()->for($project)->for($column)->create([
|
||||||
|
'name' => 'Progress',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$reviewStatus = TicketStatus::factory()->for($project)->create([
|
||||||
|
'name' => 'Review',
|
||||||
|
'position' => 2,
|
||||||
|
]);
|
||||||
|
$doneStatus = TicketStatus::factory()->for($project)->create([
|
||||||
|
'name' => 'Done',
|
||||||
|
'position' => 3,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]);
|
||||||
|
$sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]);
|
||||||
|
$reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||||
|
'allowed_ticket_status_ids' => [$reviewStatus->id],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.board', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||||
|
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.current_status.id', $sourceStatus->id)
|
||||||
|
->assertJsonCount(1, 'data.target_statuses')
|
||||||
|
->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id);
|
||||||
|
|
||||||
|
$this->get(route('projects.board.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Board')
|
||||||
|
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||||
|
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1)
|
||||||
|
->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||||
|
'allowed_ticket_status_ids' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.board', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||||
|
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||||
|
|
||||||
|
$this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.target_statuses', []);
|
||||||
|
|
||||||
|
$this->get(route('projects.board.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Board')
|
||||||
|
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||||
|
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
||||||
*/
|
*/
|
||||||
@@ -372,4 +445,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
'id' => $backlogStatus->id,
|
'id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
$backlog = TicketStatus::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$statuses = TicketStatus::factory()->for($project)->for($column)->count(2)
|
||||||
|
->sequence(['position' => 1], ['position' => 2])->create();
|
||||||
|
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]);
|
||||||
|
$statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.settings.board.columns.destroy', [$project, $column]))
|
||||||
|
->assertRedirect(route('projects.settings.board', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertModelMissing($column);
|
||||||
|
$this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
$this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all());
|
||||||
|
$this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all());
|
||||||
|
$this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null));
|
||||||
|
$this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$status = TicketStatus::factory()->for($project)->for($column)->create();
|
||||||
|
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->from(route('projects.settings.board', $project))
|
||||||
|
->delete(route('projects.settings.board.statuses.destroy', [$project, $status]))
|
||||||
|
->assertSessionHasErrors('ticket_status_id');
|
||||||
|
$this->assertModelExists($status);
|
||||||
|
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
|
||||||
|
$this->actingAs($user)->from(route('projects.settings.board', $project))
|
||||||
|
->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0])
|
||||||
|
->assertSessionHasErrors('position');
|
||||||
|
$this->assertSame(1, $column->fresh()->position);
|
||||||
|
$this->assertSame($column->name, $column->fresh()->name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectBoardSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_workflow_renders_ordered_groups_deletion_protection_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-board-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectGeneralSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_general_settings_render_setting_sources_confirmations_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-general-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project general settings rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1511,6 +1511,74 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): void
|
||||||
|
{
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(route('projects.tickets.create', $project))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->post(route('projects.tickets.store', $project), [
|
||||||
|
'title' => 'Unauthorized ticket',
|
||||||
|
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
|
||||||
|
])->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('tickets', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ticket_forms_reject_metadata_from_another_project(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$otherProject = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||||
|
$parent = Ticket::factory()->create([
|
||||||
|
'project_id' => $otherProject->id,
|
||||||
|
'ticket_key' => 'OTHER-1',
|
||||||
|
'ticket_type_id' => $this->parentType($otherProject)->id,
|
||||||
|
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
$invalidMetadata = [
|
||||||
|
'parent_ticket_id' => $parent->id,
|
||||||
|
'ticket_type_id' => $this->nonParentType($otherProject)->id,
|
||||||
|
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->post(route('projects.tickets.store', $project), [
|
||||||
|
...$invalidMetadata,
|
||||||
|
'title' => 'Rejected creation',
|
||||||
|
'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
|
||||||
|
|
||||||
|
$this->assertSame(0, $project->tickets()->count());
|
||||||
|
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'ticket_key' => 'LOCAL-1',
|
||||||
|
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
'title' => 'Original title',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
|
||||||
|
...$invalidMetadata,
|
||||||
|
'title' => 'Rejected update',
|
||||||
|
]))->assertSessionHasErrors(array_keys($invalidMetadata));
|
||||||
|
|
||||||
|
$this->assertSame('Original title', $ticket->fresh()->title);
|
||||||
|
$this->assertNull($ticket->fresh()->parent_ticket_id);
|
||||||
|
}
|
||||||
|
|
||||||
private function nonParentType(Project $project): TicketType
|
private function nonParentType(Project $project): TicketType
|
||||||
{
|
{
|
||||||
return $project->ticketTypes()
|
return $project->ticketTypes()
|
||||||
|
|||||||
@@ -487,4 +487,75 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'id' => $ticketLinkType->id,
|
'id' => $ticketLinkType->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_last_priority_is_protected_from_deletion(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
|
||||||
|
->assertRedirect(route('projects.settings.metadata', $project))
|
||||||
|
->assertSessionHas('error', true)
|
||||||
|
->assertSessionHas('status', 'The last ticket priority cannot be deleted.');
|
||||||
|
|
||||||
|
$this->assertModelExists($priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_type_order_and_usage_counts_are_returned_after_reordering(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$types = $project->ticketTypes()->get();
|
||||||
|
$first = $types->first();
|
||||||
|
$last = $types->last();
|
||||||
|
Ticket::factory()->for($project)->create([
|
||||||
|
'ticket_type_id' => $last->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.settings.metadata.types.update', [$project, $last]), [
|
||||||
|
'name' => 'Customer request', 'position' => 1,
|
||||||
|
])
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->get(route('projects.settings.metadata', $project))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('ticketTypes.0.id', $last->id)
|
||||||
|
->where('ticketTypes.0.name', 'Customer request')
|
||||||
|
->where('ticketTypes.0.position', 1)
|
||||||
|
->where('ticketTypes.0.tickets_count', 1)
|
||||||
|
->where('ticketTypes.1.id', $first->id)
|
||||||
|
->where('ticketTypes.1.position', 2)
|
||||||
|
->where('ticketTypes.1.tickets_count', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$type = $project->ticketTypes()->firstOrFail();
|
||||||
|
$foreignType = TicketType::factory()->create();
|
||||||
|
$ticket = Ticket::factory()->for($project)->create([
|
||||||
|
'ticket_type_id' => $type->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.settings.metadata', $project))
|
||||||
|
->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [
|
||||||
|
'replacement_ticket_type_id' => $foreignType->id,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('replacement_ticket_type_id');
|
||||||
|
|
||||||
|
$this->assertModelExists($type);
|
||||||
|
$this->assertSame($type->id, $ticket->fresh()->ticket_type_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectTicketMetadataSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_metadata_settings_show_configured_values_and_deletion_protection(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-metadata-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
|
|||||||
'base_url' => 'https://git.example.test',
|
'base_url' => 'https://git.example.test',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
UserGitConnection::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('git-connections.edit'))
|
->get(route('git-connections.edit'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/GitConnections')
|
->component('settings/GitConnections')
|
||||||
|
->has('gitConnections', 1)
|
||||||
|
->missing('gitConnections.0.api_token')
|
||||||
->where('gitConnections.0.account_login', 'nico')
|
->where('gitConnections.0.account_login', 'nico')
|
||||||
->where('gitConnections.0.base_url', 'https://git.example.test')
|
->where('gitConnections.0.base_url', 'https://git.example.test')
|
||||||
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
|
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
|
||||||
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
|
|||||||
'api_token' => 'wrong-token',
|
'api_token' => 'wrong-token',
|
||||||
])
|
])
|
||||||
->assertRedirect(route('git-connections.edit'))
|
->assertRedirect(route('git-connections.edit'))
|
||||||
->assertSessionHasErrors('api_token');
|
->assertSessionHasErrors('api_token')
|
||||||
|
->assertSessionMissing('_old_input.api_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase
|
|||||||
'id' => $gitConnection->id,
|
'id' => $gitConnection->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_empty_personal_connections_are_displayed(): void
|
||||||
|
{
|
||||||
|
UserGitConnection::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->get(route('git-connections.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/GitConnections')
|
||||||
|
->where('gitConnections', []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guests_cannot_access_git_connections(): void
|
||||||
|
{
|
||||||
|
$this->get(route('git-connections.edit'))->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_cannot_delete_foreign_connections(): void
|
||||||
|
{
|
||||||
|
$connection = UserGitConnection::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->delete(route('git-connections.destroy', $connection))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertModelExists($connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$connection = UserGitConnection::factory()->for($user)->create([
|
||||||
|
'base_url' => 'https://git.original.test',
|
||||||
|
'api_token' => 'original-secret',
|
||||||
|
]);
|
||||||
|
$original = $connection->fresh()->getAttributes();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('git-connections.edit'))
|
||||||
|
->patch(route('git-connections.update', $connection), [
|
||||||
|
'provider' => 'gitea',
|
||||||
|
'base_url' => 'https://git.changed.test',
|
||||||
|
'api_token' => 'replacement-secret',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('git-connections.edit'))
|
||||||
|
->assertSessionHasErrors('api_token')
|
||||||
|
->assertSessionMissing('_old_input.api_token');
|
||||||
|
|
||||||
|
$this->assertSame($original, $connection->fresh()->getAttributes());
|
||||||
|
$this->assertSame('original-secret', $connection->fresh()->api_token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GitConnectionsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_personal_git_connections_rendering_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/git-connections.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProfileUpdateTest extends TestCase
|
class ProfileUpdateTest extends TestCase
|
||||||
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$response->assertSessionHasErrors('avatar');
|
$response->assertSessionHasErrors('avatar');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_profile_and_appearance_settings_render_the_shared_localized_interface(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Settings rendering and localization checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_user_can_delete_their_account()
|
public function test_user_can_delete_their_account()
|
||||||
{
|
{
|
||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
|
|||||||
|
|
||||||
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_two_factor_setup_requires_a_valid_code_before_protection_is_enabled(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()])
|
||||||
|
->from(route('security.edit'))
|
||||||
|
->post(route('two-factor.enable'))->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertNotNull($user->refresh()->two_factor_secret);
|
||||||
|
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||||
|
$this->getJson(route('two-factor.secret-key'))->assertOk()->assertJsonStructure(['secretKey']);
|
||||||
|
$this->getJson(route('two-factor.qr-code'))->assertOk()->assertJsonStructure(['svg']);
|
||||||
|
|
||||||
|
$this->mock(TwoFactorAuthenticationProvider::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '000000')->once()->andReturn(false);
|
||||||
|
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '123456')->once()->andReturn(true);
|
||||||
|
});
|
||||||
|
$this->post(route('two-factor.confirm'), ['code' => '000000'])
|
||||||
|
->assertSessionHasErrors('code', errorBag: 'confirmTwoFactorAuthentication');
|
||||||
|
$this->assertFalse($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
$this->post(route('two-factor.confirm'), ['code' => '123456'])->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_recovery_codes_can_be_retrieved_regenerated_and_removed_with_two_factor(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->withTwoFactor()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
||||||
|
$oldCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||||
|
$this->assertSame($user->recoveryCodes(), $oldCodes);
|
||||||
|
$this->post(route('two-factor.regenerate-recovery-codes'))->assertSessionHasNoErrors();
|
||||||
|
$newCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||||
|
$this->assertCount(8, $newCodes);
|
||||||
|
$this->assertEmpty(array_intersect($oldCodes, $newCodes));
|
||||||
|
$this->delete(route('two-factor.disable'))->assertSessionHasNoErrors();
|
||||||
|
$this->assertNull($user->refresh()->two_factor_secret);
|
||||||
|
$this->assertNull($user->two_factor_recovery_codes);
|
||||||
|
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sensitive_two_factor_endpoints_reject_expired_password_confirmation(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->withTwoFactor()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
foreach (['two-factor.qr-code', 'two-factor.secret-key', 'two-factor.recovery-codes'] as $route) {
|
||||||
|
$this->getJson(route($route))->assertStatus(423);
|
||||||
|
}
|
||||||
|
$this->deleteJson(route('two-factor.disable'))->assertStatus(423);
|
||||||
|
$this->postJson(route('two-factor.regenerate-recovery-codes'))->assertStatus(423);
|
||||||
|
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SecurityUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_security_and_application_key_rendering(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/security.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Security UI rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ class UserApplicationTest extends TestCase
|
|||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/Applications')
|
->component('settings/Applications')
|
||||||
->where('applications.0.name', 'Claude Desktop')
|
->where('applications.0.name', 'Claude Desktop')
|
||||||
->where('applicationKey', null),
|
->where('applicationKey', null)
|
||||||
|
->missing('applications.0.key_hash')
|
||||||
|
->missing('applications.0.plain_text_key'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,4 +102,26 @@ class UserApplicationTest extends TestCase
|
|||||||
'id' => $application->id,
|
'id' => $application->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_application_list_only_contains_the_current_users_keys(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
UserApplication::factory()->for($user)->create(['name' => 'Own tool']);
|
||||||
|
UserApplication::factory()->create(['name' => 'Foreign tool']);
|
||||||
|
$this->actingAs($user)->get(route('applications.edit'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('applications', 1)
|
||||||
|
->where('applications.0.name', 'Own tool')
|
||||||
|
->where('applicationKey', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_application_name_does_not_issue_a_key(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->from(route('applications.edit'))
|
||||||
|
->post(route('applications.store'), ['name' => ''])
|
||||||
|
->assertSessionHasErrors('name')
|
||||||
|
->assertSessionMissing('applicationKey');
|
||||||
|
$this->assertDatabaseCount('user_applications', 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketDetailPresentationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_detail_sections_preserve_information_actions_and_permissions(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/ticket-detail.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Ticket detail behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4176';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 900 },
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const checklist = () =>
|
||||||
|
page
|
||||||
|
.locator('form')
|
||||||
|
.first()
|
||||||
|
.getByRole('region', { name: 'Acceptance criteria' });
|
||||||
|
const savedChecklist = () => page.locator('#ticket-checklist');
|
||||||
|
const field = (number) =>
|
||||||
|
page.getByRole('textbox', { name: `Criterion ${number}`, exact: true });
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
|
||||||
|
const waitState = (value) =>
|
||||||
|
page.locator(`[data-form-state="${value}"]`).first().waitFor();
|
||||||
|
const save = async () => {
|
||||||
|
await button('Save changes').click();
|
||||||
|
await waitState('success');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.goto(`${base}/projects/7/tickets/31`);
|
||||||
|
await savedChecklist().waitFor();
|
||||||
|
const boxes = savedChecklist().getByRole('checkbox');
|
||||||
|
await setResult('server');
|
||||||
|
await boxes.first().focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page
|
||||||
|
.getByText('Checklist status could not be saved. Please try again.')
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await boxes.first().isChecked(),
|
||||||
|
false,
|
||||||
|
'Failure must restore saved checkbox',
|
||||||
|
);
|
||||||
|
await setResult('success');
|
||||||
|
let payload;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.method() === 'PATCH') {
|
||||||
|
payload = request.postDataJSON();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await boxes.first().tap();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.querySelectorAll('section[aria-busy="true"]').length === 0,
|
||||||
|
);
|
||||||
|
await savedChecklist().getByText('3 of 4 complete').waitFor();
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.checked),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.id),
|
||||||
|
[11, 12, 13, 14],
|
||||||
|
);
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
await field(2).fill('Edited child');
|
||||||
|
await field(2).press('Alt+ArrowLeft');
|
||||||
|
assert.deepEqual(
|
||||||
|
await checklist()
|
||||||
|
.locator('li')
|
||||||
|
.evaluateAll((items) =>
|
||||||
|
items.map((item) => Number(item.dataset.indent)),
|
||||||
|
),
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
);
|
||||||
|
await button('Indent criterion 2').tap();
|
||||||
|
await button('Remove criterion 1').tap();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(
|
||||||
|
await field(1).evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await button('Add criterion').focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.equal(
|
||||||
|
await field(4).evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await field(4).fill('New criterion');
|
||||||
|
await button('Indent criterion 4').click();
|
||||||
|
await setResult('validation');
|
||||||
|
await button('Save changes').click();
|
||||||
|
await waitState('validation');
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
await setResult('success');
|
||||||
|
await save();
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.id),
|
||||||
|
[12, 13, 14, undefined],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.indent),
|
||||||
|
[0, 1, 0, 1],
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(await field(4).inputValue(), 'New criterion');
|
||||||
|
// Blank/whitespace text cannot silently delete a persisted criterion.
|
||||||
|
await field(1).fill(' ');
|
||||||
|
assert.equal(await field(1).evaluate((el) => el.checkValidity()), false);
|
||||||
|
const beforeInvalidSave = payload;
|
||||||
|
await button('Save changes').click();
|
||||||
|
assert.equal(payload, beforeInvalidSave);
|
||||||
|
await button('Discard changes').click();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(await field(1).evaluate((el) => el.checkValidity()), true);
|
||||||
|
for (const width of [1280, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await page.evaluate(
|
||||||
|
(theme) =>
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'dark',
|
||||||
|
theme === 'dark',
|
||||||
|
),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await checklist().evaluate(
|
||||||
|
(el) => el.scrollWidth <= el.clientWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await checklist().screenshot({
|
||||||
|
path: `/private/tmp/kan32-checklist-${width}-${theme}.png`,
|
||||||
|
});
|
||||||
|
await button('Outdent criterion 4').tap();
|
||||||
|
await button('Indent criterion 4').tap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/projects/7/tickets/31?readonly=1`);
|
||||||
|
await savedChecklist().waitFor();
|
||||||
|
assert.equal(await savedChecklist().getByRole('textbox').count(), 0);
|
||||||
|
assert.equal(await savedChecklist().getByRole('button').count(), 0);
|
||||||
|
assert.equal(
|
||||||
|
await savedChecklist().getByRole('checkbox').first().isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.goto(`${base}/projects/7/tickets/create`);
|
||||||
|
await page.locator('#title').fill('Created with criteria');
|
||||||
|
for (const text of ['First', 'Second']) {
|
||||||
|
await button('Add criterion').click();
|
||||||
|
await page.locator('textarea').last().fill(text);
|
||||||
|
}
|
||||||
|
await button('Indent criterion 2').tap();
|
||||||
|
await checklist().getByRole('checkbox').first().focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === 'POST' && request.url().endsWith('/tickets'),
|
||||||
|
);
|
||||||
|
await button('Create ticket').click();
|
||||||
|
const created = (await request).postDataJSON();
|
||||||
|
assert.deepEqual(created.acceptance_criteria, [
|
||||||
|
{ text: 'First', checked: true, indent: 0 },
|
||||||
|
{ text: 'Second', checked: true, indent: 1 },
|
||||||
|
]);
|
||||||
|
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Acceptance criteria browser checks passed: persistence, errors, keyboard, touch, readonly, creation, mobile and themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/acceptance-criteria-fixture',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
canIndentAcceptanceCriterion: canIndent,
|
||||||
|
indentAcceptanceCriterion: indent,
|
||||||
|
removeAcceptanceCriterion: remove,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState: toggle,
|
||||||
|
normalizeAcceptanceCriteria: normalize,
|
||||||
|
} = await server.ssrLoadModule('/resources/js/lib/acceptance-criteria.ts');
|
||||||
|
const items = [
|
||||||
|
{ id: 11, text: 'Parent', indent: 0, checked: false },
|
||||||
|
{ id: 12, text: 'Child', indent: 1, checked: true },
|
||||||
|
{ id: 13, text: 'Grandchild', indent: 2, checked: false },
|
||||||
|
{ id: 14, text: 'Next', indent: 0, checked: false },
|
||||||
|
];
|
||||||
|
const original = structuredClone(items);
|
||||||
|
assert.equal(canIndent(items, 0), false);
|
||||||
|
assert.equal(canIndent(items, 1), false);
|
||||||
|
assert.equal(canIndent(items, 3), true);
|
||||||
|
assert.deepEqual(
|
||||||
|
indent(items, 1, -1).map((item) => item.indent),
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(indent(indent(items, 1, -1), 1, 1), items);
|
||||||
|
assert.deepEqual(indent(items, 0, -1), items);
|
||||||
|
assert.deepEqual(indent(items, -1, 1), items);
|
||||||
|
assert.deepEqual(remove(items, 0), [
|
||||||
|
{ ...items[1], indent: 0 },
|
||||||
|
{ ...items[2], indent: 1 },
|
||||||
|
items[3],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(remove(items, 1), [
|
||||||
|
items[0],
|
||||||
|
{ ...items[2], indent: 1 },
|
||||||
|
items[3],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(remove(items, 99), items);
|
||||||
|
assert.deepEqual(
|
||||||
|
toggle(items, 0, true).map((item) => item.checked),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
toggle(items, 1, false).map((item) => item.checked),
|
||||||
|
[false, false, false, false],
|
||||||
|
);
|
||||||
|
const deep = Array.from({ length: 9 }, (_, indent) => ({
|
||||||
|
text: 'Deep',
|
||||||
|
checked: false,
|
||||||
|
indent,
|
||||||
|
}));
|
||||||
|
assert.equal(
|
||||||
|
canIndent([{ text: 'Before', indent: 0, checked: false }, ...deep], 1),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalize(remove(items, 0)).map((item) => item.id),
|
||||||
|
[12, 13, 14],
|
||||||
|
);
|
||||||
|
assert.deepEqual(items, original, 'Editing must not mutate saved criteria');
|
||||||
|
const { default: Checklist } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/AcceptanceCriteriaChecklist.vue',
|
||||||
|
);
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const render = (props) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(Checklist, props) }).use(
|
||||||
|
createLocalization(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const editable = await render({ modelValue: items });
|
||||||
|
assert.equal((editable.match(/<textarea/g) ?? []).length, 4);
|
||||||
|
assert.match(editable, /Indent criterion 4/);
|
||||||
|
assert.match(editable, /1 of 4 complete/);
|
||||||
|
const readonly = await render({ modelValue: items, readonly: true });
|
||||||
|
assert.doesNotMatch(readonly, /<textarea|Add criterion|Edit checklist/);
|
||||||
|
assert.equal((readonly.match(/type="checkbox" disabled/g) ?? []).length, 4);
|
||||||
|
assert.match(
|
||||||
|
await render({ modelValue: [], error: 'Server validation' }),
|
||||||
|
/Server validation/,
|
||||||
|
);
|
||||||
|
console.log('Acceptance criteria behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -84,15 +84,11 @@ try {
|
|||||||
renderTheme('light'),
|
renderTheme('light'),
|
||||||
renderTheme('dark'),
|
renderTheme('dark'),
|
||||||
]);
|
]);
|
||||||
const selectedButton = (html) =>
|
assert.match(light, /value="light"[^>]*checked/);
|
||||||
html.match(
|
assert.match(dark, /value="dark"[^>]*checked/);
|
||||||
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
|
assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
|
||||||
)[1];
|
assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
|
||||||
assert.match(selectedButton(light), />Light</);
|
assert.equal((dark.match(/ checked/g) ?? []).length, 1);
|
||||||
assert.match(selectedButton(dark), />Dark</);
|
|
||||||
assert.match(light, /role="group" aria-label="Color theme"/);
|
|
||||||
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
|
|
||||||
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
|
|
||||||
assert.equal(normalizeAppearance('unexpected'), 'system');
|
assert.equal(normalizeAppearance('unexpected'), 'system');
|
||||||
|
|
||||||
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
||||||
|
|||||||
@@ -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}`}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,15 +320,18 @@ try {
|
|||||||
await page.goto(`${base}/projects/7/tickets/create`);
|
await page.goto(`${base}/projects/7/tickets/create`);
|
||||||
await page.locator('#title').fill('New ticket draft');
|
await page.locator('#title').fill('New ticket draft');
|
||||||
await page.locator('#description').fill('Keep this description');
|
await page.locator('#description').fill('Keep this description');
|
||||||
|
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
.fill('[ ] Keep this checklist');
|
.fill('Keep this checklist');
|
||||||
await setResult('validation');
|
await setResult('validation');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await state('validation');
|
await state('validation');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Keep this checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Keep this checklist',
|
||||||
);
|
);
|
||||||
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
@@ -349,18 +352,22 @@ try {
|
|||||||
await page.locator('#title').waitFor();
|
await page.locator('#title').waitFor();
|
||||||
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Keep this checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Keep this checklist',
|
||||||
);
|
);
|
||||||
await setResult('success');
|
await setResult('success');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||||
|
|
||||||
await page.goto(`${base}/projects/7/tickets/31`);
|
await page.goto(`${base}/projects/7/tickets/31`);
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
await page.locator('#title').fill('Updated ticket draft');
|
await page.locator('#title').fill('Updated ticket draft');
|
||||||
|
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
.fill('[ ] Edited checklist');
|
.fill('Edited checklist');
|
||||||
await setResult('server');
|
await setResult('server');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await state('server');
|
await state('server');
|
||||||
@@ -369,8 +376,10 @@ try {
|
|||||||
'Updated ticket draft',
|
'Updated ticket draft',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Edited checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Edited checklist',
|
||||||
);
|
);
|
||||||
await setResult('success');
|
await setResult('success');
|
||||||
await page.getByRole('button', { name: 'Try again' }).click();
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import process from 'node:process';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import tailwind from '@tailwindcss/vite';
|
import tailwind from '@tailwindcss/vite';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
@@ -36,9 +37,23 @@ let ticket = {
|
|||||||
ticket_status: status,
|
ticket_status: status,
|
||||||
is_blocked: false,
|
is_blocked: false,
|
||||||
story_points: 0,
|
story_points: 0,
|
||||||
acceptance_criteria: [],
|
acceptance_criteria: process.env.CRITERIA_FIXTURE
|
||||||
|
? [
|
||||||
|
{ id: 11, text: 'Parent criterion', indent: 0, checked: false },
|
||||||
|
{ id: 12, text: 'Nested criterion', indent: 1, checked: true },
|
||||||
|
{ id: 13, text: 'Deep criterion', indent: 2, checked: false },
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
text: 'Independent criterion',
|
||||||
|
indent: 0,
|
||||||
|
checked: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
field_values: [],
|
field_values: [],
|
||||||
worklog_entries: [],
|
worklog_entries: [],
|
||||||
|
created_at: '2026-09-10T12:00:00Z',
|
||||||
|
updated_at: '2026-09-11T12:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
server.middlewares.use(async (request, response, next) => {
|
server.middlewares.use(async (request, response, next) => {
|
||||||
@@ -123,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
|
|||||||
ticketStatusOptions: [status],
|
ticketStatusOptions: [status],
|
||||||
users: [],
|
users: [],
|
||||||
parentTicketOptions: [],
|
parentTicketOptions: [],
|
||||||
canEditTicket: true,
|
canEditTicket: !url.searchParams.has('readonly'),
|
||||||
canManageProject: true,
|
canManageProject: true,
|
||||||
childTicketTree: [],
|
childTicketTree: [],
|
||||||
linkedTickets: [],
|
linkedTickets: [],
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4186';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const button = (name) => dialog().getByRole('button', { name, exact: true });
|
||||||
|
const state = (value) =>
|
||||||
|
dialog()
|
||||||
|
.locator(`[data-form-state="${value}"]`)
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'git_test_result', value, url: base }]);
|
||||||
|
const add = () =>
|
||||||
|
page.getByRole('button', { name: 'Add connection', exact: true }).click();
|
||||||
|
const edit = () =>
|
||||||
|
page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Edit alex at https://git.example.test',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
const visit = async (query = '?reset') => {
|
||||||
|
await page.goto(`${base}/settings/git-connections${query}`);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Git connections', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
};
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const fill = async (token = 'test-secret') => {
|
||||||
|
await dialog()
|
||||||
|
.getByLabel('Gitea base URL', { exact: true })
|
||||||
|
.fill('https://git.example.test');
|
||||||
|
await page.locator('#git-token').fill(token);
|
||||||
|
};
|
||||||
|
const closed = () => dialog().waitFor({ state: 'hidden' });
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('[data-slot="card"]')
|
||||||
|
.first()
|
||||||
|
.evaluate(
|
||||||
|
(element) =>
|
||||||
|
element.getBoundingClientRect().right <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`Connection list clips at ${width}px in ${theme}`,
|
||||||
|
);
|
||||||
|
if (width === 390) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/KAN-38-${theme}-list.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await add();
|
||||||
|
await dialog().waitFor();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-token').getAttribute('type'),
|
||||||
|
'password',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-token').getAttribute('autocomplete'),
|
||||||
|
'new-password',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
element.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
Promise.all(
|
||||||
|
element
|
||||||
|
.getAnimations()
|
||||||
|
.map((animation) => animation.finished),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (width === 390) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/KAN-38-${theme}-dialog.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await closed();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Add connection',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.evaluate((element) => document.activeElement === element),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await edit();
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-base-url').inputValue(),
|
||||||
|
'https://git.example.test',
|
||||||
|
);
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await noOverflow();
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?empty');
|
||||||
|
await page.getByText('No Git connections yet', { exact: true }).waitFor();
|
||||||
|
await add();
|
||||||
|
await fill();
|
||||||
|
await setResult('validation');
|
||||||
|
await button('Add connection').click();
|
||||||
|
await state('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-token').getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), 'test-secret');
|
||||||
|
await page
|
||||||
|
.getByText(
|
||||||
|
'Gitea could not authenticate with this base URL and token.',
|
||||||
|
{ exact: true },
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await page.evaluate(() => JSON.stringify(history.state)),
|
||||||
|
/test-secret/,
|
||||||
|
);
|
||||||
|
await page.route('**/settings/git-connections', (route) =>
|
||||||
|
route.abort('failed'),
|
||||||
|
);
|
||||||
|
await button('Add connection').click();
|
||||||
|
await state('network');
|
||||||
|
await page.unroute('**/settings/git-connections');
|
||||||
|
await setResult('server');
|
||||||
|
await button('Try again').click();
|
||||||
|
await state('server');
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), 'test-secret');
|
||||||
|
await setResult('success');
|
||||||
|
const submitted = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === 'POST' &&
|
||||||
|
request.url().endsWith('/settings/git-connections'),
|
||||||
|
);
|
||||||
|
await button('Try again').click();
|
||||||
|
assert.deepEqual((await submitted).postDataJSON(), {
|
||||||
|
provider: 'gitea',
|
||||||
|
base_url: 'https://git.example.test',
|
||||||
|
api_token: 'test-secret',
|
||||||
|
});
|
||||||
|
await closed();
|
||||||
|
await page.getByText('Git connection added.', { exact: true }).waitFor();
|
||||||
|
await add();
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await edit();
|
||||||
|
await page.locator('#git-token').fill('updated-secret');
|
||||||
|
const patch = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'PATCH',
|
||||||
|
);
|
||||||
|
await button('Save changes').click();
|
||||||
|
assert.equal((await patch).postDataJSON().api_token, 'updated-secret');
|
||||||
|
await closed();
|
||||||
|
await page.getByText('Git connection updated.', { exact: true }).waitFor();
|
||||||
|
await edit();
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await page.locator('#git-token').fill('discard-me');
|
||||||
|
page.once('dialog', (confirmation) => confirmation.dismiss());
|
||||||
|
await button('Cancel').click();
|
||||||
|
assert.equal(await dialog().isVisible(), true);
|
||||||
|
page.once('dialog', (confirmation) => confirmation.accept());
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
await edit();
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Remove alex at https://git.example.test',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await setResult('forbidden');
|
||||||
|
await button('Remove connection').click();
|
||||||
|
await state('server');
|
||||||
|
await page.getByText('Connection access denied', { exact: true }).waitFor();
|
||||||
|
assert.equal(await button('Try again').count(), 0);
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Remove alex at https://git.example.test',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await setResult('success');
|
||||||
|
const deletion = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'DELETE',
|
||||||
|
);
|
||||||
|
await button('Remove connection').click();
|
||||||
|
assert.equal(
|
||||||
|
new URL((await deletion).url()).pathname,
|
||||||
|
'/settings/git-connections/1',
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
await page.getByText('No Git connections yet', { exact: true }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Git connections browser checks passed: create, edit, remove, token privacy, provider errors, retries, permissions, discard, keyboard and both themes at 320/390/1440px.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import GitConnections from '../../resources/js/pages/settings/GitConnections.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: () => GitConnections,
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/git-connections-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const connection = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
provider: 'gitea',
|
||||||
|
provider_label: 'Gitea',
|
||||||
|
base_url: 'https://git.example.test',
|
||||||
|
provider_user_id: '42',
|
||||||
|
account_login: 'alex',
|
||||||
|
account_name: 'Alex Morgan',
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
};
|
||||||
|
function makePage(extra = {}) {
|
||||||
|
return {
|
||||||
|
component: 'GitConnections',
|
||||||
|
url: '/settings/git-connections',
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
status: null,
|
||||||
|
gitConnections: [connection],
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
let connections = [connection];
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/settings')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
const mutation = request.method !== 'GET';
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(/git_test_result=(\w+)/)?.[1] ??
|
||||||
|
'success';
|
||||||
|
let extra = {};
|
||||||
|
if (!mutation && url.searchParams.has('reset')) {
|
||||||
|
connections = [connection];
|
||||||
|
}
|
||||||
|
if (mutation && ['server', 'forbidden'].includes(result)) {
|
||||||
|
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||||
|
response.end('Simulated request failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mutation) {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const data = body ? JSON.parse(body) : {};
|
||||||
|
if (result === 'validation') {
|
||||||
|
extra.errors = {
|
||||||
|
api_token:
|
||||||
|
'Gitea could not authenticate with this base URL and token.',
|
||||||
|
};
|
||||||
|
} else if (request.method === 'DELETE') {
|
||||||
|
connections = connections.filter(
|
||||||
|
(item) =>
|
||||||
|
item.id !== Number(url.pathname.split('/').at(-1)),
|
||||||
|
);
|
||||||
|
extra.status = 'alex @ https://git.example.test was removed.';
|
||||||
|
} else if (request.method === 'PATCH') {
|
||||||
|
connections = connections.map((item) =>
|
||||||
|
item.id === Number(url.pathname.split('/').at(-1))
|
||||||
|
? { ...item, base_url: data.base_url }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
extra.status = 'Git connection was updated.';
|
||||||
|
} else {
|
||||||
|
connections.push({
|
||||||
|
...connection,
|
||||||
|
id: 2,
|
||||||
|
account_login: 'new-account',
|
||||||
|
base_url: data.base_url,
|
||||||
|
});
|
||||||
|
extra.status = 'Git connection was added.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage({
|
||||||
|
gitConnections: url.searchParams.has('empty') ? [] : connections,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/git-connections-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Git connections fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/GitConnections.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Gitea/);
|
||||||
|
assert.match(html, /https:\/\/git.example.test/);
|
||||||
|
assert.match(html, /Edit alex at https:\/\/git.example.test/);
|
||||||
|
assert.doesNotMatch(html, /name="api_token"/);
|
||||||
|
assert.match(
|
||||||
|
await render({ gitConnections: [] }),
|
||||||
|
/No Git connections yet/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'gitConnections.add': 'Verbindung hinzufügen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Verbindung hinzufügen/,
|
||||||
|
);
|
||||||
|
console.log('Git connections rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4188';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const requests = [];
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
|
||||||
|
requests.push(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
|
||||||
|
const visit = async (query = '?reset') => {
|
||||||
|
await result('success');
|
||||||
|
await page.goto(base + '/projects/1/settings/board' + query);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Board workflow', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
};
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const submit = async (label, method, pathname, expected) => {
|
||||||
|
const sent = page.waitForRequest((request) => request.method() === method);
|
||||||
|
await dialog().getByRole('button', { name: label, exact: true }).click();
|
||||||
|
const request = await sent;
|
||||||
|
assert.equal(new URL(request.url()).pathname, pathname);
|
||||||
|
assert.deepEqual(request.postDataJSON(), expected);
|
||||||
|
};
|
||||||
|
const closed = () => dialog().waitFor({ state: 'hidden' });
|
||||||
|
const formState = (state) =>
|
||||||
|
dialog()
|
||||||
|
.locator('[data-form-state="' + state + '"]')
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 768, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await button('Delete status Backlog').isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await button('Delete status Ready').isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await button('Delete status Review').isDisabled(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const trigger = button('Edit column In progress');
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await dialog().waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Column name', { exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'In progress',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
element.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await noOverflow();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await closed();
|
||||||
|
assert.equal(
|
||||||
|
await trigger.evaluate(
|
||||||
|
(element) => document.activeElement === element,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if ([390, 1440].includes(width)) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?reset&empty');
|
||||||
|
await page.getByText('No board columns yet', { exact: true }).waitFor();
|
||||||
|
await visit();
|
||||||
|
await button('Add column').click();
|
||||||
|
await page
|
||||||
|
.getByLabel('Column name', { exact: true })
|
||||||
|
.fill('Quality assurance');
|
||||||
|
await result('validation');
|
||||||
|
await submit('Add column', 'POST', '/projects/1/settings/board/columns', {
|
||||||
|
name: 'Quality assurance',
|
||||||
|
});
|
||||||
|
await formState('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Column name', { exact: true }).inputValue(),
|
||||||
|
'Quality assurance',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Column name', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await result('server');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Add column', exact: true })
|
||||||
|
.click();
|
||||||
|
await formState('server');
|
||||||
|
await result('success');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await closed();
|
||||||
|
await button('Edit column Quality assurance').waitFor();
|
||||||
|
const newColumnId = await page
|
||||||
|
.locator('[data-column-id]')
|
||||||
|
.last()
|
||||||
|
.getAttribute('data-column-id');
|
||||||
|
await button('Edit column Quality assurance').click();
|
||||||
|
await page.getByLabel('Column name', { exact: true }).fill('QA');
|
||||||
|
await page.getByLabel('Position', { exact: true }).fill('1');
|
||||||
|
await result('position');
|
||||||
|
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
await formState('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Position', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await result('success');
|
||||||
|
await submit(
|
||||||
|
'Save',
|
||||||
|
'PATCH',
|
||||||
|
'/projects/1/settings/board/columns/' + newColumnId,
|
||||||
|
{ name: 'QA', position: 1 },
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('[data-column-id]')
|
||||||
|
.first()
|
||||||
|
.getAttribute('data-column-id'),
|
||||||
|
newColumnId,
|
||||||
|
);
|
||||||
|
await button('Edit column QA').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Position', { exact: true }).inputValue(),
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Column name', { exact: true }).inputValue(),
|
||||||
|
'QA',
|
||||||
|
);
|
||||||
|
await page.getByLabel('Column name', { exact: true }).fill('Unsaved');
|
||||||
|
page.once('dialog', (prompt) => prompt.dismiss());
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
assert.equal(await dialog().isVisible(), true);
|
||||||
|
page.once('dialog', (prompt) => prompt.accept());
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await closed();
|
||||||
|
await button('Add status to Backlog').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Status name', { exact: true }).inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await page.getByLabel('Status name', { exact: true }).fill('Triage');
|
||||||
|
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
|
||||||
|
name: 'Triage',
|
||||||
|
board_column_id: null,
|
||||||
|
});
|
||||||
|
await closed();
|
||||||
|
await button('Rename status Triage').waitFor();
|
||||||
|
await button('Add status to Todo').click();
|
||||||
|
await page.getByLabel('Status name', { exact: true }).fill('Refinement');
|
||||||
|
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
|
||||||
|
name: 'Refinement',
|
||||||
|
board_column_id: 10,
|
||||||
|
});
|
||||||
|
await closed();
|
||||||
|
await button('Rename status Review').click();
|
||||||
|
await page.getByLabel('Status name', { exact: true }).fill('Peer review');
|
||||||
|
await page.route('**/statuses/4', (route) => route.abort('failed'));
|
||||||
|
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
await formState('network');
|
||||||
|
await page.unroute('**/statuses/4');
|
||||||
|
await submit(
|
||||||
|
'Try again',
|
||||||
|
'PATCH',
|
||||||
|
'/projects/1/settings/board/statuses/4',
|
||||||
|
{ name: 'Peer review' },
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
const before = requests.length;
|
||||||
|
await button('Delete status Peer review').click();
|
||||||
|
await dialog()
|
||||||
|
.getByText(/assigned to “Building”/)
|
||||||
|
.waitFor();
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await closed();
|
||||||
|
assert.equal(requests.length, before);
|
||||||
|
await button('Delete status Peer review').click();
|
||||||
|
await result('validation');
|
||||||
|
await submit(
|
||||||
|
'Delete',
|
||||||
|
'DELETE',
|
||||||
|
'/projects/1/settings/board/statuses/4',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
await formState('validation');
|
||||||
|
await dialog()
|
||||||
|
.getByText('This group needs at least one status.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await result('success');
|
||||||
|
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||||
|
await closed();
|
||||||
|
assert.equal(await button('Delete status Building').isDisabled(), true);
|
||||||
|
await button('Delete column In progress').click();
|
||||||
|
await dialog()
|
||||||
|
.getByText(/statuses move to the backlog/)
|
||||||
|
.waitFor();
|
||||||
|
await submit(
|
||||||
|
'Delete',
|
||||||
|
'DELETE',
|
||||||
|
'/projects/1/settings/board/columns/20',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
await page
|
||||||
|
.getByRole('list', { name: 'Statuses in Backlog', exact: true })
|
||||||
|
.getByText('Building', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await button('Edit column In progress').count(), 0);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Board from '../../resources/js/pages/projects/settings/Board.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'Board'
|
||||||
|
? Board
|
||||||
|
: { render: () => h('h1', 'Destination reached') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-board-settings-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4188, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const makeStatus = (id, name, column, position = 1) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
board_column_id: column,
|
||||||
|
position,
|
||||||
|
allowed_target_statuses: [],
|
||||||
|
});
|
||||||
|
function initial() {
|
||||||
|
const backlog = makeStatus(1, 'Backlog', null);
|
||||||
|
const ready = makeStatus(2, 'Ready', 10);
|
||||||
|
const active = makeStatus(3, 'Building', 20);
|
||||||
|
const review = makeStatus(4, 'Review', 20, 2);
|
||||||
|
backlog.allowed_target_statuses = [ready];
|
||||||
|
const columns = [
|
||||||
|
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
|
||||||
|
{
|
||||||
|
id: 20,
|
||||||
|
name: 'In progress',
|
||||||
|
position: 2,
|
||||||
|
ticket_statuses: [active, review],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
project: { id: 1, key: 'CORE', name: 'Core platform' },
|
||||||
|
status: null,
|
||||||
|
templateState: null,
|
||||||
|
backlogTicketStatuses: [backlog],
|
||||||
|
boardColumns: columns,
|
||||||
|
ticketStatuses: [
|
||||||
|
backlog,
|
||||||
|
...columns.flatMap((column) =>
|
||||||
|
column.ticket_statuses.map((status) => ({
|
||||||
|
...status,
|
||||||
|
board_column: { id: column.id, name: column.name },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePage(
|
||||||
|
extra = {},
|
||||||
|
url = '/projects/1/settings/board',
|
||||||
|
component = 'Board',
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Core platform',
|
||||||
|
key: 'CORE',
|
||||||
|
canManage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...initial(),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
let data = initial();
|
||||||
|
let nextId = 100;
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
const mutation = request.method !== 'GET';
|
||||||
|
if (!mutation && url.searchParams.has('reset')) {
|
||||||
|
data = initial();
|
||||||
|
}
|
||||||
|
let errors = {};
|
||||||
|
if (mutation) {
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(
|
||||||
|
/workflow_test_result=(\w+)/,
|
||||||
|
)?.[1] ?? 'success';
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const submitted = JSON.parse(body || '{}');
|
||||||
|
if (['server', 'forbidden'].includes(result)) {
|
||||||
|
response.statusCode = result === 'server' ? 503 : 403;
|
||||||
|
response.end('Simulated request failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === 'validation') {
|
||||||
|
errors = url.pathname.endsWith('/transitions')
|
||||||
|
? {
|
||||||
|
'allowed_ticket_status_ids.0':
|
||||||
|
'Choose a destination from this project.',
|
||||||
|
}
|
||||||
|
: request.method === 'DELETE'
|
||||||
|
? {
|
||||||
|
ticket_status_id:
|
||||||
|
'This group needs at least one status.',
|
||||||
|
}
|
||||||
|
: { name: 'Choose a different name.' };
|
||||||
|
} else if (result === 'position') {
|
||||||
|
errors = { position: 'The position must be at least 1.' };
|
||||||
|
} else {
|
||||||
|
const segments = url.pathname.split('/');
|
||||||
|
const id = Number(segments.at(-1));
|
||||||
|
const columns = data.boardColumns;
|
||||||
|
if (url.pathname.includes('/columns')) {
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const column = {
|
||||||
|
id: nextId++,
|
||||||
|
name: submitted.name,
|
||||||
|
position: columns.length + 1,
|
||||||
|
ticket_statuses: [],
|
||||||
|
};
|
||||||
|
column.ticket_statuses.push(
|
||||||
|
makeStatus(nextId++, submitted.name, column.id),
|
||||||
|
);
|
||||||
|
columns.push(column);
|
||||||
|
} else if (request.method === 'PATCH') {
|
||||||
|
const column = columns.find((item) => item.id === id);
|
||||||
|
column.name = submitted.name;
|
||||||
|
columns.splice(columns.indexOf(column), 1);
|
||||||
|
columns.splice(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
Number(submitted.position) - 1,
|
||||||
|
columns.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
column,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const column = columns.find((item) => item.id === id);
|
||||||
|
for (const status of column.ticket_statuses) {
|
||||||
|
status.board_column_id = null;
|
||||||
|
status.position =
|
||||||
|
data.backlogTicketStatuses.length + 1;
|
||||||
|
data.backlogTicketStatuses.push(status);
|
||||||
|
}
|
||||||
|
columns.splice(columns.indexOf(column), 1);
|
||||||
|
}
|
||||||
|
columns.forEach((column, index) => {
|
||||||
|
column.position = index + 1;
|
||||||
|
});
|
||||||
|
} else if (url.pathname.endsWith('/transitions')) {
|
||||||
|
const sourceId = Number(segments.at(-2));
|
||||||
|
const statuses = [
|
||||||
|
...data.backlogTicketStatuses,
|
||||||
|
...columns.flatMap((column) => column.ticket_statuses),
|
||||||
|
];
|
||||||
|
statuses.find(
|
||||||
|
(status) => status.id === sourceId,
|
||||||
|
).allowed_target_statuses = (
|
||||||
|
submitted.allowed_ticket_status_ids ?? []
|
||||||
|
).map((id) => ({ id: Number(id) }));
|
||||||
|
} else if (request.method === 'POST') {
|
||||||
|
const group =
|
||||||
|
submitted.board_column_id === null
|
||||||
|
? data.backlogTicketStatuses
|
||||||
|
: columns.find(
|
||||||
|
(item) =>
|
||||||
|
item.id === submitted.board_column_id,
|
||||||
|
).ticket_statuses;
|
||||||
|
group.push(
|
||||||
|
makeStatus(
|
||||||
|
nextId++,
|
||||||
|
submitted.name,
|
||||||
|
submitted.board_column_id,
|
||||||
|
group.length + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const group = [
|
||||||
|
data.backlogTicketStatuses,
|
||||||
|
...columns.map((column) => column.ticket_statuses),
|
||||||
|
].find((items) => items.some((item) => item.id === id));
|
||||||
|
const status = group.find((item) => item.id === id);
|
||||||
|
if (request.method === 'PATCH') {
|
||||||
|
status.name = submitted.name;
|
||||||
|
} else {
|
||||||
|
group.splice(group.indexOf(status), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.ticketStatuses = [
|
||||||
|
...data.backlogTicketStatuses,
|
||||||
|
...columns.flatMap((column) =>
|
||||||
|
column.ticket_statuses.map((status) => ({
|
||||||
|
...status,
|
||||||
|
board_column: { id: column.id, name: column.name },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
data.status = 'Updated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('empty')) {
|
||||||
|
data.boardColumns = [];
|
||||||
|
data.ticketStatuses = data.backlogTicketStatuses;
|
||||||
|
}
|
||||||
|
const page = makePage({ ...data, errors });
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-board-settings-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Project board settings fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/settings/Board.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Board workflow/);
|
||||||
|
assert.match(html, /Off the board/);
|
||||||
|
assert.match(html, /aria-current="page"/);
|
||||||
|
assert.ok(
|
||||||
|
html.indexOf('data-column-id="10"') <
|
||||||
|
html.indexOf('data-column-id="20"'),
|
||||||
|
);
|
||||||
|
assert.match(html, /From status/);
|
||||||
|
assert.match(html, /Saved destinations: 1/);
|
||||||
|
assert.match(
|
||||||
|
await render({ ticketStatuses: [] }),
|
||||||
|
/Add a status to configure/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render({ ticketStatuses: [makeStatus(1, 'Backlog', null)] }),
|
||||||
|
/No other statuses exist yet/,
|
||||||
|
);
|
||||||
|
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||||
|
for (const name of ['Backlog', 'Ready']) {
|
||||||
|
assert.match(
|
||||||
|
buttons.find((button) =>
|
||||||
|
button.includes('aria-label="Delete status ' + name + '"'),
|
||||||
|
),
|
||||||
|
/aria-disabled="true"/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(
|
||||||
|
buttons.find((button) =>
|
||||||
|
button.includes('aria-label="Delete status Review"'),
|
||||||
|
),
|
||||||
|
/aria-disabled="true"/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render({ boardColumns: [] }),
|
||||||
|
/No board columns yet/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{ de: { ...en, 'workflow.title': 'Board-Ablauf' } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Board-Ablauf/,
|
||||||
|
);
|
||||||
|
console.log('Project board settings rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4187';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const mutations = [];
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (['POST', 'DELETE'].includes(request.method())) {
|
||||||
|
mutations.push(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const confirmReset = () =>
|
||||||
|
dialog().getByRole('button', { name: 'Reset to template', exact: true });
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'general_test_result', value, url: base }]);
|
||||||
|
const visit = async (query = '?reset') => {
|
||||||
|
await result('success');
|
||||||
|
await page.goto(base + '/projects/1/settings/general' + query);
|
||||||
|
await page.getByRole('heading', { name: 'General', exact: true }).waitFor();
|
||||||
|
};
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const formState = (form, state) =>
|
||||||
|
form
|
||||||
|
.locator('[data-form-state="' + state + '"]')
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
const nav = page.getByRole('navigation', {
|
||||||
|
name: 'Project settings',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await nav
|
||||||
|
.getByRole('link', { name: 'General', exact: true })
|
||||||
|
.getAttribute('aria-current'),
|
||||||
|
'page',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await nav
|
||||||
|
.getByRole('link', { name: 'Ticket settings', exact: true })
|
||||||
|
.getAttribute('href'),
|
||||||
|
'/projects/1/settings/metadata',
|
||||||
|
);
|
||||||
|
const sources = page.getByRole('list', { name: 'Setting sources' });
|
||||||
|
await sources.getByText('Inherited', { exact: true }).waitFor();
|
||||||
|
await sources
|
||||||
|
.getByText('Local override', { exact: true })
|
||||||
|
.first()
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await button('Switch template').isDisabled(), true);
|
||||||
|
assert.equal(await button('Delete project').isDisabled(), true);
|
||||||
|
if (width === 390 || width === 1440) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: '/private/tmp/KAN-39-' + width + '-' + theme + '.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const trigger = button('Reset Project settings to template');
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await dialog().waitFor();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
element.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await noOverflow();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(
|
||||||
|
await trigger.evaluate(
|
||||||
|
(element) => document.activeElement === element,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
mutations.length,
|
||||||
|
0,
|
||||||
|
'Opening and cancelling reset must not send a mutation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?reset&noTemplate');
|
||||||
|
await page.getByText('No linked template', { exact: true }).waitFor();
|
||||||
|
assert.equal(await button('Reset Project settings to template').count(), 0);
|
||||||
|
await visit('?reset&baseline');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByText('Local override', { exact: true }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await visit('?reset&noAlternatives');
|
||||||
|
await page
|
||||||
|
.getByText('No other published templates', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await button('Switch template').count(), 0);
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await button('Reset Ticket statuses to template').click();
|
||||||
|
await dialog().waitFor();
|
||||||
|
assert.equal(await confirmReset().isDisabled(), true);
|
||||||
|
const replacement = page.locator('#replacement-10');
|
||||||
|
await replacement.selectOption('1');
|
||||||
|
await result('validation');
|
||||||
|
const resetRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await confirmReset().click();
|
||||||
|
assert.deepEqual((await resetRequest).postDataJSON(), {
|
||||||
|
areas: ['ticket_statuses'],
|
||||||
|
status_replacements: { 10: 1 },
|
||||||
|
});
|
||||||
|
await formState(dialog(), 'validation');
|
||||||
|
await page
|
||||||
|
.getByText('This area could not be reset.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await replacement.inputValue(), '1');
|
||||||
|
await result('server');
|
||||||
|
await confirmReset().click();
|
||||||
|
await formState(dialog(), 'server');
|
||||||
|
await result('success');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
const sources = page.getByRole('list', { name: 'Setting sources' });
|
||||||
|
assert.equal(
|
||||||
|
await sources.getByText('Local override', { exact: true }).count(),
|
||||||
|
1,
|
||||||
|
'Resetting statuses preserves project settings override',
|
||||||
|
);
|
||||||
|
assert.equal(await button('Reset Ticket statuses to template').count(), 0);
|
||||||
|
await button('Reset Project settings to template').click();
|
||||||
|
await confirmReset().click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(
|
||||||
|
await sources.getByText('Local override', { exact: true }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(await page.getByText('Enabled', { exact: true }).count(), 2);
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
const switchForm = page
|
||||||
|
.locator('form')
|
||||||
|
.filter({ has: page.locator('#source_template_id') });
|
||||||
|
const select = page.locator('#source_template_id');
|
||||||
|
assert.equal(
|
||||||
|
await select.locator('option[value="1"]').count(),
|
||||||
|
0,
|
||||||
|
'Current template must not be selectable',
|
||||||
|
);
|
||||||
|
await select.selectOption('2');
|
||||||
|
await page.locator('#switch_confirmation_key').fill('core');
|
||||||
|
assert.equal(await button('Switch template').isDisabled(), true);
|
||||||
|
const countBefore = mutations.length;
|
||||||
|
await switchForm.evaluate((element) =>
|
||||||
|
element.dispatchEvent(
|
||||||
|
new Event('submit', { bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
mutations.length,
|
||||||
|
countBefore,
|
||||||
|
'Submit guard must reject a mismatching key',
|
||||||
|
);
|
||||||
|
await page.locator('#switch_confirmation_key').fill('CORE');
|
||||||
|
await result('validation');
|
||||||
|
await button('Switch template').click();
|
||||||
|
await formState(switchForm, 'validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#switch_confirmation_key').inputValue(),
|
||||||
|
'CORE',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#switch_confirmation_key')
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await page.route('**/switch-template', (route) => route.abort('failed'));
|
||||||
|
await button('Switch template').click();
|
||||||
|
await formState(switchForm, 'network');
|
||||||
|
await page.unroute('**/switch-template');
|
||||||
|
await result('success');
|
||||||
|
const switched = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await switchForm
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.deepEqual((await switched).postDataJSON(), {
|
||||||
|
confirmation_key: 'CORE',
|
||||||
|
source_template_id: 2,
|
||||||
|
});
|
||||||
|
await page.getByText('Delivery baseline', { exact: true }).waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#switch_confirmation_key').inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(await button('Switch template').isDisabled(), true);
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
const deleteForm = page
|
||||||
|
.locator('form')
|
||||||
|
.filter({ has: page.locator('#confirmation_key') });
|
||||||
|
await page.locator('#confirmation_key').fill('core');
|
||||||
|
assert.equal(await button('Delete project').isDisabled(), true);
|
||||||
|
const beforeDelete = mutations.length;
|
||||||
|
await deleteForm.evaluate((element) =>
|
||||||
|
element.dispatchEvent(
|
||||||
|
new Event('submit', { bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(mutations.length, beforeDelete);
|
||||||
|
await page.locator('#confirmation_key').fill('CORE');
|
||||||
|
await result('validation');
|
||||||
|
await button('Delete project').click();
|
||||||
|
await formState(deleteForm, 'validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#confirmation_key').getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
assert.equal(await page.locator('#confirmation_key').inputValue(), 'CORE');
|
||||||
|
await result('success');
|
||||||
|
const deleted = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'DELETE',
|
||||||
|
);
|
||||||
|
await button('Delete project').click();
|
||||||
|
const deletion = await deleted;
|
||||||
|
assert.equal(new URL(deletion.url()).pathname, '/projects/1');
|
||||||
|
assert.deepEqual(deletion.postDataJSON(), { confirmation_key: 'CORE' });
|
||||||
|
await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project general settings browser checks passed: setting sources, scoped resets, confirmations, replacements, errors, retries, keyboard, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import General from '../../resources/js/pages/projects/settings/General.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'General'
|
||||||
|
? General
|
||||||
|
: { render: () => h('h1', 'Destination reached') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-general-settings-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4187, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const area = (value, label) => ({ value, label });
|
||||||
|
const template = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Team baseline',
|
||||||
|
is_published: true,
|
||||||
|
copy_areas: [],
|
||||||
|
};
|
||||||
|
const alternative = { ...template, id: 2, name: 'Delivery baseline' };
|
||||||
|
function initial() {
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: 1,
|
||||||
|
key: 'CORE',
|
||||||
|
name: 'Core platform',
|
||||||
|
project_template_id: 1,
|
||||||
|
project_template: template,
|
||||||
|
show_author: false,
|
||||||
|
show_story_points: false,
|
||||||
|
},
|
||||||
|
sourceTemplates: [template, alternative],
|
||||||
|
status: null,
|
||||||
|
templateState: {
|
||||||
|
templateId: 1,
|
||||||
|
templateName: 'Team baseline',
|
||||||
|
inheritsFromTemplate: true,
|
||||||
|
inheritedAreas: [area('board_columns', 'Board columns')],
|
||||||
|
overriddenAreas: [
|
||||||
|
area('project_settings', 'Project settings'),
|
||||||
|
area('ticket_statuses', 'Ticket statuses'),
|
||||||
|
],
|
||||||
|
effectiveConfiguration: {},
|
||||||
|
resetRequirements: {
|
||||||
|
ticket_statuses: {
|
||||||
|
removedStatuses: [
|
||||||
|
{ id: 10, name: 'Local review', ticketCount: 3 },
|
||||||
|
],
|
||||||
|
replacementOptions: [{ id: 1, name: 'Ready' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePage(
|
||||||
|
extra = {},
|
||||||
|
url = '/projects/1/settings/general',
|
||||||
|
component = 'General',
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Core platform',
|
||||||
|
key: 'CORE',
|
||||||
|
canManage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...initial(),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
let data = initial();
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
const mutation = request.method !== 'GET';
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(/general_test_result=(\w+)/)?.[1] ??
|
||||||
|
'success';
|
||||||
|
if (!mutation && url.searchParams.has('reset')) {
|
||||||
|
data = initial();
|
||||||
|
}
|
||||||
|
let extra = {};
|
||||||
|
let component = 'General';
|
||||||
|
let pageUrl = '/projects/1/settings/general';
|
||||||
|
if (mutation) {
|
||||||
|
if (['server', 'forbidden'].includes(result)) {
|
||||||
|
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||||
|
response.end('Simulated request failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const submitted = JSON.parse(body || '{}');
|
||||||
|
if (result === 'validation') {
|
||||||
|
extra.errors = url.pathname.endsWith('reset-template')
|
||||||
|
? { 'areas.0': 'This area could not be reset.' }
|
||||||
|
: { confirmation_key: 'Enter the exact project key.' };
|
||||||
|
} else if (request.method === 'DELETE') {
|
||||||
|
component = 'Destination';
|
||||||
|
pageUrl = '/projects';
|
||||||
|
} else if (url.pathname.endsWith('reset-template')) {
|
||||||
|
const reset = data.templateState.overriddenAreas.find(
|
||||||
|
(item) => item.value === submitted.areas[0],
|
||||||
|
);
|
||||||
|
data.templateState.overriddenAreas =
|
||||||
|
data.templateState.overriddenAreas.filter(
|
||||||
|
(item) => item.value !== reset.value,
|
||||||
|
);
|
||||||
|
data.templateState.inheritedAreas.push(reset);
|
||||||
|
if (reset.value === 'project_settings') {
|
||||||
|
data.project.show_author = true;
|
||||||
|
data.project.show_story_points = true;
|
||||||
|
}
|
||||||
|
data.status = 'Project template baseline was reset.';
|
||||||
|
} else {
|
||||||
|
data.project.project_template = alternative;
|
||||||
|
data.project.project_template_id = 2;
|
||||||
|
data.templateState.templateName = alternative.name;
|
||||||
|
data.templateState.templateId = 2;
|
||||||
|
data.status = 'Project template baseline was switched.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('noTemplate')) {
|
||||||
|
extra = {
|
||||||
|
project: {
|
||||||
|
...data.project,
|
||||||
|
project_template: null,
|
||||||
|
project_template_id: null,
|
||||||
|
},
|
||||||
|
templateState: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('baseline')) {
|
||||||
|
extra.templateState = {
|
||||||
|
...data.templateState,
|
||||||
|
inheritsFromTemplate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('noAlternatives')) {
|
||||||
|
extra.sourceTemplates = [template];
|
||||||
|
}
|
||||||
|
const page = makePage({ ...data, ...extra }, pageUrl, component);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-general-settings-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Project general settings fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/settings/General.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Local override/);
|
||||||
|
assert.match(html, /Inherited/);
|
||||||
|
assert.match(html, /Team baseline/);
|
||||||
|
assert.match(html, /Show author/);
|
||||||
|
assert.match(html, /href="\/projects\/1\/settings\/metadata"/);
|
||||||
|
assert.match(html, /aria-current="page"/);
|
||||||
|
assert.match(html, /Reset Project settings to template/);
|
||||||
|
assert.match(html, /Type CORE exactly/);
|
||||||
|
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||||
|
for (const label of ['Switch template', 'Delete project']) {
|
||||||
|
const button = buttons.find((item) => item.includes(label));
|
||||||
|
assert.match(button, /disabled/);
|
||||||
|
assert.match(button, /type="submit"/);
|
||||||
|
}
|
||||||
|
const standalone = await render({
|
||||||
|
templateState: null,
|
||||||
|
project: { ...initial().project, project_template: null },
|
||||||
|
});
|
||||||
|
assert.match(standalone, /No linked template/);
|
||||||
|
assert.doesNotMatch(standalone, /Reset Project settings to template/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await render({
|
||||||
|
templateState: {
|
||||||
|
...initial().templateState,
|
||||||
|
inheritsFromTemplate: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Local override/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render({ sourceTemplates: [template] }),
|
||||||
|
/No other published templates/,
|
||||||
|
);
|
||||||
|
const inherited = await render({
|
||||||
|
templateState: {
|
||||||
|
...initial().templateState,
|
||||||
|
inheritedAreas: [area('project_settings', 'Project settings')],
|
||||||
|
overriddenAreas: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(
|
||||||
|
inherited,
|
||||||
|
/Local override|Reset Project settings to template/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'projectSettings.danger': 'Gefahrenbereich',
|
||||||
|
'inheritance.area.projectSettings':
|
||||||
|
'Projekteinstellungen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Gefahrenbereich/,
|
||||||
|
);
|
||||||
|
console.log('Project general settings rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Metadata from '../../resources/js/pages/projects/settings/Metadata.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'Metadata'
|
||||||
|
? Metadata
|
||||||
|
: { render: () => h('h1', 'Destination reached') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-metadata-settings-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4189, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
function initial() {
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: 1,
|
||||||
|
key: 'CORE',
|
||||||
|
name: 'Core platform',
|
||||||
|
show_author: true,
|
||||||
|
show_story_points: false,
|
||||||
|
},
|
||||||
|
status: null,
|
||||||
|
error: false,
|
||||||
|
templateState: null,
|
||||||
|
ticketTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Task',
|
||||||
|
position: 1,
|
||||||
|
is_parent: false,
|
||||||
|
tickets_count: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Release',
|
||||||
|
position: 2,
|
||||||
|
is_parent: true,
|
||||||
|
tickets_count: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ticketPriorities: [
|
||||||
|
{ id: 3, name: 'Normal', position: 1 },
|
||||||
|
{ id: 4, name: 'Urgent', position: 2 },
|
||||||
|
],
|
||||||
|
ticketFieldDefinitions: [],
|
||||||
|
ticketLinkTypes: [],
|
||||||
|
fieldTypeOptions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePage(
|
||||||
|
extra = {},
|
||||||
|
url = '/projects/1/settings/metadata',
|
||||||
|
component = 'Metadata',
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Core platform',
|
||||||
|
key: 'CORE',
|
||||||
|
canManage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...initial(),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
let data = initial();
|
||||||
|
let nextId = 100;
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
const mutation = request.method !== 'GET';
|
||||||
|
if (!mutation && url.searchParams.has('reset')) {
|
||||||
|
data = initial();
|
||||||
|
}
|
||||||
|
let errors = {};
|
||||||
|
if (mutation) {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const submitted = JSON.parse(body || '{}');
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(
|
||||||
|
/metadata_test_result=(\w+)/,
|
||||||
|
)?.[1] ?? 'success';
|
||||||
|
if (result === 'server') {
|
||||||
|
response.statusCode = 500;
|
||||||
|
response.end('Unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = url.pathname.includes('/types') ? 'type' : 'priority';
|
||||||
|
const entries =
|
||||||
|
kind === 'type' ? data.ticketTypes : data.ticketPriorities;
|
||||||
|
const id = Number(url.pathname.split('/').at(-1));
|
||||||
|
if (
|
||||||
|
result === 'validation' ||
|
||||||
|
(submitted.name &&
|
||||||
|
entries.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.name === submitted.name && entry.id !== id,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
errors =
|
||||||
|
request.method === 'DELETE'
|
||||||
|
? {
|
||||||
|
replacement_ticket_type_id:
|
||||||
|
'Choose a parent-enabled replacement type.',
|
||||||
|
}
|
||||||
|
: { name: 'This name is already in use.' };
|
||||||
|
} else if (request.method === 'DELETE') {
|
||||||
|
entries.splice(
|
||||||
|
entries.findIndex((item) => item.id === id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else if (url.pathname.endsWith('/metadata')) {
|
||||||
|
Object.assign(data.project, submitted);
|
||||||
|
} else if (request.method === 'POST') {
|
||||||
|
entries.push({
|
||||||
|
id: nextId++,
|
||||||
|
position: entries.length + 1,
|
||||||
|
tickets_count: 0,
|
||||||
|
...submitted,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const item = entries.find((item) => item.id === id);
|
||||||
|
entries.splice(entries.indexOf(item), 1);
|
||||||
|
Object.assign(item, submitted);
|
||||||
|
entries.splice(submitted.position - 1, 0, item);
|
||||||
|
}
|
||||||
|
entries.forEach((entry, index) => (entry.position = index + 1));
|
||||||
|
data.status = Object.keys(errors).length ? null : 'Updated';
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('single')) {
|
||||||
|
data.ticketTypes = data.ticketTypes.slice(0, 1);
|
||||||
|
data.ticketPriorities = data.ticketPriorities.slice(0, 1);
|
||||||
|
}
|
||||||
|
const page = makePage({ ...data, errors });
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-metadata-settings-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Metadata settings fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/settings/Metadata.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Ticket settings/);
|
||||||
|
assert.match(html, /aria-current="page"/);
|
||||||
|
assert.ok(
|
||||||
|
html.indexOf('data-metadata-entry="type-1"') <
|
||||||
|
html.indexOf('data-metadata-entry="type-2"'),
|
||||||
|
);
|
||||||
|
assert.match(html, /Assigned tickets: 2/);
|
||||||
|
assert.match(html, /Parent type/);
|
||||||
|
assert.match(html, /id="show_author"[^>]*aria-checked="true"/);
|
||||||
|
assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/);
|
||||||
|
const single = await render({
|
||||||
|
ticketTypes: initial().ticketTypes.slice(0, 1),
|
||||||
|
ticketPriorities: initial().ticketPriorities.slice(0, 1),
|
||||||
|
});
|
||||||
|
const buttons = single.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||||
|
for (const name of ['Task', 'Normal']) {
|
||||||
|
assert.match(
|
||||||
|
buttons.find((button) =>
|
||||||
|
button.includes('aria-label="Delete ' + name + '"'),
|
||||||
|
),
|
||||||
|
/aria-disabled="true"/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(single, /The last ticket type cannot be deleted/);
|
||||||
|
assert.match(single, /The last priority cannot be deleted/);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{ de: { ...en, 'metadata.title': 'Ticket-Einstellungen' } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Ticket-Einstellungen/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render({
|
||||||
|
status: 'The last ticket type cannot be deleted.',
|
||||||
|
error: true,
|
||||||
|
}),
|
||||||
|
/role="alert"/,
|
||||||
|
);
|
||||||
|
console.log('Metadata settings checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user