519 lines
21 KiB
Vue
519 lines
21 KiB
Vue
<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, useTemplateRef, nextTick } from 'vue';
|
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
|
import InputError from '@/components/InputError.vue';
|
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import {
|
|
normalizeAcceptanceCriteria,
|
|
syncAcceptanceCriteriaDescendantCheckedState,
|
|
} from '@/lib/acceptance-criteria';
|
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
|
const props = defineProps<TicketDetailContentProps>();
|
|
const { t } = useLocalization();
|
|
const editor = useTemplateRef<HTMLDetailsElement>('editor');
|
|
async function editParent(): Promise<void> {
|
|
if (!props.canEditTicket || !editor.value) {
|
|
return;
|
|
}
|
|
editor.value.open = true;
|
|
await nextTick();
|
|
editor.value
|
|
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
|
|
?.focus();
|
|
}
|
|
defineExpose({ editParent });
|
|
const canEditAuthor = computed(
|
|
() => props.canEditTicket && props.canManageProject,
|
|
);
|
|
const canViewAuthor = computed(
|
|
() => props.project.show_author || props.canManageProject,
|
|
);
|
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
|
const acceptanceCriteriaDraft = useRemember(
|
|
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
|
|
`${draftKey}:criteria`,
|
|
);
|
|
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
|
|
const savedAcceptanceCriteria = ref(
|
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
|
);
|
|
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
|
const acceptanceCriteriaToggleError = ref('');
|
|
|
|
const savedFormData = () => ({
|
|
acceptance_criteria: normalizeAcceptanceCriteria(
|
|
savedAcceptanceCriteria.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 form = useForm(draftKey, savedFormData());
|
|
|
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
|
() =>
|
|
JSON.stringify(acceptanceCriteriaItems.value) !==
|
|
JSON.stringify(savedAcceptanceCriteria.value),
|
|
);
|
|
|
|
const isDirty = computed(
|
|
() =>
|
|
props.canEditTicket &&
|
|
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
|
);
|
|
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
|
const reset = (): void => {
|
|
if (
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle.value ||
|
|
!protection.confirmLeave()
|
|
) {
|
|
return;
|
|
}
|
|
form.reset();
|
|
form.clearErrors();
|
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
|
savedAcceptanceCriteria.value,
|
|
);
|
|
acceptanceCriteriaToggleError.value = '';
|
|
protection.clearFeedback();
|
|
};
|
|
|
|
const acceptanceCriteriaError = computed(() => {
|
|
return (
|
|
form.errors.acceptance_criteria ??
|
|
Object.entries(form.errors).find(([key]) =>
|
|
key.startsWith('acceptance_criteria.'),
|
|
)?.[1] ??
|
|
''
|
|
);
|
|
});
|
|
|
|
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 ||
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle.value ||
|
|
hasUnsavedAcceptanceCriteriaDraft.value
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
|
|
savedAcceptanceCriteria.value,
|
|
index,
|
|
checked,
|
|
);
|
|
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;
|
|
|
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
|
nextAcceptanceCriteria,
|
|
);
|
|
form.acceptance_criteria = nextAcceptanceCriteria;
|
|
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
|
} catch (_error) {
|
|
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
|
} finally {
|
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
|
}
|
|
};
|
|
|
|
const submit = (): void => {
|
|
if (
|
|
!props.canEditTicket ||
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle.value
|
|
) {
|
|
return;
|
|
}
|
|
|
|
form.acceptance_criteria = acceptanceCriteriaItems.value;
|
|
|
|
protection.submit(
|
|
ProjectTicketController.update({
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
}),
|
|
{
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
|
props.ticket.acceptance_criteria,
|
|
);
|
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
|
savedAcceptanceCriteria.value,
|
|
);
|
|
Object.assign(form, savedFormData());
|
|
form.defaults();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
</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>
|
|
<div id="ticket-checklist" class="scroll-mt-6">
|
|
<AcceptanceCriteriaChecklist
|
|
:model-value="savedAcceptanceCriteria"
|
|
:readonly="!canEditTicket"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle ||
|
|
hasUnsavedAcceptanceCriteriaDraft
|
|
"
|
|
:saving="
|
|
form.processing || isPersistingAcceptanceCriteriaToggle
|
|
"
|
|
:allow-editing="false"
|
|
persist-toggles
|
|
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
|
:error="acceptanceCriteriaToggleError"
|
|
@toggle="persistAcceptanceCriteriaToggle"
|
|
/>
|
|
</div>
|
|
<details
|
|
v-if="canEditTicket"
|
|
id="ticket-edit"
|
|
ref="editor"
|
|
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-4 sm:p-6">
|
|
<p class="text-muted-foreground mb-6 text-sm">
|
|
{{ t('ticketDetail.editDescription') }}
|
|
</p>
|
|
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
|
|
<FormFeedback
|
|
:state="protection.state.value"
|
|
@retry="submit"
|
|
/>
|
|
<TicketFormFields
|
|
v-model:title="form.title"
|
|
v-model:description="form.description"
|
|
v-model:type="form.ticket_type_id"
|
|
v-model:priority="form.ticket_priority_id"
|
|
v-model:assignee="form.assignee_user_id"
|
|
v-model:parent="form.parent_ticket_id"
|
|
v-model:points="form.story_points"
|
|
:project="project"
|
|
:ticket-types="ticketTypes"
|
|
:ticket-priorities="ticketPriorities"
|
|
:users="users"
|
|
:parent-ticket-options="parentTicketOptions"
|
|
:errors="form.errors"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle
|
|
"
|
|
>
|
|
<template #content>
|
|
<AcceptanceCriteriaChecklist
|
|
v-model="acceptanceCriteriaItems"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle
|
|
"
|
|
:error="acceptanceCriteriaError"
|
|
/>
|
|
</template>
|
|
<template #metadata>
|
|
<TicketFormField
|
|
v-if="canViewAuthor"
|
|
id="author_user_id"
|
|
v-model="form.author_user_id"
|
|
kind="select"
|
|
:label="t('ticketDetail.author')"
|
|
:hint="
|
|
!canEditAuthor
|
|
? t('ticketForm.authorRestricted')
|
|
: undefined
|
|
"
|
|
:options="[
|
|
{
|
|
value: '',
|
|
label: t('ticketDetail.noAuthor'),
|
|
},
|
|
...users.map((user) => ({
|
|
value: user.id,
|
|
label: user.name,
|
|
})),
|
|
]"
|
|
:error="form.errors.author_user_id"
|
|
:disabled="!canEditAuthor || form.processing"
|
|
/>
|
|
</template>
|
|
</TicketFormFields>
|
|
<fieldset
|
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle
|
|
"
|
|
>
|
|
<legend class="px-2 text-sm font-semibold">
|
|
{{ t('ticketForm.blocker') }}
|
|
</legend>
|
|
<div class="min-h-control flex items-center gap-3">
|
|
<input
|
|
id="is_blocked"
|
|
v-model="form.is_blocked"
|
|
type="checkbox"
|
|
:aria-invalid="Boolean(form.errors.is_blocked)"
|
|
aria-describedby="is_blocked-error"
|
|
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
|
|
/>
|
|
<Label for="is_blocked">{{
|
|
t('ticketDetail.blocked')
|
|
}}</Label>
|
|
</div>
|
|
<InputError
|
|
id="is_blocked-error"
|
|
:message="form.errors.is_blocked"
|
|
/>
|
|
<TicketFormField
|
|
id="blocked_comment"
|
|
v-model="form.blocked_comment"
|
|
kind="textarea"
|
|
:rows="3"
|
|
:label="t('ticketDetail.blockerComment')"
|
|
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
|
:error="form.errors.blocked_comment"
|
|
:required="form.is_blocked"
|
|
/>
|
|
</fieldset>
|
|
<fieldset
|
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle
|
|
"
|
|
>
|
|
<legend class="px-2 text-sm font-semibold">
|
|
{{ t('ticketDetail.customFields') }}
|
|
</legend>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{ t('ticketDetail.customFieldsDescription') }}
|
|
</p>
|
|
<InputError :message="form.errors.field_values" />
|
|
<p
|
|
v-if="!ticketFieldDefinitions.length"
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
{{ t('ticketDetail.noCustomFields') }}
|
|
</p>
|
|
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
|
|
<template
|
|
v-for="(
|
|
definition, index
|
|
) in ticketFieldDefinitions"
|
|
:key="definition.id"
|
|
>
|
|
<TicketFormField
|
|
v-if="definition.field_type === 'user'"
|
|
:id="`field-${definition.id}`"
|
|
v-model="
|
|
form.field_values[index].value_user_id
|
|
"
|
|
kind="select"
|
|
:label="definition.label"
|
|
:options="[
|
|
{
|
|
value: '',
|
|
label: t('tickets.unassigned'),
|
|
},
|
|
...users.map((user) => ({
|
|
value: user.id,
|
|
label: user.name,
|
|
})),
|
|
]"
|
|
:error="
|
|
form.errors[
|
|
`field_values.${index}.value_user_id`
|
|
] ??
|
|
form.errors[
|
|
`field_values.${index}.ticket_field_definition_id`
|
|
]
|
|
"
|
|
/>
|
|
<TicketFormField
|
|
v-else
|
|
:id="`field-${definition.id}`"
|
|
v-model="
|
|
form.field_values[index].value_number
|
|
"
|
|
kind="number"
|
|
step="0.1"
|
|
:label="definition.label"
|
|
:error="
|
|
form.errors[
|
|
`field_values.${index}.value_number`
|
|
] ??
|
|
form.errors[
|
|
`field_values.${index}.ticket_field_definition_id`
|
|
]
|
|
"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</fieldset>
|
|
<div
|
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
|
>
|
|
<Button
|
|
type="submit"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle
|
|
"
|
|
>{{
|
|
form.processing
|
|
? t('ticketForm.saving')
|
|
: t('ticketDetail.saveChanges')
|
|
}}</Button
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
:disabled="
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle ||
|
|
!isDirty
|
|
"
|
|
@click="reset"
|
|
>{{ t('ticketForm.reset') }}</Button
|
|
>
|
|
<p role="status" class="text-muted-foreground text-sm">
|
|
{{
|
|
isDirty
|
|
? t('ticketForm.unsaved')
|
|
: t('ticketForm.saved')
|
|
}}
|
|
</p>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</template>
|