264 lines
9.9 KiB
Vue
264 lines
9.9 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
|
import { useSessionStorage } from '@vueuse/core';
|
|
import { computed } from 'vue';
|
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
|
import { index as boardIndex } from '@/routes/projects/board';
|
|
import { index, show } from '@/routes/projects/index';
|
|
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
|
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
|
|
|
const props = defineProps<ProjectTicketCreatePageProps>();
|
|
const { t } = useLocalization();
|
|
|
|
const returnHref =
|
|
props.source === 'board'
|
|
? boardIndex(props.project.id)
|
|
: backlogIndex(props.project.id);
|
|
const returnLabel =
|
|
props.source === 'board'
|
|
? t('ticketForm.backToBoard')
|
|
: t('ticketForm.backToBacklog');
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: t('navigation.projects'),
|
|
href: index(),
|
|
},
|
|
{
|
|
title: props.project.name,
|
|
href: show(props.project.id),
|
|
},
|
|
{
|
|
title:
|
|
props.source === 'board'
|
|
? t('navigation.board')
|
|
: t('navigation.backlog'),
|
|
href: returnHref,
|
|
},
|
|
{
|
|
title: t('ticketForm.create'),
|
|
href: ProjectTicketController.create({
|
|
project: props.project.id,
|
|
}),
|
|
},
|
|
];
|
|
|
|
const hasTicketMetadata = computed(
|
|
() =>
|
|
props.ticketTypes.length > 0 &&
|
|
props.ticketPriorities.length > 0 &&
|
|
props.ticketStatusOptions.length > 0,
|
|
);
|
|
|
|
const draftKey = `projects:${props.project.id}:tickets:create`;
|
|
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
|
|
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
|
|
acceptance_criteria: [] as AcceptanceCriteriaModel,
|
|
assignee_user_id: '',
|
|
description: '',
|
|
parent_ticket_id: '',
|
|
story_points: '',
|
|
ticket_priority_id: String(props.ticketPriorities[0]?.id ?? ''),
|
|
ticket_status_id: String(
|
|
props.defaultTicketStatusId ?? props.ticketStatusOptions[0]?.id ?? '',
|
|
),
|
|
ticket_type_id: String(props.ticketTypes[0]?.id ?? ''),
|
|
title: '',
|
|
});
|
|
|
|
const isDirty = computed(() => form.isDirty);
|
|
const protection = useFormProtection(form);
|
|
const reset = (): void => {
|
|
if (form.processing || !protection.confirmLeave()) {
|
|
return;
|
|
}
|
|
form.reset();
|
|
form.clearErrors();
|
|
protection.clearFeedback();
|
|
};
|
|
|
|
const acceptanceCriteriaError = computed(() => {
|
|
return (
|
|
form.errors.acceptance_criteria ??
|
|
Object.entries(form.errors).find(([key]) =>
|
|
key.startsWith('acceptance_criteria.'),
|
|
)?.[1] ??
|
|
''
|
|
);
|
|
});
|
|
|
|
const submit = (): void => {
|
|
if (!hasTicketMetadata.value || form.processing) {
|
|
return;
|
|
}
|
|
|
|
protection.submit(
|
|
ProjectTicketController.store({ project: props.project.id }),
|
|
{
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
// The source page is unmounted before this callback, so its storage watcher has stopped.
|
|
window.sessionStorage.setItem(
|
|
`${draftKey}:generation`,
|
|
String(draftGeneration.value + 1),
|
|
);
|
|
draftGeneration.value++;
|
|
form.reset();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
|
|
|
<div
|
|
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
|
>
|
|
<div
|
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="space-y-3">
|
|
<Badge variant="outline" class="px-3 py-1">
|
|
{{ project.key }}
|
|
</Badge>
|
|
<h1 class="text-headline font-semibold tracking-tight">
|
|
{{ t('ticketForm.create') }}
|
|
</h1>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{
|
|
t('ticketForm.createDescription', {
|
|
project: project.name,
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-3">
|
|
<Button as-child variant="outline">
|
|
<Link :href="returnHref">{{ returnLabel }}</Link>
|
|
</Button>
|
|
<Button as-child variant="outline">
|
|
<Link :href="show(project.id)">{{
|
|
t('ticketDetail.backToProject')
|
|
}}</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="status"
|
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
|
>
|
|
{{ status }}
|
|
</div>
|
|
|
|
<Card v-if="hasTicketMetadata">
|
|
<CardHeader>
|
|
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
|
<CardDescription>
|
|
{{ t('ticketForm.keyAssigned') }}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<form class="grid 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:status="form.ticket_status_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"
|
|
:ticket-status-options="ticketStatusOptions"
|
|
:users="users"
|
|
:parent-ticket-options="parentTicketOptions"
|
|
:errors="form.errors"
|
|
:disabled="form.processing"
|
|
autofocus
|
|
>
|
|
<template #content>
|
|
<AcceptanceCriteriaChecklist
|
|
v-model="form.acceptance_criteria"
|
|
:disabled="form.processing"
|
|
:error="acceptanceCriteriaError"
|
|
/>
|
|
</template>
|
|
</TicketFormFields>
|
|
<div
|
|
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
|
>
|
|
<Button type="submit" :disabled="form.processing">{{
|
|
form.processing
|
|
? t('ticketForm.creating')
|
|
: t('ticketForm.create')
|
|
}}</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
:disabled="form.processing || !isDirty"
|
|
@click="reset"
|
|
>{{ t('ticketForm.reset') }}</Button
|
|
>
|
|
<p
|
|
role="status"
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
{{
|
|
isDirty
|
|
? t('ticketForm.unsaved')
|
|
: t('ticketForm.ready')
|
|
}}
|
|
</p>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card v-else>
|
|
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
|
<p class="text-muted-foreground">
|
|
{{ t('ticketForm.metadataRequired') }}
|
|
</p>
|
|
<div>
|
|
<Button as-child variant="outline">
|
|
<Link :href="metadataSettings(project.id)">
|
|
{{ t('ticketForm.openSettings') }}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|