210 lines
10 KiB
Vue
210 lines
10 KiB
Vue
<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>
|