Files
KanbanGit/resources/js/pages/projects/Ticket.vue
T
Koda b5ba09d350
linter / quality (pull_request) Successful in 1m26s
tests / ci (8.4) (pull_request) Successful in 1m35s
Fix acceptance criteria toggle ajax responses
2026-05-20 09:53:21 +00:00

1608 lines
77 KiB
Vue

<script setup lang="ts">
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const page = usePage();
const appName = String(page.props.name);
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 canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectIndex(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: props.ticket.ticket_key,
href: showTicket({
project: props.project.id,
ticket: props.ticket.id,
}),
},
];
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
const currentFieldValue = props.ticket.field_values?.find(
(fieldValue) =>
fieldValue.ticket_field_definition_id ===
ticketFieldDefinition.id,
);
return {
ticket_field_definition_id: ticketFieldDefinition.id,
value_number: currentFieldValue?.value_number ?? '',
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
story_points: props.ticket.story_points,
});
const acceptanceCriteriaPreviewItems = computed(() =>
savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
acceptanceCriteriaChecklist.value !==
formatChecklistLines(savedAcceptanceCriteria.value),
);
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.'),
)?.[1] ??
''
);
});
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{
branch_name: string;
repository_connection_id: string;
}>({
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
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 => {
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:
'Connect a matching Git connection in your user settings first.',
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: 'Branch name is required.',
};
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:
`${appName} could not validate this branch name right now.`,
};
} 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;
}
branchCreationForm.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
return draftItems.map((item, index) => ({
...item,
...(savedAcceptanceCriteria.value[index]?.id !== undefined
? { id: savedAcceptanceCriteria.value[index].id }
: {}),
}));
};
const buildSavedTicketPayload = (acceptanceCriteria = savedAcceptanceCriteria.value) => ({
acceptance_criteria: acceptanceCriteria,
assignee_user_id: props.ticket.assignee_user_id,
author_user_id: props.ticket.author_user_id,
blocked_comment: props.ticket.blocked_comment,
description: props.ticket.description,
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
value_number: fieldValue.value_number,
value_user_id: fieldValue.value_user_id,
})),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id,
story_points: props.ticket.story_points,
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
});
const persistAcceptanceCriteriaToggle = async (
index: number,
checked: boolean,
): Promise<void> => {
if (
!props.canEditTicket ||
isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value
) {
return;
}
const nextAcceptanceCriteria = savedAcceptanceCriteria.value.map((item, itemIndex) =>
itemIndex === index ? { ...item, checked } : item,
);
const previousChecklist = formatChecklistLines(savedAcceptanceCriteria.value);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
isPersistingAcceptanceCriteriaToggle.value = true;
acceptanceCriteriaToggleError.value = '';
try {
const response = await fetch(
ProjectTicketController.update.url({
project: props.project.id,
ticket: props.ticket.id,
}),
{
method: 'PATCH',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken ?? '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(
buildSavedTicketPayload(nextAcceptanceCriteria),
),
},
);
if (!response.ok) {
throw new Error('Checklist toggle update failed.');
}
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) {
acceptanceCriteriaChecklist.value = nextChecklist;
}
} catch (_error) {
acceptanceCriteriaToggleError.value =
'Checklist status could not be saved right now.';
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const onAcceptanceCriteriaCheckboxChange = (
index: number,
event: Event,
): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
void persistAcceptanceCriteriaToggle(index, target.checked);
};
const submit = (): void => {
if (!props.canEditTicket) {
return;
}
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
},
);
};
const formatDateTime = (value: string): string => {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="ticket.ticket_key" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline" class="px-3 py-1">
{{ ticket.ticket_key }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge v-if="ticket.ticket_status" variant="outline">
{{ ticket.ticket_status.name }}
</Badge>
<Badge v-if="!canEditTicket" variant="outline">
Read only
</Badge>
</div>
<Heading
:title="ticket.title"
description="Review ticket details, update tracked fields, and inspect the change timeline."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<div
class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<Card>
<CardHeader>
<CardTitle>Ticket details</CardTitle>
<CardDescription>
{{
canEditTicket
? 'Tracked ticket changes create worklog entries automatically.'
: 'You can review the tracked fields, but editing requires project edit permission.'
}}
</CardDescription>
</CardHeader>
<CardContent>
<form class="grid gap-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
v-model="form.title"
:disabled="
!canEditTicket || form.processing
"
/>
<InputError :message="form.errors.title" />
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
v-model="form.ticket_type_id"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div v-if="canViewAuthor" class="grid gap-2">
<Label for="author_user_id">Author</Label>
<select
id="author_user_id"
v-model="form.author_user_id"
:disabled="
!canEditAuthor || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">No author</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.author_user_id"
/>
</div>
</div>
<div
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
v-model="form.story_points"
type="number"
step="1"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">No parent ticket</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="parentTicketOption.id"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
v-model="form.description"
rows="5"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
/>
<InputError
:message="form.errors.description"
/>
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_checklist">
Acceptance criteria checklist
</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
rows="5"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials"
/>
<p class="text-muted-foreground text-xs">
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
Text changes are saved with the main ticket update.
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-xs"
>
Save checklist text changes before toggling completion directly.
</p>
<InputError
:message="acceptanceCriteriaError"
/>
<InputError
:message="acceptanceCriteriaToggleError"
/>
</div>
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Saved checklist
</p>
<ul class="space-y-2">
<li
v-for="(item, index) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<input
:id="`acceptance-criteria-${item.id ?? index}`"
:checked="item.checked"
type="checkbox"
:disabled="
!canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
@change="onAcceptanceCriteriaCheckboxChange(index, $event)"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
class="flex-1"
>
{{ item.text }}
</label>
</li>
</ul>
</div>
</div>
<div
class="border-border grid gap-3 rounded-xl border p-4"
>
<div class="flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:disabled="
!canEditTicket || form.processing
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<Label for="is_blocked">
Mark ticket as blocked
</Label>
</div>
<div class="grid gap-2">
<Label for="blocked_comment">
Blocker comment
</Label>
<textarea
id="blocked_comment"
v-model="form.blocked_comment"
rows="3"
:disabled="
!canEditTicket || form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="Describe what currently blocks progress on this ticket."
/>
<InputError
:message="form.errors.blocked_comment"
/>
</div>
</div>
<div class="grid gap-4">
<div class="space-y-1">
<h3 class="text-sm font-semibold">
Custom fields
</h3>
<p class="text-muted-foreground text-sm">
Project-configured fields can be tracked
directly on the ticket.
</p>
</div>
<div
v-if="ticketFieldDefinitions.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No custom fields are configured for this
project yet.
</div>
<div v-else class="grid gap-4 md:grid-cols-2">
<div
v-for="(
ticketFieldDefinition, index
) in ticketFieldDefinitions"
:key="ticketFieldDefinition.id"
class="grid gap-2"
>
<Label
:for="`field-${ticketFieldDefinition.id}`"
>
{{ ticketFieldDefinition.label }}
</Label>
<select
v-if="
ticketFieldDefinition.field_type ===
'user'
"
:id="`field-${ticketFieldDefinition.id}`"
v-model="
form.field_values[index]
.value_user_id
"
:disabled="
!canEditTicket ||
form.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<Input
v-else
:id="`field-${ticketFieldDefinition.id}`"
v-model="
form.field_values[index]
.value_number
"
type="number"
step="0.1"
:disabled="
!canEditTicket ||
form.processing
"
/>
<InputError
:message="
form.errors[
`field_values.${index}.value_user_id`
] ??
form.errors[
`field_values.${index}.value_number`
]
"
/>
</div>
</div>
</div>
<div
v-if="canEditTicket"
class="flex items-center gap-3"
>
<Button
type="submit"
:disabled="form.processing"
>
Save changes
</Button>
</div>
</form>
</CardContent>
</Card>
<div class="space-y-6">
<Card class="h-fit">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>Branches</CardTitle>
<CardDescription>
Matching branches are discovered from linked
repositories when the branch name starts
with this ticket key.
</CardDescription>
</div>
<Dialog v-model:open="isCreateBranchDialogOpen">
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />
Create branch
</Button>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>
Create branch from ticket
</DialogTitle>
<DialogDescription>
Pick a linked repository, confirm
the suggested branch name, and
create it directly in Gitea.
</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<div class="grid gap-2">
<Label
for="repository_connection_id"
>
Repository
</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="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 gap-2">
<Label for="branch_name">
Branch name
</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
? 'Checking whether this branch name is valid and still available...'
: (branchNameValidation?.message ??
'Select a linked repository to validate the branch name.')
}}
</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"
>
You can only create branches in
repositories that match one of
your personal Git connections.
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link
:href="editGitConnections()"
>
Open Git connections
</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="
isCreateBranchDialogOpen = false
"
>
Cancel
</Button>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>
Create now
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="visibleRepositoryBranches.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching branches were found across the
linked repositories you can access.
</div>
<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"
>
Connect a matching Git account in
your user settings to inspect
branches for this repository.
</p>
<Button
as-child
variant="outline"
size="sm"
>
<Link :href="editGitConnections()">
Open Git connections
</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>Linked tickets</CardTitle>
<CardDescription>
Track cross-ticket relationships with
project-defined link labels.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
project: project.id,
ticket: ticket.id,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="ticket_link_type_id">
Link type
</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError
:message="errors.ticket_link_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="linked_ticket_id">
Linked ticket
</Label>
<select
id="linked_ticket_id"
name="linked_ticket_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError
:message="errors.linked_ticket_id"
/>
</div>
<Button type="submit" :disabled="processing">
Save link
</Button>
</Form>
<div
v-if="linkedTickets.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No ticket links are configured for this ticket
yet.
</div>
<div v-else class="space-y-3">
<div
v-for="linkedTicket in linkedTickets"
:key="linkedTicket.id"
class="border-border rounded-xl border px-4 py-4"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">
{{
linkedTicket.ticket_link_type
.label
}}
</Badge>
<Link
:href="
showTicket({
project: project.id,
ticket: linkedTicket
.linked_ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{
linkedTicket.linked_ticket
.ticket_key
}}
</Link>
</div>
<p
class="text-muted-foreground mt-2 text-sm"
>
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form(
{
project: project.id,
ticket: ticket.id,
ticketLink: linkedTicket.id,
},
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>
Remove
</Button>
</Form>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Pull requests</CardTitle>
<CardDescription>
Matching pull requests are detected from linked
repositories by checking whether the source
branch starts with this ticket key.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="
visibleRepositoryPullRequests.length === 0
"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching pull requests were found across the
linked repositories you can access.
</div>
<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"
>
Connect a matching Git account in
your user settings to inspect pull
requests for this repository.
</p>
<Button
as-child
variant="outline"
size="sm"
>
<Link :href="editGitConnections()">
Open Git connections
</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"
>
Source branch:
{{ pullRequest.source_branch }}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
<CardDescription>
Parent tickets expose their full child tree
here, including nested parent chains.
</CardDescription>
</CardHeader>
<CardContent>
<div
v-if="childTicketTree.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No child tickets are assigned to this ticket
yet.
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Worklog</CardTitle>
<CardDescription>
Every tracked ticket change is recorded with
actor and timestamp.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="
(ticket.worklog_entries?.length ?? 0) === 0
"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No worklog entries exist for this ticket yet.
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4"
>
<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 ??
'System'
}}
</span>
</div>
<p class="text-foreground mt-3 text-sm">
{{ worklogEntry.summary }}
</p>
<p
class="text-muted-foreground mt-2 text-xs"
>
{{
formatDateTime(
worklogEntry.created_at,
)
}}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AppLayout>
</template>