Implement ticket statuses

This commit is contained in:
2026-04-14 03:09:12 +02:00
parent 2dc12c5d60
commit 8f4a8276ac
40 changed files with 1602 additions and 100 deletions
+88 -13
View File
@@ -22,6 +22,8 @@ import { index, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBacklogPageProps>();
@@ -41,7 +43,35 @@ const breadcrumbs: BreadcrumbItem[] = [
];
const hasTicketMetadata =
props.ticketTypes.length > 0 && props.ticketPriorities.length > 0;
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.backlogTicketStatuses.length > 0;
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (
ticket.ticket_status?.allowed_target_statuses ??
props.ticketStatusOptions
);
};
</script>
<template>
@@ -85,7 +115,7 @@ const hasTicketMetadata =
<CardTitle>Create ticket</CardTitle>
<CardDescription>
New tickets receive the next immutable project key and
automatically enter backlog.
enter the selected backlog status.
</CardDescription>
</CardHeader>
@@ -182,6 +212,28 @@ const hasTicketMetadata =
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">
Status
</Label>
<select
id="ticket_status_id"
name="ticket_status_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
@@ -237,7 +289,7 @@ const hasTicketMetadata =
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type and one
priority.
priority, plus one backlog status.
</p>
<div>
<Button as-child variant="outline">
@@ -253,8 +305,8 @@ const hasTicketMetadata =
<CardHeader class="border-b">
<CardTitle>Backlog tickets</CardTitle>
<CardDescription>
Tickets remain here until they are moved into a board
column.
Tickets remain here while their status belongs to
backlog.
</CardDescription>
</CardHeader>
@@ -277,6 +329,9 @@ const hasTicketMetadata =
<th class="px-4 py-3 font-medium">Key</th>
<th class="px-4 py-3 font-medium">Title</th>
<th class="px-4 py-3 font-medium">Type</th>
<th class="px-4 py-3 font-medium">
Status
</th>
<th class="px-4 py-3 font-medium">
Priority
</th>
@@ -284,7 +339,7 @@ const hasTicketMetadata =
v-if="canEditTickets"
class="px-4 py-3 font-medium"
>
Move to board
Move to status
</th>
</tr>
</thead>
@@ -330,12 +385,22 @@ const hasTicketMetadata =
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_type.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_priority.name }}
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-if="boardColumns.length > 0"
v-if="
allowedTargetStatuses(ticket)
.length > 0
"
v-bind="
ProjectBoardController.move.form(
{
@@ -353,15 +418,25 @@ const hasTicketMetadata =
value="backlog"
/>
<select
name="board_column_id"
name="ticket_status_id"
:value="
ticket.ticket_status_id ??
''
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
:value="boardColumn.id"
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ boardColumn.name }}
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
@@ -377,7 +452,7 @@ const hasTicketMetadata =
v-else
class="text-muted-foreground text-sm"
>
Add board columns first.
No allowed status moves.
</span>
</td>
</tr>
+59 -16
View File
@@ -18,6 +18,8 @@ import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { show as showTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardPageProps>();
@@ -36,16 +38,28 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const statusOptions = [
{
label: 'Backlog',
value: '',
},
...props.boardColumns.map((boardColumn) => ({
label: boardColumn.name,
value: String(boardColumn.id),
})),
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return ticket.ticket_status?.allowed_target_statuses ?? [];
};
</script>
<template>
@@ -116,6 +130,21 @@ const statusOptions = [
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<Badge variant="secondary">
@@ -160,6 +189,13 @@ const statusOptions = [
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
@@ -218,18 +254,25 @@ const statusOptions = [
>
<select
:id="`ticket-status-${ticket.id}`"
name="board_column_id"
name="ticket_status_id"
:value="
ticket.board_column_id ?? ''
ticket.ticket_status_id ??
''
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="statusOption in statusOptions"
:key="statusOption.label"
:value="statusOption.value"
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ statusOption.label }}
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
+3
View File
@@ -354,6 +354,9 @@ const formatDateTime = (value: string): string => {
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge v-if="ticket.ticket_status" variant="outline">
{{ ticket.ticket_status.name }}
</Badge>
<Badge v-if="!canEditTicket" variant="outline">
Read only
</Badge>
+253 -4
View File
@@ -93,20 +93,135 @@ const breadcrumbs: BreadcrumbItem[] = [
<CardHeader>
<CardTitle>Workflow model</CardTitle>
<CardDescription>
Backlog is always the intake queue and stays outside the
normal board column list.
Backlog is always the intake queue; board columns group
the statuses that appear on the board.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-3">
<Badge class="px-3 py-1">Backlog</Badge>
<Badge
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
variant="outline"
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<p class="text-muted-foreground text-sm">
Tickets will later be valid only in backlog or in one of
the ordered columns below.
Tickets move between statuses; backlog statuses are not
included in a board column.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backlog statuses</CardTitle>
<CardDescription>
New backlog tickets choose one of these statuses.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
class="border-border rounded-lg border p-4"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[project.id, ticketStatus.id],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`backlog-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`backlog-status-${ticketStatus.id}`"
name="name"
:default-value="ticketStatus.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[project.id, ticketStatus.id],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="errors.ticket_status_id"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
backlogTicketStatuses.length <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="backlog-status-name">
New backlog status
</Label>
<Input
id="backlog-status-name"
name="name"
placeholder="Ready"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button type="submit" :disabled="processing">
Add status
</Button>
</div>
</Form>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<CardHeader>
@@ -239,6 +354,140 @@ const breadcrumbs: BreadcrumbItem[] = [
</Button>
</Form>
</div>
<div class="border-border mt-4 border-t pt-4">
<div class="mb-3">
<p class="text-sm font-medium">
Ticket statuses
</p>
<p class="text-muted-foreground text-sm">
Moving into this column requires a
specific status.
</p>
</div>
<div class="space-y-3">
<div
v-for="ticketStatus in column.ticket_statuses ??
[]"
:key="ticketStatus.id"
class="border-border rounded-lg border p-3"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`column-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`column-status-${ticketStatus.id}`"
name="name"
:default-value="
ticketStatus.name
"
required
/>
<InputError
:message="errors.name"
/>
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="
errors.ticket_status_id
"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
(column.ticket_statuses
?.length ?? 0) <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<input
type="hidden"
name="board_column_id"
:value="column.id"
/>
<div class="grid gap-2">
<Label
:for="`new-column-status-${column.id}`"
>
New status
</Label>
<Input
:id="`new-column-status-${column.id}`"
name="name"
placeholder="Ready for QA"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
:disabled="processing"
>
Add status
</Button>
</div>
</Form>
</div>
</div>
</CardContent>
</Card>
@@ -205,7 +205,7 @@ const submitUpdate = (): void => {
updateForm.submit(
ProjectRepositorySettingsController.update([
props.project.id,
updateForm.repository_connection_id,
Number(updateForm.repository_connection_id),
]),
{
preserveScroll: true,