Compare commits

..
7 Commits
Author SHA1 Message Date
Twyco 54261dfec3 feat(KAN-38): rebuild personal Git connection settings 2026-09-15 09:28:00 +02: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
30 changed files with 3397 additions and 1270 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// $exceptions->dontFlash(['api_token']);
})->create(); })->create();
+66 -25
View File
@@ -1,39 +1,80 @@
<script setup lang="ts"> <script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'; import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import { useAppearance } from '@/composables/useAppearance'; import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization(); const { t } = useLocalization();
const tabs = [ const tabs = computed(() => [
{ value: 'light', Icon: Sun, label: 'appearance.light' }, {
{ value: 'dark', Icon: Moon, label: 'appearance.dark' }, value: 'light' as const,
{ value: 'system', Icon: Monitor, label: 'appearance.system' }, Icon: Sun,
] as const; label: t('appearance.light'),
description: t('appearance.lightDescription'),
},
{
value: 'dark' as const,
Icon: Moon,
label: t('appearance.dark'),
description: t('appearance.darkDescription'),
},
{
value: 'system' as const,
Icon: Monitor,
label: t('appearance.system'),
description: t('appearance.systemDescription'),
},
]);
</script> </script>
<template> <template>
<div <fieldset class="min-w-0">
role="group" <legend class="sr-only">{{ t('appearance.label') }}</legend>
:aria-label="t('appearance.label')" <div class="grid min-w-0 gap-3 sm:grid-cols-3">
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1" <label
> v-for="{ value, Icon, label, description } in tabs"
<button
v-for="{ value, Icon, label } in tabs"
:key="value" :key="value"
type="button" class="relative min-w-0"
: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" /> <input
<span>{{ t(label) }}</span> type="radio"
</button> 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> </div>
</fieldset>
</template> </template>
+38 -31
View File
@@ -2,10 +2,10 @@
import { Form } from '@inertiajs/vue3'; import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue'; import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -17,30 +17,41 @@ import {
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput'); const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script> </script>
<template> <template>
<div class="space-y-6"> <Card
<Heading class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
variant="small"
title="Delete account"
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"> <CardHeader class="gap-stack border-b p-5 sm:p-6">
<p class="font-medium">Warning</p> <h2 class="text-title">{{ t('profile.delete.title') }}</h2>
<p class="text-sm"> <p class="text-muted-foreground">
Please proceed with caution, this cannot be undone. {{ 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> </p>
</div> </div>
<Dialog> <Dialog>
<DialogTrigger as-child> <DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button" <Button
>Delete account</Button variant="destructive"
class="justify-self-start"
data-test="delete-user-button"
>{{ t('profile.delete.action') }}</Button
> >
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
@@ -55,28 +66,23 @@ const passwordInput = useTemplateRef('passwordInput');
v-slot="{ errors, processing, reset, clearErrors }" v-slot="{ errors, processing, reset, clearErrors }"
> >
<DialogHeader class="space-y-3"> <DialogHeader class="space-y-3">
<DialogTitle <DialogTitle>{{
>Are you sure you want to delete your t('profile.delete.confirmTitle')
account?</DialogTitle }}</DialogTitle>
>
<DialogDescription> <DialogDescription>
Once your account is deleted, all of its {{ t('profile.delete.confirmDescription') }}
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password" class="sr-only" <Label for="password" class="sr-only">{{
>Password</Label t('profile.delete.password')
> }}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
name="password" name="password"
ref="passwordInput" ref="passwordInput"
placeholder="Password" :placeholder="t('profile.delete.password')"
/> />
<InputError :message="errors.password" /> <InputError :message="errors.password" />
</div> </div>
@@ -92,7 +98,7 @@ const passwordInput = useTemplateRef('passwordInput');
} }
" "
> >
Cancel {{ t('common.cancel') }}
</Button> </Button>
</DialogClose> </DialogClose>
@@ -102,12 +108,13 @@ const passwordInput = useTemplateRef('passwordInput');
:disabled="processing" :disabled="processing"
data-test="confirm-delete-user-button" data-test="confirm-delete-user-button"
> >
Delete account {{ t('profile.delete.confirmAction') }}
</Button> </Button>
</DialogFooter> </DialogFooter>
</Form> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</div> </CardContent>
</Card>
</template> </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>
@@ -3,7 +3,15 @@ import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3'; import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue'; import {
computed,
ref,
toRef,
unref,
useTemplateRef,
nextTick,
watch,
} from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue'; import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
@@ -11,7 +19,6 @@ import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue'; import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue'; import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { import {
normalizeAcceptanceCriteria, normalizeAcceptanceCriteria,
@@ -81,6 +88,16 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData()); 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( const hasUnsavedAcceptanceCriteriaDraft = computed(
() => () =>
JSON.stringify(acceptanceCriteriaItems.value) !== JSON.stringify(acceptanceCriteriaItems.value) !==
@@ -364,44 +381,6 @@ const submit = (): void => {
/> />
</template> </template>
</TicketFormFields> </TicketFormFields>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketForm.blocker') }}
</legend>
<div class="min-h-control flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="is_blocked-error"
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
/>
<Label for="is_blocked">{{
t('ticketDetail.blocked')
}}</Label>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:required="form.is_blocked"
/>
</fieldset>
<fieldset <fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5" class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled=" :disabled="
@@ -1,273 +1,12 @@
<script setup lang="ts"> <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 { useLocalization } from '@/composables/useLocalization';
import { import type { TicketDetailGitProps } from '@/types/ticket-detail';
Card,
CardContent, defineProps<TicketDetailGitProps>();
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Link, useForm, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type {
TicketDetailGitProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailGitProps>();
const { t } = useLocalization(); const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() =>
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
(
[isOpen, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchProtection.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
</script> </script>
<template> <template>
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
<h2 id="ticket-git-title" class="text-lg font-semibold"> <h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }} {{ t('ticketDetail.git') }}
</h2> </h2>
<div class="grid min-w-0 gap-6"> <div class="grid min-w-0 gap-6">
<Card class="h-fit"> <TicketGitBranches
<CardHeader :ticket="ticket"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" :project="project"
> :can-edit-ticket="canEditTicket"
<div class="space-y-1.5"> :repository-branches="repositoryBranches"
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle> :suggested-branch-name="suggestedBranchName"
<CardDescription>{{
t('ticketDetail.branchesDescription')
}}</CardDescription>
</div>
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />{{
t('ticketDetail.createBranch')
}}</Button
>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="branchProtection.state.value"
@retry="submitBranchCreation"
/> />
<div class="grid min-w-0 gap-2"> <TicketGitPullRequests
<Label for="repository_connection_id">{{ :repository-pull-requests="repositoryPullRequests"
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(repositoryConnection.id)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/> />
</div> </div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="branchCreationForm.branch_name"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div class="flex items-start gap-3">
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !== null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t(
'ticketDetail.checkingBranch',
)
: (branchNameValidation?.message ??
t(
'ticketDetail.selectRepository',
))
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="border-border rounded-xl border border-dashed px-4 py-4"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="updateBranchDialogOpen(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>{{
t('ticketDetail.createNow')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryBranches.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="repositoryBranch.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryBranch.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectBranches') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.pullRequestsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryPullRequests.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="
repositoryPullRequest.repository_connection.id
"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryPullRequest.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectPullRequests') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 rounded-xl border px-4 py-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="text-foreground hover:text-primary font-medium transition"
>
{{ pullRequest.title }}
</a>
</div>
<p
class="text-muted-foreground mt-2 text-sm"
>
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</section> </section>
</template> </template>
@@ -15,6 +15,7 @@ const sections = [
'description', 'description',
'checklist', 'checklist',
'metadata', 'metadata',
'blocker',
'relationships', 'relationships',
'history', 'history',
'git', 'git',
@@ -8,11 +8,21 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail'; 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>(); defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization(); const { t, date } = useLocalization();
const formatDateTime = (value: string): string => const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' }); 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> </script>
<template> <template>
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
{{ t('ticketDetail.noHistory') }} {{ t('ticketDetail.noHistory') }}
</div> </div>
<div v-else class="flex flex-col gap-4"> <ol v-else class="relative grid gap-0" role="list">
<div <li
v-for="worklogEntry in ticket.worklog_entries" v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id" :key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4" 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"
> >
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span class="text-foreground text-sm font-medium">
{{ {{
worklogEntry.actor?.name ?? worklogEntry.actor?.name ??
t('ticketDetail.system') t('ticketDetail.system')
}} }}
</span> </span>
<Badge variant="outline">
{{
t(
entryTypeLabels[
worklogEntry.entry_type
],
)
}}
</Badge>
</div> </div>
<p class="text-foreground mt-3 text-sm"> <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 }} {{ worklogEntry.summary }}
</p> </p>
<p class="text-muted-foreground mt-2 text-xs"> </article>
{{ formatDateTime(worklogEntry.created_at) }} </li>
</p> </ol>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
@@ -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>
+53 -30
View File
@@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: t('navigation.profile'), title: t('navigation.profile'),
href: editProfile(), href: editProfile(),
icon: UserRound,
}, },
{ {
title: t('navigation.security'), title: t('navigation.security'),
href: editSecurity(), href: editSecurity(),
icon: ShieldCheck,
}, },
{ {
title: t('navigation.gitConnections'), title: t('navigation.gitConnections'),
href: editGitConnections(), href: editGitConnections(),
icon: GitBranch,
}, },
{ {
title: t('navigation.applications'), title: t('navigation.applications'),
href: editApplications(), href: editApplications(),
icon: KeyRound,
}, },
{ {
title: t('navigation.appearance'), title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
icon: Palette,
}, },
]); ]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<div class="px-4 py-6"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
:title="t('navigation.settings')" >
:description="t('navigation.settingsDescription')" <header class="gap-stack grid min-w-0">
/> <div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground uppercase">
{{ t('settings.eyebrow') }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.settings') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('navigation.settingsDescription') }}
</p>
</header>
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12"> <div
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0"> class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav <nav
class="flex gap-1 overflow-x-auto lg:flex-col" class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.settings')" :aria-label="t('navigation.settings')"
> >
<Button <Link
v-for="item in sidebarNavItems" v-for="item in sidebarNavItems"
:key="toUrl(item.href)" :key="toUrl(item.href)"
variant="ghost"
:class="[
'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
]"
as-child
>
<Link
:href="item.href" :href="item.href"
:aria-current=" :aria-current="
isCurrentOrParentUrl(item.href) isCurrentOrParentUrl(item.href) ? 'page' : undefined
? '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',
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',
]"
> >
<component :is="item.icon" class="h-4 w-4" /> <component
{{ item.title }} :is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link> </Link>
</Button>
</nav> </nav>
</aside> </aside>
<Separator class="lg:hidden" /> <div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<slot /> <slot />
</section> </section>
</div> </div>
+122
View File
@@ -359,6 +359,7 @@ export const en = {
'navigation.appearance': 'Appearance', 'navigation.appearance': 'Appearance',
'navigation.settingsDescription': 'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.', 'Manage your profile, security, and Git integration settings.',
'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket', 'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project', 'navigation.createProject': 'New project',
'navigation.open': 'Open navigation', 'navigation.open': 'Open navigation',
@@ -373,8 +374,58 @@ export const en = {
'navigation.breadcrumbs': 'Breadcrumbs', 'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme', 'appearance.label': 'Color theme',
'appearance.light': 'Light', 'appearance.light': 'Light',
'appearance.lightDescription':
'Use a bright workspace with dark text and clear borders.',
'appearance.dark': 'Dark', 'appearance.dark': 'Dark',
'appearance.darkDescription':
'Reduce glare with a darker workspace and balanced contrast.',
'appearance.system': 'System', 'appearance.system': 'System',
'appearance.systemDescription':
'Follow the light or dark preference set on this device.',
'appearance.title': 'Appearance',
'appearance.description': 'Choose how KanbanGit looks on this device.',
'appearance.persistenceHint':
'Your choice is saved on this device and restored when you open KanbanGit again.',
'profile.title': 'Profile',
'profile.description':
'Keep your personal details and profile picture up to date.',
'profile.information': 'Profile information',
'profile.picture': 'Profile picture',
'profile.pictureDescription':
'Help teammates recognize you throughout the workspace.',
'profile.picturePreview': 'Profile picture preview',
'profile.choosePicture': 'Choose picture',
'profile.changePicture': 'Choose another',
'profile.discardPicture': 'Discard selection',
'profile.removePicture': 'Remove picture',
'profile.removingPicture': 'Removing picture…',
'profile.selectedPicture': 'Selected: {name}',
'profile.pictureHelp':
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
'profile.name': 'Name',
'profile.namePlaceholder': 'Full name',
'profile.email': 'Email address',
'profile.emailPlaceholder': 'Email address',
'profile.emailUnverified': 'Your email address is unverified.',
'profile.resendVerification': 'Resend the verification email',
'profile.verificationSent':
'A new verification link has been sent to your email address.',
'profile.save': 'Save profile',
'profile.saving': 'Saving profile…',
'profile.unsaved': 'You have unsaved profile changes.',
'profile.delete.title': 'Delete account',
'profile.delete.description':
'Permanently delete your account and all of its resources.',
'profile.delete.warning': 'This action cannot be undone',
'profile.delete.warningDescription':
'Your account and associated data will be permanently removed.',
'profile.delete.action': 'Delete account',
'profile.delete.confirmTitle':
'Are you sure you want to delete your account?',
'profile.delete.confirmDescription':
'Enter your password to confirm that you want to permanently delete your account and its data.',
'profile.delete.password': 'Password',
'profile.delete.confirmAction': 'Permanently delete account',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close', 'common.close': 'Close',
'common.retry': 'Try again', 'common.retry': 'Try again',
@@ -456,9 +507,24 @@ export const en = {
'ticketDetail.checklistPlaceholder': 'ticketDetail.checklistPlaceholder':
'[ ] User can sign in\n [x] Validation is shown for invalid credentials', '[ ] User can sign in\n [x] Validation is shown for invalid credentials',
'ticketDetail.blocked': 'Mark ticket as blocked', '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.blockerComment': 'Blocker comment',
'ticketDetail.blockerCommentHelp':
'Explain the concrete dependency or decision preventing progress.',
'ticketDetail.blockerPlaceholder': 'ticketDetail.blockerPlaceholder':
'Describe what currently blocks progress on this ticket.', '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.customFields': 'Custom fields',
'ticketDetail.customFieldsDescription': 'ticketDetail.customFieldsDescription':
'Project-configured fields can be tracked directly on the ticket.', 'Project-configured fields can be tracked directly on the ticket.',
@@ -476,8 +542,20 @@ export const en = {
'ticketDetail.branchName': 'Branch name', 'ticketDetail.branchName': 'Branch name',
'ticketDetail.branchAccess': 'ticketDetail.branchAccess':
'You can only create branches in repositories that match one of your personal Git connections.', '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.openGitConnections': 'Open Git connections',
'ticketDetail.createNow': 'Create now', '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': 'ticketDetail.inspectBranches':
'Connect a matching Git account in your user settings to inspect branches for this repository.', 'Connect a matching Git account in your user settings to inspect branches for this repository.',
'ticketDetail.pullRequests': 'Pull requests', 'ticketDetail.pullRequests': 'Pull requests',
@@ -533,6 +611,11 @@ export const en = {
'Every tracked ticket change is recorded with actor and timestamp.', 'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.', 'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System', '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}', 'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket', 'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket', 'ticketForm.pageTitle': 'Create {project} ticket',
@@ -558,4 +641,43 @@ export const en = {
'ticketForm.saved': 'All changes saved', 'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.', 'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker', 'ticketForm.blocker': 'Blocker',
'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.',
} as const; } as const;
+8 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed, useTemplateRef } from 'vue'; import { computed, useTemplateRef } from 'vue';
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue'; import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue'; import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue'; import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
@@ -64,7 +65,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
/> />
<aside <aside
:aria-label="t('ticketDetail.metadata')" :aria-label="t('ticketDetail.metadata')"
class="min-w-0 xl:col-start-2 xl:row-span-4 xl:row-start-1" class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
> >
<TicketDetailMetadata <TicketDetailMetadata
:ticket="ticket" :ticket="ticket"
@@ -73,6 +74,12 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:ticket-field-definitions="ticketFieldDefinitions" :ticket-field-definitions="ticketFieldDefinitions"
/> />
</aside> </aside>
<TicketDetailBlocker
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
class="xl:col-start-1"
/>
<TicketDetailRelationships <TicketDetailRelationships
:ticket="ticket" :ticket="ticket"
:project="project" :project="project"
+21 -14
View File
@@ -1,35 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import AppearanceTabs from '@/components/AppearanceTabs.vue'; import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance/index'; import { edit } from '@/routes/appearance/index';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
import { computed } from 'vue';
const breadcrumbItems: BreadcrumbItem[] = [ const { t } = useLocalization();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ {
title: 'Appearance settings', title: t('navigation.appearance'),
href: edit(), href: edit(),
}, },
]; ]);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" /> <Head :title="t('appearance.title')" />
<h1 class="sr-only">Appearance settings</h1>
<SettingsLayout> <SettingsLayout>
<div class="space-y-6"> <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<Heading <CardHeader class="gap-stack border-b p-5 sm:p-6">
variant="small" <h2 class="text-title">{{ t('appearance.title') }}</h2>
title="Appearance settings" <p class="text-muted-foreground">
description="Update your account's appearance settings" {{ t('appearance.description') }}
/> </p>
</CardHeader>
<CardContent class="gap-column grid p-5 sm:p-6">
<AppearanceTabs /> <AppearanceTabs />
</div> <p class="text-caption text-muted-foreground">
{{ t('appearance.persistenceHint') }}
</p>
</CardContent>
</Card>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+448 -222
View File
@@ -1,267 +1,493 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController'; import {
import Heading from '@/components/Heading.vue'; GitBranch,
LockKeyhole,
Pencil,
Plus,
Server,
Trash2,
} from 'lucide-vue-next';
import { computed, ref } from 'vue';
import {
destroy,
store,
update,
} from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index'; import { edit } from '@/routes/git-connections/index';
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types'; import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
defineProps<SettingsGitConnectionsPageProps>(); const props = defineProps<SettingsGitConnectionsPageProps>();
const { t } = useLocalization();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.gitConnections'), href: edit() },
]);
const mode = ref<'create' | 'edit' | 'remove'>('create');
const dialogOpen = ref(false);
const selectedConnection = ref<UserGitConnectionModel | null>(null);
// Keep provider credentials out of Inertia's remembered browser history.
const connectionForm = useForm({
provider: 'gitea',
base_url: '',
api_token: '',
});
const connectionProtection = useFormProtection(connectionForm);
const removalForm = useForm({});
const removalProtection = useFormProtection(removalForm);
const processing = computed(
() => connectionForm.processing || removalForm.processing,
);
const dialogTitle = computed(() =>
t(
mode.value === 'create'
? 'gitConnections.add'
: mode.value === 'edit'
? 'gitConnections.edit'
: 'gitConnections.removeTitle',
),
);
const successTitle = computed(() =>
props.status === 'Git connection was added.'
? t('gitConnections.added')
: props.status === 'Git connection was updated.'
? t('gitConnections.updated')
: t('gitConnections.removed'),
);
const page = usePage(); function resetForms(): void {
const appName = String(page.props.name); connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
connectionForm.resetAndClearErrors();
connectionProtection.clearFeedback();
removalForm.resetAndClearErrors();
removalProtection.clearFeedback();
}
const breadcrumbItems: BreadcrumbItem[] = [ function openDialog(
{ nextMode: typeof mode.value,
title: 'Git connections', connection: UserGitConnectionModel | null = null,
href: editGitConnections(), ): void {
}, resetForms();
]; mode.value = nextMode;
selectedConnection.value = connection;
connectionForm.defaults({
provider: connection?.provider ?? 'gitea',
base_url: connection?.base_url ?? '',
api_token: '',
});
connectionForm.reset();
dialogOpen.value = true;
}
function closeDialog(open: boolean): void {
if (open || processing.value || !connectionProtection.confirmLeave()) {
return;
}
dialogOpen.value = false;
resetForms();
}
function finish(): void {
resetForms();
dialogOpen.value = false;
}
function saveConnection(): void {
const target =
mode.value === 'edit' && selectedConnection.value
? update(selectedConnection.value.id)
: store();
connectionProtection.submit(target, {
preserveScroll: true,
onSuccess: finish,
});
}
function removeConnection(): void {
if (!selectedConnection.value) {
return;
}
removalProtection.submit(destroy(selectedConnection.value.id), {
preserveScroll: true,
onSuccess: finish,
});
}
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Git connections" /> <Head :title="t('navigation.gitConnections')" />
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col gap-6"> <Card
<div class="space-y-2"> class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
<Heading >
variant="small" <CardHeader
title="Git connections" class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access." >
/> <div
<p class="text-muted-foreground text-sm"> class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
Each user needs their own application key. Without a >
matching personal connection, {{ appName }} can only <div class="gap-stack grid min-w-0">
show repository names and will link you back to this <h2 class="text-title">
page. {{ t('navigation.gitConnections') }}
</h2>
<p class="text-muted-foreground">
{{ t('gitConnections.description') }}
</p> </p>
</div> </div>
<Button
<div type="button"
v-if="status" class="shrink-0 self-start"
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" @click="openDialog('create')"
> >
{{ status }} <Plus aria-hidden="true" />{{
</div> t('gitConnections.add')
}}
<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> </Button>
</Form> </div>
</CardContent> <div
</Card> class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
>
<Card class="gap-0 overflow-hidden"> <LockKeyhole
<CardHeader class="border-b"> class="text-primary mt-0.5 size-4 shrink-0"
<CardTitle>Saved connections</CardTitle> aria-hidden="true"
<CardDescription> />
Tokens stay hidden after save. Updating a <p>{{ t('gitConnections.privacy') }}</p>
connection requires entering a fresh token. </div>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<CardContent class="space-y-4 p-6"> <FeedbackState
<div v-if="status"
v-if="gitConnections.length === 0" state="success"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm" :title="successTitle"
> />
No Git connections have been added yet. <div class="flex flex-wrap items-center gap-2">
</div> <h3 class="font-semibold">
{{ t('gitConnections.saved') }}
<div </h3>
v-for="gitConnection in gitConnections" <Badge variant="secondary">{{
:key="gitConnection.id" gitConnections.length
class="border-border rounded-xl border p-4"
>
<div
class="mb-4 flex flex-wrap items-start justify-between gap-3"
>
<div class="space-y-1">
<div
class="flex flex-wrap items-center gap-2"
>
<Badge>{{
gitConnection.provider_label
}}</Badge> }}</Badge>
<span class="text-sm font-medium">
{{
gitConnection.account_login
}}
</span>
</div> </div>
<p <FeedbackState
class="text-muted-foreground text-sm" v-if="gitConnections.length === 0"
> state="empty"
{{ :title="t('gitConnections.empty')"
gitConnection.account_name ?? :description="t('gitConnections.emptyDescription')"
'Unnamed remote account'
}}
</p>
<p
class="text-muted-foreground text-xs"
>
{{ gitConnection.base_url }}
</p>
</div>
<p class="text-muted-foreground text-xs">
Remote user ID:
{{ gitConnection.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"
/> />
<ul
<div class="grid gap-2"> v-else
<Label class="gap-column grid min-w-0"
:for="`base-url-${gitConnection.id}`" :aria-label="t('gitConnections.saved')"
> >
Gitea base URL <li
</Label> v-for="connection in gitConnections"
<Input :key="connection.id"
:id="`base-url-${gitConnection.id}`" class="gap-column grid min-w-0 rounded-lg border p-4"
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 <div class="flex min-w-0 items-start gap-3">
</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 <div
class="flex flex-wrap justify-between gap-3" class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
>
<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"
>
{{ connection.account_login }}
</h4>
<Badge variant="secondary">{{
connection.provider_label
}}</Badge>
</div>
<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>
</div>
<div
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
> >
<Button <Button
type="submit" type="button"
variant="outline" variant="outline"
:disabled="processing" :aria-label="
t('gitConnections.editAccount', {
account: connection.account_login,
server: connection.base_url,
})
"
@click="openDialog('edit', connection)"
> >
Save changes <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> </Button>
</div> </div>
</Form> </li>
</ul>
<Form </CardContent>
v-bind=" </Card>
GitConnectionController.destroy.form( <Dialog :open="dialogOpen" @update:open="closeDialog">
gitConnection.id, <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
" "
class="mt-3 flex justify-end" :description="
v-slot="{ processing }" 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 <Button
type="submit" type="submit"
variant="destructive" variant="destructive"
:disabled="processing" :disabled="removalForm.processing"
>{{
t(
removalForm.processing
? 'gitConnections.removing'
: 'gitConnections.removeTitle',
)
}}</Button
> >
Remove connection </DialogFooter>
</Button> </form>
</Form> </DialogContent>
</div> </Dialog>
</CardContent>
</Card>
</div>
</div>
</SettingsLayout> </SettingsLayout>
</AppLayout> </AppLayout>
</template> </template>
+285 -109
View File
@@ -1,209 +1,385 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3'; import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Trash2 } from 'lucide-vue-next'; import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types'; import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import type { UserModel } from '@/types/models/user-model';
defineProps<SettingsProfilePageProps>(); defineProps<SettingsProfilePageProps>();
const breadcrumbItems: BreadcrumbItem[] = [ const { t } = useLocalization();
{
title: 'Profile settings',
href: edit(),
},
];
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); const user = computed(() => page.props.auth.user);
const avatarPreview = ref<string | null>(null); const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{
title: t('navigation.profile'),
href: edit(),
},
]);
const profileForm = useForm('SettingsProfile', {
name: user.value.name,
email: user.value.email,
avatar: null as File | null,
});
const profileProtection = useFormProtection(profileForm);
const avatarDeleteForm = useForm({}); const avatarDeleteForm = useForm({});
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
const avatarPreview = ref<string | null>(null);
const displayedAvatar = computed( const displayedAvatar = computed(
() => avatarPreview.value ?? user.value.avatar, () => avatarPreview.value ?? user.value.avatar,
); );
const displayedUser = computed<UserModel>(() => ({
...user.value,
avatar: displayedAvatar.value,
}));
const canRemoveAvatar = computed( const canRemoveAvatar = computed(
() => user.value.avatar !== null && !user.value.avatar_is_generated, () =>
avatarPreview.value === null &&
user.value.avatar !== null &&
!user.value.avatar_is_generated,
); );
const profileUpdateForm = ProfileController.update.form();
const profileUpdateTarget = {
url: profileUpdateForm.action,
method: profileUpdateForm.method,
};
const clearAvatarPreview = (): void => { function clearAvatarPreview(): void {
if (avatarPreview.value) { if (avatarPreview.value) {
URL.revokeObjectURL(avatarPreview.value); URL.revokeObjectURL(avatarPreview.value);
avatarPreview.value = null; avatarPreview.value = null;
} }
}; }
const handleAvatarChange = (event: Event) => { function clearAvatarSelection(): void {
clearAvatarPreview();
profileForm.avatar = null;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (avatarInput.value) {
avatarInput.value.value = '';
}
}
function handleAvatarChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0] ?? null;
clearAvatarPreview(); clearAvatarPreview();
profileForm.avatar = file;
profileForm.clearErrors('avatar');
profileProtection.clearFeedback();
if (file) { if (file) {
avatarPreview.value = URL.createObjectURL(file); avatarPreview.value = URL.createObjectURL(file);
} }
}; }
const removeAvatar = (): void => { function submitProfile(): void {
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), { profileProtection.submit(profileUpdateTarget, {
forceFormData: true,
preserveScroll: true, preserveScroll: true,
onSuccess: clearAvatarPreview, onSuccess: () => {
profileForm.defaults({
name: profileForm.name,
email: profileForm.email,
avatar: null,
}); });
}; clearAvatarSelection();
},
});
}
onBeforeUnmount(() => { function removeAvatar(): void {
clearAvatarPreview(); avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
preserveScroll: true,
onSuccess: clearAvatarSelection,
}); });
}
onBeforeUnmount(clearAvatarPreview);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" /> <Head :title="t('profile.title')" />
<h1 class="sr-only">Profile settings</h1>
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col space-y-6"> <Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<Heading <CardHeader class="gap-stack border-b p-5 sm:p-6">
variant="small" <h2 class="text-title">{{ t('profile.title') }}</h2>
title="Profile information" <p class="text-muted-foreground">
description="Update your name, email address, and profile picture" {{ t('profile.description') }}
</p>
</CardHeader>
<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"
/> />
<Form <fieldset
v-bind="ProfileController.update.form()" class="gap-column grid min-w-0"
class="space-y-6" :disabled="profileForm.processing"
v-slot="{
errors,
processing,
progress,
recentlySuccessful,
}"
> >
<div class="grid gap-3"> <legend class="sr-only">
<Label for="avatar">Profile picture</Label> {{ t('profile.information') }}
<div class="flex items-center gap-4"> </legend>
<UserAvatar class="size-16" />
<div class="grid flex-1 gap-2"> <section
<div class="flex flex-col gap-2 sm:flex-row"> aria-labelledby="profile-picture-title"
<Input class="gap-column grid min-w-0 border-b pb-6"
id="avatar" >
name="avatar" <div class="gap-stack grid">
type="file" <h3
accept="image/png,image/jpeg,image/webp" id="profile-picture-title"
@change="handleAvatarChange" class="font-semibold"
>
{{ t('profile.picture') }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{ t('profile.pictureDescription') }}
</p>
</div>
<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 <Button
v-if="canRemoveAvatar" v-if="avatarPreview"
type="button" type="button"
variant="outline" variant="ghost"
:disabled="avatarDeleteForm.processing" @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" data-test="delete-profile-picture-button"
@click="removeAvatar" @click="removeAvatar"
> >
<Trash2 class="size-4" /> <Trash2 aria-hidden="true" />
Remove {{ t('profile.removePicture') }}
</Button> </Button>
</div> </div>
<p class="text-muted-foreground text-sm"> <input
PNG, JPG, or WebP up to 2 MB. 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> </p>
</div>
</div>
<progress <progress
v-if="progress" v-if="profileForm.progress"
class="h-1 w-full overflow-hidden rounded-full" class="h-1 w-full overflow-hidden rounded-full"
:value="progress.percentage" :value="
profileForm.progress.percentage
"
max="100" max="100"
> >
{{ progress.percentage }}% {{
profileForm.progress.percentage
}}%
</progress> </progress>
<InputError class="mt-1" :message="errors.avatar" /> <InputError
id="avatar-error"
:message="profileForm.errors.avatar"
/>
<FormFeedback
:state="
avatarDeleteProtection.state
.value
"
:title="
avatarDeleteForm.processing
? t(
'profile.removingPicture',
)
: undefined
"
@retry="removeAvatar"
/>
</div> </div>
</div>
</section>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="name">Name</Label> <Label for="name">{{
t('profile.name')
}}</Label>
<Input <Input
id="name" id="name"
class="mt-1 block w-full" v-model="profileForm.name"
name="name" name="name"
:default-value="user.name"
required required
autocomplete="name" autocomplete="name"
placeholder="Full name" :placeholder="t('profile.namePlaceholder')"
:aria-invalid="!!profileForm.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="profileForm.errors.name"
/> />
<InputError class="mt-2" :message="errors.name" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="email">Email address</Label> <Label for="email">{{
t('profile.email')
}}</Label>
<Input <Input
id="email" id="email"
v-model="profileForm.email"
type="email" type="email"
class="mt-1 block w-full"
name="email" name="email"
:default-value="user.email"
required required
autocomplete="username" autocomplete="username"
placeholder="Email address" :placeholder="t('profile.emailPlaceholder')"
:aria-invalid="!!profileForm.errors.email"
aria-describedby="email-error"
/>
<InputError
id="email-error"
:message="profileForm.errors.email"
/> />
<InputError class="mt-2" :message="errors.email" />
</div> </div>
<div v-if="mustVerifyEmail && !user.email_verified_at"> <div
<p class="text-muted-foreground -mt-4 text-sm"> v-if="
Your email address is unverified. 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 <Link
:href="send()" :href="send()"
as="button" 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" class="text-primary font-medium underline underline-offset-4"
> >
Click here to resend the verification email. {{ t('profile.resendVerification') }}
</Link> </Link>
</p> </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 <p
v-show="recentlySuccessful" v-if="status === 'verification-link-sent'"
class="text-sm text-neutral-600" role="status"
class="text-success-foreground mt-2 text-sm font-medium"
> >
Saved. {{ t('profile.verificationSent') }}
</p> </p>
</Transition>
</div> </div>
</Form>
<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> </div>
</fieldset>
</form>
</CardContent>
</Card>
<DeleteUser /> <DeleteUser />
</SettingsLayout> </SettingsLayout>
+23
View File
@@ -1,4 +1,5 @@
import type { ProjectTicketPageProps } from '@/types/page-props'; import type { ProjectTicketPageProps } from '@/types/page-props';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
export type TicketDetailHeaderProps = Pick< export type TicketDetailHeaderProps = Pick<
ProjectTicketPageProps, ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket' 'ticket' | 'project' | 'canEditTicket'
@@ -15,6 +16,10 @@ export type TicketDetailContentProps = Pick<
| 'ticketTypes' | 'ticketTypes'
| 'users' | 'users'
>; >;
export type TicketDetailBlockerProps = Pick<
ProjectTicketPageProps,
'ticket' | 'project' | 'canEditTicket'
>;
export type TicketDetailMetadataProps = Pick< export type TicketDetailMetadataProps = Pick<
ProjectTicketPageProps, ProjectTicketPageProps,
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions' 'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
@@ -28,6 +33,24 @@ export type TicketDetailGitProps = Pick<
| 'repositoryPullRequests' | 'repositoryPullRequests'
| 'suggestedBranchName' | '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< export type TicketDetailRelationshipsProps = Pick<
ProjectTicketPageProps, ProjectTicketPageProps,
| 'ticket' | 'ticket'
+61 -1
View File
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
'base_url' => 'https://git.example.test', 'base_url' => 'https://git.example.test',
]); ]);
UserGitConnection::factory()->create();
$this->actingAs($user) $this->actingAs($user)
->get(route('git-connections.edit')) ->get(route('git-connections.edit'))
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections') ->component('settings/GitConnections')
->has('gitConnections', 1)
->missing('gitConnections.0.api_token')
->where('gitConnections.0.account_login', 'nico') ->where('gitConnections.0.account_login', 'nico')
->where('gitConnections.0.base_url', 'https://git.example.test') ->where('gitConnections.0.base_url', 'https://git.example.test')
->where('gitConnections.0.provider', GitProviderKey::Gitea->value), ->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
'api_token' => 'wrong-token', 'api_token' => 'wrong-token',
]) ])
->assertRedirect(route('git-connections.edit')) ->assertRedirect(route('git-connections.edit'))
->assertSessionHasErrors('api_token'); ->assertSessionHasErrors('api_token')
->assertSessionMissing('_old_input.api_token');
} }
/** /**
@@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase
'id' => $gitConnection->id, 'id' => $gitConnection->id,
]); ]);
} }
public function test_empty_personal_connections_are_displayed(): void
{
UserGitConnection::factory()->create();
$this->actingAs(User::factory()->create())
->get(route('git-connections.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/GitConnections')
->where('gitConnections', []),
);
}
public function test_guests_cannot_access_git_connections(): void
{
$this->get(route('git-connections.edit'))->assertRedirect(route('login'));
}
public function test_users_cannot_delete_foreign_connections(): void
{
$connection = UserGitConnection::factory()->create();
$this->actingAs(User::factory()->create())
->delete(route('git-connections.destroy', $connection))
->assertForbidden();
$this->assertModelExists($connection);
}
public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void
{
Http::preventStrayRequests();
Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]);
$user = User::factory()->create();
$connection = UserGitConnection::factory()->for($user)->create([
'base_url' => 'https://git.original.test',
'api_token' => 'original-secret',
]);
$original = $connection->fresh()->getAttributes();
$this->actingAs($user)
->from(route('git-connections.edit'))
->patch(route('git-connections.update', $connection), [
'provider' => 'gitea',
'base_url' => 'https://git.changed.test',
'api_token' => 'replacement-secret',
])
->assertRedirect(route('git-connections.edit'))
->assertSessionHasErrors('api_token')
->assertSessionMissing('_old_input.api_token');
$this->assertSame($original, $connection->fresh()->getAttributes());
$this->assertSame('original-secret', $connection->fresh()->api_token);
}
} }
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Settings;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class GitConnectionsUiTest extends TestCase
{
public function test_personal_git_connections_rendering_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/git-connections.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput());
}
}
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;
use Tests\TestCase; use Tests\TestCase;
class ProfileUpdateTest extends TestCase class ProfileUpdateTest extends TestCase
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
$response->assertSessionHasErrors('avatar'); $response->assertSessionHasErrors('avatar');
} }
public function test_profile_and_appearance_settings_render_the_shared_localized_interface(): void
{
$process = new Process(['node', 'tests/Fixtures/settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Settings rendering and localization checks passed.', $process->getOutput());
}
public function test_user_can_delete_their_account() public function test_user_can_delete_their_account()
{ {
Storage::fake('public'); Storage::fake('public');
+5 -9
View File
@@ -84,15 +84,11 @@ try {
renderTheme('light'), renderTheme('light'),
renderTheme('dark'), renderTheme('dark'),
]); ]);
const selectedButton = (html) => assert.match(light, /value="light"[^>]*checked/);
html.match( assert.match(dark, /value="dark"[^>]*checked/);
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/, assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
)[1]; assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
assert.match(selectedButton(light), />Light</); assert.equal((dark.match(/ checked/g) ?? []).length, 1);
assert.match(selectedButton(dark), />Dark</);
assert.match(light, /role="group" aria-label="Color theme"/);
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
assert.equal(normalizeAppearance('unexpected'), 'system'); assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8'); const blade = await readFile('resources/views/app.blade.php', 'utf8');
+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();
}
}
+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();
}
}
+139
View File
@@ -101,6 +101,102 @@ try {
} }
await page.setViewportSize({ width: 390, height: 1000 }); await page.setViewportSize({ width: 390, height: 1000 });
await visit(); 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 tabTo(edit());
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft'); await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
@@ -246,6 +342,49 @@ try {
.waitFor(); .waitFor();
await page.getByRole('button', { name: 'Create now', exact: true }).click(); await page.getByRole('button', { name: 'Create now', exact: true }).click();
await dialog.waitFor({ state: 'hidden' }); 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( const linkSaved = page.waitForResponse(
(response) => (response) =>
response.url().includes('/tickets/42/links') && response.url().includes('/tickets/42/links') &&
+46 -8
View File
@@ -42,6 +42,7 @@ const repository = {
provider_label: 'Gitea', provider_label: 'Gitea',
base_url: 'https://git.example.test', base_url: 'https://git.example.test',
repository_full_name: 'Engineering/platform', repository_full_name: 'Engineering/platform',
repository_url: 'https://git.example.test/Engineering/platform',
has_personal_access: true, has_personal_access: true,
}; };
const parent = { const parent = {
@@ -108,9 +109,18 @@ const ticket = {
}, },
], ],
worklog_entries: [ 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, id: 1,
actor: user, actor: user,
actor_id: user.id,
entry_type: 'created', entry_type: 'created',
summary: 'Ticket created in the project backlog.', summary: 'Ticket created in the project backlog.',
created_at: '2026-09-11T12:00:00Z', created_at: '2026-09-11T12:00:00Z',
@@ -345,6 +355,9 @@ if (serve) {
const data = body ? JSON.parse(body) : {}; const data = body ? JSON.parse(body) : {};
const previous = new URL(request.headers.referer); const previous = new URL(request.headers.referer);
const params = previous.searchParams; const params = previous.searchParams;
if (request.url.includes('/branches') && params.has('slowBranch')) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (params.has('serverError')) { if (params.has('serverError')) {
response.writeHead(503); response.writeHead(503);
response.end('Simulated failure'); response.end('Simulated failure');
@@ -395,7 +408,19 @@ if (serve) {
if (params.has('reject')) { if (params.has('reject')) {
params.set('rejected', '1'); params.set('rejected', '1');
} else { } else {
savedForm = data; 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); params.set('saved', data.title);
} }
} }
@@ -476,6 +501,9 @@ if (serve) {
'feature/ENG-42-cache', 'feature/ENG-42-cache',
'Cache implementation', 'Cache implementation',
'Ticket created in the project backlog.', 'Ticket created in the project backlog.',
'Ticket created',
'Status changed',
'System',
]) { ]) {
assert.ok( assert.ok(
html.includes(text), html.includes(text),
@@ -486,6 +514,7 @@ if (serve) {
'description', 'description',
'checklist', 'checklist',
'metadata', 'metadata',
'blocker',
'relationships', 'relationships',
'history', 'history',
'git', 'git',
@@ -525,6 +554,14 @@ if (serve) {
assert.match(html, /aria-label="Criterion 1"/); assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/); assert.match(html, /Add criterion/);
assert.match(html, /Create branch/); 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, /Save link/);
assert.match(html, /Outgoing/); assert.match(html, /Outgoing/);
const incoming = await render('?incoming'); const incoming = await render('?incoming');
@@ -554,8 +591,7 @@ if (serve) {
'Unassigned', 'Unassigned',
'No author', 'No author',
'Not set', 'Not set',
'No matching branches', 'No linked repositories',
'No matching pull requests',
'No worklog entries exist', 'No worklog entries exist',
]) { ]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`); assert.ok(empty.includes(text), `Missing empty state: ${text}`);
@@ -565,11 +601,13 @@ if (serve) {
assert.match(hierarchy, /Platform delivery/); assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/); assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/); assert.match(hierarchy, /Change parent/);
assert.match(await render('?noAccess'), /Open Git connections/); const noAccess = await render('?noAccess');
assert.match( assert.match(noAccess, /Personal Git access required/);
await render('?gitError'), assert.match(noAccess, /Open Git connections/);
/Repository temporarily unavailable/, 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); const create = await render('?source=board', true);
assert.match(create, /Create ticket/); assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/); assert.match(create, /Delivery \/ In progress/);