Compare commits

..
3 Commits
Author SHA1 Message Date
Eric bfda03c86c feat(KAN-34): rework blocker and ticket history
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Successful in 8m50s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 14:53:08 +02:00
Eric d7407c58d6 feat: KAN-30
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 22:21:58 +02:00
Twyco 7b65d31b74 feat(KAN-30): rebuild responsive ticket detail view
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Successful in 8m55s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-11 18:40:00 +02:00
20 changed files with 644 additions and 1114 deletions
+1 -2
View File
@@ -55,8 +55,7 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \ RUN composer install --prefer-dist --no-interaction --no-progress \
&& corepack enable pnpm \ && pnpm ci \
&& pnpm install --frozen-lockfile \
&& pnpm run build && pnpm run build
FROM build AS test FROM build AS test
@@ -1,209 +0,0 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type {
BacklogTicketTreeEmits,
BacklogTicketTreeProps,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
const props = defineProps<BacklogTicketTreeProps>();
const emit = defineEmits<BacklogTicketTreeEmits>();
const { t } = useLocalization();
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
</script>
<template>
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
:aria-controls="`backlog-children-${node.ticket.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.ticket.id)
? 'backlog.tree.expand'
: 'backlog.tree.collapse',
{ key: node.ticket.ticket_key },
)
"
@click="emit('toggle', node.ticket.id)"
>
<ChevronRight
v-if="collapsedTicketIds.has(node.ticket.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="grid min-w-0 gap-2">
<Badge
v-if="!node.matchesFilters"
variant="secondary"
class="w-fit"
>
{{ t('backlog.filters.parentContext') }}
</Badge>
<TicketCard
:project-id="projectId"
:ticket="node.ticket"
layout="list"
:class="{ 'opacity-80': !node.matchesFilters }"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
move.form({
project: projectId,
ticket: node.ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`backlog-ticket-${node.ticket.id}`"
:options="{ preserveScroll: true }"
:on-before="() => pendingTicketId === null"
class="grid min-w-0 gap-2"
@start="emit('moveStart', node.ticket.id)"
@finish="emit('moveFinish', node.ticket.id)"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${node.ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>
{{
t('backlog.moveTicket', {
key: node.ticket.ticket_key,
})
}}
</label>
<select
:id="`backlog-ticket-status-${node.ticket.id}`"
name="ticket_status_id"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
:aria-invalid="
!!errors.ticket_status_id || undefined
"
:aria-describedby="
errors.ticket_status_id
? `backlog-ticket-status-error-${node.ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
node.ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<InputError
:id="`backlog-ticket-status-error-${node.ticket.id}`"
:message="errors.ticket_status_id"
/>
<Button
type="submit"
variant="outline"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
>
{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}
</Button>
<p
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
{{ t('board.noMovesDescription') }}
</p>
</Form>
</template>
</TicketCard>
</div>
</div>
<div
v-if="
node.children.length > 0 &&
!collapsedTicketIds.has(node.ticket.id)
"
:id="`backlog-children-${node.ticket.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<BacklogTicketTree
:nodes="node.children"
:project-id="projectId"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="emit('toggle', $event)"
@move-start="emit('moveStart', $event)"
@move-finish="emit('moveFinish', $event)"
/>
</div>
</li>
</ul>
</template>
+27 -75
View File
@@ -1,93 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeListProps } from '@/types/ticket-tree'; import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
defineProps<TicketTreeListProps>(); defineProps<{
nodes: TicketTreeNodeModel[];
const { t } = useLocalization(); projectId: number;
const collapsedTicketIds = reactive(new Set<number>()); }>();
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script> </script>
<template> <template>
<ul class="grid min-w-0 gap-3"> <ul class="space-y-3">
<li v-for="node in nodes" :key="node.id" class="min-w-0"> <li v-for="node in nodes" :key="node.id">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2"> <div class="border-border rounded-xl border px-4 py-4">
<div class="flex justify-center pt-1"> <div class="flex flex-wrap items-center gap-2">
<button <Link
v-if="node.children.length > 0" :href="
type="button" showTicket({
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2" project: projectId,
:aria-expanded="!collapsedTicketIds.has(node.id)" ticket: node.id,
:aria-controls="`ticket-tree-children-${node.id}`" })
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
" "
@click="toggleTicket(node.id)" class="text-foreground hover:text-primary font-medium transition"
> >
<ChevronRight {{ node.ticket_key }}
v-if="collapsedTicketIds.has(node.id)" </Link>
class="size-4"
aria-hidden="true" <Badge v-if="node.ticket_type_name" variant="secondary">
/> {{ node.ticket_type_name }}
<ChevronDown v-else class="size-4" aria-hidden="true" /> </Badge>
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div> </div>
<Link <div class="text-foreground mt-2 text-sm">
:href=" {{ node.title }}
showTicket({ </div>
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div> </div>
<div <div
v-if=" v-if="node.children.length > 0"
node.children.length > 0 && !collapsedTicketIds.has(node.id) class="border-border mt-3 border-l pl-4"
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
> >
<TicketTreeList <TicketTreeList
:nodes="node.children" :nodes="node.children"
+50 -73
View File
@@ -11,7 +11,6 @@ import type {
} from '@/types/ticket-presentation'; } from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), { withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true, showDescription: true,
showStatus: true, showStatus: true,
}); });
@@ -23,85 +22,63 @@ const { t } = useLocalization();
<template> <template>
<article <article
:class="[ :class="[
'bg-card text-card-foreground min-w-0 rounded-md border', 'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border', ticket.is_blocked ? 'border-warning' : 'border-border',
]" ]"
> >
<div <Link
:class=" :href="showTicket({ project: projectId, ticket: ticket.id })"
layout === 'list' :aria-label="
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch' t('tickets.open', {
: '' key: ticket.ticket_key,
title: ticket.title,
})
" "
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
@click="emit('open', ticket.id)"
> >
<Link <div class="flex min-w-0 items-start justify-between gap-3">
:href="showTicket({ project: projectId, ticket: ticket.id })" <Badge
:aria-label=" variant="outline"
t('tickets.open', { class="wrap-anywhere max-w-full whitespace-normal"
key: ticket.ticket_key,
title: ticket.title,
})
"
:class="[
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
> >
{{ ticket.title }} {{ ticket.ticket_key }}
</h3> </Badge>
<ArrowUpRight
<TicketMetadata :ticket="ticket" :show-status="showStatus" /> class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
<p />
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
:class="[
'border-t p-4',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div> </div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div v-if="$slots.actions" class="border-t p-4">
<slot name="actions" />
</div> </div>
</article> </article>
</template> </template>
@@ -0,0 +1,204 @@
<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,14 +3,13 @@ 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 { computed, ref, toRef, unref } from 'vue'; import { computed, ref, toRef, unref, 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 FormFeedback from '@/components/FormFeedback.vue'; 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 {
acceptanceCriteriaToEditorState, acceptanceCriteriaToEditorState,
@@ -77,6 +76,16 @@ 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 acceptanceCriteriaPreviewItems = computed( const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value, () => savedAcceptanceCriteria.value,
); );
@@ -459,44 +468,6 @@ 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,6 +15,7 @@ const sections = [
'description', 'description',
'checklist', 'checklist',
'metadata', 'metadata',
'blocker',
'relationships', 'relationships',
'history', 'history',
'git', 'git',
@@ -8,11 +8,21 @@ 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>
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
{{ t('ticketDetail.noHistory') }} {{ t('ticketDetail.noHistory') }}
</div> </div>
<div v-else class="flex flex-col gap-4"> <ol v-else class="relative grid gap-0" role="list">
<div <li
v-for="worklogEntry in ticket.worklog_entries" v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id" :key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4" 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"
> >
<div class="flex flex-wrap items-center gap-2"> <div
<Badge variant="outline"> class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
{{ worklogEntry.entry_type }} >
</Badge> <UserAvatar
<span class="text-foreground text-sm font-medium"> v-if="worklogEntry.actor"
{{ :user="worklogEntry.actor"
worklogEntry.actor?.name ?? size="lg"
t('ticketDetail.system') />
}} <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> </span>
</div> </div>
<p class="text-foreground mt-3 text-sm"> <article
{{ worklogEntry.summary }} class="bg-muted/20 min-w-0 rounded-xl border p-4"
</p> >
<p class="text-muted-foreground mt-2 text-xs"> <div
{{ formatDateTime(worklogEntry.created_at) }} class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
</p> >
</div> <div
</div> class="flex min-w-0 flex-wrap items-center gap-2"
>
<span
class="text-foreground text-sm font-semibold"
>
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
</span>
<Badge variant="outline">
{{
t(
entryTypeLabels[
worklogEntry.entry_type
],
)
}}
</Badge>
</div>
<time
: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 }}
</p>
</article>
</li>
</ol>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
-114
View File
@@ -1,114 +0,0 @@
import type {
BacklogFilterState,
BacklogTicketTreeNode,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
export function buildBacklogTicketTree(
tickets: TicketModel[],
): BacklogTicketTreeNode[] {
const nodesByTicketId = new Map<number, BacklogTicketTreeNode>();
for (const ticket of tickets) {
nodesByTicketId.set(ticket.id, {
children: [],
matchesFilters: true,
ticket,
});
}
const roots: BacklogTicketTreeNode[] = [];
for (const ticket of tickets) {
const node = nodesByTicketId.get(ticket.id);
if (!node) {
continue;
}
const parentNode =
ticket.parent_ticket_id === null
? undefined
: nodesByTicketId.get(ticket.parent_ticket_id);
if (parentNode) {
parentNode.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export function filterBacklogTicketTree(
nodes: BacklogTicketTreeNode[],
filters: BacklogFilterState,
): BacklogTicketTreeNode[] {
return nodes.flatMap((node) => {
const children = filterBacklogTicketTree(node.children, filters);
const matchesFilters = ticketMatchesFilters(node.ticket, filters);
if (!matchesFilters && children.length === 0) {
return [];
}
return [
{
...node,
children,
matchesFilters,
},
];
});
}
export function countBacklogTreeTickets(
nodes: BacklogTicketTreeNode[],
): number {
return nodes.reduce(
(count, node) => count + 1 + countBacklogTreeTickets(node.children),
0,
);
}
export function expandableBacklogTicketIds(
nodes: BacklogTicketTreeNode[],
): number[] {
return nodes.flatMap((node) => [
...(node.children.length > 0 ? [node.ticket.id] : []),
...expandableBacklogTicketIds(node.children),
]);
}
function ticketMatchesFilters(
ticket: TicketModel,
filters: BacklogFilterState,
): boolean {
const query = filters.query.trim().toLocaleLowerCase();
const searchableText = [
ticket.ticket_key,
ticket.title,
ticket.description,
ticket.ticket_type.name,
ticket.ticket_priority.name,
ticket.ticket_status?.name,
ticket.assignee?.name,
]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLocaleLowerCase();
return (
(query === '' || searchableText.includes(query)) &&
(filters.typeId === '' || ticket.ticket_type_id === filters.typeId) &&
(filters.priorityId === '' ||
ticket.ticket_priority_id === filters.priorityId) &&
(filters.statusId === '' ||
ticket.ticket_status_id === filters.statusId) &&
(filters.assigneeId === '' ||
(filters.assigneeId === 'unassigned'
? ticket.assignee_user_id === null
: ticket.assignee_user_id === filters.assigneeId))
);
}
+20 -36
View File
@@ -31,40 +31,6 @@ export const en = {
'board.targetStatus': 'Destination status', 'board.targetStatus': 'Destination status',
'board.noMovesDescription': 'board.noMovesDescription':
'This status does not allow moving anywhere else.', 'This status does not allow moving anywhere else.',
'backlog.title': '{name} backlog',
'backlog.description':
'Shape incoming work without losing sight of parent and child tickets.',
'backlog.openBoard': 'Open board',
'backlog.backToProject': 'Project overview',
'backlog.moveTicket': 'Move {key}',
'backlog.filters.title': 'Find backlog tickets',
'backlog.filters.description':
'Filter this view locally. Matching child tickets keep their parent context visible.',
'backlog.filters.search': 'Search',
'backlog.filters.searchPlaceholder': 'Key, title, description, or metadata',
'backlog.filters.type': 'Type',
'backlog.filters.allTypes': 'All types',
'backlog.filters.priority': 'Priority',
'backlog.filters.allPriorities': 'All priorities',
'backlog.filters.status': 'Status',
'backlog.filters.allStatuses': 'All backlog statuses',
'backlog.filters.assignee': 'Assignee',
'backlog.filters.allAssignees': 'All assignees',
'backlog.filters.clear': 'Clear filters',
'backlog.filters.parentContext': 'Parent context',
'backlog.filters.empty.title': 'No tickets match these filters',
'backlog.filters.empty.description':
'Try a broader search or clear the filters to see the complete backlog.',
'backlog.results.title': 'Backlog tickets',
'backlog.results.count': 'Showing {count} of {total} tickets',
'backlog.empty.title': 'Your backlog is clear',
'backlog.empty.description':
'New tickets without a workflow column will appear here.',
'backlog.empty.action': 'Create the first ticket',
'backlog.tree.expand': 'Expand children of {key}',
'backlog.tree.collapse': 'Collapse children of {key}',
'backlog.tree.expandAll': 'Expand all',
'backlog.tree.collapseAll': 'Collapse all',
'projectCreation.title': 'Create a project', 'projectCreation.title': 'Create a project',
'projectCreation.description': 'projectCreation.description':
'Give your work a home. Start fresh or reuse a setup that already works for your team.', 'Give your work a home. Start fresh or reuse a setup that already works for your team.',
@@ -390,8 +356,6 @@ export const en = {
'tickets.noStatus': 'No status', 'tickets.noStatus': 'No status',
'tickets.assignedTo': 'Assigned to {name}', 'tickets.assignedTo': 'Assigned to {name}',
'tickets.unassigned': 'Unassigned', 'tickets.unassigned': 'Unassigned',
'tickets.tree.expand': 'Expand children of {key}',
'tickets.tree.collapse': 'Collapse children of {key}',
'common.save': 'Save', 'common.save': 'Save',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
'common.error': 'Something went wrong.', 'common.error': 'Something went wrong.',
@@ -437,9 +401,24 @@ 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.',
@@ -497,6 +476,11 @@ 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',
+166 -399
View File
@@ -1,155 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
ArrowLeft, import Heading from '@/components/Heading.vue';
ChevronsDownUp, import { TicketListItem } from '@/components/tickets';
ChevronsUpDown,
Columns3,
Plus,
RotateCcw,
Search,
} from 'lucide-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { import {
buildBacklogTicketTree, Card,
countBacklogTreeTickets, CardContent,
expandableBacklogTicketIds, CardDescription,
filterBacklogTicketTree, CardHeader,
} from '@/lib/backlog'; CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets'; import { create as createTicket } from '@/routes/projects/tickets';
import type { import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
BacklogFilterState, import type { TicketModel } from '@/types/models/ticket-model';
BreadcrumbItem, import type { TicketStatusModel } from '@/types/models/ticket-status-model';
ProjectBacklogPageProps,
} from '@/types';
const props = defineProps<ProjectBacklogPageProps>(); const props = defineProps<ProjectBacklogPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
]);
const filters = reactive<BacklogFilterState>({ const breadcrumbs: BreadcrumbItem[] = [
assigneeId: '', {
priorityId: '', title: 'Projects',
query: '', href: index(),
statusId: '',
typeId: '',
});
const collapsedTicketIds = reactive(new Set<number>());
const pendingTicketId = ref<number | null>(null);
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
const filteredTicketTree = computed(() =>
filterBacklogTicketTree(ticketTree.value, filters),
);
const visibleTicketCount = computed(() =>
countBacklogTreeTickets(filteredTicketTree.value),
);
const expandableTicketIds = computed(() =>
expandableBacklogTicketIds(filteredTicketTree.value),
);
const hasActiveFilters = computed(
() =>
filters.query.trim() !== '' ||
filters.typeId !== '' ||
filters.priorityId !== '' ||
filters.statusId !== '' ||
filters.assigneeId !== '',
);
const allVisibleBranchesCollapsed = computed(
() =>
expandableTicketIds.value.length > 0 &&
expandableTicketIds.value.every((ticketId) =>
collapsedTicketIds.has(ticketId),
),
);
function clearFilters(): void {
filters.query = '';
filters.typeId = '';
filters.priorityId = '';
filters.statusId = '';
filters.assigneeId = '';
}
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
function toggleAllBranches(): void {
if (allVisibleBranchesCollapsed.value) {
collapsedTicketIds.clear();
return;
}
for (const ticketId of expandableTicketIds.value) {
collapsedTicketIds.add(ticketId);
}
}
function finishMove(ticketId: number): void {
if (pendingTicketId.value === ticketId) {
pendingTicketId.value = null;
}
}
watch(
() => props.project.id,
() => {
clearFilters();
collapsedTicketIds.clear();
pendingTicketId.value = null;
}, },
); {
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Backlog',
href: backlogIndex(props.project.id),
},
];
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('backlog.title', { name: project.name })" /> <Head :title="`${project.name} backlog`" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4"> <div class="flex flex-1 flex-col gap-6 p-4">
<header <div
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<div class="grid min-w-0 gap-2"> <div class="space-y-3">
<div class="flex min-w-0 flex-wrap items-center gap-3"> <Badge variant="outline" class="px-3 py-1">
<Badge {{ project.key }}
variant="outline" </Badge>
class="wrap-anywhere max-w-full whitespace-normal" <Heading
> :title="`${project.name} backlog`"
{{ project.key }} description="All newly created tickets start here before they move onto the board."
</Badge> />
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: tickets.length }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('backlog.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('backlog.description') }}
</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child> <Button v-if="canCreateTickets" as-child>
<Link <Link
:href=" :href="
@@ -159,286 +74,138 @@ watch(
) )
" "
> >
<Plus aria-hidden="true" /> Create ticket
{{ t('navigation.createTicket') }}
</Link> </Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="boardIndex(project.id)"> <Link :href="boardIndex(project.id)">Open board</Link>
<Columns3 aria-hidden="true" />
{{ t('backlog.openBoard') }}
</Link>
</Button> </Button>
<Button as-child variant="ghost">
<Link :href="show(project.id)"> <Button as-child variant="outline">
<ArrowLeft aria-hidden="true" /> <Link :href="show(project.id)">Back to project</Link>
{{ t('backlog.backToProject') }}
</Link>
</Button> </Button>
</div> </div>
</header> </div>
<FeedbackState v-if="status" state="success" :title="status" /> <div
v-if="status"
<section 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"
aria-labelledby="backlog-filter-title"
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
> >
<div {{ status }}
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" </div>
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-filter-title" class="text-title">
{{ t('backlog.filters.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('backlog.filters.description') }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="!hasActiveFilters"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<div <Card class="gap-0 overflow-hidden">
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]" <CardHeader class="border-b">
> <CardTitle>Backlog tickets</CardTitle>
<CardDescription>
Tickets remain here while their status belongs to
backlog.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div <div
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1" v-if="tickets.length === 0"
class="text-muted-foreground p-6 text-sm"
> >
<label No backlog tickets exist yet.
for="backlog-query"
class="text-caption font-semibold"
>
{{ t('backlog.filters.search') }}
</label>
<div class="relative min-w-0">
<Search
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="backlog-query"
v-model="filters.query"
type="search"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
:placeholder="
t('backlog.filters.searchPlaceholder')
"
/>
</div>
</div> </div>
<div class="grid min-w-0 gap-1.5"> <ul v-else class="gap-column grid p-4">
<label <TicketListItem
for="backlog-type" v-for="ticket in tickets"
class="text-caption font-semibold" :key="ticket.id"
:project-id="project.id"
:ticket="ticket"
> >
{{ t('backlog.filters.type') }} <template v-if="canEditTickets" #actions>
</label> <Form
<select v-bind="
id="backlog-type" ProjectBoardController.move.form({
v-model="filters.typeId" project: project.id,
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" ticket: ticket.id,
> })
<option value=""> "
{{ t('backlog.filters.allTypes') }} class="flex w-full flex-col gap-2"
</option> v-slot="{ processing }"
<option >
v-for="ticketType in ticketTypes" <input
:key="ticketType.id" type="hidden"
:value="ticketType.id" name="redirect_to"
> value="backlog"
{{ ticketType.name }} />
</option> <label
</select> :for="`backlog-ticket-status-${ticket.id}`"
</div> class="text-label text-muted-foreground uppercase"
>
<div class="grid min-w-0 gap-1.5"> Move to status
<label </label>
for="backlog-priority" <div
class="text-caption font-semibold" class="flex flex-col gap-2 sm:flex-row"
> >
{{ t('backlog.filters.priority') }} <select
</label> :id="`backlog-ticket-status-${ticket.id}`"
<select name="ticket_status_id"
id="backlog-priority" :disabled="
v-model="filters.priorityId" allowedTargetStatuses(ticket)
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" .length === 0 || processing
> "
<option value=""> class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 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"
{{ t('backlog.filters.allPriorities') }} >
</option> <option
<option v-if="
v-for="priority in ticketPriorities" allowedTargetStatuses(
:key="priority.id" ticket,
:value="priority.id" ).length === 0
> "
{{ priority.name }} value=""
</option> >
</select> No allowed moves
</div> </option>
<option
<div class="grid min-w-0 gap-1.5"> v-for="ticketStatus in allowedTargetStatuses(
<label ticket,
for="backlog-status" )"
class="text-caption font-semibold" :key="ticketStatus.id"
> :value="ticketStatus.id"
{{ t('backlog.filters.status') }} >
</label> {{
<select ticketStatusLabel(
id="backlog-status" ticketStatus,
v-model="filters.statusId" 'Unknown status',
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" )
> }}
<option value=""> </option>
{{ t('backlog.filters.allStatuses') }} </select>
</option> <Button
<option type="submit"
v-for="ticketStatus in backlogTicketStatuses" size="sm"
:key="ticketStatus.id" :disabled="
:value="ticketStatus.id" processing ||
> allowedTargetStatuses(ticket)
{{ ticketStatus.name }} .length === 0
</option> "
</select> >
</div> Move
</Button>
<div class="grid min-w-0 gap-1.5"> </div>
<label <p
for="backlog-assignee" v-if="
class="text-caption font-semibold" allowedTargetStatuses(ticket)
> .length === 0
{{ t('backlog.filters.assignee') }} "
</label> class="text-caption text-muted-foreground"
<select >
id="backlog-assignee" This status does not allow moving
v-model="filters.assigneeId" anywhere else.
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" </p>
> </Form>
<option value=""> </template>
{{ t('backlog.filters.allAssignees') }} </TicketListItem>
</option> </ul>
<option value="unassigned"> </CardContent>
{{ t('tickets.unassigned') }} </Card>
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</div>
</section>
<div
v-if="tickets.length === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.empty.title')"
:description="t('backlog.empty.description')"
/>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
<Plus aria-hidden="true" />
{{ t('backlog.empty.action') }}
</Link>
</Button>
</div>
<div
v-else-if="visibleTicketCount === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.filters.empty.title')"
:description="t('backlog.filters.empty.description')"
/>
<Button
type="button"
variant="outline"
size="sm"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<section
v-else
aria-labelledby="backlog-results-title"
class="grid min-w-0 gap-3"
>
<div
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-results-title" class="text-title">
{{ t('backlog.results.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{
t('backlog.results.count', {
count: visibleTicketCount,
total: tickets.length,
})
}}
</p>
</div>
<Button
v-if="expandableTicketIds.length > 0"
type="button"
variant="outline"
size="sm"
@click="toggleAllBranches"
>
<ChevronsUpDown
v-if="allVisibleBranchesCollapsed"
aria-hidden="true"
/>
<ChevronsDownUp v-else aria-hidden="true" />
{{
t(
allVisibleBranchesCollapsed
? 'backlog.tree.expandAll'
: 'backlog.tree.collapseAll',
)
}}
</Button>
</div>
<BacklogTicketTree
:nodes="filteredTicketTree"
:project-id="project.id"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="toggleTicket"
@move-start="pendingTicketId = $event"
@move-finish="finishMove"
/>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+8 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } 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';
@@ -62,7 +63,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-4 xl:row-start-1" class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
> >
<TicketDetailMetadata <TicketDetailMetadata
:ticket="ticket" :ticket="ticket"
@@ -71,6 +72,12 @@ 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"
-31
View File
@@ -1,31 +0,0 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
export type BacklogFilterState = {
assigneeId: BacklogAssigneeFilter;
priorityId: '' | number;
query: string;
statusId: '' | number;
typeId: '' | number;
};
export type BacklogTicketTreeNode = {
children: BacklogTicketTreeNode[];
matchesFilters: boolean;
ticket: TicketModel;
};
export type BacklogTicketTreeProps = {
canEditTickets: boolean;
collapsedTicketIds: Set<number>;
nodes: BacklogTicketTreeNode[];
pendingTicketId: number | null;
projectId: number;
};
export type BacklogTicketTreeEmits = {
moveFinish: [ticketId: number];
moveStart: [ticketId: number];
toggle: [ticketId: number];
};
-2
View File
@@ -1,6 +1,4 @@
export * from './auth'; export * from './auth';
export * from './backlog';
export * from './navigation'; export * from './navigation';
export * from './page-props'; export * from './page-props';
export * from './ticket-tree';
export * from './ui'; export * from './ui';
+4
View File
@@ -15,6 +15,10 @@ 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'
@@ -1,7 +1,6 @@
import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketModel } from '@/types/models/ticket-model';
export type TicketPresentationProps = { export type TicketPresentationProps = {
layout?: 'card' | 'list';
projectId: number; projectId: number;
showDescription?: boolean; showDescription?: boolean;
showStatus?: boolean; showStatus?: boolean;
-6
View File
@@ -1,6 +0,0 @@
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
export type TicketTreeListProps = {
nodes: TicketTreeNodeModel[];
projectId: number;
};
@@ -71,110 +71,6 @@ class ProjectBacklogTest extends TestCase
); );
} }
/**
* Ensure the backlog payload supports nested tickets and their allowed status moves.
*/
public function test_backlog_includes_nested_tickets_and_allowed_status_moves(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create([
'key' => 'TREE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$boardStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Ready',
'position' => 1,
]);
$backlogStatus->allowedTargetStatuses()->sync([$boardStatus->id]);
$parentType = $project->ticketTypes()
->where('is_parent', true)
->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$parentTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'TREE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $parentType->id,
'title' => 'Parent ticket',
]);
$childTicket = Ticket::factory()->create([
'parent_ticket_id' => $parentTicket->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'TREE-2',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $taskType->id,
'title' => 'Child ticket',
]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->get(route('projects.backlog.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('canEditTickets', true)
->has('tickets', 2)
->where('tickets.0.id', $childTicket->id)
->where('tickets.0.parent_ticket_id', $parentTicket->id)
->where('tickets.0.ticket_status.allowed_target_statuses.0.id', $boardStatus->id)
->where('tickets.1.id', $parentTicket->id)
->where('tickets.1.parent_ticket_id', null)
->where('tickets.1.ticket_status.allowed_target_statuses.0.id', $boardStatus->id),
);
$this->assertDatabaseHas('tickets', [
'id' => $parentTicket->id,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseHas('tickets', [
'id' => $childTicket->id,
'parent_ticket_id' => $parentTicket->id,
'ticket_status_id' => $backlogStatus->id,
]);
}
/**
* Ensure projects without backlog tickets receive an empty collection.
*/
public function test_projects_without_backlog_tickets_receive_an_empty_collection(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.backlog.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('tickets', []),
);
}
/** /**
* Ensure creating tickets requires the project write permission. * Ensure creating tickets requires the project write permission.
*/ */
+57
View File
@@ -101,6 +101,63 @@ 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.',
);
assert.equal(
await content()
.locator('#ticket-metadata')
.getByText('Alex Morgan', { exact: true })
.count(),
2,
);
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 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');
+26 -1
View File
@@ -108,9 +108,18 @@ 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',
@@ -361,7 +370,19 @@ if (serve) {
if (params.has('reject')) { if (params.has('reject')) {
params.set('rejected', '1'); params.set('rejected', '1');
} else { } else {
savedForm = data; savedForm = {
...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);
} }
} }
@@ -442,6 +463,9 @@ 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),
@@ -452,6 +476,7 @@ if (serve) {
'description', 'description',
'checklist', 'checklist',
'metadata', 'metadata',
'blocker',
'relationships', 'relationships',
'history', 'history',
'git', 'git',