feat(KAN-29): rework backlog ticket tree
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
<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>
|
||||||
@@ -1,45 +1,93 @@
|
|||||||
<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 { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeListProps } from '@/types/ticket-tree';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<TicketTreeListProps>();
|
||||||
nodes: TicketTreeNodeModel[];
|
|
||||||
projectId: number;
|
const { t } = useLocalization();
|
||||||
}>();
|
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="space-y-3">
|
<ul class="grid min-w-0 gap-3">
|
||||||
<li v-for="node in nodes" :key="node.id">
|
<li v-for="node in nodes" :key="node.id" class="min-w-0">
|
||||||
<div class="border-border rounded-xl border px-4 py-4">
|
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex justify-center pt-1">
|
||||||
<Link
|
<button
|
||||||
:href="
|
v-if="node.children.length > 0"
|
||||||
showTicket({
|
type="button"
|
||||||
project: projectId,
|
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"
|
||||||
ticket: node.id,
|
:aria-expanded="!collapsedTicketIds.has(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 },
|
||||||
|
)
|
||||||
"
|
"
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
@click="toggleTicket(node.id)"
|
||||||
>
|
>
|
||||||
{{ node.ticket_key }}
|
<ChevronRight
|
||||||
</Link>
|
v-if="collapsedTicketIds.has(node.id)"
|
||||||
|
class="size-4"
|
||||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
aria-hidden="true"
|
||||||
{{ node.ticket_type_name }}
|
/>
|
||||||
</Badge>
|
<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>
|
||||||
|
|
||||||
<div class="text-foreground mt-2 text-sm">
|
<Link
|
||||||
{{ node.title }}
|
:href="
|
||||||
</div>
|
showTicket({
|
||||||
|
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="node.children.length > 0"
|
v-if="
|
||||||
class="border-border mt-3 border-l pl-4"
|
node.children.length > 0 && !collapsedTicketIds.has(node.id)
|
||||||
|
"
|
||||||
|
: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"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -22,63 +23,85 @@ const { t } = useLocalization();
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
:class="[
|
:class="[
|
||||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||||
|
layout === 'card' ? 'shadow-sm' : '',
|
||||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Link
|
<div
|
||||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
:class="
|
||||||
:aria-label="
|
layout === 'list'
|
||||||
t('tickets.open', {
|
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
|
||||||
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)"
|
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
<Link
|
||||||
<Badge
|
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||||
variant="outline"
|
:aria-label="
|
||||||
class="wrap-anywhere max-w-full whitespace-normal"
|
t('tickets.open', {
|
||||||
>
|
key: ticket.ticket_key,
|
||||||
{{ ticket.ticket_key }}
|
title: ticket.title,
|
||||||
</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 }}
|
|
||||||
</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>
|
:class="[
|
||||||
</Link>
|
'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>
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="border-t p-4">
|
<h3
|
||||||
<slot name="actions" />
|
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',
|
||||||
|
layout === 'list'
|
||||||
|
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,40 @@ 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.',
|
||||||
@@ -356,6 +390,8 @@ 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.',
|
||||||
|
|||||||
@@ -1,70 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
import { TicketListItem } from '@/components/tickets';
|
ChevronsDownUp,
|
||||||
|
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 {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import {
|
||||||
|
buildBacklogTicketTree,
|
||||||
|
countBacklogTreeTickets,
|
||||||
|
expandableBacklogTicketIds,
|
||||||
|
filterBacklogTicketTree,
|
||||||
|
} from '@/lib/backlog';
|
||||||
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, show } from '@/routes/projects/index';
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
import type {
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
BacklogFilterState,
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
BreadcrumbItem,
|
||||||
|
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 breadcrumbs: BreadcrumbItem[] = [
|
const filters = reactive<BacklogFilterState>({
|
||||||
{
|
assigneeId: '',
|
||||||
title: 'Projects',
|
priorityId: '',
|
||||||
href: index(),
|
query: '',
|
||||||
},
|
statusId: '',
|
||||||
{
|
typeId: '',
|
||||||
title: props.project.name,
|
});
|
||||||
href: show(props.project.id),
|
const collapsedTicketIds = reactive(new Set<number>());
|
||||||
},
|
const pendingTicketId = ref<number | null>(null);
|
||||||
{
|
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
||||||
title: 'Backlog',
|
const filteredTicketTree = computed(() =>
|
||||||
href: backlogIndex(props.project.id),
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
function clearFilters(): void {
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
filters.query = '';
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} backlog`" />
|
<Head :title="t('backlog.title', { name: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
{{ project.key }}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
<Heading
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
:title="`${project.name} backlog`"
|
>
|
||||||
description="All newly created tickets start here before they move onto the board."
|
{{ project.key }}
|
||||||
/>
|
</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-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -74,138 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Create ticket
|
<Plus aria-hidden="true" />
|
||||||
|
{{ t('navigation.createTicket') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
<Link :href="boardIndex(project.id)">
|
||||||
|
<Columns3 aria-hidden="true" />
|
||||||
|
{{ t('backlog.openBoard') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Button as-child variant="outline">
|
<Link :href="show(project.id)">
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
<ArrowLeft aria-hidden="true" />
|
||||||
|
{{ t('backlog.backToProject') }}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="backlog-filter-title"
|
||||||
|
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
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 class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-type"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.type') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-type"
|
||||||
|
v-model="filters.typeId"
|
||||||
|
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="">
|
||||||
|
{{ t('backlog.filters.allTypes') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="ticketType in ticketTypes"
|
||||||
|
:key="ticketType.id"
|
||||||
|
:value="ticketType.id"
|
||||||
|
>
|
||||||
|
{{ ticketType.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-priority"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.priority') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-priority"
|
||||||
|
v-model="filters.priorityId"
|
||||||
|
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="">
|
||||||
|
{{ t('backlog.filters.allPriorities') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="priority in ticketPriorities"
|
||||||
|
:key="priority.id"
|
||||||
|
:value="priority.id"
|
||||||
|
>
|
||||||
|
{{ priority.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-status"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.status') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-status"
|
||||||
|
v-model="filters.statusId"
|
||||||
|
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="">
|
||||||
|
{{ t('backlog.filters.allStatuses') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="ticketStatus in backlogTicketStatuses"
|
||||||
|
:key="ticketStatus.id"
|
||||||
|
:value="ticketStatus.id"
|
||||||
|
>
|
||||||
|
{{ ticketStatus.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<label
|
||||||
|
for="backlog-assignee"
|
||||||
|
class="text-caption font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('backlog.filters.assignee') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="backlog-assignee"
|
||||||
|
v-model="filters.assigneeId"
|
||||||
|
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="">
|
||||||
|
{{ t('backlog.filters.allAssignees') }}
|
||||||
|
</option>
|
||||||
|
<option value="unassigned">
|
||||||
|
{{ t('tickets.unassigned') }}
|
||||||
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="status"
|
v-else-if="visibleTicketCount === 0"
|
||||||
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"
|
class="grid justify-items-start gap-3"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
<section
|
||||||
<CardHeader class="border-b">
|
v-else
|
||||||
<CardTitle>Backlog tickets</CardTitle>
|
aria-labelledby="backlog-results-title"
|
||||||
<CardDescription>
|
class="grid min-w-0 gap-3"
|
||||||
Tickets remain here while their status belongs to
|
>
|
||||||
backlog.
|
<div
|
||||||
</CardDescription>
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
</CardHeader>
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
<CardContent class="p-0">
|
<h2 id="backlog-results-title" class="text-title">
|
||||||
<div
|
{{ t('backlog.results.title') }}
|
||||||
v-if="tickets.length === 0"
|
</h2>
|
||||||
class="text-muted-foreground p-6 text-sm"
|
<p class="text-caption text-muted-foreground">
|
||||||
>
|
{{
|
||||||
No backlog tickets exist yet.
|
t('backlog.results.count', {
|
||||||
|
count: visibleTicketCount,
|
||||||
|
total: tickets.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<ul v-else class="gap-column grid p-4">
|
<BacklogTicketTree
|
||||||
<TicketListItem
|
:nodes="filteredTicketTree"
|
||||||
v-for="ticket in tickets"
|
:project-id="project.id"
|
||||||
:key="ticket.id"
|
:can-edit-tickets="canEditTickets"
|
||||||
:project-id="project.id"
|
:collapsed-ticket-ids="collapsedTicketIds"
|
||||||
:ticket="ticket"
|
:pending-ticket-id="pendingTicketId"
|
||||||
>
|
@toggle="toggleTicket"
|
||||||
<template v-if="canEditTickets" #actions>
|
@move-start="pendingTicketId = $event"
|
||||||
<Form
|
@move-finish="finishMove"
|
||||||
v-bind="
|
/>
|
||||||
ProjectBoardController.move.form({
|
</section>
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="flex w-full flex-col gap-2"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="redirect_to"
|
|
||||||
value="backlog"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
:for="`backlog-ticket-status-${ticket.id}`"
|
|
||||||
class="text-label text-muted-foreground uppercase"
|
|
||||||
>
|
|
||||||
Move to status
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
:id="`backlog-ticket-status-${ticket.id}`"
|
|
||||||
name="ticket_status_id"
|
|
||||||
:disabled="
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length === 0 || processing
|
|
||||||
"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-if="
|
|
||||||
allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
value=""
|
|
||||||
>
|
|
||||||
No allowed moves
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
)"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="ticketStatus.id"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(
|
|
||||||
ticketStatus,
|
|
||||||
'Unknown status',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
:disabled="
|
|
||||||
processing ||
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length === 0
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length === 0
|
|
||||||
"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>
|
|
||||||
This status does not allow moving
|
|
||||||
anywhere else.
|
|
||||||
</p>
|
|
||||||
</Form>
|
|
||||||
</template>
|
|
||||||
</TicketListItem>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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];
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
|
|
||||||
|
export type TicketTreeListProps = {
|
||||||
|
nodes: TicketTreeNodeModel[];
|
||||||
|
projectId: number;
|
||||||
|
};
|
||||||
@@ -71,6 +71,110 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user