feat: KAN-30
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-12 13:33:53 +02:00
committed by Twyco
parent 509d7abf82
commit d646f8b537
9 changed files with 1058 additions and 603 deletions
@@ -0,0 +1,89 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { ProjectTicketCreatePageProps } from '@/types';
const props = defineProps<
Pick<
ProjectTicketCreatePageProps,
| 'project'
| 'ticketTypes'
| 'ticketPriorities'
| 'users'
| 'parentTicketOptions'
> & {
errors: Record<string, string>;
disabled?: boolean;
autofocus?: boolean;
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
}
>();
const title = defineModel<string>('title', { required: true });
const description = defineModel<string>('description', { required: true });
const type = defineModel<string | number>('type', { required: true });
const priority = defineModel<string | number>('priority', { required: true });
const assignee = defineModel<string | number>('assignee', { required: true });
const parent = defineModel<string | number>('parent', { required: true });
const points = defineModel<string | number | null>('points', {
required: true,
});
const status = defineModel<string | number>('status');
const { t } = useLocalization();
const typeOptions = computed(() =>
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
);
const priorityOptions = computed(() =>
props.ticketPriorities.map((item) => ({
value: item.id,
label: item.name,
})),
);
const userOptions = computed(() => [
{ value: '', label: t('tickets.unassigned') },
...props.users.map((item) => ({ value: item.id, label: item.name })),
]);
const parentOptions = computed(() => [
{ value: '', label: t('ticketDetail.noParent') },
...props.parentTicketOptions.map((item) => ({
value: item.id,
label: `${item.ticket_key} - ${item.title}`,
})),
]);
const statusOptions = computed(
() =>
props.ticketStatusOptions?.map((item) => ({
value: item.id,
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
})) ?? [],
);
</script>
<template>
<div class="grid min-w-0 gap-6">
<TicketFormField
id="title"
v-model="title"
:label="t('ticketDetail.title')"
:error="errors.title"
:disabled="disabled"
:autofocus="autofocus"
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
:placeholder="t('ticketForm.descriptionPlaceholder')"
/>
<slot name="content" />
<fieldset
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
:disabled="disabled"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.metadata') }}
</legend>
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
<TicketFormField
id="ticket_type_id"
v-model="type"
kind="select"
:label="t('ticketDetail.type')"
:options="typeOptions"
:error="errors.ticket_type_id"
:disabled="disabled"
required
/>
<TicketFormField
id="ticket_priority_id"
v-model="priority"
kind="select"
:label="t('ticketDetail.priority')"
:options="priorityOptions"
:error="errors.ticket_priority_id"
:disabled="disabled"
required
/>
<TicketFormField
v-if="ticketStatusOptions && status !== undefined"
id="ticket_status_id"
v-model="status"
kind="select"
:label="t('ticketDetail.status')"
:options="statusOptions"
:error="errors.ticket_status_id"
:disabled="disabled"
required
/>
<TicketFormField
id="assignee_user_id"
v-model="assignee"
kind="select"
:label="t('ticketDetail.assignee')"
:options="userOptions"
:error="errors.assignee_user_id"
:disabled="disabled"
/>
<TicketFormField
id="parent_ticket_id"
v-model="parent"
kind="select"
:label="t('ticketDetail.parent')"
:options="parentOptions"
:error="errors.parent_ticket_id"
:disabled="disabled"
/>
<TicketFormField
v-if="project.show_story_points"
id="story_points"
v-model="points"
kind="number"
:label="t('ticketDetail.storyPoints')"
:error="errors.story_points"
:disabled="disabled"
:min="0"
step="1"
/>
<slot name="metadata" />
</div>
</fieldset>
</div>
</template>
@@ -8,7 +8,8 @@ import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicke
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 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 {
@@ -45,7 +46,7 @@ const savedAcceptanceCriteria = ref(
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm(draftKey, {
const savedFormData = () => ({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
@@ -74,6 +75,8 @@ const form = useForm(draftKey, {
story_points: props.ticket.story_points,
});
const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
@@ -84,11 +87,27 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
formatChecklistLines(savedAcceptanceCriteria.value),
);
const protection = useFormProtection(form, {
isDirty: () =>
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();
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => {
return (
@@ -140,6 +159,7 @@ const persistAcceptanceCriteriaToggle = async (
): Promise<void> => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value
) {
@@ -191,6 +211,8 @@ const persistAcceptanceCriteriaToggle = async (
if (acceptanceCriteriaChecklist.value === previousChecklist) {
acceptanceCriteriaChecklist.value = nextChecklist;
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
}
} catch (_error) {
acceptanceCriteriaToggleError.value = t(
@@ -216,7 +238,11 @@ const onAcceptanceCriteriaCheckboxChange = async (
};
const submit = (): void => {
if (!props.canEditTicket) {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value
) {
return;
}
@@ -236,6 +262,8 @@ const submit = (): void => {
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
form.defaults();
},
},
);
@@ -359,339 +387,224 @@ const submit = (): void => {
>
{{ t('ticketDetail.edit') }}
</summary>
<div class="border-t p-6">
<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 gap-6" @submit.prevent="submit">
<form class="grid min-w-0 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"
<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
"
>
<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
<template #content>
<TicketFormField
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"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
/>
<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"
</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"
/>
</div>
</div>
<div class="border-border grid gap-3 rounded-xl border p-4">
<div class="flex items-center gap-3">
</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"
:disabled="!canEditTicket || form.processing"
class="border-input text-primary focus:ring-ring size-4 rounded"
: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>
<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"
<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') }}
</div>
<div v-else class="grid gap-4 md:grid-cols-2">
<div
</p>
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
<template
v-for="(
ticketFieldDefinition, index
definition, index
) in ticketFieldDefinitions"
:key="ticketFieldDefinition.id"
class="grid min-w-0 gap-2"
:key="definition.id"
>
<Label
:for="`field-${ticketFieldDefinition.id}`"
>
{{ ticketFieldDefinition.label }}
</Label>
<select
v-if="
ticketFieldDefinition.field_type ===
'user'
"
:id="`field-${ticketFieldDefinition.id}`"
<TicketFormField
v-if="definition.field_type === 'user'"
:id="`field-${definition.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="
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}.value_number`
`field_values.${index}.ticket_field_definition_id`
]
"
/>
</div>
<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>
</div>
<div v-if="canEditTicket" class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">{{
t('ticketDetail.saveChanges')
}}</Button>
</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>
+24
View File
@@ -498,4 +498,28 @@ export const en = {
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System',
'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket',
'ticketForm.createDescription': 'Add a ticket to {project}.',
'ticketForm.backToBoard': 'Back to board',
'ticketForm.backToBacklog': 'Back to backlog',
'ticketForm.details': 'Ticket details',
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
'ticketForm.titlePlaceholder': 'Implement repository sync',
'ticketForm.descriptionPlaceholder':
'Describe the expected behavior and relevant context.',
'ticketForm.checklistHelp':
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
'ticketForm.checklistPreview': 'Checklist preview',
'ticketForm.metadataRequired':
'Ticket creation needs at least one type, one priority, and one status.',
'ticketForm.openSettings': 'Open ticket settings',
'ticketForm.creating': 'Creating ticket…',
'ticketForm.saving': 'Saving changes…',
'ticketForm.reset': 'Discard changes',
'ticketForm.unsaved': 'Unsaved changes',
'ticketForm.ready': 'Ready for a new ticket',
'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker',
} as const;
+143 -291
View File
@@ -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&#10; [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>