Compare commits
11
Commits
7b65d31b74
..
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad |
+2
-1
@@ -55,7 +55,8 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
||||
COPY . /var/www
|
||||
|
||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||
&& pnpm ci \
|
||||
&& corepack enable pnpm \
|
||||
&& pnpm install --frozen-lockfile \
|
||||
&& pnpm run build
|
||||
|
||||
FROM build AS test
|
||||
|
||||
@@ -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">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||
import { reactive } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
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<{
|
||||
nodes: TicketTreeNodeModel[];
|
||||
projectId: number;
|
||||
}>();
|
||||
defineProps<TicketTreeListProps>();
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="node in nodes" :key="node.id">
|
||||
<div class="border-border rounded-xl border px-4 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: projectId,
|
||||
ticket: node.id,
|
||||
})
|
||||
<ul class="grid min-w-0 gap-3">
|
||||
<li v-for="node in nodes" :key="node.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.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 }}
|
||||
</Link>
|
||||
|
||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||
{{ node.ticket_type_name }}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
v-if="collapsedTicketIds.has(node.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="text-foreground mt-2 text-sm">
|
||||
{{ node.title }}
|
||||
</div>
|
||||
<Link
|
||||
:href="
|
||||
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
|
||||
v-if="node.children.length > 0"
|
||||
class="border-border mt-3 border-l pl-4"
|
||||
v-if="
|
||||
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
|
||||
:nodes="node.children"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '@/types/ticket-presentation';
|
||||
|
||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||
layout: 'card',
|
||||
showDescription: true,
|
||||
showStatus: true,
|
||||
});
|
||||
@@ -22,63 +23,85 @@ const { t } = useLocalization();
|
||||
<template>
|
||||
<article
|
||||
: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',
|
||||
]"
|
||||
>
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
<div
|
||||
:class="
|
||||
layout === 'list'
|
||||
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
|
||||
: ''
|
||||
"
|
||||
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">
|
||||
<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 }}
|
||||
</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,
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Link>
|
||||
"
|
||||
: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>
|
||||
|
||||
<div v-if="$slots.actions" class="border-t p-4">
|
||||
<slot name="actions" />
|
||||
<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',
|
||||
layout === 'list'
|
||||
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: 'text' | 'number' | 'textarea' | 'select';
|
||||
options?: { value: string | number; label: string }[];
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
autofocus?: boolean;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
min?: number;
|
||||
step?: string;
|
||||
}>(),
|
||||
{ kind: 'text', rows: 5 },
|
||||
);
|
||||
|
||||
const model = defineModel<string | number | null>({ required: true });
|
||||
const controlClass =
|
||||
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 content-start gap-2">
|
||||
<Label :for="id">{{ label }}</Label>
|
||||
<select
|
||||
v-if="kind === 'select'"
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:aria-invalid="Boolean(error)"
|
||||
:aria-describedby="`${id}-hint ${id}-error`"
|
||||
:class="controlClass"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<textarea
|
||||
v-else-if="kind === 'textarea'"
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:name="id"
|
||||
:rows="rows"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:aria-invalid="Boolean(error)"
|
||||
:aria-describedby="`${id}-hint ${id}-error`"
|
||||
:class="controlClass"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:name="id"
|
||||
:type="kind"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:autofocus="autofocus"
|
||||
:placeholder="placeholder"
|
||||
:min="min"
|
||||
:step="step"
|
||||
:maxlength="kind === 'text' ? 255 : undefined"
|
||||
:invalid="Boolean(error)"
|
||||
:aria-describedby="`${id}-hint ${id}-error`"
|
||||
/>
|
||||
<p
|
||||
:id="`${id}-hint`"
|
||||
class="text-muted-foreground text-xs empty:hidden"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
<InputError :id="`${id}-error`" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { ProjectTicketCreatePageProps } from '@/types';
|
||||
|
||||
const props = defineProps<
|
||||
Pick<
|
||||
ProjectTicketCreatePageProps,
|
||||
| 'project'
|
||||
| 'ticketTypes'
|
||||
| 'ticketPriorities'
|
||||
| 'users'
|
||||
| 'parentTicketOptions'
|
||||
> & {
|
||||
errors: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
|
||||
}
|
||||
>();
|
||||
const title = defineModel<string>('title', { required: true });
|
||||
const description = defineModel<string>('description', { required: true });
|
||||
const type = defineModel<string | number>('type', { required: true });
|
||||
const priority = defineModel<string | number>('priority', { required: true });
|
||||
const assignee = defineModel<string | number>('assignee', { required: true });
|
||||
const parent = defineModel<string | number>('parent', { required: true });
|
||||
const points = defineModel<string | number | null>('points', {
|
||||
required: true,
|
||||
});
|
||||
const status = defineModel<string | number>('status');
|
||||
const { t } = useLocalization();
|
||||
const typeOptions = computed(() =>
|
||||
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
|
||||
);
|
||||
const priorityOptions = computed(() =>
|
||||
props.ticketPriorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
})),
|
||||
);
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: t('tickets.unassigned') },
|
||||
...props.users.map((item) => ({ value: item.id, label: item.name })),
|
||||
]);
|
||||
const parentOptions = computed(() => [
|
||||
{ value: '', label: t('ticketDetail.noParent') },
|
||||
...props.parentTicketOptions.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.ticket_key} - ${item.title}`,
|
||||
})),
|
||||
]);
|
||||
const statusOptions = computed(
|
||||
() =>
|
||||
props.ticketStatusOptions?.map((item) => ({
|
||||
value: item.id,
|
||||
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
|
||||
})) ?? [],
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<TicketFormField
|
||||
id="title"
|
||||
v-model="title"
|
||||
:label="t('ticketDetail.title')"
|
||||
:error="errors.title"
|
||||
:disabled="disabled"
|
||||
:autofocus="autofocus"
|
||||
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="description"
|
||||
v-model="description"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.description')"
|
||||
:error="errors.description"
|
||||
:disabled="disabled"
|
||||
:placeholder="t('ticketForm.descriptionPlaceholder')"
|
||||
/>
|
||||
<slot name="content" />
|
||||
<fieldset
|
||||
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<legend class="px-2 text-sm font-semibold">
|
||||
{{ t('ticketDetail.metadata') }}
|
||||
</legend>
|
||||
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||
<TicketFormField
|
||||
id="ticket_type_id"
|
||||
v-model="type"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.type')"
|
||||
:options="typeOptions"
|
||||
:error="errors.ticket_type_id"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="ticket_priority_id"
|
||||
v-model="priority"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.priority')"
|
||||
:options="priorityOptions"
|
||||
:error="errors.ticket_priority_id"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
v-if="ticketStatusOptions && status !== undefined"
|
||||
id="ticket_status_id"
|
||||
v-model="status"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.status')"
|
||||
:options="statusOptions"
|
||||
:error="errors.ticket_status_id"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="assignee_user_id"
|
||||
v-model="assignee"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.assignee')"
|
||||
:options="userOptions"
|
||||
:error="errors.assignee_user_id"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<TicketFormField
|
||||
id="parent_ticket_id"
|
||||
v-model="parent"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.parent')"
|
||||
:options="parentOptions"
|
||||
:error="errors.parent_ticket_id"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<TicketFormField
|
||||
v-if="project.show_story_points"
|
||||
id="story_points"
|
||||
v-model="points"
|
||||
kind="number"
|
||||
:label="t('ticketDetail.storyPoints')"
|
||||
:error="errors.story_points"
|
||||
:disabled="disabled"
|
||||
:min="0"
|
||||
step="1"
|
||||
/>
|
||||
<slot name="metadata" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,8 @@ import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicke
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ const savedAcceptanceCriteria = ref(
|
||||
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||
const acceptanceCriteriaToggleError = ref('');
|
||||
|
||||
const form = useForm(draftKey, {
|
||||
const savedFormData = () => ({
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
@@ -74,6 +75,8 @@ const form = useForm(draftKey, {
|
||||
story_points: props.ticket.story_points,
|
||||
});
|
||||
|
||||
const form = useForm(draftKey, savedFormData());
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(
|
||||
() => savedAcceptanceCriteria.value,
|
||||
);
|
||||
@@ -84,11 +87,27 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||
formatChecklistLines(savedAcceptanceCriteria.value),
|
||||
);
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () =>
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
props.canEditTicket &&
|
||||
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
||||
});
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
||||
const reset = (): void => {
|
||||
if (
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle.value ||
|
||||
!protection.confirmLeave()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
protection.clearFeedback();
|
||||
};
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
@@ -140,6 +159,7 @@ const persistAcceptanceCriteriaToggle = async (
|
||||
): Promise<void> => {
|
||||
if (
|
||||
!props.canEditTicket ||
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle.value ||
|
||||
hasUnsavedAcceptanceCriteriaDraft.value
|
||||
) {
|
||||
@@ -191,6 +211,8 @@ const persistAcceptanceCriteriaToggle = async (
|
||||
|
||||
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
||||
acceptanceCriteriaChecklist.value = nextChecklist;
|
||||
form.acceptance_criteria = nextAcceptanceCriteria;
|
||||
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||
}
|
||||
} catch (_error) {
|
||||
acceptanceCriteriaToggleError.value = t(
|
||||
@@ -216,7 +238,11 @@ const onAcceptanceCriteriaCheckboxChange = async (
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!props.canEditTicket) {
|
||||
if (
|
||||
!props.canEditTicket ||
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,6 +262,8 @@ const submit = (): void => {
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
Object.assign(form, savedFormData());
|
||||
form.defaults();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -359,339 +387,224 @@ const submit = (): void => {
|
||||
>
|
||||
{{ t('ticketDetail.edit') }}
|
||||
</summary>
|
||||
<div class="border-t p-6">
|
||||
<div class="border-t p-4 sm:p-6">
|
||||
<p class="text-muted-foreground mb-6 text-sm">
|
||||
{{ t('ticketDetail.editDescription') }}
|
||||
</p>
|
||||
<form class="grid gap-6" @submit.prevent="submit">
|
||||
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="title">{{ t('ticketDetail.title') }}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
/>
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="ticket_type_id">{{
|
||||
t('ticketDetail.type')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_type_id"
|
||||
v-model="form.ticket_type_id"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="ticketType.id"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.ticket_type_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="ticket_priority_id">{{
|
||||
t('ticketDetail.priority')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_priority_id"
|
||||
v-model="form.ticket_priority_id"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
:value="ticketPriority.id"
|
||||
>
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.ticket_priority_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="assignee_user_id">{{
|
||||
t('ticketDetail.assignee')
|
||||
}}</Label>
|
||||
<select
|
||||
id="assignee_user_id"
|
||||
v-model="form.assignee_user_id"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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 value="">
|
||||
{{ t('tickets.unassigned') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.assignee_user_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="canViewAuthor" class="grid min-w-0 gap-2">
|
||||
<Label for="author_user_id">{{
|
||||
t('ticketDetail.author')
|
||||
}}</Label>
|
||||
<select
|
||||
id="author_user_id"
|
||||
v-model="form.author_user_id"
|
||||
:disabled="!canEditAuthor || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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 value="">
|
||||
{{ t('ticketDetail.noAuthor') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.author_user_id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid min-w-0 gap-2"
|
||||
v-if="project.show_story_points"
|
||||
<TicketFormFields
|
||||
v-model:title="form.title"
|
||||
v-model:description="form.description"
|
||||
v-model:type="form.ticket_type_id"
|
||||
v-model:priority="form.ticket_priority_id"
|
||||
v-model:assignee="form.assignee_user_id"
|
||||
v-model:parent="form.parent_ticket_id"
|
||||
v-model:points="form.story_points"
|
||||
:project="project"
|
||||
:ticket-types="ticketTypes"
|
||||
:ticket-priorities="ticketPriorities"
|
||||
:users="users"
|
||||
:parent-ticket-options="parentTicketOptions"
|
||||
:errors="form.errors"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>
|
||||
<Label for="story_points">{{
|
||||
t('ticketDetail.storyPoints')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="story_points"
|
||||
name="story_points"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
v-model="form.story_points"
|
||||
type="number"
|
||||
step="1"
|
||||
/>
|
||||
<InputError :message="form.errors.story_points" />
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="parent_ticket_id">{{
|
||||
t('ticketDetail.parent')
|
||||
}}</Label>
|
||||
<select
|
||||
id="parent_ticket_id"
|
||||
v-model="form.parent_ticket_id"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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 value="">
|
||||
{{ t('ticketDetail.noParent') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="parentTicketOption in parentTicketOptions"
|
||||
:key="parentTicketOption.id"
|
||||
:value="parentTicketOption.id"
|
||||
>
|
||||
{{
|
||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.parent_ticket_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="description">{{
|
||||
t('ticketDetail.description')
|
||||
}}</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="5"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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"
|
||||
/>
|
||||
<InputError :message="form.errors.description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="acceptance_criteria_checklist">{{
|
||||
t('ticketDetail.checklistEditor')
|
||||
}}</Label>
|
||||
<textarea
|
||||
<template #content>
|
||||
<TicketFormField
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="5"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.checklistEditor')"
|
||||
:hint="t('ticketForm.checklistHelp')"
|
||||
:placeholder="
|
||||
t('ticketDetail.checklistPlaceholder')
|
||||
"
|
||||
:error="acceptanceCriteriaError"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketDetail.checklistHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{{ t('ticketDetail.saveChecklistFirst') }}
|
||||
</p>
|
||||
<InputError :message="acceptanceCriteriaError" />
|
||||
<InputError
|
||||
:message="acceptanceCriteriaToggleError"
|
||||
</template>
|
||||
<template #metadata>
|
||||
<TicketFormField
|
||||
v-if="canViewAuthor"
|
||||
id="author_user_id"
|
||||
v-model="form.author_user_id"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.author')"
|
||||
:hint="
|
||||
!canEditAuthor
|
||||
? t('ticketForm.authorRestricted')
|
||||
: undefined
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
value: '',
|
||||
label: t('ticketDetail.noAuthor'),
|
||||
},
|
||||
...users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
})),
|
||||
]"
|
||||
:error="form.errors.author_user_id"
|
||||
:disabled="!canEditAuthor || form.processing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-border grid gap-3 rounded-xl border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
</template>
|
||||
</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"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
: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>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="blocked_comment">{{
|
||||
t('ticketDetail.blockerComment')
|
||||
}}</Label>
|
||||
<textarea
|
||||
id="blocked_comment"
|
||||
v-model="form.blocked_comment"
|
||||
rows="3"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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"
|
||||
:placeholder="
|
||||
t('ticketDetail.blockerPlaceholder')
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.blocked_comment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('ticketDetail.customFields') }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.customFieldsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ticketFieldDefinitions.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
<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
|
||||
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('ticketDetail.customFields') }}
|
||||
</legend>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.customFieldsDescription') }}
|
||||
</p>
|
||||
<InputError :message="form.errors.field_values" />
|
||||
<p
|
||||
v-if="!ticketFieldDefinitions.length"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noCustomFields') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2">
|
||||
<div
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||
<template
|
||||
v-for="(
|
||||
ticketFieldDefinition, index
|
||||
definition, index
|
||||
) in ticketFieldDefinitions"
|
||||
:key="ticketFieldDefinition.id"
|
||||
class="grid min-w-0 gap-2"
|
||||
:key="definition.id"
|
||||
>
|
||||
<Label
|
||||
:for="`field-${ticketFieldDefinition.id}`"
|
||||
>
|
||||
{{ ticketFieldDefinition.label }}
|
||||
</Label>
|
||||
|
||||
<select
|
||||
v-if="
|
||||
ticketFieldDefinition.field_type ===
|
||||
'user'
|
||||
"
|
||||
:id="`field-${ticketFieldDefinition.id}`"
|
||||
<TicketFormField
|
||||
v-if="definition.field_type === 'user'"
|
||||
:id="`field-${definition.id}`"
|
||||
v-model="
|
||||
form.field_values[index].value_user_id
|
||||
"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 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 value="">
|
||||
{{ t('tickets.unassigned') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<Input
|
||||
v-else
|
||||
:id="`field-${ticketFieldDefinition.id}`"
|
||||
v-model="
|
||||
form.field_values[index].value_number
|
||||
"
|
||||
type="number"
|
||||
step="0.1"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
:message="
|
||||
kind="select"
|
||||
:label="definition.label"
|
||||
:options="[
|
||||
{
|
||||
value: '',
|
||||
label: t('tickets.unassigned'),
|
||||
},
|
||||
...users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
})),
|
||||
]"
|
||||
:error="
|
||||
form.errors[
|
||||
`field_values.${index}.value_user_id`
|
||||
] ??
|
||||
form.errors[
|
||||
`field_values.${index}.value_number`
|
||||
`field_values.${index}.ticket_field_definition_id`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<TicketFormField
|
||||
v-else
|
||||
:id="`field-${definition.id}`"
|
||||
v-model="
|
||||
form.field_values[index].value_number
|
||||
"
|
||||
kind="number"
|
||||
step="0.1"
|
||||
:label="definition.label"
|
||||
:error="
|
||||
form.errors[
|
||||
`field_values.${index}.value_number`
|
||||
] ??
|
||||
form.errors[
|
||||
`field_values.${index}.ticket_field_definition_id`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canEditTicket" class="flex items-center gap-3">
|
||||
<Button type="submit" :disabled="form.processing">{{
|
||||
t('ticketDetail.saveChanges')
|
||||
}}</Button>
|
||||
</fieldset>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>{{
|
||||
form.processing
|
||||
? t('ticketForm.saving')
|
||||
: t('ticketDetail.saveChanges')
|
||||
}}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle ||
|
||||
!isDirty
|
||||
"
|
||||
@click="reset"
|
||||
>{{ t('ticketForm.reset') }}</Button
|
||||
>
|
||||
<p role="status" class="text-muted-foreground text-sm">
|
||||
{{
|
||||
isDirty
|
||||
? t('ticketForm.unsaved')
|
||||
: t('ticketForm.saved')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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.noMovesDescription':
|
||||
'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.description':
|
||||
'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.assignedTo': 'Assigned to {name}',
|
||||
'tickets.unassigned': 'Unassigned',
|
||||
'tickets.tree.expand': 'Expand children of {key}',
|
||||
'tickets.tree.collapse': 'Collapse children of {key}',
|
||||
'common.save': 'Save',
|
||||
'common.loading': 'Loading…',
|
||||
'common.error': 'Something went wrong.',
|
||||
@@ -462,4 +498,28 @@ export const en = {
|
||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||
'ticketDetail.system': 'System',
|
||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||
'ticketForm.create': 'Create ticket',
|
||||
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||
'ticketForm.createDescription': 'Add a ticket to {project}.',
|
||||
'ticketForm.backToBoard': 'Back to board',
|
||||
'ticketForm.backToBacklog': 'Back to backlog',
|
||||
'ticketForm.details': 'Ticket details',
|
||||
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
|
||||
'ticketForm.titlePlaceholder': 'Implement repository sync',
|
||||
'ticketForm.descriptionPlaceholder':
|
||||
'Describe the expected behavior and relevant context.',
|
||||
'ticketForm.checklistHelp':
|
||||
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
|
||||
'ticketForm.checklistPreview': 'Checklist preview',
|
||||
'ticketForm.metadataRequired':
|
||||
'Ticket creation needs at least one type, one priority, and one status.',
|
||||
'ticketForm.openSettings': 'Open ticket settings',
|
||||
'ticketForm.creating': 'Creating ticket…',
|
||||
'ticketForm.saving': 'Saving changes…',
|
||||
'ticketForm.reset': 'Discard changes',
|
||||
'ticketForm.unsaved': 'Unsaved changes',
|
||||
'ticketForm.ready': 'Ready for a new ticket',
|
||||
'ticketForm.saved': 'All changes saved',
|
||||
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||
'ticketForm.blocker': 'Blocker',
|
||||
} as const;
|
||||
|
||||
@@ -1,70 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { TicketListItem } from '@/components/tickets';
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowLeft,
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
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 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 type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type {
|
||||
BacklogFilterState,
|
||||
BreadcrumbItem,
|
||||
ProjectBacklogPageProps,
|
||||
} from '@/types';
|
||||
|
||||
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[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Backlog',
|
||||
href: backlogIndex(props.project.id),
|
||||
},
|
||||
];
|
||||
const filters = reactive<BacklogFilterState>({
|
||||
assigneeId: '',
|
||||
priorityId: '',
|
||||
query: '',
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
);
|
||||
};
|
||||
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;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} backlog`"
|
||||
description="All newly created tickets start here before they move onto the board."
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ 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 class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
@@ -74,138 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
)
|
||||
"
|
||||
>
|
||||
Create ticket
|
||||
<Plus aria-hidden="true" />
|
||||
{{ t('navigation.createTicket') }}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<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 as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Button as-child variant="ghost">
|
||||
<Link :href="show(project.id)">
|
||||
<ArrowLeft aria-hidden="true" />
|
||||
{{ t('backlog.backToProject') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</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
|
||||
v-if="status"
|
||||
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"
|
||||
v-else-if="visibleTicketCount === 0"
|
||||
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>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<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
|
||||
v-if="tickets.length === 0"
|
||||
class="text-muted-foreground p-6 text-sm"
|
||||
>
|
||||
No backlog tickets exist yet.
|
||||
<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>
|
||||
|
||||
<ul v-else class="gap-column grid p-4">
|
||||
<TicketListItem
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
:project-id="project.id"
|
||||
:ticket="ticket"
|
||||
>
|
||||
<template v-if="canEditTickets" #actions>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
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>
|
||||
<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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
||||
import { computed, toRef, unref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -15,8 +16,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildAcceptanceCriteria,
|
||||
@@ -27,20 +26,22 @@ import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
const props = defineProps<ProjectTicketCreatePageProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const returnHref =
|
||||
props.source === 'board'
|
||||
? boardIndex(props.project.id)
|
||||
: backlogIndex(props.project.id);
|
||||
const returnLabel =
|
||||
props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
||||
props.source === 'board'
|
||||
? t('ticketForm.backToBoard')
|
||||
: t('ticketForm.backToBacklog');
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
title: t('navigation.projects'),
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
@@ -48,21 +49,26 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
||||
title:
|
||||
props.source === 'board'
|
||||
? t('navigation.board')
|
||||
: t('navigation.backlog'),
|
||||
href: returnHref,
|
||||
},
|
||||
{
|
||||
title: 'Create ticket',
|
||||
title: t('ticketForm.create'),
|
||||
href: ProjectTicketController.create({
|
||||
project: props.project.id,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const hasTicketMetadata =
|
||||
props.ticketTypes.length > 0 &&
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
const hasTicketMetadata = computed(
|
||||
() =>
|
||||
props.ticketTypes.length > 0 &&
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0,
|
||||
);
|
||||
|
||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||
const acceptanceCriteriaDraft = useRemember(
|
||||
@@ -90,9 +96,19 @@ const form = useForm(draftKey, {
|
||||
title: '',
|
||||
});
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
});
|
||||
const isDirty = computed(
|
||||
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
||||
const reset = (): void => {
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
acceptanceCriteriaChecklist.value = '';
|
||||
protection.clearFeedback();
|
||||
};
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
@@ -109,6 +125,9 @@ const acceptanceCriteriaError = computed(() => {
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
if (!hasTicketMetadata.value || form.processing) {
|
||||
return;
|
||||
}
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
@@ -117,31 +136,22 @@ const submit = (): void => {
|
||||
ProjectTicketController.store({ project: props.project.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
acceptanceCriteriaChecklist.value = '';
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
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}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`Create ${project.name} ticket`" />
|
||||
<Head :title="t('ticketForm.pageTitle', { project: project.name })" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
@@ -149,10 +159,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
title="Create ticket"
|
||||
:description="`Add a ticket to ${project.name}.`"
|
||||
/>
|
||||
<h1 class="text-headline font-semibold tracking-tight">
|
||||
{{ t('ticketForm.create') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('ticketForm.createDescription', {
|
||||
project: project.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@@ -160,7 +176,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="show(project.id)">{{
|
||||
t('ticketDetail.backToProject')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,9 +192,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
|
||||
<Card v-if="hasTicketMetadata">
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket details</CardTitle>
|
||||
<CardTitle>{{ t('ticketForm.details') }}</CardTitle>
|
||||
<CardDescription>
|
||||
The ticket key is assigned after creation.
|
||||
{{ t('ticketForm.keyAssigned') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -186,258 +204,93 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
:invalid="Boolean(form.errors.title)"
|
||||
aria-describedby="title-error"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
placeholder="Implement repository sync"
|
||||
/>
|
||||
<InputError
|
||||
id="title-error"
|
||||
:message="form.errors.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
<TicketFormFields
|
||||
v-model:title="form.title"
|
||||
v-model:description="form.description"
|
||||
v-model:type="form.ticket_type_id"
|
||||
v-model:priority="form.ticket_priority_id"
|
||||
v-model:status="form.ticket_status_id"
|
||||
v-model:assignee="form.assignee_user_id"
|
||||
v-model:parent="form.parent_ticket_id"
|
||||
v-model:points="form.story_points"
|
||||
:project="project"
|
||||
:ticket-types="ticketTypes"
|
||||
:ticket-priorities="ticketPriorities"
|
||||
:ticket-status-options="ticketStatusOptions"
|
||||
:users="users"
|
||||
:parent-ticket-options="parentTicketOptions"
|
||||
:errors="form.errors"
|
||||
:disabled="form.processing"
|
||||
autofocus
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="Describe the expected behavior and relevant context."
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_checklist"
|
||||
>
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="
|
||||
acceptanceCriteriaChecklist
|
||||
"
|
||||
rows="6"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Use <code>[ ]</code> /
|
||||
<code>[x]</code> for checklist
|
||||
items. Indent with two spaces for
|
||||
nested steps.
|
||||
</p>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
acceptanceCriteriaPreviewItems.length >
|
||||
0
|
||||
"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Checklist preview
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="mt-0.5">{{
|
||||
item.checked ? '☑' : '☐'
|
||||
}}</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid h-fit gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_type_id">Type</Label>
|
||||
<select
|
||||
id="ticket_type_id"
|
||||
v-model="form.ticket_type_id"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="String(ticketType.id)"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.ticket_type_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_priority_id">
|
||||
Priority
|
||||
</Label>
|
||||
<select
|
||||
id="ticket_priority_id"
|
||||
v-model="form.ticket_priority_id"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
:value="String(ticketPriority.id)"
|
||||
>
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors.ticket_priority_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_status_id">Status</Label>
|
||||
<select
|
||||
id="ticket_status_id"
|
||||
v-model="form.ticket_status_id"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketStatus in ticketStatusOptions"
|
||||
:key="ticketStatus.id"
|
||||
:value="String(ticketStatus.id)"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(ticketStatus)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.ticket_status_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="assignee_user_id">
|
||||
Assignee
|
||||
</Label>
|
||||
<select
|
||||
id="assignee_user_id"
|
||||
v-model="form.assignee_user_id"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="String(user.id)"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.assignee_user_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="parent_ticket_id">
|
||||
Parent ticket
|
||||
</Label>
|
||||
<select
|
||||
id="parent_ticket_id"
|
||||
v-model="form.parent_ticket_id"
|
||||
:disabled="form.processing"
|
||||
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] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">
|
||||
No parent ticket
|
||||
</option>
|
||||
<option
|
||||
v-for="parentTicketOption in parentTicketOptions"
|
||||
:key="parentTicketOption.id"
|
||||
:value="
|
||||
String(parentTicketOption.id)
|
||||
"
|
||||
>
|
||||
{{
|
||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.parent_ticket_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
<TicketFormField
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.checklistEditor')"
|
||||
:hint="t('ticketForm.checklistHelp')"
|
||||
:placeholder="
|
||||
t('ticketDetail.checklistPlaceholder')
|
||||
"
|
||||
:error="acceptanceCriteriaError"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<div
|
||||
v-if="project.show_story_points"
|
||||
class="grid gap-2"
|
||||
v-if="acceptanceCriteriaPreviewItems.length"
|
||||
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<Label for="story_points">
|
||||
Estimated story points
|
||||
</Label>
|
||||
<Input
|
||||
id="story_points"
|
||||
v-model="form.story_points"
|
||||
type="number"
|
||||
step="1"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.story_points"
|
||||
/>
|
||||
<p class="mb-3 font-medium">
|
||||
{{ t('ticketForm.checklistPreview') }}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
||||
}"
|
||||
class="flex min-w-0 items-start gap-2"
|
||||
>
|
||||
<span aria-hidden="true">{{
|
||||
item.checked ? '☑' : '☐'
|
||||
}}</span>
|
||||
<span
|
||||
class="wrap-anywhere min-w-0"
|
||||
>{{ item.text }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create ticket
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="returnHref">
|
||||
{{ returnLabel }}
|
||||
</Link>
|
||||
</Button>
|
||||
</template>
|
||||
</TicketFormFields>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-5"
|
||||
>
|
||||
<Button type="submit" :disabled="form.processing">{{
|
||||
form.processing
|
||||
? t('ticketForm.creating')
|
||||
: t('ticketForm.create')
|
||||
}}</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing || !isDirty"
|
||||
@click="reset"
|
||||
>{{ t('ticketForm.reset') }}</Button
|
||||
>
|
||||
<p
|
||||
role="status"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{
|
||||
isDirty
|
||||
? t('ticketForm.unsaved')
|
||||
: t('ticketForm.ready')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -446,13 +299,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Card v-else>
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
Ticket creation needs at least one type, one priority,
|
||||
and one status.
|
||||
{{ t('ticketForm.metadataRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
Open ticket settings
|
||||
{{ t('ticketForm.openSettings') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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 './backlog';
|
||||
export * from './navigation';
|
||||
export * from './page-props';
|
||||
export * from './ticket-tree';
|
||||
export * from './ui';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
|
||||
export type TicketPresentationProps = {
|
||||
layout?: 'card' | 'list';
|
||||
projectId: number;
|
||||
showDescription?: 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.
|
||||
*/
|
||||
|
||||
@@ -1511,6 +1511,74 @@ class ProjectTicketControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): 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.tickets.create', $project))
|
||||
->assertForbidden();
|
||||
|
||||
$this->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'Unauthorized ticket',
|
||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
|
||||
])->assertForbidden();
|
||||
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
}
|
||||
|
||||
public function test_ticket_forms_reject_metadata_from_another_project(): void
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$otherProject = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||
$parent = Ticket::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'ticket_key' => 'OTHER-1',
|
||||
'ticket_type_id' => $this->parentType($otherProject)->id,
|
||||
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$invalidMetadata = [
|
||||
'parent_ticket_id' => $parent->id,
|
||||
'ticket_type_id' => $this->nonParentType($otherProject)->id,
|
||||
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||
];
|
||||
|
||||
$this->actingAs($editor)
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
...$invalidMetadata,
|
||||
'title' => 'Rejected creation',
|
||||
'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
|
||||
])
|
||||
->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
|
||||
|
||||
$this->assertSame(0, $project->tickets()->count());
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'ticket_key' => 'LOCAL-1',
|
||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'title' => 'Original title',
|
||||
]);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
|
||||
...$invalidMetadata,
|
||||
'title' => 'Rejected update',
|
||||
]))->assertSessionHasErrors(array_keys($invalidMetadata));
|
||||
|
||||
$this->assertSame('Original title', $ticket->fresh()->title);
|
||||
$this->assertNull($ticket->fresh()->parent_ticket_id);
|
||||
}
|
||||
|
||||
private function nonParentType(Project $project): TicketType
|
||||
{
|
||||
return $project->ticketTypes()
|
||||
|
||||
@@ -2,14 +2,17 @@ import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
|
||||
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Ticket'
|
||||
? Ticket
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
name === 'TicketCreate'
|
||||
? TicketCreate
|
||||
: name === 'Ticket'
|
||||
? Ticket
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
|
||||
@@ -123,10 +123,13 @@ const fieldDefinitions = [
|
||||
{ id: 1, label: 'Impact score', field_type: 'number' },
|
||||
{ id: 2, label: 'Reviewer', field_type: 'user' },
|
||||
];
|
||||
let savedForm = {};
|
||||
function makePage(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const params = parsed.searchParams;
|
||||
const detail = parsed.pathname.startsWith('/projects/7/tickets/');
|
||||
const creating = parsed.pathname.endsWith('/create');
|
||||
const detail =
|
||||
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
|
||||
const data = structuredClone(ticket);
|
||||
if (parsed.pathname.endsWith('/41')) {
|
||||
Object.assign(data, linked, {
|
||||
@@ -136,7 +139,7 @@ function makePage(url) {
|
||||
});
|
||||
}
|
||||
if (params.has('saved')) {
|
||||
data.title = params.get('saved');
|
||||
Object.assign(data, savedForm, { title: params.get('saved') });
|
||||
}
|
||||
if (params.has('long')) {
|
||||
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
|
||||
@@ -168,7 +171,11 @@ function makePage(url) {
|
||||
? 'Repository temporarily unavailable.'
|
||||
: null;
|
||||
return {
|
||||
component: detail ? 'Ticket' : 'Destination',
|
||||
component: creating
|
||||
? 'TicketCreate'
|
||||
: detail
|
||||
? 'Ticket'
|
||||
: 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
@@ -176,7 +183,28 @@ function makePage(url) {
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: params.has('rejected')
|
||||
? { title: 'This title is not allowed.' }
|
||||
? {
|
||||
title: 'This title is not allowed.',
|
||||
...(params.has('allErrors')
|
||||
? {
|
||||
description: 'Invalid description.',
|
||||
ticket_type_id: 'Invalid type.',
|
||||
ticket_priority_id: 'Invalid priority.',
|
||||
ticket_status_id: 'Invalid status.',
|
||||
assignee_user_id: 'Invalid assignee.',
|
||||
author_user_id: 'Invalid author.',
|
||||
parent_ticket_id: 'Invalid parent.',
|
||||
story_points: 'Invalid points.',
|
||||
'acceptance_criteria.0.text':
|
||||
'Invalid checklist.',
|
||||
blocked_comment: 'Invalid blocker.',
|
||||
'field_values.0.value_number':
|
||||
'Invalid number.',
|
||||
'field_values.1.value_user_id':
|
||||
'Invalid reviewer.',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {},
|
||||
sidebarOpen: true,
|
||||
auth: {
|
||||
@@ -204,7 +232,17 @@ function makePage(url) {
|
||||
canManageProject: params.has('admin'),
|
||||
status: params.has('saved') ? 'Ticket updated.' : null,
|
||||
users: [user, ticket.author],
|
||||
ticketTypes: [data.ticket_type],
|
||||
ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
|
||||
source: params.get('source') === 'board' ? 'board' : 'backlog',
|
||||
defaultTicketStatusId: 3,
|
||||
ticketStatusOptions: [
|
||||
{
|
||||
id: 3,
|
||||
name: 'In progress',
|
||||
board_column: { name: 'Delivery' },
|
||||
},
|
||||
{ id: 4, name: 'Backlog', board_column: null },
|
||||
],
|
||||
ticketPriorities: [data.ticket_priority],
|
||||
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
|
||||
parentTicketOptions: [parent],
|
||||
@@ -282,6 +320,11 @@ if (serve) {
|
||||
const data = JSON.parse(body);
|
||||
const previous = new URL(request.headers.referer);
|
||||
const params = previous.searchParams;
|
||||
if (params.has('serverError')) {
|
||||
response.writeHead(503);
|
||||
response.end('Simulated failure');
|
||||
return;
|
||||
}
|
||||
if (!request.headers['x-inertia']) {
|
||||
assert.equal(request.method, 'PATCH');
|
||||
assert.equal(request.url, '/projects/7/tickets/42');
|
||||
@@ -311,18 +354,19 @@ if (serve) {
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
'field_values' in data &&
|
||||
(request.method === 'POST' || 'field_values' in data) &&
|
||||
'acceptance_criteria' in data &&
|
||||
'parent_ticket_id' in data,
|
||||
);
|
||||
if (params.has('reject')) {
|
||||
params.set('rejected', '1');
|
||||
} else {
|
||||
savedForm = data;
|
||||
params.set('saved', data.title);
|
||||
}
|
||||
}
|
||||
response.writeHead(303, {
|
||||
Location: `/projects/7/tickets/42?${params}`,
|
||||
Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
@@ -364,10 +408,15 @@ if (serve) {
|
||||
const { default: Ticket } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/Ticket.vue',
|
||||
);
|
||||
const render = async (query = '') => {
|
||||
const { default: TicketCreate } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/TicketCreate.vue',
|
||||
);
|
||||
const render = async (query = '', creating = false) => {
|
||||
const result = await createInertiaApp({
|
||||
page: makePage(`/projects/7/tickets/42${query}`),
|
||||
resolve: () => Ticket,
|
||||
page: makePage(
|
||||
`/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
|
||||
),
|
||||
resolve: () => (creating ? TicketCreate : Ticket),
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
@@ -473,6 +522,37 @@ if (serve) {
|
||||
await render('?gitError'),
|
||||
/Repository temporarily unavailable/,
|
||||
);
|
||||
const create = await render('?source=board', true);
|
||||
assert.match(create, /Create ticket/);
|
||||
assert.match(create, /Delivery \/ In progress/);
|
||||
assert.match(create, /Back to board/);
|
||||
assert.match(create, /id="ticket_status_id"/);
|
||||
assert.doesNotMatch(
|
||||
create,
|
||||
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
await render('?hidePoints', true),
|
||||
/id="story_points"/,
|
||||
);
|
||||
const unavailable = await render('?noMetadata', true);
|
||||
assert.doesNotMatch(unavailable, /<form/);
|
||||
assert.match(unavailable, /Ticket creation needs at least one type/);
|
||||
for (const creating of [false, true]) {
|
||||
const invalid = await render('?rejected&allErrors', creating);
|
||||
for (const id of [
|
||||
'title',
|
||||
'description',
|
||||
'ticket_type_id',
|
||||
'ticket_priority_id',
|
||||
'assignee_user_id',
|
||||
'parent_ticket_id',
|
||||
'story_points',
|
||||
'acceptance_criteria_checklist',
|
||||
]) {
|
||||
assert.ok(invalid.includes(`id="${id}-error"`));
|
||||
}
|
||||
}
|
||||
console.log('Ticket detail behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const base = 'http://127.0.0.1:4185/projects/7/tickets';
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const field = (label) => page.getByLabel(label, { exact: true });
|
||||
const form = () => page.locator('form').first();
|
||||
const state = (value) =>
|
||||
form().locator(`[data-form-state="${value}"]`).waitFor();
|
||||
const button = (label) =>
|
||||
form().getByRole('button', { name: label, exact: true });
|
||||
async function visit(create, query = '') {
|
||||
await page.goto(`${base}/${create ? 'create' : '42'}${query}`);
|
||||
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||
if (!create) {
|
||||
await page.locator('#ticket-edit > summary').click();
|
||||
}
|
||||
}
|
||||
async function fill(create) {
|
||||
await field('Title').fill('A complete ticket');
|
||||
await field('Description').fill('Description with context.');
|
||||
await field('Assignee').selectOption('2');
|
||||
await field('Parent ticket').selectOption('40');
|
||||
await field('Estimated story points').fill('0');
|
||||
await field('Acceptance criteria checklist').fill(
|
||||
'[ ] Main criterion\n [x] Nested criterion',
|
||||
);
|
||||
if (create) {
|
||||
await field('Status').selectOption('4');
|
||||
} else {
|
||||
await field('Author').selectOption('1');
|
||||
await field('Blocker comment').fill('Waiting for review');
|
||||
await field('Impact score').fill('2.5');
|
||||
await field('Reviewer').selectOption('2');
|
||||
}
|
||||
}
|
||||
async function submit(create) {
|
||||
const request = page.waitForRequest(
|
||||
(r) =>
|
||||
r.method() === (create ? 'POST' : 'PATCH') &&
|
||||
r.url().split('?')[0] === (create ? base : `${base}/42`),
|
||||
);
|
||||
await button(create ? 'Create ticket' : 'Save changes').click();
|
||||
return (await request).postDataJSON();
|
||||
}
|
||||
try {
|
||||
for (const create of [true, false]) {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
await visit(create, '?admin');
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
`Overflow ${create}/${width}/${theme}`,
|
||||
);
|
||||
await field('Title').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await field('Description').evaluate(
|
||||
(el) =>
|
||||
el === document.activeElement &&
|
||||
el.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await fill(create);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await button('Discard changes').click();
|
||||
assert.equal(
|
||||
await field('Title').inputValue(),
|
||||
'A complete ticket',
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await button('Discard changes').click();
|
||||
assert.equal(
|
||||
await button('Discard changes').isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await field('Title').inputValue(),
|
||||
create
|
||||
? ''
|
||||
: 'Implement a resilient caching layer for the project API',
|
||||
);
|
||||
}
|
||||
}
|
||||
await visit(create, '?admin&reject&allErrors');
|
||||
await fill(create);
|
||||
const data = await submit(create);
|
||||
assert.equal(data.title, 'A complete ticket');
|
||||
assert.equal(data.description, 'Description with context.');
|
||||
assert.equal(Number(data.ticket_type_id), 1);
|
||||
assert.equal(Number(data.ticket_priority_id), 2);
|
||||
assert.equal(Number(data.assignee_user_id), 2);
|
||||
assert.equal(Number(data.parent_ticket_id), 40);
|
||||
assert.equal(Number(data.story_points), 0);
|
||||
assert.deepEqual(
|
||||
data.acceptance_criteria.map(({ text, checked, indent }) => ({
|
||||
text,
|
||||
checked,
|
||||
indent,
|
||||
})),
|
||||
[
|
||||
{ text: 'Main criterion', checked: false, indent: 0 },
|
||||
{ text: 'Nested criterion', checked: true, indent: 1 },
|
||||
],
|
||||
);
|
||||
if (create) {
|
||||
assert.equal(Number(data.ticket_status_id), 4);
|
||||
assert.equal('author_user_id' in data, false);
|
||||
} else {
|
||||
assert.equal(Number(data.author_user_id), 1);
|
||||
assert.equal(data.is_blocked, true);
|
||||
assert.equal(data.blocked_comment, 'Waiting for review');
|
||||
assert.equal(Number(data.field_values[0].value_number), 2.5);
|
||||
assert.equal(Number(data.field_values[1].value_user_id), 2);
|
||||
assert.deepEqual(
|
||||
data.acceptance_criteria.map((item) => item.id),
|
||||
[10, 11],
|
||||
);
|
||||
assert.equal('ticket_status_id' in data, false);
|
||||
}
|
||||
await state('validation');
|
||||
const fields = [
|
||||
'Title',
|
||||
'Description',
|
||||
'Type',
|
||||
'Priority',
|
||||
'Assignee',
|
||||
'Parent ticket',
|
||||
'Estimated story points',
|
||||
'Acceptance criteria checklist',
|
||||
...(create
|
||||
? ['Status']
|
||||
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
|
||||
];
|
||||
for (const label of fields) {
|
||||
const control = field(label);
|
||||
assert.equal(
|
||||
await control.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
label,
|
||||
);
|
||||
const errorId = (await control.getAttribute('aria-describedby'))
|
||||
.split(' ')
|
||||
.at(-1);
|
||||
assert.ok(
|
||||
(await page.locator(`[id="${errorId}"]`).innerText()).length,
|
||||
`Missing associated error: ${label}`,
|
||||
);
|
||||
}
|
||||
assert.equal(await field('Title').inputValue(), 'A complete ticket');
|
||||
await page.route(create ? `${base}` : `${base}/42`, (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await button(create ? 'Create ticket' : 'Save changes').click();
|
||||
await state('network');
|
||||
assert.equal(
|
||||
await field('Description').inputValue(),
|
||||
'Description with context.',
|
||||
);
|
||||
await page.unrouteAll();
|
||||
await visit(create, '?admin&serverError');
|
||||
await fill(create);
|
||||
await submit(create);
|
||||
await state('server');
|
||||
assert.equal(await field('Title').inputValue(), 'A complete ticket');
|
||||
let pending;
|
||||
let requests = 0;
|
||||
await page.route(create ? `${base}` : `${base}/42`, (route) => {
|
||||
requests++;
|
||||
pending = route;
|
||||
});
|
||||
await button('Try again').click();
|
||||
await state('loading');
|
||||
assert.equal(await field('Title').isDisabled(), true);
|
||||
assert.equal(
|
||||
await button(
|
||||
create ? 'Creating ticket…' : 'Saving changes…',
|
||||
).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await form().evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
await pending.abort();
|
||||
await state('network');
|
||||
await page.unrouteAll();
|
||||
await visit(create, '?admin');
|
||||
await fill(create);
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await page
|
||||
.locator('#main-content')
|
||||
.getByRole('link', {
|
||||
name: create ? 'Back to backlog' : 'Open backlog',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.ok(page.url().includes('/tickets/'));
|
||||
const successData = await submit(create);
|
||||
await page
|
||||
.getByRole('heading', { name: successData.title, exact: true })
|
||||
.waitFor();
|
||||
if (create) {
|
||||
await page.goBack();
|
||||
await field('Title').waitFor();
|
||||
assert.equal(await field('Title').inputValue(), '');
|
||||
assert.equal(
|
||||
await field('Acceptance criteria checklist').inputValue(),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
await form()
|
||||
.getByText('All changes saved', { exact: true })
|
||||
.waitFor();
|
||||
await field('Title').fill('Another draft');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await button('Discard changes').click();
|
||||
assert.equal(
|
||||
await field('Title').inputValue(),
|
||||
'A complete ticket',
|
||||
);
|
||||
assert.equal(await field('Impact score').inputValue(), '2.5');
|
||||
}
|
||||
}
|
||||
await visit(false, '?hideAuthor&hidePoints');
|
||||
assert.equal(await field('Author').count(), 0);
|
||||
assert.equal(await field('Estimated story points').count(), 0);
|
||||
await visit(false);
|
||||
assert.equal(await field('Author').isDisabled(), true);
|
||||
await visit(true, '?hidePoints');
|
||||
assert.equal(await field('Estimated story points').count(), 0);
|
||||
await visit(true, '?noMetadata');
|
||||
assert.equal(await page.locator('form').count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user