Compare commits

..
32 Commits
Author SHA1 Message Date
Twyco b226ee8d48 Merge pull request 'KAN-42: Rebuild ticket types, priorities and display settings' (#137) from feature/KAN-42-ticket-types-priorities-display into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 22m13s
CI/CD / Publish immutable image (push) Successful in 43s
Reviewed-on: #137
2026-09-15 14:05:51 +00:00
Twyco ac553f0087 feat(KAN-42): rebuild ticket types priorities and display settings
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 16:05:30 +02:00
Twyco 28d20882ac Merge pull request 'KAN-41: Erlaubte Statusübergänge bearbeiten' (#136) from feature/KAN-41-allowed-status-transitions into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #136
2026-09-15 14:05:14 +00:00
Twyco 208989d5fc feat(KAN-41): edit allowed status transitions
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 16:05:06 +02:00
Twyco d2f659de8f Merge pull request 'KAN-40: Workflow-Einstellungen für Spalten und Status' (#135) from feature/KAN-40-workflow-columns-and-statuses into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #135
2026-09-15 14:04:52 +00:00
Twyco f461439ce6 feat(KAN-40): rebuild workflow column and status settings
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 36s
2026-09-15 16:04:05 +02:00
Twyco 3eea473336 Merge pull request 'KAN-39: Allgemeine Projekteinstellungen und Vererbung' (#134) from feature/KAN-39-project-settings-inheritance into main
CI/CD / preconditions (push) Successful in 1m4s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #134
2026-09-15 14:03:25 +00:00
Twyco bfec4f464e feat(KAN-39): rebuild general project settings and inheritance
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
2026-09-15 16:02:41 +02:00
Twyco 2edee702d4 Merge pull request 'KAN-38: Persönliche Git-Verbindungen' (#133) from feature/KAN-38-personal-git-connections into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #133
2026-09-15 14:01:31 +00:00
Twyco 9b90c24224 feat(KAN-38): rebuild personal Git connection settings 2026-09-15 14:01:31 +00:00
Twyco 3869e5b8a6 Merge pull request 'KAN-37: Sicherheit und Anwendungsschlüssel' (#132) from feature/KAN-37-security-application-keys into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #132
2026-09-15 14:01:23 +00:00
Twyco 196623bb67 feat(KAN-37): rebuild security and application key settings 2026-09-15 14:01:23 +00:00
Twyco bc6a113acf Merge pull request 'KAN-36: Profil und Erscheinungsbild' (#131) from feature/KAN-36-profile-and-appearance into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 38s
Reviewed-on: #131
2026-09-15 14:01:17 +00:00
Twyco 1377ee8510 feat(KAN-36): rebuild profile and appearance settings
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Failing after 3m17s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 17:36:41 +02:00
Twyco 2409c3b18f Merge pull request 'KAN-35 ticket changes' (#129) from feature/KAN-35-ticket-git-area-and-branch-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1m1s
Reviewed-on: #129
2026-09-14 14:52:29 +00:00
Twyco f61298fda4 test(KAN-35): isolate branch error browser scenario
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5s
2026-09-14 14:52:19 +00:00
Eric 7a4c6f3865 KAN-35 ticket changes 2026-09-14 14:52:19 +00:00
Twyco 79c78b3434 Merge pull request 'Feature/kan 34 blocker ticket history' (#130) from feature/KAN-34-blocker-ticket-history into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Canceled after 5s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #130
2026-09-14 14:52:12 +00:00
Eric 38438b4700 feat(KAN-34): rework blocker and ticket history
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Failing after 3m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 16:20:09 +02:00
Twyco fed0b657c0 Merge pull request 'Feature/kan 33 ticketbeziehungen und hierarchie' (#128) from feature/KAN-33-ticketbeziehungen-und-hierarchie into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 20m52s
CI/CD / Publish immutable image (push) Successful in 3m39s
Reviewed-on: #128
2026-09-14 09:45:54 +00:00
Twyco 1fe0c0b2d9 feat(KAN-33): redesign ticket relationships and hierarchy
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 5s
2026-09-14 09:45:45 +00:00
Twyco 463cb7fe36 Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #127
2026-09-14 09:45:39 +00:00
Twyco 3bf5a0c508 feat(KAN-32): add hierarchical acceptance criteria checklist
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 31s
2026-09-14 09:45:28 +00:00
Twyco 32de1f425a docker test env fix
CI/CD / preconditions (push) Successful in 1m2s
CI/CD / test_and_check_app (push) Successful in 11m39s
CI/CD / Publish immutable image (push) Successful in 3m47s
2026-09-14 10:52:07 +02:00
Twyco 1af98cd56e docker env example fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Failing after 1m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:48:29 +02:00
Twyco aadf159ac5 docker test fix
CI/CD / preconditions (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 3m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:36:56 +02:00
Twyco 5c47453d4d rollback test app action
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Failing after 8m23s
CI/CD / Publish immutable image (push) Skipped
2026-09-12 14:23:18 +02:00
Twyco ba7e174e82 Merge pull request 'Feature/kan 31 ticket create edit forms' (#126) from feature/KAN-31-ticket-create-edit-forms into main
CI/CD / Publish immutable image (push) Successful in 4m13s
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 8m40s
Reviewed-on: #126
2026-09-12 11:34:10 +00:00
Eric d646f8b537 feat: KAN-30
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-12 13:33:53 +02:00
Twyco 509d7abf82 Merge pull request 'KAN-30 Responsive ticket detail view' (#125) from feature/KAN-30-ticket-detail-layout into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #125
2026-09-12 06:22:31 +00:00
Twyco 5cc1840592 feat(KAN-30): rebuild responsive ticket detail view 2026-09-12 06:22:31 +00:00
Twyco 11b06d9b8c Merge pull request 'KAN-29 Backlog and ticket tree' (#124) from feature/KAN-29-backlog-ticket-tree into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #124
2026-09-12 06:22:20 +00:00
90 changed files with 13944 additions and 5263 deletions
-3
View File
@@ -33,9 +33,6 @@
vendor
**/.DS_Store
.editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production
LICENSE
README.md
-4
View File
@@ -1,4 +0,0 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+12 -66
View File
@@ -1,72 +1,18 @@
name: "Lint and Test API"
description: "Run Pint and the Pest test suite for the Laravel API"
name: test_and_check_app
description: Build the test image and run all application checks
runs:
using: composite
steps:
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
- name: Verify Composer lockfile
- name: Test and check app
shell: bash
run: |
if [ ! -f composer.lock ]; then
echo "::error::api/composer.lock not found"
exit 1
fi
shell: bash
set -Eeuo pipefail
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Build Vite app
run: pnpm run build
shell: bash
- name: Run ci check
run: composer run ci:check
shell: bash
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
+2 -1
View File
@@ -61,7 +61,8 @@ RUN composer install --prefer-dist --no-interaction --no-progress \
FROM build AS test
RUN touch .env \
RUN cp .env.example .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->dontFlash(['api_token']);
})->create();
+69 -28
View File
@@ -1,39 +1,80 @@
<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 { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [
{ value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const;
const tabs = computed(() => [
{
value: 'light' as const,
Icon: Sun,
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>
<template>
<div
role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)"
:class="[
'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',
appearance === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]"
>
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span>{{ t(label) }}</span>
</button>
</div>
<fieldset class="min-w-0">
<legend class="sr-only">{{ t('appearance.label') }}</legend>
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
<label
v-for="{ value, Icon, label, description } in tabs"
:key="value"
class="relative min-w-0"
>
<input
type="radio"
name="appearance"
:value="value"
:checked="appearance === value"
:aria-describedby="`appearance-${value}-description`"
class="peer sr-only"
@change="updateAppearance(value)"
/>
<span
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 class="flex items-center justify-between gap-3">
<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>
+91 -84
View File
@@ -2,10 +2,10 @@
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogClose,
@@ -17,97 +17,104 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script>
<template>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
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"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription>
</DialogHeader>
<Card
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
<p class="text-muted-foreground">
{{ t('profile.delete.description') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<div
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
>
<div class="gap-stack grid">
<p class="text-destructive font-semibold">
{{ t('profile.delete.warning') }}
</p>
<p class="text-body text-muted-foreground">
{{ t('profile.delete.warningDescription') }}
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button
variant="destructive"
class="justify-self-start"
data-test="delete-user-button"
>{{ t('profile.delete.action') }}</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('profile.delete.confirmTitle')
}}</DialogTitle>
<DialogDescription>
{{ t('profile.delete.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only"
>Password</Label
>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password" class="sr-only">{{
t('profile.delete.password')
}}</Label>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
:placeholder="t('profile.delete.password')"
/>
<InputError :message="errors.password" />
</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
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Cancel
{{ t('profile.delete.confirmAction') }}
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</template>
+150 -104
View File
@@ -1,123 +1,169 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
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 AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
const { t } = useLocalization();
const {
recoveryCodesList,
fetchRecoveryCodes,
clearRecoveryCodes,
errors,
loading,
} = useTwoFactorAuth();
const visible = ref(false);
const showRegenerate = ref(false);
const form = useForm({});
const protection = useFormProtection(form);
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
function hide(): void {
visible.value = false;
clearRecoveryCodes();
}
function toggle(): void {
if (visible.value) {
hide();
} else {
visible.value = true;
void fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
function closeRegenerate(open: boolean): void {
if (!form.processing) {
showRegenerate.value = open;
protection.clearFeedback();
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
}
function regenerate(): void {
protection.submit(regenerateRecoveryCodes(), {
preserveScroll: true,
onBefore: hide,
onSuccess: () => {
showRegenerate.value = false;
visible.value = true;
return fetchRecoveryCodes();
},
});
}
</script>
<template>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
<section
class="gap-column grid min-w-0 rounded-lg border p-4"
aria-labelledby="recovery-title"
>
<div class="gap-stack grid">
<h3
id="recovery-title"
class="flex items-center gap-2 font-semibold"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
codes
</Button>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
<LockKeyhole class="text-primary size-4" />{{
t('security.recoveryTitle')
}}
</h3>
<p class="text-body text-muted-foreground">
{{ t('security.recoveryDescription') }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<Button
variant="outline"
:aria-expanded="visible"
aria-controls="recovery-codes"
:disabled="form.processing"
@click="toggle"
><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
variant="secondary"
type="submit"
:disabled="processing"
>
<RefreshCw /> Regenerate codes
</Button>
</Form>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible
? 'h-auto opacity-100'
: 'h-0 opacity-0',
]"
</template>
<template v-else>
<ul
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
:aria-label="t('security.recoveryTitle')"
>
<li v-for="code in recoveryCodesList" :key="code">
{{ code }}
</li>
</ul>
<p class="text-caption text-muted-foreground">
{{ t('security.recoveryOnce') }}
</p>
</template>
</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">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
<DialogHeader>
<DialogTitle>{{
t('security.regenerateTitle')
}}</DialogTitle>
<DialogDescription>{{
t('security.regenerateDescription')
}}</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">
<div
v-for="n in 8"
:key="n"
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
></div>
</div>
<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>
<Button :loading="form.processing" @click="regenerate">{{
t('security.regenerate')
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</template>
+220 -256
View File
@@ -1,297 +1,261 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { useForm } from '@inertiajs/vue3';
import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.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 {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
const props = defineProps<{
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
};
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
}>();
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();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
useTwoFactorAuth();
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',
};
function close(open: boolean): void {
if (!form.processing) {
isOpen.value = open;
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
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();
});
}
async function next(): Promise<void> {
if (props.twoFactorEnabled || !props.requiresConfirmation) {
close(false);
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
verification.value = true;
await nextTick();
codeInput.value?.$el?.focus();
}
function submit(): void {
protection.submit(confirm(), {
preserveScroll: true,
errorBag: 'confirmTwoFactorAuthentication',
onSuccess: () => {
isOpen.value = false;
},
onFinish: () => {
form.reset();
},
});
}
async function copyKey(): Promise<void> {
const key = manualSetupKey.value;
if (!key) {
return;
}
showVerificationStep.value = false;
code.value = '';
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
} catch {
if (isOpen.value) {
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>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
<Dialog :open="isOpen" @update:open="close">
<DialogContent
class="sm:max-w-md"
:show-close-button="!form.processing"
@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
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
>
<div
class="absolute inset-0 grid grid-cols-5 opacity-50"
>
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-border border-r last:border-r-0"
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
v-html="qrCodeSvg"
/>
<div class="grid min-w-0 gap-2">
<Label for="setup-key">{{
t('security.manualKey')
}}</Label>
<div class="flex min-w-0 gap-2">
<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
class="absolute inset-0 grid grid-rows-5 opacity-50"
<p
v-if="copied"
role="status"
class="text-caption text-success-foreground"
>
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-border border-b last:border-b-0"
/>
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
{{ t('security.copied') }}
</p>
<InputError
v-if="copyError"
:message="t('security.copyError')"
/>
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
>
<div
v-if="!qrCodeSvg"
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
>
<Spinner class="size-6" />
</div>
<div
v-else
class="relative z-10 overflow-hidden border p-5"
>
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div
class="relative flex w-full items-center justify-center"
>
<div
class="bg-border absolute inset-0 top-1/2 h-px w-full"
/>
<span class="bg-card relative px-2 py-1"
>or, enter the code manually</span
>
</div>
<div
class="flex w-full items-center justify-center space-x-2"
>
<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 }"
<p
v-if="requiresConfirmation && !twoFactorEnabled"
class="text-caption text-muted-foreground"
>
{{ t('security.setupPendingDescription') }}
</p>
<DialogFooter>
<Button variant="outline" @click="close(false)">{{
t('common.close')
}}</Button>
<Button v-if="hasSetupData" @click="next">{{
t(
twoFactorEnabled || !requiresConfirmation
? 'common.done'
: 'security.continue',
)
}}</Button>
</DialogFooter>
</template>
<form v-else class="gap-column grid" @submit.prevent="submit">
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="authentication-code">{{
t('security.authenticationCode')
}}</Label>
<Input
id="authentication-code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
required
:disabled="form.processing"
:aria-invalid="!!form.errors.code"
aria-describedby="authentication-code-error"
class="font-mono text-lg tracking-widest"
/>
<InputError
id="authentication-code-error"
:message="form.errors.code"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="
verification = false;
form.resetAndClearErrors();
protection.clearFeedback();
"
>{{ t('common.back') }}</Button
>
<input type="hidden" name="code" :value="code" />
<div
ref="pinInputContainerRef"
class="relative w-full space-y-3"
>
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<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>
<Button
type="submit"
:disabled="form.code.length !== 6"
:loading="form.processing"
>{{ t('security.confirm') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</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">
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{
areas: string[];
projectId: number;
templateState?: ProjectTemplateState | null;
}>();
const { t } = useLocalization();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
@@ -28,51 +34,82 @@ const form = useForm<{
areas: [],
status_replacements: {},
});
const activeArea = computed(() => form.areas[0]);
const protection = useFormProtection(form);
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
const inheritedAreas = computed(
() =>
props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const overriddenAreas = computed(
() =>
props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const statusRequirement = computed(
() => props.templateState?.resetRequirements?.ticket_statuses,
const statusRequirement = computed(() =>
pendingArea.value?.value === 'ticket_statuses'
? props.templateState?.resetRequirements?.ticket_statuses
: undefined,
);
const canReset = (area: ProjectTemplateStateArea): boolean => {
if (area.value !== 'ticket_statuses') {
return true;
}
return (
statusRequirement.value?.removedStatuses.every(
(status) =>
form.status_replacements[String(status.id)] !== undefined,
) ?? true
);
};
const resetArea = (area: ProjectTemplateStateArea): void => {
if (!canReset(area)) {
const canReset = computed(
() =>
statusRequirement.value?.removedStatuses.every((status) =>
statusRequirement.value?.replacementOptions.some(
(option) =>
option.id === form.status_replacements[String(status.id)],
),
) ?? true,
);
const areaKeys = {
project_settings: 'inheritance.area.projectSettings',
board_columns: 'inheritance.area.boardColumns',
ticket_statuses: 'inheritance.area.ticketStatuses',
status_transitions: 'inheritance.area.statusTransitions',
ticket_types: 'inheritance.area.ticketTypes',
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;
}
form.areas = [area.value];
form.post(ProjectController.resetTemplate.url(props.projectId), {
pendingArea.value = null;
form.resetAndClearErrors();
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,
onSuccess: () => form.reset(),
onSuccess: () => {
form.resetAndClearErrors();
pendingArea.value = null;
},
});
};
}
</script>
<template>
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
"
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Every setting area inherits independently. Local changes stay
unchanged when the template is updated until that area is reset.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
</div>
<p class="text-muted-foreground break-words">
{{
t('inheritance.description', {
template: templateState.templateName,
})
}}
</p>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="area in inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<p class="text-caption text-muted-foreground">
{{ t('inheritance.help') }}
</p>
<ul
class="grid min-w-0 divide-y"
:aria-label="t('inheritance.areas')"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Badge>{{ area.label }} · template overridden</Badge>
<Button
type="button"
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"
<li
v-for="area in inheritedAreas"
:key="area.value"
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
>
<p class="text-muted-foreground text-sm">
Choose where tickets in local statuses should move
before those statuses are removed.
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2 sm:grid-cols-2 sm:items-center"
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge
variant="outline"
class="border-primary/25 text-primary"
>{{ t('projectSettings.inherited') }}</Badge
>
<Label :for="`replacement-${status.id}`">
{{ status.name }} ({{ status.ticketCount }} tickets)
</Label>
<div class="grid gap-1">
<select
:id="`replacement-${status.id}`"
v-model="
form.status_replacements[String(status.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 focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select replacement status
</option>
<option
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>
</li>
<li
v-for="area in overriddenAreas"
:key="area.value"
class="gap-stack grid min-w-0 py-3"
>
<div
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
>
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge variant="secondary">{{
t('projectSettings.override')
}}</Badge>
</div>
</div>
<InputError :message="form.errors.areas" />
</div>
<div
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
>
<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>
<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>
</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>
@@ -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>
+87 -100
View File
@@ -1,120 +1,107 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { computed, onScopeDispose, ref } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
/** Keep secrets within the consuming component and discard late responses after hiding them. */
export function useTwoFactorAuth() {
const { t } = useLocalization();
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const loading = ref(false);
let pending: AbortController | null = null;
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal,
});
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[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
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 => {
function clearTwoFactorAuthData(): void {
pending?.abort();
pending = null;
loading.value = false;
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
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 {
clearErrors();
recoveryCodesList.value = await fetchJson<string[]>(
recoveryCodes.url(),
);
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
if (kind === 'setup') {
const [qr, key] = await Promise.all([
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
fetchJson<{ secretKey: string }>(
secretKey.url(),
request.signal,
),
]);
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 {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
loading,
hasSetupData: computed(() =>
Boolean(qrCodeSvg.value && manualSetupKey.value),
),
clearSetupData: clearTwoFactorAuthData,
clearRecoveryCodes: clearTwoFactorAuthData,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
clearErrors,
fetchSetupData: () => load('setup'),
fetchRecoveryCodes: () => load('recovery'),
};
};
}
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Settings2 } from 'lucide-vue-next';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
defineProps<{ projectName: string; projectKey: string }>();
const { projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
</script>
<template>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
>
<header class="gap-stack grid min-w-0">
<div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground break-all uppercase">
{{ projectKey }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.projectSettings') }}
</h1>
<p class="text-muted-foreground break-words">
{{ t('projectSettings.description', { project: projectName }) }}
</p>
</header>
<div
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.projectSettings')"
>
<Link
v-for="item in projectSettingsItems"
:key="toUrl(item.href)"
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="[
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
isCurrentUrl(item.href)
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
<slot />
</section>
</div>
</div>
</div>
</template>
+55 -32
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
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 { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{
title: t('navigation.profile'),
href: editProfile(),
icon: UserRound,
},
{
title: t('navigation.security'),
href: editSecurity(),
icon: ShieldCheck,
},
{
title: t('navigation.gitConnections'),
href: editGitConnections(),
icon: GitBranch,
},
{
title: t('navigation.applications'),
href: editApplications(),
icon: KeyRound,
},
{
title: t('navigation.appearance'),
href: editAppearance(),
icon: Palette,
},
]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script>
<template>
<div class="px-4 py-6">
<Heading
:title="t('navigation.settings')"
:description="t('navigation.settingsDescription')"
/>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl 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 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">
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<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 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<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')"
>
<Button
<Link
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href) ? 'page' : undefined
"
:class="[
'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
'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',
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
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<Separator class="lg:hidden" />
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<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">
<slot />
</section>
</div>
+70
View File
@@ -5,6 +5,76 @@ import type {
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 = {
checklist: string;
};
+560
View File
@@ -1,4 +1,178 @@
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.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
@@ -340,6 +514,7 @@ export const en = {
'navigation.appearance': 'Appearance',
'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.',
'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.open': 'Open navigation',
@@ -354,8 +529,58 @@ export const en = {
'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme',
'appearance.light': 'Light',
'appearance.lightDescription':
'Use a bright workspace with dark text and clear borders.',
'appearance.dark': 'Dark',
'appearance.darkDescription':
'Reduce glare with a darker workspace and balanced contrast.',
'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.close': 'Close',
'common.retry': 'Try again',
@@ -400,4 +625,339 @@ export const en = {
one: '{count} ticket',
other: '{count} tickets',
},
'ticketDetail.description': 'Description',
'ticketDetail.noDescription': 'No description has been added yet.',
'ticketDetail.checklist': 'Acceptance criteria',
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
'ticketDetail.saveChecklistFirst':
'Save checklist text changes before toggling completion directly.',
'ticketDetail.edit': 'Edit ticket',
'ticketDetail.editDescription':
'Tracked ticket changes create worklog entries automatically.',
'ticketDetail.git': 'Git',
'ticketDetail.relationships': 'Relationships',
'ticketDetail.history': 'Worklog',
'ticketDetail.metadata': 'Ticket information',
'ticketDetail.sections': 'Ticket sections',
'ticketDetail.status': 'Status',
'ticketDetail.type': 'Type',
'ticketDetail.priority': 'Priority',
'ticketDetail.assignee': 'Assignee',
'ticketDetail.author': 'Author',
'ticketDetail.noAuthor': 'No author',
'ticketDetail.storyPoints': 'Estimated story points',
'ticketDetail.notSet': 'Not set',
'ticketDetail.created': 'Created',
'ticketDetail.updated': 'Last updated',
'ticketDetail.backToProject': 'Back to project',
'ticketDetail.openBacklog': 'Open backlog',
'ticketDetail.openBoard': 'Open board',
'ticketDetail.readOnly': 'Read only',
'ticketDetail.title': 'Title',
'ticketDetail.parent': 'Parent ticket',
'ticketDetail.noParent': 'No parent ticket',
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
'ticketDetail.checklistHelp':
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
'ticketDetail.checklistPlaceholder':
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
'ticketDetail.blocked': 'Mark ticket as blocked',
'ticketDetail.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;
File diff suppressed because it is too large Load Diff
+120 -320
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { useSessionStorage } from '@vueuse/core';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge';
@@ -15,32 +16,30 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import {
buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>();
const { t } = useLocalization();
const returnHref =
props.source === 'board'
? boardIndex(props.project.id)
: backlogIndex(props.project.id);
const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog';
props.source === 'board'
? t('ticketForm.backToBoard')
: t('ticketForm.backToBacklog');
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
title: t('navigation.projects'),
href: index(),
},
{
@@ -48,36 +47,31 @@ const breadcrumbs: BreadcrumbItem[] = [
href: show(props.project.id),
},
{
title: props.source === 'board' ? 'Board' : 'Backlog',
title:
props.source === 'board'
? t('navigation.board')
: t('navigation.backlog'),
href: returnHref,
},
{
title: 'Create ticket',
title: t('ticketForm.create'),
href: ProjectTicketController.create({
project: props.project.id,
}),
},
];
const hasTicketMetadata =
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
const hasTicketMetadata = computed(
() =>
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0,
);
const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
acceptance_criteria: [] as AcceptanceCriteriaModel,
assignee_user_id: '',
description: '',
parent_ticket_id: '',
@@ -90,13 +84,16 @@ const form = useForm(draftKey, {
title: '',
});
const protection = useFormProtection(form, {
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
});
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
const isDirty = computed(() => form.isDirty);
const protection = useFormProtection(form);
const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
return;
}
form.reset();
form.clearErrors();
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => {
return (
@@ -109,39 +106,35 @@ const acceptanceCriteriaError = computed(() => {
});
const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
if (!hasTicketMetadata.value || form.processing) {
return;
}
protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
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>
<template>
<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
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">
{{ project.key }}
</Badge>
<Heading
title="Create ticket"
:description="`Add a ticket to ${project.name}.`"
/>
<h1 class="text-headline font-semibold tracking-tight">
{{ t('ticketForm.create') }}
</h1>
<p class="text-muted-foreground text-sm">
{{
t('ticketForm.createDescription', {
project: project.name,
})
}}
</p>
</div>
<div class="flex flex-wrap gap-3">
@@ -160,7 +159,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Link :href="returnHref">{{ returnLabel }}</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="show(project.id)">{{
t('ticketDetail.backToProject')
}}</Link>
</Button>
</div>
</div>
@@ -174,9 +175,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-if="hasTicketMetadata">
<CardHeader>
<CardTitle>Ticket details</CardTitle>
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
<CardDescription>
The ticket key is assigned after creation.
{{ t('ticketForm.keyAssigned') }}
</CardDescription>
</CardHeader>
@@ -186,258 +187,58 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
required
autofocus
:disabled="form.processing"
placeholder="Implement repository sync"
/>
<InputError
id="title-error"
:message="form.errors.title"
/>
</div>
<div
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
<TicketFormFields
v-model:title="form.title"
v-model:description="form.description"
v-model:type="form.ticket_type_id"
v-model:priority="form.ticket_priority_id"
v-model:status="form.ticket_status_id"
v-model:assignee="form.assignee_user_id"
v-model:parent="form.parent_ticket_id"
v-model:points="form.story_points"
:project="project"
:ticket-types="ticketTypes"
:ticket-priorities="ticketPriorities"
:ticket-status-options="ticketStatusOptions"
:users="users"
:parent-ticket-options="parentTicketOptions"
:errors="form.errors"
:disabled="form.processing"
autofocus
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
v-model="form.description"
rows="4"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError
:message="form.errors.description"
/>
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label
for="acceptance_criteria_checklist"
>
Acceptance criteria checklist
</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="
acceptanceCriteriaChecklist
"
rows="6"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials"
/>
<p
class="text-muted-foreground text-xs"
>
Use <code>[ ]</code> /
<code>[x]</code> for checklist
items. Indent with two spaces for
nested steps.
</p>
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Checklist preview
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<span class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</div>
</div>
<div class="grid h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
v-model="form.ticket_type_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="String(ticketType.id)"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="String(ticketPriority.id)"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
v-model="form.ticket_status_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="String(user.id)"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="
String(parentTicketOption.id)
"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
v-model="form.story_points"
type="number"
step="1"
:disabled="form.processing"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
<template #content>
<AcceptanceCriteriaChecklist
v-model="form.acceptance_criteria"
:disabled="form.processing"
:error="acceptanceCriteriaError"
/>
</template>
</TicketFormFields>
<div
class="flex flex-wrap items-center gap-3 border-t pt-5"
>
<Button type="submit" :disabled="form.processing">{{
form.processing
? t('ticketForm.creating')
: t('ticketForm.create')
}}</Button>
<Button
type="button"
variant="outline"
:disabled="form.processing || !isDirty"
@click="reset"
>{{ t('ticketForm.reset') }}</Button
>
<p
role="status"
class="text-muted-foreground text-sm"
>
{{
isDirty
? t('ticketForm.unsaved')
: t('ticketForm.ready')
}}
</p>
</div>
</form>
</CardContent>
@@ -446,13 +247,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type, one priority,
and one status.
{{ t('ticketForm.metadataRequired') }}
</p>
<div>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Open ticket settings
{{ t('ticketForm.openSettings') }}
</Link>
</Button>
</div>
+175 -640
View File
@@ -1,674 +1,209 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Head } from '@inertiajs/vue3';
import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.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 { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects';
import { board } from '@/routes/projects/settings';
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 breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
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
);
};
const { t } = useLocalization();
const target = ref<WorkflowEditorTarget | null>(null);
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('workflow.title'), href: board(props.project.id) },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</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
<Head :title="t('workflow.pageTitle', { project: project.name })" />
<SettingsLayout :project-name="project.name" :project-key="project.key">
<header class="grid gap-3">
<h2 class="text-title">{{ t('workflow.title') }}</h2>
<p class="text-muted-foreground">
{{ t('workflow.description') }}
</p>
</header>
<FeedbackState
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>
state="success"
:title="t('workflow.updated')"
/>
<TemplateInheritancePanel
:project-id="project.id"
:template-state="templateState"
:areas="[
'board_columns',
'ticket_statuses',
'status_transitions',
]"
:project-id="project.id"
:template-state="templateState"
/>
<Card>
<CardHeader>
<CardTitle>Workflow model</CardTitle>
<CardDescription>
Backlog is always the intake queue; board columns group
the statuses that appear on the board.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-3">
<Badge class="px-3 py-1">Backlog</Badge>
<Badge
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
variant="outline"
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<Inbox
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 class="font-semibold">
{{ t('workflow.backlog') }}
</h3>
<Badge variant="secondary">{{
t('workflow.offBoard')
}}</Badge>
</div>
<p class="text-muted-foreground text-sm">
Tickets move between statuses; backlog statuses are not
included in a board column.
{{ t('workflow.backlogDescription') }}
</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>
<CardContent class="p-0">
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">
Source status
</th>
<th class="px-4 py-3 font-medium">
Allowed move targets
</th>
<th class="px-4 py-3 font-medium">Save</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="sourceStatus in ticketStatuses"
:key="sourceStatus.id"
class="bg-background align-top"
>
<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>
<CardContent class="p-5 sm:p-6"
><WorkflowStatusList
:column="null"
:statuses="backlogTicketStatuses"
@edit="target = $event"
/></CardContent>
</Card>
<section
class="grid min-w-0 gap-4"
aria-labelledby="workflow-columns"
>
<header
class="flex flex-wrap items-center justify-between gap-3"
>
<div class="flex items-center gap-2">
<Columns3
class="text-primary size-5"
aria-hidden="true"
/>
<h3 id="workflow-columns" class="font-semibold">
{{ t('workflow.columns') }}
</h3>
<Badge variant="secondary">{{
boardColumns.length
}}</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backlog statuses</CardTitle>
<CardDescription>
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"
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { kind: 'column', action: 'create' }"
><Plus class="size-4" aria-hidden="true" />{{
t('workflow.addColumn')
}}</Button
>
<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 }"
</header>
<p class="text-muted-foreground text-sm">
{{ t('workflow.columnsDescription') }}
</p>
<FeedbackState
v-if="boardColumns.length === 0"
state="empty"
: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">
<Label
:for="`backlog-status-${ticketStatus.id}`"
>
Status name
</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],
)
<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: column.position,
})
"
v-slot="{ errors, processing }"
>{{ column.position }}</span
>
<InputError
:message="errors.ticket_status_id"
class="mb-2"
/>
<h4
class="min-w-0 flex-1 basis-24 break-words font-semibold"
>
{{ column.name }}
</h4>
<div class="flex gap-1">
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
backlogTicketStatuses.length <= 1
type="button"
variant="ghost"
size="icon"
:aria-label="
t('workflow.changeColumn', {
name: column.name,
})
"
>
Delete
</Button>
</Form>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="backlog-status-name">
New backlog status
</Label>
<Input
id="backlog-status-name"
name="name"
placeholder="Ready"
required
@click="
target = {
kind: 'column',
action: 'edit',
column,
}
"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('workflow.removeColumn', {
name: column.name,
})
"
@click="
target = {
kind: 'column',
action: 'delete',
column,
}
"
><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 class="flex items-end">
<Button type="submit" :disabled="processing">
Add status
</Button>
</div>
</Form>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Add column</CardTitle>
<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>
</li>
</ol>
</section>
<WorkflowTransitionMatrix
:project-id="project.id"
:statuses="ticketStatuses"
/>
<WorkflowEditorDialog
:project-id="project.id"
:column-count="boardColumns.length"
:target="target"
@close="target = null"
/>
</SettingsLayout>
</AppLayout>
</template>
+393 -311
View File
@@ -1,281 +1,316 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { Layers3, Trash2 } from 'lucide-vue-next';
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 TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects/index';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { general } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps<ProjectGeneralSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'General settings',
href: generalSettings(props.project.id),
},
];
const switchTemplateForm = useForm({
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.general'), href: general(props.project.id) },
]);
const switchTemplateForm = useForm<{
confirmation_key: string;
source_template_id: number | undefined;
}>({
confirmation_key: '',
source_template_id: props.sourceTemplates.find(
(template) => template.id !== props.project.project_template_id,
)?.id,
source_template_id: undefined,
});
const deleteForm = useForm({
confirmation_key: '',
});
const deleteForm = useForm({ confirmation_key: '' });
const switchProtection = useFormProtection(switchTemplateForm);
const deleteProtection = useFormProtection(deleteForm);
const processing = computed(
() => switchTemplateForm.processing || deleteForm.processing,
);
const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id,
),
);
const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed(
() =>
isSwitchConfirmationValid.value &&
switchTemplateForm.source_template_id !== undefined,
switchTemplateForm.confirmation_key === props.project.key &&
availableSwitchTemplates.value.some(
(template) => template.id === switchTemplateForm.source_template_id,
),
);
const isDeleteConfirmationValid = computed(
() => 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 => {
if (!canSwitchTemplate.value) {
function submitSwitchTemplate(): void {
if (!canSwitchTemplate.value || processing.value) {
return;
}
switchProtection.submit(switchTemplate(props.project.id), {
preserveScroll: true,
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
});
}
switchTemplateForm.post(
ProjectController.switchTemplate.url(props.project.id),
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) {
function submitDelete(): void {
if (!isDeleteConfirmationValid.value || processing.value) {
return;
}
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
deleteProtection.submit(destroy(props.project.id), {
preserveScroll: true,
});
};
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} general settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="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
<Head
:title="t('projectSettings.pageTitle', { project: project.name })"
/>
<SettingsLayout :project-name="project.name" :project-key="project.key">
<FeedbackState
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 }}
</div>
<Card>
<CardHeader>
<CardTitle>Project identity</CardTitle>
<CardDescription>
These values define the project across tickets, boards,
and repository matching.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('navigation.general') }}</h2>
<p class="text-muted-foreground">
{{ t('projectSettings.generalDescription') }}
</p>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Project name
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.name }}
</p>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('projectSettings.identity') }}
</h3>
<Badge variant="secondary">{{
t('projectSettings.local')
}}</Badge>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
<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.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>
<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>
</Card>
<Card>
<CardHeader>
<CardTitle>Template baseline</CardTitle>
<CardDescription>
Switch this project to another published template. Use
the inheritance panels to reset individual settings to
the currently linked template.
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
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>
<TemplateInheritancePanel
:project-id="project.id"
:areas="inheritanceAreas"
:template-state="templateState"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.template') }}
</h2>
</div>
<div class="grid gap-6">
<form
class="space-y-4 rounded-xl border p-4"
@submit.prevent="submitSwitchTemplate"
<p class="text-muted-foreground">
{{ t('projectSettings.templateDescription') }}
</p>
</CardHeader>
<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">
<h3 class="text-sm font-semibold">
Switch to another template
</h3>
<p class="text-muted-foreground text-sm">
Applying a different template replaces the
copied areas with that template's current
baseline.
</p>
</div>
{{ t('projectSettings.standaloneDescription') }}
</p>
</div>
<FeedbackState
v-if="availableSwitchTemplates.length === 0"
state="empty"
:title="t('projectSettings.noAlternatives')"
:description="
t('projectSettings.noAlternativesDescription')
"
/>
<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">
<Label for="source_template_id"
>Published template</Label
>
<Label for="source_template_id">{{
t('projectSettings.publishedTemplate')
}}</Label>
<select
id="source_template_id"
v-model="
switchTemplateForm.source_template_id
"
:disabled="
switchTemplateForm.processing ||
availableSwitchTemplates.length === 0
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="
!!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>
Select a template
{{
t('projectSettings.selectTemplate')
}}
</option>
<option
v-for="template in availableSwitchTemplates"
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
</option>
</select>
<InputError
id="source-template-error"
:message="
switchTemplateForm.errors
.source_template_id
"
/>
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key"
>Confirmation key</Label
>
<Label for="switch_confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="switch_confirmation_key"
v-model="
switchTemplateForm.confirmation_key
"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
autocomplete="off"
: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
id="switch-confirm-error"
:message="
switchTemplateForm.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">
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
type="submit"
variant="destructive"
:disabled="
deleteForm.processing ||
!isDeleteConfirmationValid
"
variant="outline"
class="justify-self-start"
:disabled="!canSwitchTemplate"
>{{
t(
switchTemplateForm.processing
? 'projectSettings.switching'
: 'projectSettings.switch',
)
}}</Button
>
Delete project
</Button>
</div>
</fieldset>
</form>
</CardContent>
</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>
</template>
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -1,35 +1,42 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
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 SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance/index';
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(),
},
];
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" />
<h1 class="sr-only">Appearance settings</h1>
<Head :title="t('appearance.title')" />
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update your account's appearance settings"
/>
<AppearanceTabs />
</div>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('appearance.title') }}</h2>
<p class="text-muted-foreground">
{{ t('appearance.description') }}
</p>
</CardHeader>
<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>
</AppLayout>
</template>
+290 -170
View File
@@ -1,198 +1,318 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Head, useForm } from '@inertiajs/vue3';
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
destroy,
store,
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
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 { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.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';
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[] = [
{
title: 'Applications',
href: editApplications(),
},
];
const formatDate = (value: string | null): string => {
if (value === null) {
return 'Never';
function formatDate(value: string | null): string {
if (!value) {
return t('applications.never');
}
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short',
}).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>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Applications" />
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('applications.title')" />
<SettingsLayout>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Heading
variant="small"
title="Applications"
description="Create API keys for tools that should use KanbanGit with your account permissions."
/>
<p class="text-muted-foreground text-sm">
Keys are shown once. Delete an application when a tool
should lose access.
</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
v-if="applicationKey"
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"
>
<p class="font-medium">Save this API key now.</p>
<p class="mt-1">
It will not be shown again after you leave this page.
</p>
<div class="gap-stack grid">
<h2 class="text-title">{{ t('applications.title') }}</h2>
<p class="text-muted-foreground">
{{ t('applications.description') }}
</p>
</div>
<p
v-if="status"
role="status"
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
>
{{ status }}
</p>
<section
v-if="applicationKey"
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
aria-labelledby="application-key-title"
>
<h3 id="application-key-title" class="font-semibold">
{{ t('applications.saveKey') }}
</h3>
<p class="text-body text-muted-foreground">
{{ t('applications.keyOnce') }}
</p>
<Label for="application-key" class="sr-only">{{
t('applications.apiKey')
}}</Label>
<div class="flex min-w-0 flex-wrap gap-2">
<Input
class="mt-3 font-mono text-xs"
id="application-key"
:model-value="applicationKey"
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 class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Create application</CardTitle>
<CardDescription>
Name the tool that will receive an API key.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="UserApplicationController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
<p
v-if="copied"
role="status"
class="text-caption text-success-foreground"
>
{{ t('security.copied') }}
</p>
<InputError
v-if="copyError"
:message="t('security.copyError')"
/>
</section>
<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="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">
<Label for="name">Application name</Label>
<Input
id="name"
name="name"
type="text"
required
autofocus
placeholder="Claude Desktop"
/>
<InputError :message="errors.name" />
<div>
<dt class="inline font-medium">
{{ t('applications.created') }}
</dt>
<dd class="inline">
{{ formatDate(application.created_at) }}
</dd>
</div>
<Button type="submit" :disabled="processing">
Create application
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<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>
<dt class="inline font-medium">
{{ t('applications.lastUsed') }}
</dt>
<dd class="inline">
{{
formatDate(application.last_used_at)
}}
</dd>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</dl>
</div>
<Button
variant="outline"
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>
</AppLayout>
</template>
+464 -238
View File
@@ -1,267 +1,493 @@
<script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import Heading from '@/components/Heading.vue';
import { Head, useForm } from '@inertiajs/vue3';
import {
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
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 AppLayout from '@/layouts/AppLayout.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 { 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();
const appName = String(page.props.name);
function resetForms(): void {
connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
connectionForm.resetAndClearErrors();
connectionProtection.clearFeedback();
removalForm.resetAndClearErrors();
removalProtection.clearFeedback();
}
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Git connections',
href: editGitConnections(),
},
];
function openDialog(
nextMode: typeof mode.value,
connection: UserGitConnectionModel | null = null,
): 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>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Git connections" />
<Head :title="t('navigation.gitConnections')" />
<SettingsLayout>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Heading
variant="small"
title="Git connections"
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"
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
>
{{ status }}
</div>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Add Gitea connection</CardTitle>
<CardDescription>
Connect a Gitea account by storing the instance
base URL and a personal application token.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="GitConnectionController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
>
<input
name="provider"
type="hidden"
value="gitea"
/>
<div class="grid gap-2">
<Label for="base_url">Gitea base URL</Label>
<Input
id="base_url"
name="base_url"
type="url"
required
autofocus
placeholder="https://git.example.com"
/>
<InputError :message="errors.base_url" />
</div>
<div class="grid gap-2">
<Label for="api_token"
>Application token</Label
>
<Input
id="api_token"
name="api_token"
type="password"
required
placeholder="Paste your Gitea application key"
/>
<InputError :message="errors.api_token" />
</div>
<Button type="submit" :disabled="processing">
Add connection
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Saved connections</CardTitle>
<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
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('navigation.gitConnections') }}
</h2>
<p class="text-muted-foreground">
{{ t('gitConnections.description') }}
</p>
</div>
<Button
type="button"
class="shrink-0 self-start"
@click="openDialog('create')"
>
<Plus aria-hidden="true" />{{
t('gitConnections.add')
}}
</Button>
</div>
<div
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
>
<LockKeyhole
class="text-primary mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>{{ t('gitConnections.privacy') }}</p>
</div>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<FeedbackState
v-if="status"
state="success"
:title="successTitle"
/>
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('gitConnections.saved') }}
</h3>
<Badge variant="secondary">{{
gitConnections.length
}}</Badge>
</div>
<FeedbackState
v-if="gitConnections.length === 0"
state="empty"
:title="t('gitConnections.empty')"
:description="t('gitConnections.emptyDescription')"
/>
<ul
v-else
class="gap-column grid min-w-0"
:aria-label="t('gitConnections.saved')"
>
<li
v-for="connection in gitConnections"
:key="connection.id"
class="gap-column grid min-w-0 rounded-lg border p-4"
>
<div class="flex min-w-0 items-start gap-3">
<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">
<div
class="flex flex-wrap items-center gap-2"
<GitBranch
class="size-5"
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>{{
gitConnection.provider_label
}}</Badge>
<span class="text-sm font-medium">
{{
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>
{{ connection.account_login }}
</h4>
<Badge variant="secondary">{{
connection.provider_label
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
Remote user ID:
{{ gitConnection.provider_user_id }}
<p
class="text-caption text-muted-foreground break-words"
>
{{
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>
</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>
</CardContent>
</Card>
</div>
</div>
<div
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
>
<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>
</AppLayout>
</template>
+330 -154
View File
@@ -1,209 +1,385 @@
<script setup lang="ts">
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref } from 'vue';
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
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 UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification';
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { UserModel } from '@/types/models/user-model';
defineProps<SettingsProfilePageProps>();
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Profile settings',
href: edit(),
},
];
const { t } = useLocalization();
const page = usePage();
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 avatarDeleteProtection = useFormProtection(avatarDeleteForm);
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
const avatarPreview = ref<string | null>(null);
const displayedAvatar = computed(
() => avatarPreview.value ?? user.value.avatar,
);
const displayedUser = computed<UserModel>(() => ({
...user.value,
avatar: displayedAvatar.value,
}));
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) {
URL.revokeObjectURL(avatarPreview.value);
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 file = input.files?.[0];
const file = input.files?.[0] ?? null;
clearAvatarPreview();
profileForm.avatar = file;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (file) {
avatarPreview.value = URL.createObjectURL(file);
}
};
}
const removeAvatar = (): void => {
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
function submitProfile(): void {
profileProtection.submit(profileUpdateTarget, {
forceFormData: true,
preserveScroll: true,
onSuccess: clearAvatarPreview,
onSuccess: () => {
profileForm.defaults({
name: profileForm.name,
email: profileForm.email,
avatar: null,
});
clearAvatarSelection();
},
});
};
}
onBeforeUnmount(() => {
clearAvatarPreview();
});
function removeAvatar(): void {
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
preserveScroll: true,
onSuccess: clearAvatarSelection,
});
}
onBeforeUnmount(clearAvatarPreview);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" />
<h1 class="sr-only">Profile settings</h1>
<Head :title="t('profile.title')" />
<SettingsLayout>
<div class="flex flex-col space-y-6">
<Heading
variant="small"
title="Profile information"
description="Update your name, email address, and profile picture"
/>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('profile.title') }}</h2>
<p class="text-muted-foreground">
{{ t('profile.description') }}
</p>
</CardHeader>
<Form
v-bind="ProfileController.update.form()"
class="space-y-6"
v-slot="{
errors,
processing,
progress,
recentlySuccessful,
}"
>
<div class="grid gap-3">
<Label for="avatar">Profile picture</Label>
<div class="flex items-center gap-4">
<UserAvatar class="size-16" />
<div class="grid flex-1 gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<Input
id="avatar"
name="avatar"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="handleAvatarChange"
/>
<Button
v-if="canRemoveAvatar"
type="button"
variant="outline"
:disabled="avatarDeleteForm.processing"
data-test="delete-profile-picture-button"
@click="removeAvatar"
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid min-w-0"
:aria-busy="profileForm.processing"
@submit.prevent="submitProfile"
>
<FormFeedback
:state="profileProtection.state.value"
:title="
profileForm.processing
? t('profile.saving')
: undefined
"
@retry="submitProfile"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="profileForm.processing"
>
<legend class="sr-only">
{{ t('profile.information') }}
</legend>
<section
aria-labelledby="profile-picture-title"
class="gap-column grid min-w-0 border-b pb-6"
>
<div class="gap-stack grid">
<h3
id="profile-picture-title"
class="font-semibold"
>
<Trash2 class="size-4" />
Remove
</Button>
{{ t('profile.picture') }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureDescription') }}
</p>
</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>
</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">
<Label for="name">Name</Label>
<Input
id="name"
class="mt-1 block w-full"
name="name"
:default-value="user.name"
required
autocomplete="name"
placeholder="Full name"
/>
<InputError class="mt-2" :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
class="mt-1 block w-full"
name="email"
:default-value="user.email"
required
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>
<div class="flex flex-wrap items-center gap-3">
<Button
type="submit"
:disabled="profileForm.processing"
data-test="update-profile-button"
>
{{
profileForm.processing
? t('profile.saving')
: t('profile.save')
}}
</Button>
<p
v-if="profileForm.isDirty"
class="text-caption text-muted-foreground"
>
{{ t('profile.unsaved') }}
</p>
</div>
</fieldset>
</form>
</CardContent>
</Card>
<DeleteUser />
</SettingsLayout>
+309 -191
View File
@@ -1,222 +1,340 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted, ref } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/Heading.vue';
import { computed, onUnmounted, ref } from 'vue';
import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
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 { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/security/index';
import { disable, enable } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types';
type Props = {
canManageTwoFactor?: boolean;
mustChangePassword?: boolean;
requiresConfirmation?: boolean;
status?: string | null;
twoFactorEnabled?: boolean;
};
withDefaults(defineProps<Props>(), {
canManageTwoFactor: false,
mustChangePassword: false,
requiresConfirmation: false,
status: null,
twoFactorEnabled: false,
});
const breadcrumbs: BreadcrumbItem[] = [
const props = withDefaults(
defineProps<{
canManageTwoFactor?: boolean;
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
}>(),
{
title: 'Security settings',
href: edit(),
canManageTwoFactor: false,
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();
const showSetupModal = ref<boolean>(false);
onUnmounted(() => clearTwoFactorAuthData());
function savePassword(): void {
passwordProtection.submit(update(), {
preserveScroll: true,
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>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Security settings" />
<h1 class="sr-only">Security settings</h1>
<Head :title="t('security.title')" />
<SettingsLayout>
<div
v-if="mustChangePassword || status"
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"
>
{{
mustChangePassword
? 'You must change your password before continuing anywhere else in the app.'
: 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.
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">
{{ t('security.passwordTitle') }}
</h2>
<p class="text-muted-foreground">
{{ t('security.passwordDescription') }}
</p>
<div>
<Button
v-if="hasSetupData"
@click="showSetupModal = true"
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid"
: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
</Button>
<Form
v-else
v-bind="enable.form()"
@success="showSetupModal = true"
#default="{ processing }"
>
<Button type="submit" :disabled="processing">
Enable 2FA
</Button>
</Form>
</div>
</div>
<div
v-else
class="flex flex-col items-start justify-start space-y-4"
>
<p class="text-muted-foreground text-sm">
You will be prompted for a secure, random pin during
login, which you can retrieve from the TOTP-supported
application on your phone.
</p>
<div class="relative inline">
<Form v-bind="disable.form()" #default="{ processing }">
<legend class="sr-only">
{{ t('security.passwordTitle') }}
</legend>
<div class="grid gap-2">
<Label for="current_password">{{
t('security.currentPassword')
}}</Label>
<PasswordInput
id="current_password"
v-model="passwordForm.current_password"
name="current_password"
required
autocomplete="current-password"
:disabled="passwordForm.processing"
:aria-invalid="
!!passwordForm.errors.current_password
"
aria-describedby="current-password-error"
/>
<InputError
id="current-password-error"
:message="
passwordForm.errors.current_password
"
/>
</div>
<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
variant="destructive"
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
</Button>
</Form>
</fieldset>
</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>
<TwoFactorRecoveryCodes />
</div>
<TwoFactorSetupModal
v-model:isOpen="showSetupModal"
:requiresConfirmation="requiresConfirmation"
:twoFactorEnabled="twoFactorEnabled"
/>
</div>
<p class="text-muted-foreground">
{{
t(
props.twoFactorEnabled
? 'security.enabledDescription'
: 'security.twoFactorDescription',
)
}}
</p>
</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>
</AppLayout>
</template>
+10
View File
@@ -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 }
);
+20
View File
@@ -0,0 +1,20 @@
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export type WorkflowEditorTarget =
| { kind: 'column'; action: 'create'; column?: never }
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
| {
kind: 'status';
action: 'create';
column: BoardColumnModel | null;
status?: never;
replacement?: never;
}
| {
kind: 'status';
action: 'edit' | 'delete';
column: BoardColumnModel | null;
status: TicketStatusModel;
replacement?: TicketStatusModel;
};
+69
View File
@@ -0,0 +1,69 @@
import type { ProjectTicketPageProps } from '@/types/page-props';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
export type TicketDetailHeaderProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailContentProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'canManageProject'
| 'parentTicketOptions'
| 'ticketFieldDefinitions'
| 'ticketPriorities'
| 'ticketTypes'
| 'users'
>;
export type TicketDetailBlockerProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailMetadataProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
>;
export type TicketDetailGitProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'repositoryBranches'
| 'repositoryPullRequests'
| 'suggestedBranchName'
>;
export type TicketGitBranchesProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'repositoryBranches'
| 'suggestedBranchName'
>;
export type TicketGitPullRequestsProps = Pick<
ProjectTicketPageProps,
'repositoryPullRequests'
>;
export type TicketBranchCreateDialogProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'suggestedBranchName'
> & {
repositoryConnections: RepositoryConnectionModel[];
};
export type TicketDetailRelationshipsProps = Pick<
ProjectTicketPageProps,
| 'ticket'
| 'project'
| 'canEditTicket'
| 'parentTicketOptions'
| 'childTicketTree'
| 'linkedTickets'
| 'ticketLinkTypes'
| 'ticketLinkOptions'
>;
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
export type TicketBranchFormData = {
branch_name: string;
repository_connection_id: string;
};
@@ -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());
}
}
@@ -298,6 +298,79 @@ class ProjectBoardSettingsControllerTest extends TestCase
$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.
*/
@@ -372,4 +445,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
'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
{
return $project->ticketTypes()
@@ -487,4 +487,75 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'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());
}
}
+61 -1
View File
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
'base_url' => 'https://git.example.test',
]);
UserGitConnection::factory()->create();
$this->actingAs($user)
->get(route('git-connections.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections')
->has('gitConnections', 1)
->missing('gitConnections.0.api_token')
->where('gitConnections.0.account_login', 'nico')
->where('gitConnections.0.base_url', 'https://git.example.test')
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
'api_token' => 'wrong-token',
])
->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,
]);
}
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\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProfileUpdateTest extends TestCase
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
$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()
{
Storage::fake('public');
+52
View File
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
$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());
}
}
+18
View File
@@ -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 -1
View File
@@ -25,7 +25,9 @@ class UserApplicationTest extends TestCase
->assertInertia(fn (Assert $page) => $page
->component('settings/Applications')
->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,
]);
}
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();
}
+103
View File
@@ -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();
}
+5 -9
View File
@@ -84,15 +84,11 @@ try {
renderTheme('light'),
renderTheme('dark'),
]);
const selectedButton = (html) =>
html.match(
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
)[1];
assert.match(selectedButton(light), />Light</);
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.match(light, /value="light"[^>]*checked/);
assert.match(dark, /value="dark"[^>]*checked/);
assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
assert.equal((dark.match(/ checked/g) ?? []).length, 1);
assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8');
+19 -10
View File
@@ -320,15 +320,18 @@ try {
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
);
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click();
@@ -349,18 +352,22 @@ try {
await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
);
await setResult('success');
await page.locator('form button[type="submit"]').first().click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#ticket-edit > summary').click();
await page.locator('#title').fill('Updated ticket draft');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Edited checklist');
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('Edited checklist');
await setResult('server');
await page.locator('form button[type="submit"]').first().click();
await state('server');
@@ -369,8 +376,10 @@ try {
'Updated ticket draft',
);
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Edited checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Edited checklist',
);
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
+17 -2
View File
@@ -1,4 +1,5 @@
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
@@ -36,9 +37,23 @@ let ticket = {
ticket_status: status,
is_blocked: false,
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: [],
worklog_entries: [],
created_at: '2026-09-10T12:00:00Z',
updated_at: '2026-09-11T12:00:00Z',
};
server.middlewares.use(async (request, response, next) => {
@@ -123,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
ticketStatusOptions: [status],
users: [],
parentTicketOptions: [],
canEditTicket: true,
canEditTicket: !url.searchParams.has('readonly'),
canManageProject: true,
childTicketTree: [],
linkedTickets: [],
+242
View File
@@ -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),
});
+210
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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),
});
+354
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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),
});
+307
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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();
}
}
+316
View File
@@ -0,0 +1,316 @@
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:4185';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('dialog', (dialog) => dialog.accept());
const result = (value) =>
context.addCookies([{ name: 'security_test_result', value, url: base }]);
const enabled = (value) =>
context.addCookies([
{ name: 'security_enabled', value: String(value), url: base },
]);
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
let recoveryFetches = 0;
page.on('request', (request) => {
if (
request.url().endsWith('/user/two-factor-recovery-codes') &&
request.method() === 'GET'
) {
recoveryFetches++;
}
});
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 });
for (const path of ['security', 'applications']) {
await page.goto(`${base}/settings/${path}`);
await page
.getByRole('heading', {
name:
path === 'security'
? 'Update password'
: 'Applications',
exact: true,
})
.waitFor();
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${path} overflow at ${width}/${theme}`,
);
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.ok(
(await page.locator('section').first().boundingBox())
.width <= width,
`${path} content is clipped at ${width}/${theme}`,
);
}
await page.goto(`${base}/settings/security`);
await button('Enable 2FA').click();
await page.getByLabel('Manual setup key').waitFor();
assert.equal(
await page.getByLabel('Manual setup key').inputValue(),
'MANUALSETUPTESTKEY',
);
assert.equal(
await dialog().evaluate(
(el) => el.scrollWidth <= el.clientWidth,
),
true,
);
await button('Continue').click();
const code = page.getByLabel('Authentication code', {
exact: true,
});
await code.waitFor();
assert.equal(await code.getAttribute('inputmode'), 'numeric');
assert.equal(
await code.getAttribute('autocomplete'),
'one-time-code',
);
await code.focus();
await page.keyboard.press('Tab');
assert.equal(
await button('Back').evaluate(
(el) => el === document.activeElement,
),
true,
);
await page.keyboard.press('Escape');
await dialog().waitFor({ state: 'hidden' });
assert.equal(await page.getByLabel('Manual setup key').count(), 0);
assert.match(
await page.locator('body').innerText(),
/Awaiting confirmation/,
);
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await button('Continue setup').click();
await button('Continue').click();
await result('validation');
await page
.getByLabel('Authentication code', { exact: true })
.fill('000000');
await button('Confirm').click();
await page
.getByText('The provided two factor authentication code was invalid.', {
exact: true,
})
.waitFor();
assert.equal(
await page
.getByLabel('Authentication code', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.equal(
await page
.getByLabel('Authentication code', { exact: true })
.inputValue(),
'',
);
await result('success');
await page
.getByLabel('Authentication code', { exact: true })
.fill('123456');
await button('Confirm').click();
await dialog().waitFor({ state: 'hidden' });
await button('Disable 2FA').waitFor();
assert.equal(
recoveryFetches,
0,
'Recovery codes are fetched only on request',
);
await button('View recovery codes').click();
await page.getByText('old-code-0001', { exact: true }).waitFor();
await button('Hide recovery codes').click();
assert.equal(
await page.getByText('old-code-0001', { exact: true }).count(),
0,
);
await button('View recovery codes').click();
await page.getByText('old-code-0001', { exact: true }).waitFor();
assert.equal(recoveryFetches, 2);
await button('Regenerate codes').click();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
assert.equal(
await page.getByText('old-code-0001', { exact: true }).count(),
1,
);
await button('Regenerate codes').click();
await dialog()
.getByRole('button', { name: 'Regenerate codes', exact: true })
.click();
await dialog().waitFor({ state: 'hidden' });
await page.getByText('old-code-0001', { exact: true }).waitFor();
assert.equal(recoveryFetches, 3);
await button('Hide recovery codes').click();
await page.route('**/user/two-factor-recovery-codes', async (route) => {
if (route.request().method() === 'GET') {
await new Promise((resolve) => setTimeout(resolve, 350));
await route.fulfill({ json: ['late-secret-code'] });
} else {
await route.continue();
}
});
await button('View recovery codes').click();
await button('Hide recovery codes').click();
await page.waitForTimeout(450);
assert.equal(
await page.getByText('late-secret-code', { exact: true }).count(),
0,
);
await page.unroute('**/user/two-factor-recovery-codes');
await page.route('**/user/two-factor-recovery-codes', (route) =>
route.fulfill({ status: 423, json: {} }),
);
await button('View recovery codes').click();
await page
.getByText(
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
{ exact: true },
)
.waitFor();
await page.unroute('**/user/two-factor-recovery-codes');
await button('Try again').click();
await page.getByText('old-code-0001', { exact: true }).waitFor();
await button('Disable 2FA').click();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await button('Disable 2FA').click();
await dialog()
.getByRole('button', { name: 'Disable 2FA', exact: true })
.click();
await dialog().waitFor({ state: 'hidden' });
await button('Enable 2FA').waitFor();
assert.equal(
await page.getByText('old-code-0001', { exact: true }).count(),
0,
);
await page.route('**/user/two-factor-secret-key', (route) => route.abort());
await button('Enable 2FA').click();
await page
.getByText(
'Authenticator setup could not be loaded. Please try again.',
{ exact: true },
)
.waitFor();
assert.equal(await button('Continue').count(), 0);
await page.unroute('**/user/two-factor-secret-key');
await button('Try again').click();
await page.getByLabel('Manual setup key').waitFor();
await page.locator('[data-slot="dialog-close"]').click();
await dialog().waitFor({ state: 'hidden' });
await page.getByLabel('Current password', { exact: true }).fill('wrong');
await page.getByLabel('New password', { exact: true }).fill('new-password');
await page
.getByLabel('Confirm password', { exact: true })
.fill('new-password');
await result('validation');
await button('Save password').click();
await page
.getByText('The password is incorrect.', { exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Current password', { exact: true }).inputValue(),
'',
);
assert.equal(
await page.getByLabel('New password', { exact: true }).inputValue(),
'',
);
assert.equal(
await page
.getByLabel('Current password', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.doesNotMatch(
await page.evaluate(() => JSON.stringify(history.state)),
/MANUALSETUPTESTKEY|123456|new-password/,
);
await result('success');
await page.goto(`${base}/settings/applications`);
assert.equal(await button('Copy key').count(), 0);
await page.getByLabel('Application name').fill('My tool');
await page.route('**/settings/applications', (route) =>
route.request().method() === 'POST' ? route.abort() : route.continue(),
);
await button('Create application').click();
await page.locator('[data-form-state="network"]').waitFor();
assert.equal(
await page.getByLabel('Application name').inputValue(),
'My tool',
);
await page.unroute('**/settings/applications');
await button('Try again').click();
await page.getByLabel('API key', { exact: true }).waitFor();
assert.ok(
(await page.locator('section').first().boundingBox()).width <= 390,
'One-time key content is clipped on mobile',
);
await button('Copy key').click();
assert.equal(
await page.evaluate(() => navigator.clipboard.readText()),
'kg_app_one_time_test_key',
);
await page.reload();
assert.equal(await button('Copy key').count(), 0);
assert.equal(await page.getByLabel('API key', { exact: true }).count(), 0);
await page
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
.click();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await page
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
.click();
await result('expired');
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
await dialog()
.getByText('Your session has expired', { exact: true })
.waitFor();
assert.equal(
await page.locator('button[aria-label="Delete Desktop tool"]').count(),
1,
);
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await result('success');
await page
.getByRole('button', { name: 'Delete Desktop tool', exact: true })
.click();
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
await page
.getByText('No applications have been created yet.', { exact: true })
.waitFor();
assert.deepEqual(errors, []);
console.log(
'Security browser behavior checks passed: 2FA setup, confirmation, cancellation, recovery hiding and regeneration, late responses, errors, password reset, one-time key copy, deletion, keyboard, mobile and both themes.',
);
} finally {
await browser.close();
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Security from '../../resources/js/pages/settings/Security.vue';
import Applications from '../../resources/js/pages/settings/Applications.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) => (name === 'Security' ? Security : Applications),
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+238
View File
@@ -0,0 +1,238 @@
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/security-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const application = {
id: 1,
name: 'Desktop tool',
created_at: '2026-09-14T00:00:00Z',
last_used_at: null,
};
const key = 'kg_app_one_time_test_key';
function makePage(url, extra = {}) {
return {
component: url.startsWith('/settings/security')
? 'Security'
: 'Applications',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
canManageTwoFactor: true,
requiresConfirmation: true,
twoFactorEnabled: false,
applications: [application],
applicationKey: null,
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) {
server.middlewares.use(async (request, response, next) => {
const path = new URL(request.url, 'http://localhost').pathname;
if (!path.startsWith('/settings') && !path.startsWith('/user/')) {
return next();
}
const result =
request.headers.cookie?.match(/security_test_result=(\w+)/)?.[1] ??
'success';
const mutation = request.method !== 'GET';
if (mutation && ['server', 'expired'].includes(result)) {
response.statusCode = result === 'expired' ? 419 : 503;
response.end('Simulated failure');
return;
}
let body = '';
if (mutation) {
for await (const chunk of request) {
body += chunk;
}
}
const submitted = body ? JSON.parse(body) : {};
const cookies = request.headers.cookie ?? '';
let enabled = cookies.includes('security_enabled=true');
let extra = { twoFactorEnabled: enabled };
let url = path;
if (path.startsWith('/user/two-factor-') && !mutation) {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify(
path.endsWith('qr-code')
? {
svg: '<svg viewBox="0 0 100 100"><rect width="100" height="100" fill="black"/></svg>',
}
: path.endsWith('secret-key')
? { secretKey: 'MANUALSETUPTESTKEY' }
: ['old-code-0001', 'old-code-0002'],
),
);
return;
}
if (mutation) {
url = path.startsWith('/settings/applications')
? '/settings/applications'
: '/settings/security';
if (result === 'validation') {
extra.errors = path.includes('confirmed-two-factor')
? {
confirmTwoFactorAuthentication: {
code: 'The provided two factor authentication code was invalid.',
},
}
: path.includes('applications')
? { name: 'The name field is required.' }
: { current_password: 'The password is incorrect.' };
} else if (path.includes('confirmed-two-factor')) {
enabled = true;
response.setHeader(
'Set-Cookie',
'security_enabled=true; Path=/',
);
} else if (
path === '/user/two-factor-authentication' &&
request.method === 'DELETE'
) {
enabled = false;
response.setHeader(
'Set-Cookie',
'security_enabled=false; Path=/',
);
} else if (
path === '/settings/applications' &&
request.method === 'POST'
) {
extra.applicationKey = key;
extra.applications = [{ ...application, name: submitted.name }];
} else if (
path.startsWith('/settings/applications/') &&
request.method === 'DELETE'
) {
extra.applications = [];
}
extra.twoFactorEnabled = enabled;
}
const page = makePage(url, 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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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/security-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Security 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 Security = (
await server.ssrLoadModule(
'/resources/js/pages/settings/Security.vue',
)
).default;
const Applications = (
await server.ssrLoadModule(
'/resources/js/pages/settings/Applications.vue',
)
).default;
const render = async (url, component, extra = {}) =>
(
await createInertiaApp({
page: makePage(url, extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance()),
})
).body;
const security = await render('/settings/security', Security);
assert.match(security, /autocomplete="current-password"/);
assert.match(security, /Enable 2FA/);
assert.doesNotMatch(
await render('/settings/security', Security, {
canManageTwoFactor: false,
}),
/Enable 2FA/,
);
const enabled = await render('/settings/security', Security, {
twoFactorEnabled: true,
});
assert.match(enabled, /View recovery codes/);
assert.doesNotMatch(enabled, /old-code/);
const hidden = await render('/settings/applications', Applications);
assert.doesNotMatch(hidden, /id="application-key"/);
assert.doesNotMatch(hidden, /Copy key/);
const shown = await render('/settings/applications', Applications, {
applicationKey: key,
});
assert.match(shown, /id="application-key"/);
assert.match(shown, /Copy key/);
assert.match(shown, /kg_app_one_time_test_key/);
console.log('Security UI rendering checks passed.');
} finally {
await server.close();
}
}
+146
View File
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
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:4184';
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);
console.error(error.message);
});
page.on('dialog', (dialog) => dialog.accept());
const setResult = (value) =>
context.addCookies([{ name: 'settings_test_result', value, url: base }]);
const state = (value) =>
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
const themeRadio = (value) =>
page.locator(`input[name="appearance"][value="${value}"]`);
const chooseTheme = (value) =>
page
.locator(`label:has(input[name="appearance"][value="${value}"])`)
.click();
const image = {
name: 'profile.png',
mimeType: 'image/png',
buffer: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=',
'base64',
),
};
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 page.goto(`${base}/settings/appearance`);
await page
.getByRole('heading', { name: 'Settings', exact: true })
.waitFor();
await chooseTheme(theme);
assert.equal(await themeRadio(theme).isChecked(), true);
assert.equal(
await page
.locator('html')
.evaluate((element) => element.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`Appearance overflows at ${width}px in ${theme}`,
);
}
}
await page.goto(`${base}/settings/appearance`);
const light = themeRadio('light');
await light.focus();
await page.keyboard.press('ArrowRight');
assert.equal(await themeRadio('dark').isChecked(), true);
await page.reload();
assert.equal(await themeRadio('dark').isChecked(), true);
await page.setViewportSize({ width: 320, height: 900 });
await page.goto(`${base}/settings/profile`);
const avatarInput = page.locator('#avatar');
assert.equal(
await avatarInput.getAttribute('accept'),
'image/png,image/jpeg,image/webp',
);
await avatarInput.setInputFiles(image);
await page.getByText('Selected: profile.png', { exact: true }).waitFor();
assert.match(
await page
.locator('img[alt="Profile picture preview"]')
.getAttribute('src'),
/^blob:/,
);
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await setResult('validation');
await page.getByLabel('Name', { exact: true }).fill('Updated User');
await page
.getByRole('button', { name: 'Save profile', exact: true })
.click();
await state('validation');
await page
.getByText('The avatar must be a file of type: jpg, jpeg, png, webp.', {
exact: true,
})
.waitFor();
assert.equal(await avatarInput.getAttribute('aria-invalid'), 'true');
assert.equal(
await page.getByLabel('Name', { exact: true }).inputValue(),
'Updated User',
);
assert.match(
await page
.locator('img[alt="Profile picture preview"]')
.getAttribute('src'),
/^blob:/,
);
await page.route('**/settings/profile?*', (route) => route.abort('failed'));
await page
.getByRole('button', { name: 'Save profile', exact: true })
.click();
await state('network');
await page.unroute('**/settings/profile?*');
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await page.goto(`${base}/settings/profile`);
await page
.getByRole('button', { name: 'Remove picture', exact: true })
.click();
await page
.getByRole('button', { name: 'Remove picture', exact: true })
.waitFor({ state: 'detached' });
assert.deepEqual(errors, []);
console.log(
'Settings browser checks passed: avatar preview, upload errors and retry, removal, keyboard, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Appearance from '../../resources/js/pages/settings/Appearance.vue';
import Profile from '../../resources/js/pages/settings/Profile.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) => (name === 'Appearance' ? Appearance : Profile),
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+207
View File
@@ -0,0 +1,207 @@
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/settings-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4184, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
function makeUser(generated = false) {
return {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: generated ? null : '/avatar.png',
avatar_color: '#10B981',
avatar_is_generated: generated,
email_verified_at: null,
must_change_password_on_next_login: false,
created_at: '2026-09-14T00:00:00Z',
updated_at: '2026-09-14T00:00:00Z',
};
}
function makePage(url, errors = {}, generated = false) {
return {
component: url.startsWith('/settings/appearance')
? 'Appearance'
: 'Profile',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors,
sidebarOpen: true,
mustVerifyEmail: true,
status: null,
appearance: 'system',
auth: {
user: makeUser(generated),
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: { project: null },
},
};
}
if (serve) {
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/settings')) {
return next();
}
let url = request.url;
let errors = {};
let generated = false;
if (request.method !== 'GET') {
const result =
request.headers.cookie?.match(
/settings_test_result=(\w+)/,
)?.[1] ?? 'success';
if (result === 'server') {
response.statusCode = 503;
response.end('Simulated server failure');
return;
}
if (result === 'validation') {
errors = {
avatar: 'The avatar must be a file of type: jpg, jpeg, png, webp.',
};
}
generated = request.url.startsWith('/settings/profile/avatar');
url = '/settings/profile';
}
const page = makePage(url, errors, generated);
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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en" data-appearance="system"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><script>try{const value=localStorage.getItem("appearance");if(["light","dark","system"].includes(value)){document.documentElement.dataset.appearance=value}}catch{}</script></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/settings-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log(
'Settings fixture ready at http://127.0.0.1:4184/settings/profile',
);
} 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 Profile = (
await server.ssrLoadModule(
'/resources/js/pages/settings/Profile.vue',
)
).default;
const Appearance = (
await server.ssrLoadModule(
'/resources/js/pages/settings/Appearance.vue',
)
).default;
const render = async (
url,
component,
locale = createLocalization(),
) => {
const response = await createInertiaApp({
page: makePage(url),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance('system')),
});
return response.body;
};
const profile = await render('/settings/profile', Profile);
assert.match(profile, /Account settings/);
assert.match(profile, /Profile picture/);
assert.match(profile, /Choose picture/);
assert.match(profile, /accept="image\/png,image\/jpeg,image\/webp"/);
assert.match(
profile,
/On mobile, you can select from your photo library or camera/,
);
assert.match(profile, /aria-current="page"[^>]*>[\s\S]*Profile/);
assert.match(profile, /Delete account/);
const appearance = await render('/settings/appearance', Appearance);
assert.equal((appearance.match(/name="appearance"/g) ?? []).length, 3);
assert.match(appearance, /value="light"/);
assert.match(appearance, /value="dark"/);
assert.match(appearance, /value="system"[^>]*checked/);
assert.match(appearance, /Follow the light or dark preference/);
assert.match(appearance, /aria-current="page"[^>]*>[\s\S]*Appearance/);
const localized = await render(
'/settings/profile',
Profile,
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'settings.eyebrow': 'Kontoeinstellungen',
'profile.title': 'Profil',
'profile.choosePicture': 'Bild auswählen',
},
},
),
);
assert.match(localized, /Kontoeinstellungen/);
assert.match(localized, /Profil/);
assert.match(localized, /Bild auswählen/);
console.log('Settings rendering and localization checks passed.');
} finally {
await server.close();
}
}
+441
View File
@@ -0,0 +1,441 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
const content = () => page.locator('#main-content');
const visit = async (query = '') => {
await page.goto(base + query);
await page.getByRole('heading', { level: 1 }).waitFor();
};
const edit = () => page.locator('#ticket-edit > summary');
const tabTo = async (locator) => {
await content().focus();
for (let step = 0; step < 45; step++) {
await page.keyboard.press('Tab');
if (
await locator.evaluate(
(element) => element === document.activeElement,
)
) {
assert.equal(
await locator.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Ticket control is unreachable by keyboard');
};
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
for (const query of ['', '?empty', '?long']) {
await visit(query);
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`Page overflow at ${width}/${theme}/${query}`,
);
const description = await page
.locator('#ticket-description')
.boundingBox();
const metadata = await page
.locator('#ticket-metadata')
.boundingBox();
if (width >= 1280) {
assert.ok(
metadata.x > description.x + description.width,
'Desktop metadata must be beside the content.',
);
} else {
assert.ok(
metadata.y > description.y + description.height,
'Mobile metadata must be stacked.',
);
}
await edit().click();
assert.equal(
await page.getByLabel('Title', { exact: true }).isVisible(),
true,
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`Editor overflow at ${width}/${theme}/${query}`,
);
await edit().click();
}
await visit();
await page.screenshot({
path: `/private/tmp/kan30-detail-${width}-${theme}.png`,
fullPage: true,
});
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await visit();
const blocker = content().locator('#ticket-blocker');
const blockerToggle = blocker.getByRole('checkbox', {
name: 'Mark ticket as blocked',
exact: true,
});
const blockerComment = blocker.getByLabel('Blocker comment', {
exact: true,
});
assert.equal(await blockerToggle.isChecked(), true);
assert.equal(
await blocker.getByText('Alex Morgan', { exact: true }).count(),
0,
);
assert.equal(
await content()
.locator('#ticket-history')
.getByText('Alex Morgan', { exact: true })
.count(),
1,
);
assert.equal(
await content()
.locator('#ticket-history')
.getByText('Status changed', { exact: true })
.count(),
1,
);
await blockerComment.fill('');
assert.equal(
await blockerComment.evaluate(
(element) => element.validity.valueMissing,
),
true,
);
await blockerComment.fill('Waiting for a production decision.');
await blocker.getByRole('button', { name: 'Save blocker' }).click();
await page.getByText('Ticket updated.', { exact: true }).waitFor();
assert.equal(
await blockerComment.inputValue(),
'Waiting for a production decision.',
);
for (const label of ['Assignee', 'Reviewer']) {
const field = content()
.locator('#ticket-metadata dl > div')
.filter({ has: page.getByText(label, { exact: true }) });
assert.match(await field.locator('dd').innerText(), /Alex Morgan/);
}
await blockerToggle.uncheck();
await blocker.getByRole('button', { name: 'Unblock ticket' }).click();
await blocker
.getByText('This ticket currently has no active blocker.', {
exact: true,
})
.waitFor();
assert.equal(await blocker.getByLabel('Blocker comment').count(), 0);
await content().getByRole('button', { name: 'Change parent' }).click();
await page.waitForFunction(
() => document.activeElement?.id === 'parent_ticket_id',
);
const saveTicket = content().getByRole('button', {
name: 'Save changes',
exact: true,
});
assert.equal(
await content()
.getByRole('button', { name: 'Discard changes', exact: true })
.isDisabled(),
true,
'Saving the blocker must also update the ticket form defaults.',
);
await page
.getByLabel('Title', { exact: true })
.fill('Updated title after unblocking');
const ticketUpdate = page.waitForRequest(
(request) =>
request.method() === 'PATCH' &&
new URL(request.url()).pathname === '/projects/7/tickets/42',
);
await saveTicket.click();
const savedTicket = (await ticketUpdate).postDataJSON();
assert.equal(savedTicket.is_blocked, false);
assert.equal(savedTicket.blocked_comment, '');
assert.equal(Number(savedTicket.parent_ticket_id), 40);
assert.deepEqual(savedTicket.acceptance_criteria, [
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
{
id: 11,
text: 'Invalidate when records change',
checked: false,
indent: 1,
},
]);
await page
.getByRole('heading', { name: 'Updated title after unblocking' })
.waitFor();
await visit();
await tabTo(edit());
await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page.waitForFunction(
() =>
document.querySelectorAll(
'#ticket-checklist input[type=checkbox]',
)[1].checked,
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
await page
.getByLabel('Criterion 1', { exact: true })
.fill('A draft checklist item');
assert.equal(
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.isDisabled(),
true,
);
await edit().click();
await edit().click();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
page.once('dialog', (dialog) => dialog.dismiss());
await content()
.getByRole('link', { name: 'Open board', exact: true })
.click();
assert.match(page.url(), /\/tickets\/42/);
page.once('dialog', (dialog) => dialog.accept());
await content()
.getByRole('link', { name: 'Open board', exact: true })
.click();
await page.waitForURL('**/projects/7/board');
await page.goBack();
await edit().waitFor();
if (!(await page.locator('#ticket-edit').getAttribute('open'))) {
await edit().click();
}
if (!(await page.getByLabel('Title', { exact: true }).isVisible())) {
await edit().click();
}
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
assert.equal(
await page.getByLabel('Criterion 1', { exact: true }).inputValue(),
'A draft checklist item',
);
await visit('?reject');
await edit().click();
await page.getByLabel('Title', { exact: true }).fill('Rejected draft');
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
await page
.getByText('This title is not allowed.', { exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Rejected draft',
);
assert.equal(
await page
.getByLabel('Title', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await visit();
await edit().click();
await page
.getByLabel('Title', { exact: true })
.fill('Saved updated ticket title');
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
await page
.getByRole('heading', {
name: 'Saved updated ticket title',
exact: true,
})
.waitFor();
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?rejectToggle');
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page
.getByRole('alert')
.filter({ hasText: 'Checklist status could not be saved' })
.waitFor();
assert.equal(
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.isChecked(),
false,
);
await visit();
const createBranch = page.getByRole('button', {
name: 'Create branch',
exact: true,
});
await tabTo(createBranch);
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog');
await dialog.waitFor();
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert.equal(
await createBranch.evaluate((el) => el === document.activeElement),
true,
);
await createBranch.click();
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page.getByRole('button', { name: 'Create now', exact: true }).click();
await dialog.waitFor({ state: 'hidden' });
await visit('?slowBranch&serverError');
await page
.getByRole('button', { name: 'Create branch', exact: true })
.click();
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page
.getByLabel('Branch name', { exact: true })
.fill('feature/ENG-42-preserve-this-name');
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
const failedBranchCreation = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/branches') &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Create now', exact: true }).click();
await page.getByText('Creating branch…', { exact: true }).waitFor();
assert.equal(
await page
.locator('button[type="submit"]')
.last()
.getAttribute('aria-busy'),
'true',
);
assert.equal((await failedBranchCreation).status(), 503);
await page
.getByText('The request could not be completed', { exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Branch name', { exact: true }).inputValue(),
'feature/ENG-42-preserve-this-name',
);
assert.equal(await page.getByRole('dialog').isVisible(), true);
await page.getByLabel('Branch name', { exact: true }).fill('eng-42-cache');
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
await visit();
const linkSaved = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/links') &&
response.request().method() === 'POST',
);
await page.getByLabel('Linked ticket', { exact: true }).selectOption('41');
await page.getByRole('button', { name: 'Save link', exact: true }).click();
assert.equal((await linkSaved).status(), 303);
await page
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
.waitFor();
const linkRemoved = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/links/3') &&
response.request().method() === 'DELETE',
);
await page
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
.click();
assert.equal((await linkRemoved).status(), 303);
await visit('?readonly&hideAuthor&hidePoints');
assert.equal(await content().locator('form').count(), 0);
assert.equal(
await content().getByText('Sam Chen', { exact: true }).count(),
0,
);
assert.equal(
await content()
.getByText('Estimated story points', { exact: true })
.count(),
0,
);
await tabTo(
content().getByRole('link', {
name: 'ENG-41 Related infrastructure',
exact: true,
}),
);
await page.keyboard.press('Enter');
await page.waitForURL('**/tickets/41');
await page
.getByRole('heading', { name: 'Related infrastructure', exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Related infrastructure',
);
assert.deepEqual(errors, []);
console.log(
'Ticket detail browser checks passed: desktop/mobile, both themes, keyboard, drafts, saves, checklist rollback, links, Git dialog and direct navigation.',
);
} finally {
await browser.close();
}
+22
View File
@@ -0,0 +1,22 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'TicketCreate'
? TicketCreate
: name === 'Ticket'
? Ticket
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+649
View File
@@ -0,0 +1,649 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/ticket-detail-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
};
const project = {
id: 7,
key: 'ENG',
name: 'Engineering',
show_author: true,
show_story_points: true,
};
const repository = {
id: 9,
provider: 'gitea',
provider_label: 'Gitea',
base_url: 'https://git.example.test',
repository_full_name: 'Engineering/platform',
repository_url: 'https://git.example.test/Engineering/platform',
has_personal_access: true,
};
const parent = {
id: 40,
ticket_key: 'ENG-40',
title: 'Platform delivery',
project_id: 7,
};
const linked = {
id: 41,
ticket_key: 'ENG-41',
title: 'Related infrastructure',
project_id: 7,
};
const ticket = {
id: 42,
project_id: 7,
sequence_number: 42,
ticket_key: 'ENG-42',
title: 'Implement a resilient caching layer for the project API',
description:
'Keep every existing ticket action available.\nLongUnbrokenDescriptionThatMustWrapWithoutCausingHorizontalScrollingOnTheSmallestSupportedMobileViewport.',
acceptance_criteria: [
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
{
id: 11,
text: 'Invalidate when records change',
checked: false,
indent: 1,
},
],
assignee_user_id: 1,
assignee: user,
author_user_id: 2,
author: { ...user, id: 2, name: 'Sam Chen' },
parent_ticket_id: 40,
parent_ticket: parent,
is_blocked: true,
blocked_comment: 'Waiting for infrastructure approval.',
story_points: 0,
ticket_type_id: 1,
ticket_type: { id: 1, name: 'Custom development task', is_parent: false },
ticket_priority_id: 2,
ticket_priority: { id: 2, name: 'Urgent project priority' },
ticket_status_id: 3,
ticket_status: {
id: 3,
name: 'In progress',
board_column: { id: 1, name: 'Delivery' },
},
field_values: [
{
id: 1,
ticket_field_definition_id: 1,
value_number: 0,
value_user_id: null,
},
{
id: 2,
ticket_field_definition_id: 2,
value_number: null,
value_user_id: 1,
user_value: user,
},
],
worklog_entries: [
{
id: 2,
actor: null,
actor_id: null,
entry_type: 'status_changed',
summary: 'Moved from Backlog to Delivery / In progress.',
created_at: '2026-09-11T13:00:00Z',
},
{
id: 1,
actor: user,
actor_id: user.id,
entry_type: 'created',
summary: 'Ticket created in the project backlog.',
created_at: '2026-09-11T12:00:00Z',
},
],
created_at: '2026-09-10T12:00:00Z',
updated_at: '2026-09-11T12:00:00Z',
};
const fieldDefinitions = [
{ id: 1, label: 'Impact score', field_type: 'number' },
{ id: 2, label: 'Reviewer', field_type: 'user' },
];
let savedForm = {};
function makePage(url) {
const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams;
const creating = parsed.pathname.endsWith('/create');
const detail =
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
const data = structuredClone(ticket);
if (parsed.pathname.endsWith('/41')) {
Object.assign(data, linked, {
parent_ticket_id: null,
parent_ticket: null,
title: 'Related infrastructure',
});
}
if (params.has('saved')) {
Object.assign(data, savedForm, { title: params.get('saved') });
}
if (params.has('long')) {
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
}
if (params.has('parent')) {
data.ticket_type.is_parent = true;
}
if (params.has('empty')) {
Object.assign(data, {
description: null,
acceptance_criteria: [],
assignee: null,
assignee_user_id: null,
author: null,
author_user_id: null,
parent_ticket: null,
parent_ticket_id: null,
is_blocked: false,
blocked_comment: null,
story_points: null,
ticket_status: null,
field_values: [],
worklog_entries: [],
});
}
const noAccess = params.has('noAccess');
const repo = { ...repository, has_personal_access: !noAccess };
const repositoryError = params.has('gitError')
? 'Repository temporarily unavailable.'
: null;
return {
component: creating
? 'TicketCreate'
: detail
? 'Ticket'
: 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: params.has('linkRejected')
? {
ticket_link_type_id: 'Invalid relationship type.',
linked_ticket_id: 'Choose another ticket.',
}
: params.has('rejected')
? {
title: 'This title is not allowed.',
...(params.has('allErrors')
? {
description: 'Invalid description.',
ticket_type_id: 'Invalid type.',
ticket_priority_id: 'Invalid priority.',
ticket_status_id: 'Invalid status.',
assignee_user_id: 'Invalid assignee.',
author_user_id: 'Invalid author.',
parent_ticket_id: 'Invalid parent.',
story_points: 'Invalid points.',
'acceptance_criteria.0.text':
'Invalid checklist.',
blocked_comment: 'Invalid blocker.',
'field_values.0.value_number':
'Invalid number.',
'field_values.1.value_user_id':
'Invalid reviewer.',
}
: {}),
}
: {},
sidebarOpen: true,
auth: {
user,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: {
project: {
...project,
canManage: false,
canCreateTickets: !params.has('readonly'),
},
},
project: {
...project,
show_author: !params.has('hideAuthor'),
show_story_points: !params.has('hidePoints'),
},
ticket: data,
canEditTicket: !params.has('readonly'),
canManageProject: params.has('admin'),
status: params.has('saved') ? 'Ticket updated.' : null,
users: [user, ticket.author],
ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
source: params.get('source') === 'board' ? 'board' : 'backlog',
defaultTicketStatusId: 3,
ticketStatusOptions: [
{
id: 3,
name: 'In progress',
board_column: { name: 'Delivery' },
},
{ id: 4, name: 'Backlog', board_column: null },
],
ticketPriorities: [data.ticket_priority],
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
parentTicketOptions: [parent],
childTicketTree: params.has('parent')
? [
{
...linked,
ticket_type_name: 'Epic',
children: [
{
id: 43,
ticket_key: 'ENG-43',
title: 'Nested child with a long title that must wrap on mobile',
ticket_type_name: 'Task',
children: [],
},
],
children_count: 0,
},
]
: [],
linkedTickets:
params.has('empty') || params.has('noLinks')
? []
: [
{
id: 3,
source_ticket_id: params.has('incoming')
? 41
: 42,
target_ticket_id: params.has('incoming')
? 42
: 41,
ticket_link_type: {
id: 1,
label: params.has('incoming')
? 'Blocked by'
: 'Blocks',
},
linked_ticket: linked,
},
],
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
ticketLinkTypes: params.has('noTypes')
? []
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }],
repositoryBranches: params.has('empty')
? []
: [
{
repository_connection: repo,
branches: [{ name: 'feature/ENG-42-cache' }],
error: repositoryError,
},
],
repositoryPullRequests: params.has('empty')
? []
: [
{
repository_connection: repo,
pull_requests: [
{
number: 12,
status: 'open',
title: 'Cache implementation',
source_branch: 'feature/ENG-42-cache',
html_url:
'https://git.example.test/Engineering/platform/pulls/12',
},
],
error: repositoryError,
},
],
suggestedBranchName: `${data.ticket_key.toLowerCase()}-cache`,
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
if (request.url.includes('/branches/validate')) {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
is_valid: true,
message: 'Branch name is available.',
}),
);
return;
}
if (request.method !== 'GET') {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = body ? JSON.parse(body) : {};
const previous = new URL(request.headers.referer);
const params = previous.searchParams;
if (request.url.includes('/branches') && params.has('slowBranch')) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (params.has('serverError')) {
response.writeHead(503);
response.end('Simulated failure');
return;
}
if (!request.headers['x-inertia']) {
assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/projects/7/tickets/42');
assert.equal(
data.title,
ticket.title,
'Checklist toggle must not save the title draft.',
);
assert.equal(
data.acceptance_criteria[0].checked,
data.acceptance_criteria[1].checked,
'Toggling a parent must update its descendants.',
);
response.writeHead(params.has('rejectToggle') ? 422 : 200, {
'Content-Type': 'application/json',
});
response.end('{}');
return;
}
if (request.url.includes('/branches')) {
assert.equal(data.branch_name, 'eng-42-cache');
assert.equal(Number(data.repository_connection_id), 9);
} else if (request.url.includes('/links')) {
assert.ok(
request.url.endsWith('/links/3?_method=DELETE') ||
request.method === 'DELETE' ||
Number(data.linked_ticket_id) === 41,
);
if (params.has('rejectLink')) {
params.set('linkRejected', '1');
} else if (request.method === 'DELETE') {
params.set('noLinks', '1');
} else {
params.delete('noLinks');
params.delete('linkRejected');
}
} else {
assert.ok(
(request.method === 'POST' || 'field_values' in data) &&
'acceptance_criteria' in data &&
'parent_ticket_id' in data,
);
if (params.has('reject')) {
params.set('rejected', '1');
} else {
savedForm = {
...data,
blocked_comment: data.is_blocked
? data.blocked_comment
: null,
field_values: data.field_values.map((fieldValue) => ({
...fieldValue,
user_value:
Number(fieldValue.value_user_id) === user.id
? user
: null,
})),
};
params.set('saved', data.title);
}
}
response.writeHead(303, {
Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
});
response.end();
return;
}
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.writeHead(200, {
'X-Inertia': 'true',
'Content-Type': 'application/json',
});
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="${escaped}"></div><script type="module" src="/tests/Fixtures/ticket-detail-preview.mjs"></script></body></html>`,
),
);
});
await server.listen();
console.log(
'Ticket detail fixture ready at http://127.0.0.1:4185/projects/7/tickets/42',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Ticket } = await server.ssrLoadModule(
'/resources/js/pages/projects/Ticket.vue',
);
const { default: TicketCreate } = await server.ssrLoadModule(
'/resources/js/pages/projects/TicketCreate.vue',
);
const render = async (query = '', creating = false) => {
const result = await createInertiaApp({
page: makePage(
`/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
),
resolve: () => (creating ? TicketCreate : Ticket),
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return result.body.split('id="main-content"')[1];
};
const html = await render();
for (const text of [
ticket.title,
'Keep every existing ticket action available.',
'Cache requests safely',
'Invalidate when records change',
'Urgent project priority',
'Delivery / In progress',
'Alex Morgan',
'Sam Chen',
'Impact score',
'Reviewer',
'Waiting for infrastructure approval.',
'feature/ENG-42-cache',
'Cache implementation',
'Ticket created in the project backlog.',
'Ticket created',
'Status changed',
'System',
]) {
assert.ok(
html.includes(text),
`Missing ticket information: ${text}`,
);
}
for (const section of [
'description',
'checklist',
'metadata',
'blocker',
'relationships',
'history',
'git',
]) {
assert.ok(html.includes(`href="#ticket-${section}"`));
assert.ok(html.includes(`id="ticket-${section}"`));
}
for (const route of [
'/projects/7',
'/projects/7/backlog',
'/projects/7/board',
'/projects/7/tickets/40',
'/projects/7/tickets/41',
]) {
assert.ok(html.includes(`href="${route}"`));
}
assert.match(html, /<details[^>]+id="ticket-edit"/);
for (const field of [
'title',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'author_user_id',
'story_points',
'parent_ticket_id',
'description',
'is_blocked',
'blocked_comment',
'field-1',
'field-2',
]) {
assert.ok(
html.includes(`id="${field}"`),
`Missing editor: ${field}`,
);
}
assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/);
assert.match(html, /Create branch/);
assert.match(
html,
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(
html,
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(html, /Save link/);
assert.match(html, /Outgoing/);
const incoming = await render('?incoming');
assert.match(incoming, /Incoming/);
assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/);
assert.match(
await render('?noTargets'),
/No other tickets are available/,
);
assert.match(html, /Remove/);
const readOnly = await render('?readonly');
assert.doesNotMatch(
readOnly,
/<form|<details|Create branch|Save changes|Save link/,
);
assert.match(readOnly, /type="checkbox" disabled/);
assert.match(readOnly, /Read only/);
const hidden = await render('?readonly&hideAuthor&hidePoints');
assert.doesNotMatch(hidden, /Sam Chen|Estimated story points/);
assert.match(await render('?readonly&hideAuthor&admin'), /Sam Chen/);
const empty = await render('?empty');
for (const text of [
'No description has been added yet.',
'No acceptance criteria yet.',
'No status',
'Unassigned',
'No author',
'Not set',
'No linked repositories',
'No worklog entries exist',
]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
}
const hierarchy = await render('?parent');
assert.match(hierarchy, /Child tickets/);
assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/);
const noAccess = await render('?noAccess');
assert.match(noAccess, /Personal Git access required/);
assert.match(noAccess, /Open Git connections/);
const gitError = await render('?gitError');
assert.match(gitError, /Branches unavailable/);
assert.match(gitError, /Pull requests unavailable/);
assert.match(gitError, /Repository temporarily unavailable/);
const create = await render('?source=board', true);
assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/);
assert.match(create, /Back to board/);
assert.match(create, /id="ticket_status_id"/);
assert.doesNotMatch(
create,
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
);
assert.doesNotMatch(
await render('?hidePoints', true),
/id="story_points"/,
);
const unavailable = await render('?noMetadata', true);
assert.doesNotMatch(unavailable, /<form/);
assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [
'title',
'description',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'parent_ticket_id',
'story_points',
]) {
assert.ok(invalid.includes(`id="${id}-error"`));
}
}
console.log('Ticket detail behavior checks passed.');
} finally {
await server.close();
}
}
+279
View File
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets';
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const field = (label) => form().getByLabel(label, { exact: true });
const form = () => page.locator('form').first();
const state = (value) =>
form().locator(`[data-form-state="${value}"]`).waitFor();
const button = (label) =>
form().getByRole('button', { name: label, exact: true });
async function visit(create, query = '') {
await page.goto(`${base}/${create ? 'create' : '42'}${query}`);
await page.getByRole('heading', { level: 1 }).waitFor();
if (!create) {
await page.locator('#ticket-edit > summary').click();
}
}
async function fill(create) {
await field('Title').fill('A complete ticket');
await field('Description').fill('Description with context.');
await field('Assignee').selectOption('2');
await field('Parent ticket').selectOption('40');
await field('Estimated story points').fill('0');
if (create) {
await button('Add criterion').click();
await button('Add criterion').click();
await button('Indent criterion 2').click();
}
await field('Criterion 1').fill('Main criterion');
await field('Criterion 2').fill('Nested criterion');
await form()
.getByRole('checkbox', {
name: 'Complete criterion 2: Nested criterion',
exact: true,
})
.check();
if (create) {
await field('Status').selectOption('4');
} else {
await field('Author').selectOption('1');
await field('Blocker comment').fill('Waiting for review');
await field('Impact score').fill('2.5');
await field('Reviewer').selectOption('2');
}
}
async function submit(create) {
const request = page.waitForRequest(
(r) =>
r.method() === (create ? 'POST' : 'PATCH') &&
r.url().split('?')[0] === (create ? base : `${base}/42`),
);
await button(create ? 'Create ticket' : 'Save changes').click();
return (await request).postDataJSON();
}
try {
for (const create of [true, false]) {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await visit(create, '?admin');
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth,
),
true,
`Overflow ${create}/${width}/${theme}`,
);
await field('Title').focus();
await page.keyboard.press('Tab');
assert.equal(
await field('Description').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await fill(create);
await page.screenshot({
path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`,
fullPage: true,
});
page.once('dialog', (dialog) => dialog.dismiss());
await button('Discard changes').click();
assert.equal(
await field('Title').inputValue(),
'A complete ticket',
);
page.once('dialog', (dialog) => dialog.accept());
await button('Discard changes').click();
assert.equal(
await button('Discard changes').isDisabled(),
true,
);
assert.equal(
await field('Title').inputValue(),
create
? ''
: 'Implement a resilient caching layer for the project API',
);
}
}
await visit(create, '?admin&reject&allErrors');
await fill(create);
const data = await submit(create);
assert.equal(data.title, 'A complete ticket');
assert.equal(data.description, 'Description with context.');
assert.equal(Number(data.ticket_type_id), 1);
assert.equal(Number(data.ticket_priority_id), 2);
assert.equal(Number(data.assignee_user_id), 2);
assert.equal(Number(data.parent_ticket_id), 40);
assert.equal(Number(data.story_points), 0);
assert.deepEqual(
data.acceptance_criteria.map(({ text, checked, indent }) => ({
text,
checked,
indent,
})),
[
{ text: 'Main criterion', checked: false, indent: 0 },
{ text: 'Nested criterion', checked: true, indent: 1 },
],
);
if (create) {
assert.equal(Number(data.ticket_status_id), 4);
assert.equal('author_user_id' in data, false);
} else {
assert.equal(Number(data.author_user_id), 1);
assert.equal(data.is_blocked, true);
assert.equal(data.blocked_comment, 'Waiting for review');
assert.equal(Number(data.field_values[0].value_number), 2.5);
assert.equal(Number(data.field_values[1].value_user_id), 2);
assert.deepEqual(
data.acceptance_criteria.map((item) => item.id),
[10, 11],
);
assert.equal('ticket_status_id' in data, false);
}
await state('validation');
const fields = [
'Title',
'Description',
'Type',
'Priority',
'Assignee',
'Parent ticket',
'Estimated story points',
'Criterion 1',
...(create
? ['Status']
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
];
for (const label of fields) {
const control = field(label);
assert.equal(
await control.getAttribute('aria-invalid'),
'true',
label,
);
const errorId = (await control.getAttribute('aria-describedby'))
.split(' ')
.at(-1);
assert.ok(
(await page.locator(`[id="${errorId}"]`).innerText()).length,
`Missing associated error: ${label}`,
);
}
assert.equal(await field('Title').inputValue(), 'A complete ticket');
await page.route(create ? `${base}` : `${base}/42`, (route) =>
route.abort('failed'),
);
await button(create ? 'Create ticket' : 'Save changes').click();
await state('network');
assert.equal(
await field('Description').inputValue(),
'Description with context.',
);
await page.unrouteAll();
await visit(create, '?admin&serverError');
await fill(create);
await submit(create);
await state('server');
assert.equal(await field('Title').inputValue(), 'A complete ticket');
let pending;
let requests = 0;
await page.route(create ? `${base}` : `${base}/42`, (route) => {
requests++;
pending = route;
});
await button('Try again').click();
await state('loading');
assert.equal(await field('Title').isDisabled(), true);
assert.equal(
await button(
create ? 'Creating ticket…' : 'Saving changes…',
).isDisabled(),
true,
);
await form().evaluate((el) =>
el.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(requests, 1);
await pending.abort();
await state('network');
await page.unrouteAll();
await visit(create, '?admin');
await fill(create);
page.once('dialog', (dialog) => dialog.dismiss());
await page
.locator('#main-content')
.getByRole('link', {
name: create ? 'Back to backlog' : 'Open backlog',
exact: true,
})
.click();
assert.ok(page.url().includes('/tickets/'));
const successData = await submit(create);
await page
.getByRole('heading', { name: successData.title, exact: true })
.waitFor();
if (create) {
await page.goBack();
await page
.getByRole('heading', { name: 'Create ticket', exact: true })
.waitFor();
await field('Title').waitFor();
assert.equal(await field('Title').inputValue(), '');
assert.equal(await field('Criterion 1').count(), 0);
} else {
await form()
.getByText('All changes saved', { exact: true })
.waitFor();
await field('Title').fill('Another draft');
page.once('dialog', (dialog) => dialog.accept());
await button('Discard changes').click();
assert.equal(
await field('Title').inputValue(),
'A complete ticket',
);
assert.equal(await field('Impact score').inputValue(), '2.5');
}
}
await visit(false, '?hideAuthor&hidePoints');
assert.equal(await field('Author').count(), 0);
assert.equal(await field('Estimated story points').count(), 0);
await visit(false);
assert.equal(await field('Author').isDisabled(), true);
await visit(true, '?hidePoints');
assert.equal(await field('Estimated story points').count(), 0);
await visit(true, '?noMetadata');
assert.equal(await page.locator('form').count(), 0);
assert.deepEqual(errors, []);
console.log(
'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,223 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage();
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
const section = () => page.locator('#ticket-relationships');
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
async function visit(query = '') {
await page.goto(base + query);
await section().waitFor();
}
const control = (label) => section().getByLabel(label, { exact: true });
const save = () =>
section().getByRole('button', { name: 'Save link', exact: true });
const remove = () =>
section().getByRole('button', {
name: 'Remove link to ENG-41',
exact: true,
});
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await visit('?parent&incoming');
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
for (const text of [
'Platform delivery',
'Child tickets',
'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) {
assert.equal(
await section()
.getByText(text, { exact: true })
.isVisible(),
true,
text,
);
}
const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41',
});
await collapse.focus();
await page.keyboard.press('Enter');
assert.equal(
await section()
.getByText(
'Nested child with a long title that must wrap on mobile',
{ exact: true },
)
.count(),
0,
);
await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus();
await page.keyboard.press('Tab');
assert.equal(
await control('Linked ticket').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await control('Linked ticket').selectOption('41');
await section()
.locator('form')
.last()
.getByText('ENG-42', { exact: true })
.waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
`Overflow ${width}/${theme}`,
);
await section().screenshot({
path: `/private/tmp/kan33-relationships-${width}-${theme}.png`,
});
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.click();
assert.equal(
await page
.locator('#parent_ticket_id')
.evaluate((el) => el === document.activeElement),
true,
);
assert.equal(
await page.locator('#ticket-edit').evaluate((el) => el.open),
true,
);
}
}
await visit('?noLinks');
assert.equal(await save().isDisabled(), true);
await control('Linked ticket').selectOption('41');
let request = page.waitForRequest(
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
);
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
linked_ticket_id: 41,
});
await section().getByText('Outgoing', { exact: true }).waitFor();
await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '',
);
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Linked tickets'),
);
request = page.waitForRequest(
(r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'),
);
await remove().click();
await request;
await section()
.getByText('No ticket links are configured for this ticket yet.', {
exact: true,
})
.waitFor();
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Linked tickets'),
);
await visit('?rejectLink');
await control('Linked ticket').selectOption('41');
await save().click();
await section()
.getByText('Choose another ticket.', { exact: true })
.waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Link type', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
}
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
await save().click();
await section().locator('[data-form-state="network"]').waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
await page.unrouteAll();
await visit('?serverError');
await remove().click();
await section().locator('[data-form-state="server"]').waitFor();
assert.equal(await section().locator('[data-ticket-link]').count(), 1);
let pending;
await page.route('**/tickets/42/links/3', (route) => {
pending = route;
});
await section()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await section().locator('[data-form-state="loading"]').waitFor();
assert.equal(await remove().isDisabled(), true);
await pending.abort();
await section().locator('[data-form-state="network"]').waitFor();
await page.unrouteAll();
await visit();
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.click();
await page.getByLabel('Parent ticket', { exact: true }).selectOption('');
request = page.waitForRequest(
(r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'),
);
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
assert.equal((await request).postDataJSON().parent_ticket_id, '');
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?readonly&parent&incoming');
assert.equal(await section().locator('form').count(), 0);
assert.equal(
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.count(),
0,
);
assert.equal(
await section().getByText('Blocked by', { exact: true }).isVisible(),
true,
);
for (const [query, message] of [
['?noTypes', 'No link types are configured for this project.'],
[
'?noTargets',
'No other tickets are available to link in this project.',
],
]) {
await visit(query);
assert.equal(
await section().getByText(message, { exact: true }).isVisible(),
true,
);
assert.equal(await save().count(), 0);
}
assert.deepEqual(errors, []);
console.log(
'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,164 @@
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();
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const editor = page.getByRole('region', { name: 'Status moves', exact: true });
const source = () => editor.getByLabel('From status', { exact: true });
const check = (name) =>
editor.getByRole('checkbox', { name: new RegExp(name) });
const button = (name) => editor.getByRole('button', { name, exact: true });
const state = (name) => editor.locator('[data-form-state="' + name + '"]');
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 source().waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const save = async (id, targets) => {
const sent = page.waitForRequest((request) => request.method() === 'PATCH');
await button('Save moves').click();
const request = await sent;
assert.equal(
new URL(request.url()).pathname,
'/projects/1/settings/board/statuses/' + id + '/transitions',
);
assert.deepEqual(request.postDataJSON(), {
allowed_ticket_status_ids: targets,
});
};
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();
assert.equal(await check('Ready').isChecked(), true);
assert.equal(await check('Building').isChecked(), false);
assert.equal(await check('Backlog').count(), 0);
assert.equal(await button('Save moves').isDisabled(), true);
await check('Building').focus();
await page.keyboard.press('Space');
assert.equal(await check('Building').isChecked(), true);
await editor
.getByText('Unsaved changes', { exact: true })
.waitFor();
await noOverflow();
await button('Reset changes').click();
assert.equal(await check('Building').isChecked(), false);
assert.equal(await check('Ready').isChecked(), true);
if ([1440, 390].includes(width)) {
await editor.scrollIntoViewIfNeeded();
await page.screenshot({
path: '/private/tmp/KAN-41-' + width + '-' + theme + '.png',
});
}
}
}
await visit();
await check('Ready').uncheck();
await check('Review').check();
await result('validation');
await save(1, [4]);
await state('validation').waitFor();
await editor
.getByText('Choose a destination from this project.', { exact: true })
.waitFor();
assert.equal(await check('Review').isChecked(), true);
assert.equal(await check('Ready').isChecked(), false);
assert.equal(
await editor.getByRole('group').getAttribute('aria-invalid'),
'true',
);
await result('server');
await save(1, [4]);
await state('server').waitFor();
await result('success');
await button('Try again').click();
await state('success').waitFor();
assert.equal(await button('Save moves').isDisabled(), true);
assert.equal(await check('Review').isChecked(), true);
await page.reload();
await source().waitFor();
assert.equal(await check('Review').isChecked(), true);
assert.equal(await check('Ready').isChecked(), false);
await source().selectOption('4');
assert.equal(await check('Backlog').isChecked(), false);
assert.equal(await check('Review').count(), 0);
await check('Backlog').check();
page.once('dialog', (prompt) => prompt.dismiss());
await source().selectOption('1');
assert.equal(await source().inputValue(), '4');
assert.equal(await check('Backlog').isChecked(), true);
page.once('dialog', (prompt) => prompt.accept());
await source().selectOption('1');
assert.equal(await source().inputValue(), '1');
assert.equal(await check('Review').isChecked(), true);
await check('Review').uncheck();
await editor.getByText(/With no destinations selected/).waitFor();
await save(1, []);
await state('success').waitFor();
await page.reload();
await source().waitFor();
assert.equal(await editor.locator('input:checked').count(), 0);
await editor.getByText('Saved destinations: 0', { exact: true }).waitFor();
// A disconnected request retains the draft and supports a single retry.
await check('Building').check();
await page.route('**/statuses/1/transitions', (route) =>
route.abort('failed'),
);
await save(1, [3]);
await state('network').waitFor();
assert.equal(await check('Building').isChecked(), true);
await page.unroute('**/statuses/1/transitions');
await button('Try again').click();
await state('success').waitFor();
// Pending saves disable inputs, source changes and duplicate submits.
let release;
const blocked = new Promise((resolve) => {
release = resolve;
});
await page.route('**/statuses/1/transitions', async (route) => {
await blocked;
await route.continue();
});
await check('Review').check();
await save(1, [3, 4]);
await state('loading').waitFor();
assert.equal(await source().isDisabled(), true);
assert.equal(await check('Building').isDisabled(), true);
assert.equal(
await editor.locator('button[type=submit]').isDisabled(),
true,
);
release();
await state('success').waitFor();
await page.unroute('**/statuses/1/transitions');
await visit('?reset&empty');
await editor
.getByText('No other statuses exist yet.', { exact: true })
.waitFor();
assert.equal(await editor.getByRole('checkbox').count(), 0);
await noOverflow();
assert.deepEqual(errors, []);
console.log(
'Transition browser checks passed: directed rules, persisted selection, clearing all targets, reset, source-change protection, validation/network/server errors, retry, pending saves, keyboard, responsive widths and both themes.',
);
} finally {
await browser.close();
}