feat(KAN-30): rebuild responsive ticket detail view
This commit is contained in:
@@ -0,0 +1,700 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||
import { computed, ref, toRef, unref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import {
|
||||
acceptanceCriteriaToEditorState,
|
||||
buildAcceptanceCriteria,
|
||||
formatChecklistLines,
|
||||
normalizeAcceptanceCriteria,
|
||||
syncAcceptanceCriteriaDescendantCheckedState,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailContentProps>();
|
||||
const { t } = useLocalization();
|
||||
const canEditAuthor = computed(
|
||||
() => props.canEditTicket && props.canManageProject,
|
||||
);
|
||||
const canViewAuthor = computed(
|
||||
() => props.project.show_author || props.canManageProject,
|
||||
);
|
||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
{ checklist: acceptanceCriteriaEditorState.checklist },
|
||||
`${draftKey}:checklist`,
|
||||
);
|
||||
const acceptanceCriteriaChecklist = toRef(
|
||||
unref(acceptanceCriteriaDraft),
|
||||
'checklist',
|
||||
);
|
||||
const savedAcceptanceCriteria = ref(
|
||||
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||
);
|
||||
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||
const acceptanceCriteriaToggleError = ref('');
|
||||
|
||||
const form = useForm(draftKey, {
|
||||
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 protection = useFormProtection(form, {
|
||||
isDirty: () =>
|
||||
props.canEditTicket &&
|
||||
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
||||
});
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('acceptance_criteria.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
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 = syncAcceptanceCriteriaDescendantCheckedState(
|
||||
savedAcceptanceCriteria.value,
|
||||
index,
|
||||
checked,
|
||||
);
|
||||
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 = t(
|
||||
'ticketDetail.checklistSaveFailed',
|
||||
);
|
||||
} finally {
|
||||
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onAcceptanceCriteriaCheckboxChange = async (
|
||||
index: number,
|
||||
event: Event,
|
||||
): Promise<void> => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await persistAcceptanceCriteriaToggle(index, target.checked);
|
||||
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!props.canEditTicket) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
||||
|
||||
protection.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-6">
|
||||
<section
|
||||
id="ticket-description"
|
||||
aria-labelledby="ticket-description-title"
|
||||
class="scroll-mt-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader
|
||||
><h2
|
||||
id="ticket-description-title"
|
||||
class="text-lg font-semibold"
|
||||
>
|
||||
{{ t('ticketDetail.description') }}
|
||||
</h2></CardHeader
|
||||
>
|
||||
<CardContent>
|
||||
<p
|
||||
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
||||
:class="{
|
||||
'text-muted-foreground': !ticket.description,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
ticket.description ||
|
||||
t('ticketDetail.noDescription')
|
||||
}}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<section
|
||||
id="ticket-checklist"
|
||||
aria-labelledby="ticket-checklist-title"
|
||||
class="scroll-mt-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader
|
||||
><h2
|
||||
id="ticket-checklist-title"
|
||||
class="text-lg font-semibold"
|
||||
>
|
||||
{{ t('ticketDetail.checklist') }}
|
||||
</h2></CardHeader
|
||||
>
|
||||
<CardContent class="space-y-3">
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="item.id ?? index"
|
||||
:style="{
|
||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
||||
}"
|
||||
class="flex min-w-0 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-3.5 size-4 shrink-0 rounded"
|
||||
@change="
|
||||
onAcceptanceCriteriaCheckboxChange(
|
||||
index,
|
||||
$event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<label
|
||||
:for="`acceptance-criteria-${item.id ?? index}`"
|
||||
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
|
||||
>
|
||||
{{ item.text }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.noChecklist') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.saveChecklistFirst') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="acceptanceCriteriaToggleError"
|
||||
role="alert"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ acceptanceCriteriaToggleError }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<details
|
||||
v-if="canEditTicket"
|
||||
id="ticket-edit"
|
||||
class="bg-card group rounded-xl border shadow-sm"
|
||||
>
|
||||
<summary
|
||||
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
|
||||
>
|
||||
{{ t('ticketDetail.edit') }}
|
||||
</summary>
|
||||
<div class="border-t p-6">
|
||||
<p class="text-muted-foreground mb-6 text-sm">
|
||||
{{ t('ticketDetail.editDescription') }}
|
||||
</p>
|
||||
<form class="grid gap-6" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="title">{{ t('ticketDetail.title') }}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
/>
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="ticket_type_id">{{
|
||||
t('ticketDetail.type')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_type_id"
|
||||
v-model="form.ticket_type_id"
|
||||
:disabled="!canEditTicket || form.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="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="ticketType.id"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.ticket_type_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="ticket_priority_id">{{
|
||||
t('ticketDetail.priority')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_priority_id"
|
||||
v-model="form.ticket_priority_id"
|
||||
:disabled="!canEditTicket || form.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="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 min-w-0 gap-2">
|
||||
<Label for="assignee_user_id">{{
|
||||
t('ticketDetail.assignee')
|
||||
}}</Label>
|
||||
<select
|
||||
id="assignee_user_id"
|
||||
v-model="form.assignee_user_id"
|
||||
:disabled="!canEditTicket || form.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 value="">
|
||||
{{ t('tickets.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 min-w-0 gap-2">
|
||||
<Label for="author_user_id">{{
|
||||
t('ticketDetail.author')
|
||||
}}</Label>
|
||||
<select
|
||||
id="author_user_id"
|
||||
v-model="form.author_user_id"
|
||||
:disabled="!canEditAuthor || form.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 value="">
|
||||
{{ t('ticketDetail.noAuthor') }}
|
||||
</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 min-w-0 gap-2"
|
||||
v-if="project.show_story_points"
|
||||
>
|
||||
<Label for="story_points">{{
|
||||
t('ticketDetail.storyPoints')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="story_points"
|
||||
name="story_points"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
v-model="form.story_points"
|
||||
type="number"
|
||||
step="1"
|
||||
/>
|
||||
<InputError :message="form.errors.story_points" />
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="parent_ticket_id">{{
|
||||
t('ticketDetail.parent')
|
||||
}}</Label>
|
||||
<select
|
||||
id="parent_ticket_id"
|
||||
v-model="form.parent_ticket_id"
|
||||
:disabled="!canEditTicket || form.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 value="">
|
||||
{{ t('ticketDetail.noParent') }}
|
||||
</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 min-w-0 gap-2">
|
||||
<Label for="description">{{
|
||||
t('ticketDetail.description')
|
||||
}}</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="5"
|
||||
:disabled="!canEditTicket || form.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"
|
||||
/>
|
||||
<InputError :message="form.errors.description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="acceptance_criteria_checklist">{{
|
||||
t('ticketDetail.checklistEditor')
|
||||
}}</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="5"
|
||||
:disabled="!canEditTicket || form.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"
|
||||
:placeholder="
|
||||
t('ticketDetail.checklistPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketDetail.checklistHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{{ t('ticketDetail.saveChecklistFirst') }}
|
||||
</p>
|
||||
<InputError :message="acceptanceCriteriaError" />
|
||||
<InputError
|
||||
:message="acceptanceCriteriaToggleError"
|
||||
/>
|
||||
</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">{{
|
||||
t('ticketDetail.blocked')
|
||||
}}</Label>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="blocked_comment">{{
|
||||
t('ticketDetail.blockerComment')
|
||||
}}</Label>
|
||||
<textarea
|
||||
id="blocked_comment"
|
||||
v-model="form.blocked_comment"
|
||||
rows="3"
|
||||
:disabled="!canEditTicket || form.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"
|
||||
:placeholder="
|
||||
t('ticketDetail.blockerPlaceholder')
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.blocked_comment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('ticketDetail.customFields') }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.customFieldsDescription') }}
|
||||
</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"
|
||||
>
|
||||
{{ t('ticketDetail.noCustomFields') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(
|
||||
ticketFieldDefinition, index
|
||||
) in ticketFieldDefinitions"
|
||||
:key="ticketFieldDefinition.id"
|
||||
class="grid min-w-0 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="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 value="">
|
||||
{{ t('tickets.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">{{
|
||||
t('ticketDetail.saveChanges')
|
||||
}}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,658 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
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 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>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-git"
|
||||
aria-labelledby="ticket-git-title"
|
||||
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||
>
|
||||
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.git') }}
|
||||
</h2>
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<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>{{ t('ticketDetail.branches') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.branchesDescription')
|
||||
}}</CardDescription>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
:open="isCreateBranchDialogOpen"
|
||||
@update:open="updateBranchDialogOpen"
|
||||
>
|
||||
<Button
|
||||
v-if="canCreateBranches"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
@click="isCreateBranchDialogOpen = true"
|
||||
>
|
||||
<GitBranchPlus class="size-4" />{{
|
||||
t('ticketDetail.createBranch')
|
||||
}}</Button
|
||||
>
|
||||
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader class="space-y-3">
|
||||
<DialogTitle>{{
|
||||
t('ticketDetail.createBranchTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('ticketDetail.createBranchDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
class="grid gap-4"
|
||||
@submit.prevent="submitBranchCreation"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="branchProtection.state.value"
|
||||
@retry="submitBranchCreation"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
|
||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { show } from '@/routes/projects';
|
||||
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
|
||||
defineProps<TicketDetailHeaderProps>();
|
||||
const { t } = useLocalization();
|
||||
const sections = [
|
||||
'description',
|
||||
'checklist',
|
||||
'metadata',
|
||||
'relationships',
|
||||
'history',
|
||||
'git',
|
||||
] as const;
|
||||
</script>
|
||||
<template>
|
||||
<header class="flex min-w-0 flex-col gap-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button as-child variant="ghost" class="max-w-full"
|
||||
><Link :href="show(project.id)"
|
||||
><ArrowLeft aria-hidden="true" />{{
|
||||
t('ticketDetail.backToProject')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button as-child variant="outline"
|
||||
><Link :href="backlogIndex(project.id)"
|
||||
><ListTree aria-hidden="true" />{{
|
||||
t('ticketDetail.openBacklog')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
<Button as-child variant="outline"
|
||||
><Link :href="boardIndex(project.id)"
|
||||
><Columns3 aria-hidden="true" />{{
|
||||
t('ticketDetail.openBoard')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-primary font-mono text-sm font-semibold">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><Badge v-if="!canEditTicket" variant="outline">{{
|
||||
t('ticketDetail.readOnly')
|
||||
}}</Badge>
|
||||
</div>
|
||||
<h1
|
||||
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h1>
|
||||
<TicketMetadata :ticket="ticket" />
|
||||
</div>
|
||||
<nav
|
||||
:aria-label="t('ticketDetail.sections')"
|
||||
class="flex flex-wrap gap-1 border-b pb-3"
|
||||
>
|
||||
<a
|
||||
v-for="section in sections"
|
||||
:key="section"
|
||||
:href="`#ticket-${section}`"
|
||||
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
|
||||
>{{ t(`ticketDetail.${section}`) }}</a
|
||||
>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||
defineProps<TicketDetailHistoryProps>();
|
||||
const { t, date } = useLocalization();
|
||||
const formatDateTime = (value: string): string =>
|
||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-history"
|
||||
aria-labelledby="ticket-history-title"
|
||||
class="wrap-anywhere min-w-0 scroll-mt-6"
|
||||
>
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<h2 id="ticket-history-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.history') }}
|
||||
</h2>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.historyDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noHistory') }}
|
||||
</div>
|
||||
|
||||
<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 ??
|
||||
t('ticketDetail.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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { UserAvatar } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailMetadataProps>();
|
||||
const { t, date, number } = useLocalization();
|
||||
const fields = computed(() =>
|
||||
props.ticketFieldDefinitions.map((definition) => {
|
||||
const value = props.ticket.field_values?.find(
|
||||
(field) => field.ticket_field_definition_id === definition.id,
|
||||
);
|
||||
return {
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
value:
|
||||
definition.field_type === 'user'
|
||||
? (value?.user_value?.name ?? t('tickets.unassigned'))
|
||||
: value?.value_number != null
|
||||
? number(value.value_number)
|
||||
: t('ticketDetail.notSet'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
id="ticket-metadata"
|
||||
aria-labelledby="ticket-metadata-title"
|
||||
class="min-w-0 scroll-mt-6"
|
||||
>
|
||||
<Card class="bg-muted/30">
|
||||
<CardHeader
|
||||
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.metadata') }}
|
||||
</h2></CardHeader
|
||||
>
|
||||
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
|
||||
<dl
|
||||
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('ticketDetail.status') }}
|
||||
</dt>
|
||||
<dd>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticket.ticket_status,
|
||||
t('tickets.noStatus'),
|
||||
)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('ticketDetail.type') }}
|
||||
</dt>
|
||||
<dd>{{ ticket.ticket_type.name }}</dd>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('ticketDetail.priority') }}
|
||||
</dt>
|
||||
<dd>
|
||||
<Badge variant="outline">{{
|
||||
ticket.ticket_priority.name
|
||||
}}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('ticketDetail.assignee') }}
|
||||
</dt>
|
||||
<dd class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
v-if="ticket.assignee"
|
||||
:user="ticket.assignee"
|
||||
size="sm"
|
||||
/>{{
|
||||
ticket.assignee?.name ?? t('tickets.unassigned')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.show_author || canManageProject"
|
||||
class="space-y-2"
|
||||
>
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('ticketDetail.author') }}
|
||||
</dt>
|
||||
<dd class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
v-if="ticket.author"
|
||||
:user="ticket.author"
|
||||
size="sm"
|
||||
/>{{
|
||||
ticket.author?.name ??
|
||||
t('ticketDetail.noAuthor')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="project.show_story_points" class="space-y-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('ticketDetail.storyPoints') }}
|
||||
</dt>
|
||||
<dd>
|
||||
{{
|
||||
ticket.story_points != null
|
||||
? number(ticket.story_points)
|
||||
: t('ticketDetail.notSet')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.id"
|
||||
class="space-y-2"
|
||||
>
|
||||
<dt class="text-muted-foreground">{{ field.label }}</dt>
|
||||
<dd>{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div
|
||||
v-if="ticket.is_blocked"
|
||||
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ ticket.blocked_comment }}
|
||||
</p>
|
||||
</div>
|
||||
<dl
|
||||
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<dt>{{ t('ticketDetail.created') }}</dt>
|
||||
<dd>
|
||||
<time :datetime="ticket.created_at">{{
|
||||
date(new Date(ticket.created_at), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}}</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<dt>{{ t('ticketDetail.updated') }}</dt>
|
||||
<dd>
|
||||
<time :datetime="ticket.updated_at">{{
|
||||
date(new Date(ticket.updated_at), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { Form, Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||
const { t } = useLocalization();
|
||||
const currentParentTicket = computed(
|
||||
() =>
|
||||
props.ticket.parent_ticket ??
|
||||
props.parentTicketOptions.find(
|
||||
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
|
||||
) ??
|
||||
null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="ticket-relationships"
|
||||
aria-labelledby="ticket-relationships-title"
|
||||
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
||||
>
|
||||
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.relationships') }}
|
||||
</h2>
|
||||
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.childrenDescription')
|
||||
}}</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"
|
||||
>
|
||||
{{ t('ticketDetail.noChildren') }}
|
||||
</div>
|
||||
|
||||
<TicketTreeList
|
||||
v-else
|
||||
:nodes="childTicketTree"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="currentParentTicket !== null" class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.parentDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: currentParentTicket.id,
|
||||
})
|
||||
"
|
||||
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ currentParentTicket.ticket_key }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-sm">
|
||||
{{ currentParentTicket.title }}
|
||||
</div>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.linkedTicketsDescription')
|
||||
}}</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 min-w-0 gap-2">
|
||||
<Label for="ticket_link_type_id">{{
|
||||
t('ticketDetail.linkType')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_link_type_id"
|
||||
name="ticket_link_type_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]"
|
||||
>
|
||||
<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 min-w-0 gap-2">
|
||||
<Label for="linked_ticket_id">{{
|
||||
t('ticketDetail.linkedTicket')
|
||||
}}</Label>
|
||||
<select
|
||||
id="linked_ticket_id"
|
||||
name="linked_ticket_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]"
|
||||
>
|
||||
<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">{{
|
||||
t('ticketDetail.saveLink')
|
||||
}}</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"
|
||||
>
|
||||
{{ t('ticketDetail.noLinks') }}
|
||||
</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"
|
||||
>{{ t('ticketDetail.removeLink') }}</Button
|
||||
>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -400,4 +400,102 @@ export const en = {
|
||||
one: '{count} ticket',
|
||||
other: '{count} tickets',
|
||||
},
|
||||
'ticketDetail.description': 'Description',
|
||||
'ticketDetail.noDescription': 'No description has been added yet.',
|
||||
'ticketDetail.checklist': 'Acceptance criteria',
|
||||
'ticketDetail.noChecklist': 'No acceptance criteria have been added yet.',
|
||||
'ticketDetail.saveChecklistFirst':
|
||||
'Save checklist text changes before toggling completion directly.',
|
||||
'ticketDetail.edit': 'Edit ticket',
|
||||
'ticketDetail.editDescription':
|
||||
'Tracked ticket changes create worklog entries automatically.',
|
||||
'ticketDetail.git': 'Git',
|
||||
'ticketDetail.relationships': 'Relationships',
|
||||
'ticketDetail.history': 'Worklog',
|
||||
'ticketDetail.metadata': 'Ticket information',
|
||||
'ticketDetail.sections': 'Ticket sections',
|
||||
'ticketDetail.status': 'Status',
|
||||
'ticketDetail.type': 'Type',
|
||||
'ticketDetail.priority': 'Priority',
|
||||
'ticketDetail.assignee': 'Assignee',
|
||||
'ticketDetail.author': 'Author',
|
||||
'ticketDetail.noAuthor': 'No author',
|
||||
'ticketDetail.storyPoints': 'Estimated story points',
|
||||
'ticketDetail.notSet': 'Not set',
|
||||
'ticketDetail.created': 'Created',
|
||||
'ticketDetail.updated': 'Last updated',
|
||||
'ticketDetail.backToProject': 'Back to project',
|
||||
'ticketDetail.openBacklog': 'Open backlog',
|
||||
'ticketDetail.openBoard': 'Open board',
|
||||
'ticketDetail.readOnly': 'Read only',
|
||||
'ticketDetail.title': 'Title',
|
||||
'ticketDetail.parent': 'Parent ticket',
|
||||
'ticketDetail.noParent': 'No parent ticket',
|
||||
'ticketDetail.checklistEditor': 'Acceptance criteria checklist',
|
||||
'ticketDetail.checklistHelp':
|
||||
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps. Text changes are saved with the main ticket update.',
|
||||
'ticketDetail.checklistPlaceholder':
|
||||
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||
'ticketDetail.blockerComment': 'Blocker comment',
|
||||
'ticketDetail.blockerPlaceholder':
|
||||
'Describe what currently blocks progress on this ticket.',
|
||||
'ticketDetail.customFields': 'Custom fields',
|
||||
'ticketDetail.customFieldsDescription':
|
||||
'Project-configured fields can be tracked directly on the ticket.',
|
||||
'ticketDetail.noCustomFields':
|
||||
'No custom fields are configured for this project yet.',
|
||||
'ticketDetail.saveChanges': 'Save changes',
|
||||
'ticketDetail.branches': 'Branches',
|
||||
'ticketDetail.branchesDescription':
|
||||
'Matching branches are discovered from linked repositories when the branch name starts with this ticket key.',
|
||||
'ticketDetail.createBranch': 'Create branch',
|
||||
'ticketDetail.createBranchTitle': 'Create branch from ticket',
|
||||
'ticketDetail.createBranchDescription':
|
||||
'Pick a linked repository, confirm the suggested branch name, and create it directly in Gitea.',
|
||||
'ticketDetail.repository': 'Repository',
|
||||
'ticketDetail.branchName': 'Branch name',
|
||||
'ticketDetail.branchAccess':
|
||||
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||
'ticketDetail.createNow': 'Create now',
|
||||
'ticketDetail.inspectBranches':
|
||||
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||
'ticketDetail.pullRequests': 'Pull requests',
|
||||
'ticketDetail.pullRequestsDescription':
|
||||
'Matching pull requests are detected from linked repositories by checking whether the source branch starts with this ticket key.',
|
||||
'ticketDetail.inspectPullRequests':
|
||||
'Connect a matching Git account in your user settings to inspect pull requests for this repository.',
|
||||
'ticketDetail.checkingBranch':
|
||||
'Checking whether this branch name is valid and still available...',
|
||||
'ticketDetail.selectRepository':
|
||||
'Select a linked repository to validate the branch name.',
|
||||
'ticketDetail.connectGitFirst':
|
||||
'Connect a matching Git connection in your user settings first.',
|
||||
'ticketDetail.branchRequired': 'Branch name is required.',
|
||||
'ticketDetail.branchValidationFailed':
|
||||
'{app} could not validate this branch name right now.',
|
||||
'ticketDetail.checklistSaveFailed':
|
||||
'Checklist status could not be saved right now.',
|
||||
'ticketDetail.children': 'Child tickets',
|
||||
'ticketDetail.childrenDescription':
|
||||
'Parent tickets expose their full child tree here, including nested parent chains.',
|
||||
'ticketDetail.noChildren':
|
||||
'No child tickets are assigned to this ticket yet.',
|
||||
'ticketDetail.parentDescription':
|
||||
'This ticket belongs to the parent shown here.',
|
||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||
'ticketDetail.linkedTicketsDescription':
|
||||
'Track cross-ticket relationships with project-defined link labels.',
|
||||
'ticketDetail.linkType': 'Link type',
|
||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||
'ticketDetail.saveLink': 'Save link',
|
||||
'ticketDetail.noLinks':
|
||||
'No ticket links are configured for this ticket yet.',
|
||||
'ticketDetail.removeLink': 'Remove',
|
||||
'ticketDetail.historyDescription':
|
||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||
'ticketDetail.system': 'System',
|
||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||
} as const;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||
export type TicketDetailHeaderProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canEditTicket'
|
||||
>;
|
||||
export type TicketDetailContentProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'canManageProject'
|
||||
| 'parentTicketOptions'
|
||||
| 'ticketFieldDefinitions'
|
||||
| 'ticketPriorities'
|
||||
| 'ticketTypes'
|
||||
| 'users'
|
||||
>;
|
||||
export type TicketDetailMetadataProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||
>;
|
||||
export type TicketDetailGitProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'repositoryBranches'
|
||||
| 'repositoryPullRequests'
|
||||
| 'suggestedBranchName'
|
||||
>;
|
||||
export type TicketDetailRelationshipsProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'parentTicketOptions'
|
||||
| 'childTicketTree'
|
||||
| 'linkedTickets'
|
||||
| 'ticketLinkTypes'
|
||||
| 'ticketLinkOptions'
|
||||
>;
|
||||
export type TicketDetailHistoryProps = Pick<ProjectTicketPageProps, 'ticket'>;
|
||||
export type TicketBranchFormData = {
|
||||
branch_name: string;
|
||||
repository_connection_id: string;
|
||||
};
|
||||
Reference in New Issue
Block a user