feat: KAN-30
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
||||
import { computed, toRef, unref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
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';
|
||||
@@ -15,8 +16,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildAcceptanceCriteria,
|
||||
@@ -27,20 +26,22 @@ 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';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
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' ? 'Back to board' : 'Back to backlog';
|
||||
props.source === 'board'
|
||||
? t('ticketForm.backToBoard')
|
||||
: t('ticketForm.backToBacklog');
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
title: t('navigation.projects'),
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
@@ -48,21 +49,26 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
||||
title:
|
||||
props.source === 'board'
|
||||
? t('navigation.board')
|
||||
: t('navigation.backlog'),
|
||||
href: returnHref,
|
||||
},
|
||||
{
|
||||
title: 'Create ticket',
|
||||
title: t('ticketForm.create'),
|
||||
href: ProjectTicketController.create({
|
||||
project: props.project.id,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const hasTicketMetadata =
|
||||
props.ticketTypes.length > 0 &&
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
const hasTicketMetadata = computed(
|
||||
() =>
|
||||
props.ticketTypes.length > 0 &&
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0,
|
||||
);
|
||||
|
||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
@@ -90,9 +96,19 @@ const form = useForm(draftKey, {
|
||||
title: '',
|
||||
});
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
});
|
||||
const isDirty = computed(
|
||||
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
||||
const reset = (): void => {
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
acceptanceCriteriaChecklist.value = '';
|
||||
protection.clearFeedback();
|
||||
};
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
@@ -109,6 +125,9 @@ const acceptanceCriteriaError = computed(() => {
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
if (!hasTicketMetadata.value || form.processing) {
|
||||
return;
|
||||
}
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
@@ -117,31 +136,22 @@ const submit = (): void => {
|
||||
ProjectTicketController.store({ project: props.project.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
acceptanceCriteriaChecklist.value = '';
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`Create ${project.name} ticket`" />
|
||||
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<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"
|
||||
>
|
||||
@@ -149,10 +159,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
title="Create ticket"
|
||||
:description="`Add a ticket to ${project.name}.`"
|
||||
/>
|
||||
<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">
|
||||
@@ -160,7 +176,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="show(project.id)">{{
|
||||
t('ticketDetail.backToProject')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,9 +192,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
|
||||
<Card v-if="hasTicketMetadata">
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket details</CardTitle>
|
||||
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
||||
<CardDescription>
|
||||
The ticket key is assigned after creation.
|
||||
{{ t('ticketForm.keyAssigned') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -186,258 +204,93 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
placeholder="Implement repository sync"
|
||||
/>
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
<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
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="Describe the expected behavior and relevant context."
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_checklist"
|
||||
>
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="
|
||||
acceptanceCriteriaChecklist
|
||||
"
|
||||
rows="6"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Use <code>[ ]</code> /
|
||||
<code>[x]</code> for checklist
|
||||
items. Indent with two spaces for
|
||||
nested steps.
|
||||
</p>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
acceptanceCriteriaPreviewItems.length >
|
||||
0
|
||||
"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Checklist preview
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="mt-0.5">{{
|
||||
item.checked ? '☑' : '☐'
|
||||
}}</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid h-fit gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_type_id">Type</Label>
|
||||
<select
|
||||
id="ticket_type_id"
|
||||
v-model="form.ticket_type_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="String(ticketType.id)"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.ticket_type_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_priority_id">
|
||||
Priority
|
||||
</Label>
|
||||
<select
|
||||
id="ticket_priority_id"
|
||||
v-model="form.ticket_priority_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
:value="String(ticketPriority.id)"
|
||||
>
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors.ticket_priority_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_status_id">Status</Label>
|
||||
<select
|
||||
id="ticket_status_id"
|
||||
v-model="form.ticket_status_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketStatus in ticketStatusOptions"
|
||||
:key="ticketStatus.id"
|
||||
:value="String(ticketStatus.id)"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(ticketStatus)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.ticket_status_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="assignee_user_id">
|
||||
Assignee
|
||||
</Label>
|
||||
<select
|
||||
id="assignee_user_id"
|
||||
v-model="form.assignee_user_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="String(user.id)"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.assignee_user_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="parent_ticket_id">
|
||||
Parent ticket
|
||||
</Label>
|
||||
<select
|
||||
id="parent_ticket_id"
|
||||
v-model="form.parent_ticket_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">
|
||||
No parent ticket
|
||||
</option>
|
||||
<option
|
||||
v-for="parentTicketOption in parentTicketOptions"
|
||||
:key="parentTicketOption.id"
|
||||
:value="
|
||||
String(parentTicketOption.id)
|
||||
"
|
||||
>
|
||||
{{
|
||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.parent_ticket_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
<TicketFormField
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.checklistEditor')"
|
||||
:hint="t('ticketForm.checklistHelp')"
|
||||
:placeholder="
|
||||
t('ticketDetail.checklistPlaceholder')
|
||||
"
|
||||
:error="acceptanceCriteriaError"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<div
|
||||
v-if="project.show_story_points"
|
||||
class="grid gap-2"
|
||||
v-if="acceptanceCriteriaPreviewItems.length"
|
||||
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<Label for="story_points">
|
||||
Estimated story points
|
||||
</Label>
|
||||
<Input
|
||||
id="story_points"
|
||||
v-model="form.story_points"
|
||||
type="number"
|
||||
step="1"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.story_points"
|
||||
/>
|
||||
<p class="mb-3 font-medium">
|
||||
{{ t('ticketForm.checklistPreview') }}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
||||
}"
|
||||
class="flex min-w-0 items-start gap-2"
|
||||
>
|
||||
<span aria-hidden="true">{{
|
||||
item.checked ? '☑' : '☐'
|
||||
}}</span>
|
||||
<span
|
||||
class="wrap-anywhere min-w-0"
|
||||
>{{ item.text }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create ticket
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="returnHref">
|
||||
{{ returnLabel }}
|
||||
</Link>
|
||||
</Button>
|
||||
</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>
|
||||
@@ -446,13 +299,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Card v-else>
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
Ticket creation needs at least one type, one priority,
|
||||
and one status.
|
||||
{{ t('ticketForm.metadataRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
Open ticket settings
|
||||
{{ t('ticketForm.openSettings') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user