Compare commits
4
Commits
v0.2.3
..
f61298fda4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 |
@@ -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')
|
<TicketGitPullRequests
|
||||||
}}</CardDescription>
|
:repository-pull-requests="repositoryPullRequests"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
:open="isCreateBranchDialogOpen"
|
|
||||||
@update:open="updateBranchDialogOpen"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
v-if="canCreateBranches"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
class="gap-2"
|
|
||||||
@click="isCreateBranchDialogOpen = true"
|
|
||||||
>
|
|
||||||
<GitBranchPlus class="size-4" />{{
|
|
||||||
t('ticketDetail.createBranch')
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<DialogContent class="sm:max-w-lg">
|
|
||||||
<DialogHeader class="space-y-3">
|
|
||||||
<DialogTitle>{{
|
|
||||||
t('ticketDetail.createBranchTitle')
|
|
||||||
}}</DialogTitle>
|
|
||||||
<DialogDescription>{{
|
|
||||||
t('ticketDetail.createBranchDescription')
|
|
||||||
}}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="grid gap-4"
|
|
||||||
@submit.prevent="submitBranchCreation"
|
|
||||||
>
|
|
||||||
<FormFeedback
|
|
||||||
:state="branchProtection.state.value"
|
|
||||||
@retry="submitBranchCreation"
|
|
||||||
/>
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<Label for="repository_connection_id">{{
|
|
||||||
t('ticketDetail.repository')
|
|
||||||
}}</Label>
|
|
||||||
<select
|
|
||||||
id="repository_connection_id"
|
|
||||||
v-model="
|
|
||||||
branchCreationForm.repository_connection_id
|
|
||||||
"
|
|
||||||
:disabled="
|
|
||||||
branchCreationForm.processing
|
|
||||||
"
|
|
||||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="repositoryConnection in linkedRepositoryConnections"
|
|
||||||
:key="repositoryConnection.id"
|
|
||||||
:value="
|
|
||||||
String(repositoryConnection.id)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
branchCreationForm.errors
|
|
||||||
.repository_connection_id
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<Label for="branch_name">{{
|
|
||||||
t('ticketDetail.branchName')
|
|
||||||
}}</Label>
|
|
||||||
<Input
|
|
||||||
id="branch_name"
|
|
||||||
v-model="branchCreationForm.branch_name"
|
|
||||||
:disabled="
|
|
||||||
branchCreationForm.processing ||
|
|
||||||
!selectedBranchRepositoryHasPersonalAccess
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
branchCreationForm.errors
|
|
||||||
.branch_name
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-xl border px-4 py-3"
|
|
||||||
:class="
|
|
||||||
branchNameValidation?.is_valid
|
|
||||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
|
||||||
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<CheckCircle2
|
|
||||||
v-if="
|
|
||||||
branchNameValidation?.is_valid
|
|
||||||
"
|
|
||||||
class="mt-0.5 size-4 shrink-0"
|
|
||||||
/>
|
|
||||||
<XCircle
|
|
||||||
v-else-if="
|
|
||||||
branchNameValidation !== null
|
|
||||||
"
|
|
||||||
class="mt-0.5 size-4 shrink-0"
|
|
||||||
/>
|
|
||||||
<p class="text-sm">
|
|
||||||
{{
|
|
||||||
isCheckingBranchName
|
|
||||||
? t(
|
|
||||||
'ticketDetail.checkingBranch',
|
|
||||||
)
|
|
||||||
: (branchNameValidation?.message ??
|
|
||||||
t(
|
|
||||||
'ticketDetail.selectRepository',
|
|
||||||
))
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!selectedBranchRepositoryHasPersonalAccess &&
|
|
||||||
selectedBranchRepositoryConnection !==
|
|
||||||
null
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border border-dashed px-4 py-4"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.branchAccess') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="mt-3"
|
|
||||||
>
|
|
||||||
<Link :href="editGitConnections()">{{
|
|
||||||
t('ticketDetail.openGitConnections')
|
|
||||||
}}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter class="gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
@click="updateBranchDialogOpen(false)"
|
|
||||||
>{{ t('common.cancel') }}</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="
|
|
||||||
branchCreationForm.processing ||
|
|
||||||
isCheckingBranchName ||
|
|
||||||
branchNameValidation?.is_valid !==
|
|
||||||
true
|
|
||||||
"
|
|
||||||
>{{
|
|
||||||
t('ticketDetail.createNow')
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<FeedbackState
|
|
||||||
v-if="visibleRepositoryBranches.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('tickets.noBranches')"
|
|
||||||
:description="t('tickets.noBranchesDescription')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="repositoryBranch in visibleRepositoryBranches"
|
|
||||||
:key="repositoryBranch.repository_connection.id"
|
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{
|
|
||||||
repositoryBranch.repository_connection
|
|
||||||
.provider_label
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="font-medium">
|
|
||||||
{{
|
|
||||||
repositoryBranch.repository_connection
|
|
||||||
.repository_full_name
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{
|
|
||||||
repositoryBranch.repository_connection
|
|
||||||
.base_url
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!repositoryBranch.repository_connection
|
|
||||||
.has_personal_access
|
|
||||||
"
|
|
||||||
class="mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.inspectBranches') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button as-child variant="outline" size="sm">
|
|
||||||
<Link :href="editGitConnections()">{{
|
|
||||||
t('ticketDetail.openGitConnections')
|
|
||||||
}}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="repositoryBranch.error"
|
|
||||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
|
||||||
>
|
|
||||||
{{ repositoryBranch.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Badge
|
|
||||||
v-for="branch in repositoryBranch.branches"
|
|
||||||
:key="branch.name"
|
|
||||||
variant="outline"
|
|
||||||
class="px-3 py-1"
|
|
||||||
>
|
|
||||||
{{ branch.name }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="h-fit">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.pullRequestsDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<FeedbackState
|
|
||||||
v-if="visibleRepositoryPullRequests.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('tickets.noPullRequests')"
|
|
||||||
:description="t('tickets.noPullRequestsDescription')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
|
||||||
:key="
|
|
||||||
repositoryPullRequest.repository_connection.id
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{
|
|
||||||
repositoryPullRequest
|
|
||||||
.repository_connection
|
|
||||||
.provider_label
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="font-medium">
|
|
||||||
{{
|
|
||||||
repositoryPullRequest
|
|
||||||
.repository_connection
|
|
||||||
.repository_full_name
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{
|
|
||||||
repositoryPullRequest.repository_connection
|
|
||||||
.base_url
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!repositoryPullRequest.repository_connection
|
|
||||||
.has_personal_access
|
|
||||||
"
|
|
||||||
class="mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.inspectPullRequests') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button as-child variant="outline" size="sm">
|
|
||||||
<Link :href="editGitConnections()">{{
|
|
||||||
t('ticketDetail.openGitConnections')
|
|
||||||
}}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="repositoryPullRequest.error"
|
|
||||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
|
||||||
>
|
|
||||||
{{ repositoryPullRequest.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="mt-3 space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
|
||||||
:key="pullRequest.html_url"
|
|
||||||
class="border-border/70 rounded-xl border px-4 py-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge variant="outline">
|
|
||||||
#{{ pullRequest.number }}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{ pullRequest.status }}
|
|
||||||
</Badge>
|
|
||||||
<a
|
|
||||||
:href="pullRequest.html_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
|
||||||
>
|
|
||||||
{{ pullRequest.title }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground mt-2 text-sm"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('ticketDetail.sourceBranch', {
|
|
||||||
name: pullRequest.source_branch,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</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="flex flex-wrap items-center gap-2">
|
<div
|
||||||
<Badge variant="outline">
|
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
||||||
{{ worklogEntry.entry_type }}
|
>
|
||||||
</Badge>
|
<UserAvatar
|
||||||
<span class="text-foreground text-sm font-medium">
|
v-if="worklogEntry.actor"
|
||||||
{{
|
:user="worklogEntry.actor"
|
||||||
worklogEntry.actor?.name ??
|
size="lg"
|
||||||
t('ticketDetail.system')
|
/>
|
||||||
}}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-foreground mt-3 text-sm">
|
<article
|
||||||
{{ worklogEntry.summary }}
|
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
||||||
</p>
|
>
|
||||||
<p class="text-muted-foreground mt-2 text-xs">
|
<div
|
||||||
{{ formatDateTime(worklogEntry.created_at) }}
|
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div
|
||||||
</div>
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-foreground text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
worklogEntry.actor?.name ??
|
||||||
|
t('ticketDetail.system')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
entryTypeLabels[
|
||||||
|
worklogEntry.entry_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
:datetime="worklogEntry.created_at"
|
||||||
|
class="text-muted-foreground shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatDateTime(worklogEntry.created_at)
|
||||||
|
}}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="text-foreground mt-3 text-sm leading-6">
|
||||||
|
{{ worklogEntry.summary }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
</CardContent>
|
</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>
|
||||||
@@ -456,9 +456,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 +491,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 +560,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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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') &&
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user