Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa0af6e04 | ||
|
|
44c619df76 |
@@ -1,204 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
|
|
||||||
|
|
||||||
const props = defineProps<TicketDetailBlockerProps>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
|
|
||||||
const savedFormData = () => ({
|
|
||||||
acceptance_criteria: props.ticket.acceptance_criteria,
|
|
||||||
assignee_user_id: props.ticket.assignee_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 form = useForm(
|
|
||||||
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
|
|
||||||
savedFormData(),
|
|
||||||
);
|
|
||||||
const protection = useFormProtection(form);
|
|
||||||
const isBlocked = computed(() => Boolean(form.is_blocked));
|
|
||||||
|
|
||||||
const reset = (): void => {
|
|
||||||
if (form.processing || !protection.confirmLeave()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
form.clearErrors();
|
|
||||||
protection.clearFeedback();
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (): void => {
|
|
||||||
if (!props.canEditTicket || form.processing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
protection.submit(
|
|
||||||
ProjectTicketController.update({
|
|
||||||
project: props.project.id,
|
|
||||||
ticket: props.ticket.id,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
Object.assign(form, savedFormData());
|
|
||||||
form.defaults();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
id="ticket-blocker"
|
|
||||||
aria-labelledby="ticket-blocker-title"
|
|
||||||
class="min-w-0 scroll-mt-6"
|
|
||||||
>
|
|
||||||
<Card :class="{ 'border-warning/40': isBlocked }">
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
|
|
||||||
{{ t('ticketDetail.blocker') }}
|
|
||||||
</h2>
|
|
||||||
<Badge :variant="isBlocked ? 'warning' : 'outline'">
|
|
||||||
{{
|
|
||||||
isBlocked
|
|
||||||
? t('ticketDetail.blockerActive')
|
|
||||||
: t('ticketDetail.blockerInactive')
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
{{ t('ticketDetail.blockerDescription') }}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent v-if="canEditTicket">
|
|
||||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
|
||||||
<FormFeedback
|
|
||||||
:state="protection.state.value"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="is_blocked"
|
|
||||||
v-model="form.is_blocked"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="form.processing"
|
|
||||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
|
||||||
aria-describedby="ticket-blocker-state-description is_blocked-error"
|
|
||||||
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
|
|
||||||
/>
|
|
||||||
<div class="grid min-w-0 gap-1">
|
|
||||||
<Label for="is_blocked">
|
|
||||||
{{ t('ticketDetail.blocked') }}
|
|
||||||
</Label>
|
|
||||||
<p
|
|
||||||
id="ticket-blocker-state-description"
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isBlocked
|
|
||||||
? t(
|
|
||||||
'ticketDetail.blockerActiveDescription',
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
'ticketDetail.blockerInactiveDescription',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<InputError
|
|
||||||
id="is_blocked-error"
|
|
||||||
:message="form.errors.is_blocked"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TicketFormField
|
|
||||||
v-if="isBlocked"
|
|
||||||
id="blocked_comment"
|
|
||||||
v-model="form.blocked_comment"
|
|
||||||
kind="textarea"
|
|
||||||
:rows="3"
|
|
||||||
:label="t('ticketDetail.blockerComment')"
|
|
||||||
:hint="t('ticketDetail.blockerCommentHelp')"
|
|
||||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
|
||||||
:error="form.errors.blocked_comment"
|
|
||||||
:disabled="form.processing"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="!form.isDirty || form.processing"
|
|
||||||
@click="reset"
|
|
||||||
>
|
|
||||||
{{ t('ticketDetail.resetBlocker') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:loading="form.processing"
|
|
||||||
:loading-label="t('ticketDetail.savingBlocker')"
|
|
||||||
:disabled="!form.isDirty"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isBlocked
|
|
||||||
? t('ticketDetail.saveBlocker')
|
|
||||||
: t('ticketDetail.unblockTicket')
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardContent v-else>
|
|
||||||
<p
|
|
||||||
class="whitespace-pre-wrap text-sm leading-6"
|
|
||||||
:class="{ 'text-muted-foreground': !isBlocked }"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isBlocked
|
|
||||||
? ticket.blocked_comment
|
|
||||||
: t('ticketDetail.blockerInactiveDescription')
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -3,15 +3,7 @@ import { useLocalization } from '@/composables/useLocalization';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
import { useForm, useRemember } from '@inertiajs/vue3';
|
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||||
import {
|
import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue';
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
toRef,
|
|
||||||
unref,
|
|
||||||
useTemplateRef,
|
|
||||||
nextTick,
|
|
||||||
watch,
|
|
||||||
} from 'vue';
|
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
@@ -19,6 +11,7 @@ import FormFeedback from '@/components/FormFeedback.vue';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import {
|
import {
|
||||||
normalizeAcceptanceCriteria,
|
normalizeAcceptanceCriteria,
|
||||||
@@ -88,16 +81,6 @@ const savedFormData = () => ({
|
|||||||
|
|
||||||
const form = useForm(draftKey, savedFormData());
|
const form = useForm(draftKey, savedFormData());
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
|
|
||||||
([isBlocked, blockedComment]) => {
|
|
||||||
form.is_blocked = isBlocked;
|
|
||||||
form.blocked_comment = blockedComment ?? '';
|
|
||||||
form.defaults('is_blocked', isBlocked);
|
|
||||||
form.defaults('blocked_comment', blockedComment ?? '');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||||
() =>
|
() =>
|
||||||
JSON.stringify(acceptanceCriteriaItems.value) !==
|
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||||
@@ -381,6 +364,44 @@ const submit = (): void => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</TicketFormFields>
|
</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
|
<fieldset
|
||||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const sections = [
|
|||||||
'description',
|
'description',
|
||||||
'checklist',
|
'checklist',
|
||||||
'metadata',
|
'metadata',
|
||||||
'blocker',
|
|
||||||
'relationships',
|
'relationships',
|
||||||
'history',
|
'history',
|
||||||
'git',
|
'git',
|
||||||
|
|||||||
@@ -8,21 +8,11 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { UserAvatar } from '@/components/ui/avatar';
|
|
||||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||||
import type { TranslationKey } from '@/types/localization';
|
|
||||||
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
|
|
||||||
defineProps<TicketDetailHistoryProps>();
|
defineProps<TicketDetailHistoryProps>();
|
||||||
const { t, date } = useLocalization();
|
const { t, date } = useLocalization();
|
||||||
const formatDateTime = (value: string): string =>
|
const formatDateTime = (value: string): string =>
|
||||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
|
||||||
created: 'ticketDetail.historyType.created',
|
|
||||||
link_removed: 'ticketDetail.historyType.linkRemoved',
|
|
||||||
link_updated: 'ticketDetail.historyType.linkUpdated',
|
|
||||||
status_changed: 'ticketDetail.historyType.statusChanged',
|
|
||||||
updated: 'ticketDetail.historyType.updated',
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -49,70 +39,31 @@ const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
|||||||
{{ t('ticketDetail.noHistory') }}
|
{{ t('ticketDetail.noHistory') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol v-else class="relative grid gap-0" role="list">
|
<div v-else class="flex flex-col gap-4">
|
||||||
<li
|
<div
|
||||||
v-for="worklogEntry in ticket.worklog_entries"
|
v-for="worklogEntry in ticket.worklog_entries"
|
||||||
:key="worklogEntry.id"
|
:key="worklogEntry.id"
|
||||||
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
|
class="border-border rounded-xl border px-4 py-4"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-if="worklogEntry.actor"
|
|
||||||
:user="worklogEntry.actor"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
aria-hidden="true"
|
|
||||||
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
|
|
||||||
>
|
|
||||||
S
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<article
|
|
||||||
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex min-w-0 flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-foreground text-sm font-semibold"
|
|
||||||
>
|
>
|
||||||
|
<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 ??
|
worklogEntry.actor?.name ??
|
||||||
t('ticketDetail.system')
|
t('ticketDetail.system')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline">
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
entryTypeLabels[
|
|
||||||
worklogEntry.entry_type
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<time
|
<p class="text-foreground mt-3 text-sm">
|
||||||
:datetime="worklogEntry.created_at"
|
|
||||||
class="text-muted-foreground shrink-0 text-xs"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
formatDateTime(worklogEntry.created_at)
|
|
||||||
}}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p class="text-foreground mt-3 text-sm leading-6">
|
|
||||||
{{ worklogEntry.summary }}
|
{{ worklogEntry.summary }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
<p class="text-muted-foreground mt-2 text-xs">
|
||||||
</li>
|
{{ formatDateTime(worklogEntry.created_at) }}
|
||||||
</ol>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -456,24 +456,9 @@ export const en = {
|
|||||||
'ticketDetail.checklistPlaceholder':
|
'ticketDetail.checklistPlaceholder':
|
||||||
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||||
'ticketDetail.blocked': 'Mark ticket as blocked',
|
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||||
'ticketDetail.blocker': 'Blocker',
|
|
||||||
'ticketDetail.blockerDescription':
|
|
||||||
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
|
|
||||||
'ticketDetail.blockerActive': 'Blocked',
|
|
||||||
'ticketDetail.blockerInactive': 'Not blocked',
|
|
||||||
'ticketDetail.blockerActiveDescription':
|
|
||||||
'A blocker reason is required while this ticket is blocked.',
|
|
||||||
'ticketDetail.blockerInactiveDescription':
|
|
||||||
'This ticket currently has no active blocker.',
|
|
||||||
'ticketDetail.blockerComment': 'Blocker comment',
|
'ticketDetail.blockerComment': 'Blocker comment',
|
||||||
'ticketDetail.blockerCommentHelp':
|
|
||||||
'Explain the concrete dependency or decision preventing progress.',
|
|
||||||
'ticketDetail.blockerPlaceholder':
|
'ticketDetail.blockerPlaceholder':
|
||||||
'Describe what currently blocks progress on this ticket.',
|
'Describe what currently blocks progress on this ticket.',
|
||||||
'ticketDetail.saveBlocker': 'Save blocker',
|
|
||||||
'ticketDetail.unblockTicket': 'Unblock ticket',
|
|
||||||
'ticketDetail.resetBlocker': 'Discard blocker changes',
|
|
||||||
'ticketDetail.savingBlocker': 'Saving blocker…',
|
|
||||||
'ticketDetail.customFields': 'Custom fields',
|
'ticketDetail.customFields': 'Custom fields',
|
||||||
'ticketDetail.customFieldsDescription':
|
'ticketDetail.customFieldsDescription':
|
||||||
'Project-configured fields can be tracked directly on the ticket.',
|
'Project-configured fields can be tracked directly on the ticket.',
|
||||||
@@ -560,11 +545,6 @@ export const en = {
|
|||||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
'ticketDetail.system': 'System',
|
'ticketDetail.system': 'System',
|
||||||
'ticketDetail.historyType.created': 'Ticket created',
|
|
||||||
'ticketDetail.historyType.updated': 'Ticket updated',
|
|
||||||
'ticketDetail.historyType.statusChanged': 'Status changed',
|
|
||||||
'ticketDetail.historyType.linkUpdated': 'Link added',
|
|
||||||
'ticketDetail.historyType.linkRemoved': 'Link removed',
|
|
||||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||||
'ticketForm.create': 'Create ticket',
|
'ticketForm.create': 'Create ticket',
|
||||||
'ticketForm.pageTitle': 'Create {project} ticket',
|
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed, useTemplateRef } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
|
|
||||||
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
||||||
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
||||||
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
||||||
@@ -65,7 +64,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
:aria-label="t('ticketDetail.metadata')"
|
:aria-label="t('ticketDetail.metadata')"
|
||||||
class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
|
class="min-w-0 xl:col-start-2 xl:row-span-4 xl:row-start-1"
|
||||||
>
|
>
|
||||||
<TicketDetailMetadata
|
<TicketDetailMetadata
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
@@ -74,12 +73,6 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
:ticket-field-definitions="ticketFieldDefinitions"
|
:ticket-field-definitions="ticketFieldDefinitions"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<TicketDetailBlocker
|
|
||||||
:ticket="ticket"
|
|
||||||
:project="project"
|
|
||||||
:can-edit-ticket="canEditTicket"
|
|
||||||
class="xl:col-start-1"
|
|
||||||
/>
|
|
||||||
<TicketDetailRelationships
|
<TicketDetailRelationships
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
:project="project"
|
:project="project"
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export type TicketDetailContentProps = Pick<
|
|||||||
| 'ticketTypes'
|
| 'ticketTypes'
|
||||||
| 'users'
|
| 'users'
|
||||||
>;
|
>;
|
||||||
export type TicketDetailBlockerProps = Pick<
|
|
||||||
ProjectTicketPageProps,
|
|
||||||
'ticket' | 'project' | 'canEditTicket'
|
|
||||||
>;
|
|
||||||
export type TicketDetailMetadataProps = Pick<
|
export type TicketDetailMetadataProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||||
|
|||||||
@@ -101,102 +101,6 @@ try {
|
|||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 390, height: 1000 });
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
await visit();
|
await visit();
|
||||||
const blocker = content().locator('#ticket-blocker');
|
|
||||||
const blockerToggle = blocker.getByRole('checkbox', {
|
|
||||||
name: 'Mark ticket as blocked',
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
const blockerComment = blocker.getByLabel('Blocker comment', {
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
assert.equal(await blockerToggle.isChecked(), true);
|
|
||||||
assert.equal(
|
|
||||||
await blocker.getByText('Alex Morgan', { exact: true }).count(),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
await content()
|
|
||||||
.locator('#ticket-history')
|
|
||||||
.getByText('Alex Morgan', { exact: true })
|
|
||||||
.count(),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
await content()
|
|
||||||
.locator('#ticket-history')
|
|
||||||
.getByText('Status changed', { exact: true })
|
|
||||||
.count(),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
await blockerComment.fill('');
|
|
||||||
assert.equal(
|
|
||||||
await blockerComment.evaluate(
|
|
||||||
(element) => element.validity.valueMissing,
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await blockerComment.fill('Waiting for a production decision.');
|
|
||||||
await blocker.getByRole('button', { name: 'Save blocker' }).click();
|
|
||||||
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
|
||||||
assert.equal(
|
|
||||||
await blockerComment.inputValue(),
|
|
||||||
'Waiting for a production decision.',
|
|
||||||
);
|
|
||||||
for (const label of ['Assignee', 'Reviewer']) {
|
|
||||||
const field = content()
|
|
||||||
.locator('#ticket-metadata dl > div')
|
|
||||||
.filter({ has: page.getByText(label, { exact: true }) });
|
|
||||||
assert.match(await field.locator('dd').innerText(), /Alex Morgan/);
|
|
||||||
}
|
|
||||||
await blockerToggle.uncheck();
|
|
||||||
await blocker.getByRole('button', { name: 'Unblock ticket' }).click();
|
|
||||||
await blocker
|
|
||||||
.getByText('This ticket currently has no active blocker.', {
|
|
||||||
exact: true,
|
|
||||||
})
|
|
||||||
.waitFor();
|
|
||||||
assert.equal(await blocker.getByLabel('Blocker comment').count(), 0);
|
|
||||||
await content().getByRole('button', { name: 'Change parent' }).click();
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => document.activeElement?.id === 'parent_ticket_id',
|
|
||||||
);
|
|
||||||
const saveTicket = content().getByRole('button', {
|
|
||||||
name: 'Save changes',
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
await content()
|
|
||||||
.getByRole('button', { name: 'Discard changes', exact: true })
|
|
||||||
.isDisabled(),
|
|
||||||
true,
|
|
||||||
'Saving the blocker must also update the ticket form defaults.',
|
|
||||||
);
|
|
||||||
await page
|
|
||||||
.getByLabel('Title', { exact: true })
|
|
||||||
.fill('Updated title after unblocking');
|
|
||||||
const ticketUpdate = page.waitForRequest(
|
|
||||||
(request) =>
|
|
||||||
request.method() === 'PATCH' &&
|
|
||||||
new URL(request.url()).pathname === '/projects/7/tickets/42',
|
|
||||||
);
|
|
||||||
await saveTicket.click();
|
|
||||||
const savedTicket = (await ticketUpdate).postDataJSON();
|
|
||||||
assert.equal(savedTicket.is_blocked, false);
|
|
||||||
assert.equal(savedTicket.blocked_comment, '');
|
|
||||||
assert.equal(Number(savedTicket.parent_ticket_id), 40);
|
|
||||||
assert.deepEqual(savedTicket.acceptance_criteria, [
|
|
||||||
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
text: 'Invalidate when records change',
|
|
||||||
checked: false,
|
|
||||||
indent: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await page
|
|
||||||
.getByRole('heading', { name: 'Updated title after unblocking' })
|
|
||||||
.waitFor();
|
|
||||||
await visit();
|
|
||||||
await tabTo(edit());
|
await tabTo(edit());
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
|
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
|
||||||
|
|||||||
@@ -109,18 +109,9 @@ const ticket = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
worklog_entries: [
|
worklog_entries: [
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
actor: null,
|
|
||||||
actor_id: null,
|
|
||||||
entry_type: 'status_changed',
|
|
||||||
summary: 'Moved from Backlog to Delivery / In progress.',
|
|
||||||
created_at: '2026-09-11T13:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
actor: user,
|
actor: user,
|
||||||
actor_id: user.id,
|
|
||||||
entry_type: 'created',
|
entry_type: 'created',
|
||||||
summary: 'Ticket created in the project backlog.',
|
summary: 'Ticket created in the project backlog.',
|
||||||
created_at: '2026-09-11T12:00:00Z',
|
created_at: '2026-09-11T12:00:00Z',
|
||||||
@@ -408,19 +399,7 @@ if (serve) {
|
|||||||
if (params.has('reject')) {
|
if (params.has('reject')) {
|
||||||
params.set('rejected', '1');
|
params.set('rejected', '1');
|
||||||
} else {
|
} else {
|
||||||
savedForm = {
|
savedForm = data;
|
||||||
...data,
|
|
||||||
blocked_comment: data.is_blocked
|
|
||||||
? data.blocked_comment
|
|
||||||
: null,
|
|
||||||
field_values: data.field_values.map((fieldValue) => ({
|
|
||||||
...fieldValue,
|
|
||||||
user_value:
|
|
||||||
Number(fieldValue.value_user_id) === user.id
|
|
||||||
? user
|
|
||||||
: null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
params.set('saved', data.title);
|
params.set('saved', data.title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,9 +480,6 @@ if (serve) {
|
|||||||
'feature/ENG-42-cache',
|
'feature/ENG-42-cache',
|
||||||
'Cache implementation',
|
'Cache implementation',
|
||||||
'Ticket created in the project backlog.',
|
'Ticket created in the project backlog.',
|
||||||
'Ticket created',
|
|
||||||
'Status changed',
|
|
||||||
'System',
|
|
||||||
]) {
|
]) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes(text),
|
html.includes(text),
|
||||||
@@ -514,7 +490,6 @@ if (serve) {
|
|||||||
'description',
|
'description',
|
||||||
'checklist',
|
'checklist',
|
||||||
'metadata',
|
'metadata',
|
||||||
'blocker',
|
|
||||||
'relationships',
|
'relationships',
|
||||||
'history',
|
'history',
|
||||||
'git',
|
'git',
|
||||||
|
|||||||
Reference in New Issue
Block a user