Files
KanbanGit/resources/js/pages/projects/Ticket.vue
T
Twyco ad3597694d
linter / quality (pull_request) Successful in 1m35s
tests / ci (8.4) (pull_request) Successful in 2m27s
Add searchable ticket property dropdowns
2026-07-18 22:10:04 +02:00

1974 lines
93 KiB
Vue

<script setup lang="ts">
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
import {
AlignLeft,
CheckCircle2,
ChevronDown,
CircleAlert,
Clock3,
GitBranch,
GitBranchPlus,
GitPullRequest,
Link2,
ListChecks,
Network,
SlidersHorizontal,
XCircle,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { normalizeAcceptanceCriteria } from '@/lib/acceptance-criteria';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
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 InputError from '@/components/InputError.vue';
import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEditor.vue';
import InlineSaveStatus from '@/components/projects/InlineSaveStatus.vue';
import InlineTextEditor from '@/components/projects/InlineTextEditor.vue';
import PropertySelectDropdown from '@/components/projects/PropertySelectDropdown.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import UserSelectDropdown from '@/components/projects/UserSelectDropdown.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
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 boardIndex } from '@/routes/projects/board';
import { index as projectIndex } 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 {
AcceptanceCriteriaEditorCommit,
AcceptanceCriteriaSaveState,
BreadcrumbItem,
PropertySelectOption,
ProjectTicketPageProps,
ProjectTicketUpdatePayload,
QueuedTicketAutosave,
TicketAutosaveErrorResponse,
TicketAutosaveResponse,
TicketEditableField,
TicketStatusMoveResponse,
} from '@/types';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
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 currentTicketStatus = ref<TicketStatusModel | null>(
props.ticket.ticket_status,
);
const selectedTicketStatusId = ref<number | string>(
props.ticket.ticket_status_id ?? '',
);
const isStatusMoving = ref(false);
const statusMoveError = ref('');
const activeProjectSection = computed(() =>
currentTicketStatus.value === null ||
currentTicketStatus.value.board_column_id === null
? 'backlog'
: 'board',
);
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: boardIndex(props.project.id),
},
{
title: props.ticket.ticket_key,
href: showTicket({
project: props.project.id,
ticket: props.ticket.id,
}),
},
];
const form = useForm<ProjectTicketUpdatePayload>({
acceptance_criteria: normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
),
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 queuedAutosave = ref<QueuedTicketAutosave | null>(null);
const isAutosaveRequestActive = ref(false);
const savingField = ref<TicketEditableField | null>(null);
const autosaveErrors = ref<Partial<Record<TicketEditableField, string>>>({});
const activeSelectField = ref<TicketEditableField | null>(null);
const currentTicketType = computed(
() =>
props.ticketTypes.find(
(ticketType) => ticketType.id === Number(form.ticket_type_id),
) ?? props.ticket.ticket_type,
);
const ticketTypeOptions = computed<PropertySelectOption[]>(() =>
props.ticketTypes.map((ticketType) => ({
label: ticketType.name,
value: ticketType.id,
})),
);
const ticketPriorityOptions = computed<PropertySelectOption[]>(() =>
props.ticketPriorities.map((ticketPriority) => ({
label: ticketPriority.name,
value: ticketPriority.id,
})),
);
const ticketStatusOptions = computed<PropertySelectOption[]>(() => {
const currentStatus = currentTicketStatus.value;
if (currentStatus === null) {
return [];
}
return [currentStatus, ...(currentStatus.allowed_target_statuses ?? [])]
.filter(
(ticketStatus, index, statuses) =>
statuses.findIndex(
(candidateStatus) => candidateStatus.id === ticketStatus.id,
) === index,
)
.map((ticketStatus) => ({
description:
ticketStatus.board_column !== null &&
ticketStatus.board_column !== undefined &&
ticketStatus.board_column.name !== ticketStatus.name
? ticketStatus.board_column.name
: null,
label: ticketStatus.name,
value: ticketStatus.id,
}));
});
const currentParentTicketOption = computed(
() =>
props.parentTicketOptions.find(
(parentTicket) => parentTicket.id === Number(form.parent_ticket_id),
) ?? null,
);
const currentParentTicket = computed(() => currentParentTicketOption.value);
const branchCount = computed(() =>
visibleRepositoryBranches.value.reduce(
(count, repositoryBranch) => count + repositoryBranch.branches.length,
0,
),
);
const pullRequestCount = computed(() =>
visibleRepositoryPullRequests.value.reduce(
(count, repositoryPullRequest) =>
count + repositoryPullRequest.pull_requests.length,
0,
),
);
const relationshipCount = computed(
() =>
props.linkedTickets.length +
props.childTicketTree.length +
(currentParentTicket.value === null ? 0 : 1),
);
const worklogCount = computed(() => props.ticket.worklog_entries?.length ?? 0);
const visibleAcceptanceCriteria = computed(() =>
form.acceptance_criteria.filter(
(criterion) => criterion.text.trim() !== '',
),
);
const completedAcceptanceCriteriaCount = computed(
() =>
visibleAcceptanceCriteria.value.filter((criterion) => criterion.checked)
.length,
);
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 buildTicketPayload = (
acceptanceCriteria = form.acceptance_criteria,
): ProjectTicketUpdatePayload => ({
acceptance_criteria: normalizeAcceptanceCriteria(acceptanceCriteria),
assignee_user_id: form.assignee_user_id,
author_user_id: form.author_user_id,
blocked_comment: form.blocked_comment,
description: form.description,
field_values: form.field_values.map((fieldValue) => ({ ...fieldValue })),
is_blocked: form.is_blocked,
parent_ticket_id: form.parent_ticket_id,
story_points: form.story_points,
ticket_priority_id: form.ticket_priority_id,
ticket_type_id: form.ticket_type_id,
title: form.title,
});
const acceptanceCriteriaSignature = (
acceptanceCriteria: AcceptanceCriteriaModel,
): string =>
JSON.stringify(
normalizeAcceptanceCriteria(acceptanceCriteria).map((item) => ({
checked: item.checked,
indent: item.indent,
text: item.text,
})),
);
const clearAutosaveError = (field: TicketEditableField): void => {
const nextErrors = { ...autosaveErrors.value };
delete nextErrors[field];
autosaveErrors.value = nextErrors;
};
const extractAutosaveError = async (response: Response): Promise<string> => {
try {
const body = (await response.json()) as TicketAutosaveErrorResponse;
const firstValidationError = Object.values(body.errors ?? {})[0]?.[0];
return (
firstValidationError ??
body.message ??
'The ticket change could not be saved.'
);
} catch (_error) {
return 'The ticket change could not be saved.';
}
};
const runAutosaveQueue = async (): Promise<void> => {
if (
isAutosaveRequestActive.value ||
queuedAutosave.value === null ||
!props.canEditTicket
) {
return;
}
const request = queuedAutosave.value;
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
queuedAutosave.value = null;
isAutosaveRequestActive.value = true;
savingField.value = request.field;
clearAutosaveError(request.field);
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(request.payload),
},
);
if (!response.ok) {
throw new Error(await extractAutosaveError(response));
}
const result = (await response.json()) as TicketAutosaveResponse;
if (
request.field === 'acceptance_criteria' &&
form.acceptance_criteria.every(
(criterion) => criterion.text.trim() !== '',
) &&
acceptanceCriteriaSignature(form.acceptance_criteria) ===
acceptanceCriteriaSignature(request.payload.acceptance_criteria)
) {
form.acceptance_criteria = normalizeAcceptanceCriteria(
result.acceptance_criteria,
);
}
} catch (error) {
autosaveErrors.value = {
...autosaveErrors.value,
[request.field]:
error instanceof Error
? error.message
: 'The ticket change could not be saved.',
};
} finally {
isAutosaveRequestActive.value = false;
savingField.value = null;
if (queuedAutosave.value !== null) {
void runAutosaveQueue();
}
}
};
const requestTicketAutosave = (
field: TicketEditableField,
acceptanceCriteria = form.acceptance_criteria,
): void => {
if (!props.canEditTicket) {
return;
}
clearAutosaveError(field);
queuedAutosave.value = {
field,
payload: buildTicketPayload(acceptanceCriteria),
};
void runAutosaveQueue();
};
const saveStateFor = (
field: TicketEditableField,
): AcceptanceCriteriaSaveState => {
if (savingField.value === field) {
return 'saving';
}
if (autosaveErrors.value[field] !== undefined) {
return 'error';
}
return 'idle';
};
const errorFor = (field: TicketEditableField): string =>
autosaveErrors.value[field] ?? '';
const commitTextField = (
field: 'blocked_comment' | 'description' | 'story_points' | 'title',
): void => {
if (field === 'title') {
form.title = form.title.trim();
if (form.title === '') {
autosaveErrors.value = {
...autosaveErrors.value,
title: 'Title is required.',
};
return;
}
}
requestTicketAutosave(field);
};
const commitSelectField = (field: TicketEditableField): void => {
activeSelectField.value = null;
requestTicketAutosave(field);
};
const moveTicketStatus = async (value: number | string): Promise<void> => {
if (!props.canEditTicket || isStatusMoving.value) {
return;
}
const previousStatusId = currentTicketStatus.value?.id ?? '';
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
isStatusMoving.value = true;
statusMoveError.value = '';
try {
const response = await fetch(
ProjectBoardController.move.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({
redirect_to: activeProjectSection.value,
ticket_status_id: Number(value),
}),
},
);
if (!response.ok) {
throw new Error(await extractAutosaveError(response));
}
const result = (await response.json()) as TicketStatusMoveResponse;
currentTicketStatus.value = result.ticket_status;
selectedTicketStatusId.value = result.ticket_status_id ?? '';
} catch (error) {
selectedTicketStatusId.value = previousStatusId;
statusMoveError.value =
error instanceof Error
? error.message
: 'The ticket status could not be changed.';
} finally {
isStatusMoving.value = false;
}
};
const commitAcceptanceCriteria = (
commit: AcceptanceCriteriaEditorCommit,
): void => {
requestTicketAutosave('acceptance_criteria', commit.items);
};
const toggleBlocked = (): void => {
form.is_blocked = !form.is_blocked;
requestTicketAutosave('is_blocked');
};
const formatDateTime = (value: string): string => {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="ticket.ticket_key" />
<div class="app-page max-w-none gap-5">
<header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="min-w-0 flex-1 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
class="rounded px-2.5 py-1 font-semibold"
>
{{ ticket.ticket_key }}
</Badge>
<Badge variant="secondary" class="rounded">
{{ currentTicketType.name }}
</Badge>
<Badge
v-if="currentTicketStatus"
variant="outline"
class="rounded"
>
{{ currentTicketStatus.name }}
</Badge>
<Badge
v-if="form.is_blocked"
variant="destructive"
class="rounded"
>
Blocked
</Badge>
<Badge
v-if="!canEditTicket"
variant="outline"
class="rounded"
>
Read only
</Badge>
</div>
<InlineTextEditor
:value="form.title"
:editable="canEditTicket"
placeholder="Untitled ticket"
variant="title"
:save-state="saveStateFor('title')"
:error="errorFor('title')"
@update:value="form.title = String($event ?? '')"
@commit="commitTextField('title')"
/>
</div>
</header>
<div class="overflow-x-auto">
<ProjectTabs
:active="activeProjectSection"
:can-manage-project="canManageProject"
:project="project"
/>
</div>
<div v-if="status" class="status-success">
{{ status }}
</div>
<div
class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]"
>
<main class="min-w-0 space-y-4">
<section
class="border-outline-variant bg-card overflow-hidden rounded border"
>
<div
class="border-outline-variant bg-surface-container-low flex items-center gap-2 border-b px-4 py-2.5"
>
<AlignLeft class="text-primary size-4" />
<h2
class="text-foreground text-[11px] font-bold uppercase tracking-[0.12em]"
>
Description
</h2>
</div>
<div class="px-4 py-4 sm:px-5 sm:py-5">
<InlineTextEditor
:value="form.description"
:editable="canEditTicket"
multiline
placeholder="Add a description with the relevant context and expected behavior."
:save-state="saveStateFor('description')"
:error="errorFor('description')"
@update:value="
form.description = String($event ?? '')
"
@commit="commitTextField('description')"
/>
</div>
</section>
<section
v-if="form.is_blocked"
class="overflow-hidden rounded border border-amber-300 bg-amber-50/80 dark:border-amber-900/70 dark:bg-amber-950/25"
>
<div
class="flex items-start justify-between gap-4 px-4 py-3 sm:px-5"
>
<div class="flex min-w-0 flex-1 items-start gap-3">
<CircleAlert
class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-300"
/>
<div class="min-w-0 flex-1 space-y-1">
<p
class="text-[11px] font-bold uppercase tracking-[0.12em] text-amber-900 dark:text-amber-100"
>
Blocked
</p>
<InlineTextEditor
:value="form.blocked_comment"
:editable="canEditTicket"
multiline
placeholder="Describe what currently blocks this ticket."
:save-state="
saveStateFor('blocked_comment')
"
:error="errorFor('blocked_comment')"
@update:value="
form.blocked_comment = String(
$event ?? '',
)
"
@commit="
commitTextField('blocked_comment')
"
/>
</div>
</div>
<Button
v-if="canEditTicket"
type="button"
variant="outline"
size="sm"
class="shrink-0"
:disabled="
saveStateFor('is_blocked') === 'saving'
"
@click="toggleBlocked"
>
Unblock
</Button>
</div>
</section>
<section
class="border-outline-variant bg-card overflow-hidden rounded border"
>
<div
class="border-outline-variant bg-surface-container-low flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5"
>
<div class="flex items-center gap-2">
<ListChecks class="text-primary size-4" />
<h2
class="text-foreground text-[11px] font-bold uppercase tracking-[0.12em]"
>
Acceptance criteria
</h2>
</div>
<span class="text-muted-foreground text-xs">
{{ completedAcceptanceCriteriaCount }} /
{{ visibleAcceptanceCriteria.length }} complete
</span>
</div>
<div class="px-4 py-4 sm:px-5 sm:py-5">
<AcceptanceCriteriaEditor
v-model="form.acceptance_criteria"
:editable="canEditTicket"
:save-state="
saveStateFor('acceptance_criteria')
"
:error="errorFor('acceptance_criteria')"
@commit="commitAcceptanceCriteria"
/>
</div>
</section>
</main>
<aside
class="border-outline-variant bg-card overflow-hidden rounded border xl:sticky xl:top-20"
>
<div
class="border-outline-variant bg-surface-container-low flex items-center gap-2 border-b px-4 py-2.5"
>
<SlidersHorizontal class="text-primary size-4" />
<h2
class="text-foreground text-[11px] font-bold uppercase tracking-[0.12em]"
>
Properties
</h2>
</div>
<div class="divide-outline-variant divide-y">
<div
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Status</span
>
<div class="min-w-0">
<PropertySelectDropdown
v-model="selectedTicketStatusId"
:options="ticketStatusOptions"
:editable="canEditTicket"
:disabled="isStatusMoving"
label="Status"
:searchable="true"
@commit="moveTicketStatus"
/>
<InlineSaveStatus
:state="statusMoveError ? 'error' : 'idle'"
:error="statusMoveError"
/>
</div>
</div>
<div
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Assignee</span
>
<div class="min-w-0">
<UserSelectDropdown
v-model="form.assignee_user_id"
:users="users"
:editable="canEditTicket"
label="Assignee"
empty-label="Unassigned"
@commit="
commitSelectField('assignee_user_id')
"
/>
<InlineSaveStatus
:state="saveStateFor('assignee_user_id')"
:error="errorFor('assignee_user_id')"
/>
</div>
</div>
<div
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Type</span
>
<div class="min-w-0">
<PropertySelectDropdown
v-model="form.ticket_type_id"
:options="ticketTypeOptions"
:editable="canEditTicket"
label="Type"
@commit="
commitSelectField('ticket_type_id')
"
/>
<InlineSaveStatus
:state="saveStateFor('ticket_type_id')"
:error="errorFor('ticket_type_id')"
/>
</div>
</div>
<div
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Priority</span
>
<div class="min-w-0">
<PropertySelectDropdown
v-model="form.ticket_priority_id"
:options="ticketPriorityOptions"
:editable="canEditTicket"
label="Priority"
:searchable="true"
@commit="
commitSelectField('ticket_priority_id')
"
/>
<InlineSaveStatus
:state="saveStateFor('ticket_priority_id')"
:error="errorFor('ticket_priority_id')"
/>
</div>
</div>
<div
v-if="project.show_story_points"
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Points</span
>
<InlineTextEditor
:value="form.story_points"
:editable="canEditTicket"
input-type="number"
placeholder="Not estimated"
variant="property"
:save-state="saveStateFor('story_points')"
:error="errorFor('story_points')"
@update:value="form.story_points = $event"
@commit="commitTextField('story_points')"
/>
</div>
<div
v-if="canViewAuthor"
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Author</span
>
<div class="min-w-0">
<UserSelectDropdown
v-model="form.author_user_id"
:users="users"
:editable="canEditAuthor"
label="Author"
empty-label="No author"
@commit="
commitSelectField('author_user_id')
"
/>
<InlineSaveStatus
:state="saveStateFor('author_user_id')"
:error="errorFor('author_user_id')"
/>
</div>
</div>
<div
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Parent</span
>
<div class="min-w-0">
<select
v-if="
activeSelectField === 'parent_ticket_id'
"
v-model="form.parent_ticket_id"
autofocus
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
@change="
commitSelectField('parent_ticket_id')
"
@blur="activeSelectField = null"
>
<option value="">No parent</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="parentTicketOption.id"
>
{{ parentTicketOption.ticket_key }} —
{{ parentTicketOption.title }}
</option>
</select>
<button
v-else-if="canEditTicket"
type="button"
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full truncate rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
@click="
activeSelectField = 'parent_ticket_id'
"
>
{{
currentParentTicketOption?.ticket_key ??
'No parent'
}}
</button>
<p
v-else
class="min-h-7 truncate text-sm font-medium"
>
{{
currentParentTicketOption?.ticket_key ??
'No parent'
}}
</p>
<InlineSaveStatus
:state="saveStateFor('parent_ticket_id')"
:error="errorFor('parent_ticket_id')"
/>
</div>
</div>
<div
v-for="(
ticketFieldDefinition, index
) in ticketFieldDefinitions"
:key="ticketFieldDefinition.id"
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs">{{
ticketFieldDefinition.label
}}</span>
<div
v-if="
ticketFieldDefinition.field_type === 'user'
"
class="min-w-0"
>
<UserSelectDropdown
v-model="
form.field_values[index].value_user_id
"
:users="users"
:editable="canEditTicket"
:label="ticketFieldDefinition.label"
empty-label="Unassigned"
@commit="
commitSelectField(
`field-${ticketFieldDefinition.id}`,
)
"
/>
<InlineSaveStatus
:state="
saveStateFor(
`field-${ticketFieldDefinition.id}`,
)
"
:error="
errorFor(
`field-${ticketFieldDefinition.id}`,
)
"
/>
</div>
<InlineTextEditor
v-else
:value="form.field_values[index].value_number"
:editable="canEditTicket"
input-type="number"
placeholder="Not set"
variant="property"
:save-state="
saveStateFor(
`field-${ticketFieldDefinition.id}`,
)
"
:error="
errorFor(
`field-${ticketFieldDefinition.id}`,
)
"
@update:value="
form.field_values[index].value_number =
$event
"
@commit="
requestTicketAutosave(
`field-${ticketFieldDefinition.id}`,
)
"
/>
</div>
<div
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
>
<span class="text-muted-foreground pt-1 text-xs"
>Blocked</span
>
<div>
<button
v-if="canEditTicket"
type="button"
class="hover:text-primary focus-visible:ring-ring min-h-7 rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
@click="toggleBlocked"
>
{{ form.is_blocked ? 'Yes' : 'No' }}
</button>
<p v-else class="min-h-7 text-sm font-medium">
{{ form.is_blocked ? 'Yes' : 'No' }}
</p>
<InlineSaveStatus
:state="saveStateFor('is_blocked')"
:error="errorFor('is_blocked')"
/>
</div>
</div>
</div>
</aside>
</div>
<div class="space-y-2">
<Collapsible
v-slot="{ open }"
class="border-outline-variant bg-card overflow-hidden rounded border"
>
<CollapsibleTrigger as-child>
<button
type="button"
class="hover:bg-primary/[0.025] focus-visible:ring-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:px-5"
>
<div class="flex min-w-0 items-center gap-3">
<GitBranch
class="text-primary size-4 shrink-0"
/>
<div class="min-w-0">
<h2 class="text-sm font-semibold">
Development
</h2>
<p class="text-muted-foreground text-xs">
{{ branchCount }} branches ·
{{ pullRequestCount }} pull requests
</p>
</div>
</div>
<ChevronDown
class="text-muted-foreground size-4 shrink-0 transition-transform"
:class="open ? 'rotate-180' : ''"
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div
class="border-outline-variant grid gap-4 border-t px-4 py-4 sm:px-5 xl:grid-cols-2"
>
<section
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
>
<div
class="border-outline-variant flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div>
<h3
class="flex items-center gap-2 text-sm font-semibold"
>
<GitBranch class="size-4" />
Branches
</h3>
<p
class="text-muted-foreground mt-1 text-xs leading-5"
>
Branches whose names start with
{{ ticket.ticket_key }}.
</p>
</div>
<Dialog
v-model:open="isCreateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
size="sm"
@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
and confirm the suggested
branch
name.</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 focus-visible:border-ring focus-visible:ring-ring/50 rounded border px-3 py-2 text-sm outline-none focus-visible:ring-[3px] 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 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-outline-variant rounded border border-dashed px-4 py-4"
>
<p
class="text-muted-foreground text-sm"
>
Connect a matching
personal Git account
before creating a
branch.
</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>
</div>
<div class="space-y-3 p-4">
<div
v-if="
visibleRepositoryBranches.length ===
0
"
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
>
No matching branches were found.
</div>
<div
v-for="repositoryBranch in visibleRepositoryBranches"
v-else
:key="
repositoryBranch
.repository_connection.id
"
class="border-outline-variant bg-background rounded border p-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">{{
repositoryBranch
.repository_connection
.provider_label
}}</Badge
><span
class="text-sm font-medium"
>{{
repositoryBranch
.repository_connection
.repository_full_name
}}</span
>
</div>
<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
to inspect branches.
</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 border border-amber-200 bg-amber-50 px-3 py-2 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"
>{{ branch.name }}</Badge
>
</div>
</div>
</div>
</section>
<section
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
>
<div
class="border-outline-variant border-b px-4 py-3"
>
<h3
class="flex items-center gap-2 text-sm font-semibold"
>
<GitPullRequest class="size-4" /> Pull
requests
</h3>
<p
class="text-muted-foreground mt-1 text-xs leading-5"
>
Detected when the source branch starts
with {{ ticket.ticket_key }}.
</p>
</div>
<div class="space-y-3 p-4">
<div
v-if="
visibleRepositoryPullRequests.length ===
0
"
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
>
No matching pull requests were found.
</div>
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
v-else
:key="
repositoryPullRequest
.repository_connection.id
"
class="border-outline-variant bg-background rounded border p-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">{{
repositoryPullRequest
.repository_connection
.provider_label
}}</Badge
><span
class="text-sm font-medium"
>{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}</span
>
</div>
<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
to inspect pull requests.
</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 border border-amber-200 bg-amber-50 px-3 py-2 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-2">
<a
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="border-outline-variant hover:border-primary/50 block rounded border px-3 py-2 transition"
>
<div
class="flex items-center gap-2"
>
<Badge variant="outline"
>#{{
pullRequest.number
}}</Badge
><span
class="text-sm font-medium"
>{{
pullRequest.title
}}</span
>
</div>
<p
class="text-muted-foreground mt-1 text-xs"
>
{{
pullRequest.source_branch
}}
· {{ pullRequest.status }}
</p>
</a>
</div>
</div>
</div>
</section>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible
v-slot="{ open }"
class="border-outline-variant bg-card overflow-hidden rounded border"
>
<CollapsibleTrigger as-child>
<button
type="button"
class="hover:bg-primary/[0.025] focus-visible:ring-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:px-5"
>
<div class="flex min-w-0 items-center gap-3">
<Network class="text-primary size-4 shrink-0" />
<div class="min-w-0">
<h2 class="text-sm font-semibold">
Relationships
</h2>
<p class="text-muted-foreground text-xs">
{{ relationshipCount }} linked or
related tickets
</p>
</div>
</div>
<ChevronDown
class="text-muted-foreground size-4 shrink-0 transition-transform"
:class="open ? 'rotate-180' : ''"
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div
class="border-outline-variant grid gap-4 border-t px-4 py-4 sm:px-5 xl:grid-cols-2"
>
<section
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
>
<div
class="border-outline-variant border-b px-4 py-3"
>
<h3
class="flex items-center gap-2 text-sm font-semibold"
>
<Link2 class="size-4" /> Linked tickets
</h3>
<p
class="text-muted-foreground mt-1 text-xs"
>
Project-defined cross-ticket
relationships.
</p>
</div>
<div class="space-y-3 p-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form(
{
project: project.id,
ticket: ticket.id,
},
)
"
class="border-outline-variant grid gap-3 rounded border border-dashed p-3"
v-slot="{ errors, processing }"
>
<div class="grid gap-1.5">
<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 rounded border px-3 py-2 text-sm"
>
<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-1.5">
<Label for="linked_ticket_id"
>Ticket</Label
><select
id="linked_ticket_id"
name="linked_ticket_id"
class="border-input bg-background rounded border px-3 py-2 text-sm"
>
<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"
size="sm"
:disabled="processing"
>Save link</Button
>
</Form>
<div
v-if="linkedTickets.length === 0"
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
>
No ticket links are configured yet.
</div>
<div
v-for="linkedTicket in linkedTickets"
v-else
:key="linkedTicket.id"
class="border-outline-variant bg-background rounded border p-3"
>
<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="hover:text-primary text-sm font-semibold"
>{{
linkedTicket.linked_ticket
.ticket_key
}}</Link
>
</div>
<p
class="text-muted-foreground mt-1 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-2 flex justify-end"
v-slot="{ processing: deleting }"
><Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>Remove</Button
></Form
>
</div>
</div>
</section>
<section
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
>
<div
class="border-outline-variant border-b px-4 py-3"
>
<h3 class="text-sm font-semibold">
Ticket hierarchy
</h3>
<p
class="text-muted-foreground mt-1 text-xs"
>
Parent and child relationships for this
ticket.
</p>
</div>
<div class="p-4">
<div v-if="ticket.ticket_type?.is_parent">
<div
v-if="childTicketTree.length === 0"
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
>
No child tickets are assigned yet.
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</div>
<Link
v-else-if="currentParentTicket !== null"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-outline-variant hover:border-primary/50 bg-background block rounded border p-3 transition"
><p class="text-sm font-semibold">
{{ currentParentTicket.ticket_key }}
</p>
<p
class="text-muted-foreground mt-1 text-sm"
>
{{ currentParentTicket.title }}
</p></Link
>
<div
v-else
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
>
This ticket has no parent or child
tickets.
</div>
</div>
</section>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible
v-slot="{ open }"
class="border-outline-variant bg-card overflow-hidden rounded border"
>
<CollapsibleTrigger as-child>
<button
type="button"
class="hover:bg-primary/[0.025] focus-visible:ring-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:px-5"
>
<div class="flex min-w-0 items-center gap-3">
<Clock3 class="text-primary size-4 shrink-0" />
<div class="min-w-0">
<h2 class="text-sm font-semibold">
Worklog
</h2>
<p class="text-muted-foreground text-xs">
{{ worklogCount }} recorded changes
</p>
</div>
</div>
<ChevronDown
class="text-muted-foreground size-4 shrink-0 transition-transform"
:class="open ? 'rotate-180' : ''"
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div
class="border-outline-variant border-t px-4 py-4 sm:px-5"
>
<div
v-if="worklogCount === 0"
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
>
No worklog entries exist yet.
</div>
<ol
v-else
class="border-outline-variant relative space-y-0 border-l pl-5"
>
<li
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="relative pb-5 last:pb-0"
>
<span
class="border-outline-variant bg-background absolute -left-[1.45rem] top-1.5 size-2.5 rounded-full border"
/>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
worklogEntry.entry_type
}}</Badge
><span class="text-sm font-medium">{{
worklogEntry.actor?.name ?? 'System'
}}</span
><span
class="text-muted-foreground text-xs"
>{{
formatDateTime(
worklogEntry.created_at,
)
}}</span
>
</div>
<p class="mt-2 text-sm">
{{ worklogEntry.summary }}
</p>
</li>
</ol>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</AppLayout>
</template>