Compare commits
15
Commits
802294d6a2
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 | ||
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 |
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
|||||||
'tickets' => TicketResource::collection(
|
'tickets' => TicketResource::collection(
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
|||||||
->where('assignee_user_id', $assignee->id)
|
->where('assignee_user_id', $assignee->id)
|
||||||
->whereIn('project_id', $visibleProjectIds)
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
->with([
|
->with([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'project',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'ticketType',
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return Inertia::render('Dashboard', [
|
||||||
|
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ArrowUpRight } from 'lucide-vue-next';
|
||||||
|
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
|
import type {
|
||||||
|
TicketPresentationEmits,
|
||||||
|
TicketPresentationProps,
|
||||||
|
} from '@/types/ticket-presentation';
|
||||||
|
|
||||||
|
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||||
|
showDescription: true,
|
||||||
|
showStatus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<TicketPresentationEmits>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
:class="[
|
||||||
|
'bg-card text-card-foreground min-w-0 rounded-md border 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,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div v-if="$slots.actions" class="border-t p-4">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ArrowUpRight } from 'lucide-vue-next';
|
||||||
|
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
|
import type {
|
||||||
|
TicketPresentationEmits,
|
||||||
|
TicketPresentationProps,
|
||||||
|
} from '@/types/ticket-presentation';
|
||||||
|
|
||||||
|
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||||
|
showDescription: true,
|
||||||
|
showStatus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<TicketPresentationEmits>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||||
|
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
|
||||||
|
<Link
|
||||||
|
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||||
|
:aria-label="
|
||||||
|
t('tickets.open', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
title: ticket.title,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
|
||||||
|
@click="emit('open', ticket.id)"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_key }}
|
||||||
|
</Badge>
|
||||||
|
<h3
|
||||||
|
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
</h3>
|
||||||
|
<ArrowUpRight
|
||||||
|
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="showDescription && ticket.description"
|
||||||
|
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ 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 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
|
||||||
|
>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
showStatus?: boolean;
|
||||||
|
ticket: TicketModel;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showStatus: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const statusLabel = computed(() =>
|
||||||
|
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gap-stack flex min-w-0 flex-wrap items-center">
|
||||||
|
<Badge
|
||||||
|
v-if="ticket.is_blocked"
|
||||||
|
variant="warning"
|
||||||
|
:aria-label="t('tickets.blocked')"
|
||||||
|
>
|
||||||
|
<OctagonAlert aria-hidden="true" />
|
||||||
|
{{ t('tickets.blocked') }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
:aria-label="
|
||||||
|
t('tickets.typeLabel', { name: ticket.ticket_type.name })
|
||||||
|
"
|
||||||
|
:data-ticket-type-id="ticket.ticket_type.id"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_type.name }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t('tickets.priorityLabel', {
|
||||||
|
name: ticket.ticket_priority.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:data-ticket-priority-id="ticket.ticket_priority.id"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_priority.name }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
v-if="showStatus"
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
|
||||||
|
:data-ticket-status-id="ticket.ticket_status?.id"
|
||||||
|
>
|
||||||
|
{{ statusLabel }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="ticket.assignee"
|
||||||
|
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
|
||||||
|
:aria-label="
|
||||||
|
t('tickets.assignedTo', { name: ticket.assignee.name })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="ticket.assignee"
|
||||||
|
size="sm"
|
||||||
|
:label="ticket.assignee.name"
|
||||||
|
/>
|
||||||
|
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-caption text-muted-foreground flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<CircleUserRound class="size-4" aria-hidden="true" />
|
||||||
|
{{ t('tickets.unassigned') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as TicketCard } from './TicketCard.vue';
|
||||||
|
export { default as TicketListItem } from './TicketListItem.vue';
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
/** Pointer capture keeps mouse, pen and touch drags on the same code path. */
|
||||||
|
export function useBoardDrag(options: {
|
||||||
|
canStart: (ticket: TicketModel) => boolean;
|
||||||
|
drop: (ticket: TicketModel, columnId: number) => void;
|
||||||
|
}) {
|
||||||
|
const ticket = shallowRef<TicketModel | null>(null);
|
||||||
|
const active = ref(false);
|
||||||
|
const x = ref(0);
|
||||||
|
const y = ref(0);
|
||||||
|
const columnId = ref<number | null>(null);
|
||||||
|
let pointerId: number | null = null;
|
||||||
|
let handle: HTMLElement | null = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const draggedTicket = computed(() => (active.value ? ticket.value : null));
|
||||||
|
|
||||||
|
function locateTarget(): void {
|
||||||
|
const target = document
|
||||||
|
.elementFromPoint(x.value, y.value)
|
||||||
|
?.closest<HTMLElement>('[data-board-drop-column]');
|
||||||
|
columnId.value = target ? Number(target.dataset.boardDropColumn) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scroll(): void {
|
||||||
|
if (!active.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = document.elementFromPoint(x.value, y.value);
|
||||||
|
const region = element?.closest<HTMLElement>(
|
||||||
|
'[data-board-drag-scroll]',
|
||||||
|
);
|
||||||
|
if (region) {
|
||||||
|
const bounds = region.getBoundingClientRect();
|
||||||
|
const left = Math.max(0, bounds.left);
|
||||||
|
const right = Math.min(window.innerWidth, bounds.right);
|
||||||
|
const delta =
|
||||||
|
x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0;
|
||||||
|
region.scrollLeft += delta;
|
||||||
|
}
|
||||||
|
if (!element?.closest('[data-board-mobile-targets]')) {
|
||||||
|
const delta =
|
||||||
|
y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0;
|
||||||
|
window.scrollBy(0, delta);
|
||||||
|
}
|
||||||
|
locateTarget();
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
const previousPointer = pointerId;
|
||||||
|
pointerId = null;
|
||||||
|
active.value = false;
|
||||||
|
ticket.value = null;
|
||||||
|
columnId.value = null;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
if (
|
||||||
|
previousPointer !== null &&
|
||||||
|
handle?.hasPointerCapture(previousPointer)
|
||||||
|
) {
|
||||||
|
handle.releasePointerCapture(previousPointer);
|
||||||
|
}
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(event: PointerEvent, source: TicketModel): void {
|
||||||
|
if (
|
||||||
|
pointerId !== null ||
|
||||||
|
!event.isPrimary ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
!options.canStart(source)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle = event.currentTarget as HTMLElement;
|
||||||
|
handle.focus({ preventScroll: true });
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
ticket.value = source;
|
||||||
|
startX = x.value = event.clientX;
|
||||||
|
startY = y.value = event.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event: PointerEvent): void {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.value = event.clientX;
|
||||||
|
y.value = event.clientY;
|
||||||
|
if (
|
||||||
|
!active.value &&
|
||||||
|
Math.hypot(x.value - startX, y.value - startY) >= 6
|
||||||
|
) {
|
||||||
|
active.value = true;
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
if (active.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
locateTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(event: PointerEvent): void {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.value = event.clientX;
|
||||||
|
y.value = event.clientY;
|
||||||
|
locateTarget();
|
||||||
|
const source = draggedTicket.value;
|
||||||
|
const target = columnId.value;
|
||||||
|
cancel();
|
||||||
|
if (source && target !== null) {
|
||||||
|
options.drop(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPointer(event: PointerEvent): void {
|
||||||
|
if (event.pointerId === pointerId) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape' && pointerId !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointermove', move, { passive: false });
|
||||||
|
document.addEventListener('pointerup', finish);
|
||||||
|
document.addEventListener('pointercancel', cancelPointer);
|
||||||
|
document.addEventListener('lostpointercapture', cancelPointer);
|
||||||
|
document.addEventListener('keydown', keydown);
|
||||||
|
window.addEventListener('blur', cancel);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancel();
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', finish);
|
||||||
|
document.removeEventListener('pointercancel', cancelPointer);
|
||||||
|
document.removeEventListener('lostpointercapture', cancelPointer);
|
||||||
|
document.removeEventListener('keydown', keydown);
|
||||||
|
window.removeEventListener('blur', cancel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { draggedTicket, columnId, x, y, start, cancel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
|
export function ticketStatusLabel(
|
||||||
|
ticketStatus: TicketStatusModel | null,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
if (ticketStatus === null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ticketStatus.board_column === null ||
|
||||||
|
ticketStatus.board_column === undefined ||
|
||||||
|
ticketStatus.board_column.name === ticketStatus.name
|
||||||
|
) {
|
||||||
|
return ticketStatus.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,173 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'board.title': '{name} board',
|
||||||
|
'board.description':
|
||||||
|
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
||||||
|
'board.columns': 'Workflow columns',
|
||||||
|
'board.selectColumn': 'Choose a column',
|
||||||
|
'board.columnOption': '{name} ({count})',
|
||||||
|
'board.scrollHint': 'Scroll horizontally to explore all columns.',
|
||||||
|
'board.openBacklog': 'Open backlog',
|
||||||
|
'board.backToProject': 'Project overview',
|
||||||
|
'board.empty.title': 'No workflow columns yet',
|
||||||
|
'board.empty.description':
|
||||||
|
'Set up columns to start tracking work on this board.',
|
||||||
|
'board.emptyColumn': 'No tickets in this column.',
|
||||||
|
'board.emptyStatus': 'No tickets in this status.',
|
||||||
|
'board.noStatuses': 'No statuses configured for this column.',
|
||||||
|
'board.moveTicket': 'Move {key}',
|
||||||
|
'board.updateStatus': 'Update status',
|
||||||
|
'board.updatingStatus': 'Updating…',
|
||||||
|
'board.noMoves': 'No allowed moves',
|
||||||
|
'board.drag': 'Drag to move',
|
||||||
|
'board.dragTicket': 'Drag {key} to change status',
|
||||||
|
'board.dragHint':
|
||||||
|
'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.',
|
||||||
|
'board.dropHint': 'Drop on a column. Hold near an edge to scroll.',
|
||||||
|
'board.dragging':
|
||||||
|
'Dragging {key}. Drop on an allowed column or press Escape to cancel.',
|
||||||
|
'board.chooseStatus': 'Choose a destination status',
|
||||||
|
'board.chooseStatusDescription':
|
||||||
|
'More than one status is available for {key} in this column.',
|
||||||
|
'board.targetStatus': 'Destination status',
|
||||||
|
'board.noMovesDescription':
|
||||||
|
'This status does not allow moving anywhere else.',
|
||||||
|
'projectCreation.title': 'Create a project',
|
||||||
|
'projectCreation.description':
|
||||||
|
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
||||||
|
'projectCreation.start': 'How would you like to start?',
|
||||||
|
'projectCreation.scratch': 'Start from scratch',
|
||||||
|
'projectCreation.scratchDescription':
|
||||||
|
'Create an empty project with the default setup.',
|
||||||
|
'projectCreation.duplicate': 'Duplicate a project',
|
||||||
|
'projectCreation.duplicateDescription':
|
||||||
|
'Choose an existing project and copy selected settings.',
|
||||||
|
'projectCreation.template': 'Use a template',
|
||||||
|
'projectCreation.templateDescription':
|
||||||
|
'Start with a saved, reusable project configuration.',
|
||||||
|
'projectCreation.noProjects':
|
||||||
|
'No accessible projects are available to copy yet.',
|
||||||
|
'projectCreation.noTemplates': 'No published templates are available yet.',
|
||||||
|
'projectCreation.details': 'Project details',
|
||||||
|
'projectCreation.name': 'Project name',
|
||||||
|
'projectCreation.namePlaceholder': 'Platform engineering',
|
||||||
|
'projectCreation.key': 'Project key',
|
||||||
|
'projectCreation.keyPlaceholder': 'CORE',
|
||||||
|
'projectCreation.keyHelp':
|
||||||
|
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
|
||||||
|
'projectCreation.copyTitle': 'Choose what to copy',
|
||||||
|
'projectCreation.copyHelp':
|
||||||
|
'Copy configuration into an independent project. Select only what you need.',
|
||||||
|
'projectCreation.sourceProject': 'Source project',
|
||||||
|
'projectCreation.selectProject': 'Select a source project',
|
||||||
|
'projectCreation.copyOptions': 'Configuration areas',
|
||||||
|
'projectCreation.copyDefaults':
|
||||||
|
'Unselected areas keep the default setup. You can also continue without copying any settings.',
|
||||||
|
'projectCreation.chooseTemplate': 'Choose a template',
|
||||||
|
'projectCreation.templateLabel': 'Template',
|
||||||
|
'projectCreation.selectTemplate': 'Select a template',
|
||||||
|
'projectCreation.templateHelp':
|
||||||
|
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
|
||||||
|
'projectCreation.includedAreas': 'Included configuration',
|
||||||
|
'projectCreation.templateDefaults':
|
||||||
|
'This template uses the default project setup.',
|
||||||
|
'projectCreation.summary': 'Your new project',
|
||||||
|
'projectCreation.untitled': 'Add a project name',
|
||||||
|
'projectCreation.ticketPreview': 'First ticket key',
|
||||||
|
'projectCreation.setup': 'Starting point',
|
||||||
|
'projectCreation.cleanStart':
|
||||||
|
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
|
||||||
|
'projectCreation.submit': 'Create project',
|
||||||
|
'projectCreation.creating': 'Creating project…',
|
||||||
|
'projectCreation.areas.projectSettings.label': 'Project settings',
|
||||||
|
'projectCreation.areas.projectSettings.description':
|
||||||
|
'Story points and author visibility.',
|
||||||
|
'projectCreation.areas.boardColumns.label': 'Board columns',
|
||||||
|
'projectCreation.areas.boardColumns.description':
|
||||||
|
'Columns and their order on the board.',
|
||||||
|
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
|
||||||
|
'projectCreation.areas.ticketStatuses.description':
|
||||||
|
'Statuses and their board columns.',
|
||||||
|
'projectCreation.areas.statusTransitions.label': 'Status transitions',
|
||||||
|
'projectCreation.areas.statusTransitions.description':
|
||||||
|
'Allowed moves between statuses.',
|
||||||
|
'projectCreation.areas.ticketTypes.label': 'Ticket types',
|
||||||
|
'projectCreation.areas.ticketTypes.description':
|
||||||
|
'Available types, including parent types.',
|
||||||
|
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
|
||||||
|
'projectCreation.areas.ticketPriorities.description':
|
||||||
|
'Available priority levels.',
|
||||||
|
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
|
||||||
|
'projectCreation.areas.ticketFields.description':
|
||||||
|
'Field definitions without ticket values.',
|
||||||
|
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
|
||||||
|
'projectCreation.areas.ticketLinkTypes.description':
|
||||||
|
'Relationship labels between tickets.',
|
||||||
|
'projectCreation.areas.repositories.label': 'Repository connections',
|
||||||
|
'projectCreation.areas.repositories.description':
|
||||||
|
'Repositories linked to the project.',
|
||||||
|
'projectCreation.areas.notifications.label': 'Notification actions',
|
||||||
|
'projectCreation.areas.notifications.description':
|
||||||
|
'Notification rules and delivery settings.',
|
||||||
|
'projectCreation.selectedCount': {
|
||||||
|
one: '{count} area selected',
|
||||||
|
other: '{count} areas selected',
|
||||||
|
},
|
||||||
|
|
||||||
|
'projects.workspace': 'Workspace',
|
||||||
|
'projects.description':
|
||||||
|
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
|
||||||
|
'projects.create': 'New project',
|
||||||
|
'projects.updated': 'Project update',
|
||||||
|
'projects.available': {
|
||||||
|
one: '{count} project available',
|
||||||
|
other: '{count} projects available',
|
||||||
|
},
|
||||||
|
'projects.open': 'Open {name} overview',
|
||||||
|
'projects.openBoard': 'Open {name} board',
|
||||||
|
'projects.openBacklog': 'Open {name} backlog',
|
||||||
|
'projects.overview': 'Project overview',
|
||||||
|
'projects.empty.title': 'No projects to show',
|
||||||
|
'projects.empty.createDescription':
|
||||||
|
'Create a project to give your team a place to plan and track work.',
|
||||||
|
'projects.empty.accessDescription':
|
||||||
|
'Projects appear here when you have access. Ask a workspace or project administrator to add you.',
|
||||||
|
'projects.empty.create': 'Create your first project',
|
||||||
|
'projects.back': 'All projects',
|
||||||
|
'projects.hubDescription':
|
||||||
|
'Follow work on the board or explore tickets in the backlog.',
|
||||||
|
'projects.workAreas': 'Project work areas',
|
||||||
|
'projects.boardDescription':
|
||||||
|
'See work across the project’s workflow and open tickets in their current status.',
|
||||||
|
'projects.backlogDescription':
|
||||||
|
'Explore incoming work, review ticket details, and plan what comes next.',
|
||||||
|
'projects.boardAction': 'Open board',
|
||||||
|
'projects.backlogAction': 'Open backlog',
|
||||||
|
'projects.settingsDescription':
|
||||||
|
'Manage this project’s configuration, integrations, and access.',
|
||||||
|
'projects.saveTemplate': 'Save as new template',
|
||||||
|
'projects.templateTitle': 'Based on {name}',
|
||||||
|
'projects.templateDescription':
|
||||||
|
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||||
|
'projects.inheritedArea': '{name} · inherited',
|
||||||
|
'projects.overriddenArea': '{name} · overridden',
|
||||||
|
'dashboard.eyebrow': 'Personal workspace',
|
||||||
|
'dashboard.greeting': 'Welcome back, {name}',
|
||||||
|
'dashboard.description':
|
||||||
|
'A current view of tickets assigned to you in projects you can access.',
|
||||||
|
'dashboard.summary': 'Work summary',
|
||||||
|
'dashboard.summary.assignedTickets': 'Assigned tickets',
|
||||||
|
'dashboard.summary.projects': 'Projects with assigned work',
|
||||||
|
'dashboard.summary.blockedTickets': 'Blocked tickets',
|
||||||
|
'dashboard.assignedWork': 'My assigned tickets',
|
||||||
|
'dashboard.assignedWorkDescription':
|
||||||
|
'Your work is grouped by project and ordered with the newest tickets first.',
|
||||||
|
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
|
||||||
|
'dashboard.openProject': 'Open {name}',
|
||||||
|
'dashboard.openProjectAction': 'Open project',
|
||||||
|
'dashboard.empty.title': 'No tickets assigned to you',
|
||||||
|
'dashboard.empty.description':
|
||||||
|
'Tickets assigned to you in projects you can access will appear here.',
|
||||||
|
'dashboard.empty.action': 'Browse projects',
|
||||||
'auth.home': 'KanbanGit home',
|
'auth.home': 'KanbanGit home',
|
||||||
'auth.accountAccess': 'Account access',
|
'auth.accountAccess': 'Account access',
|
||||||
'auth.backToLogin': 'Return to sign in',
|
'auth.backToLogin': 'Return to sign in',
|
||||||
@@ -17,6 +186,54 @@ export const en = {
|
|||||||
'auth.login.pending': 'Signing in…',
|
'auth.login.pending': 'Signing in…',
|
||||||
'auth.login.help': 'Need an account? Contact your workspace administrator.',
|
'auth.login.help': 'Need an account? Contact your workspace administrator.',
|
||||||
'auth.login.statusTitle': 'Account update',
|
'auth.login.statusTitle': 'Account update',
|
||||||
|
'auth.forgot.title': 'Reset your password',
|
||||||
|
'auth.forgot.description':
|
||||||
|
'Enter your account email and we will send you a secure reset link.',
|
||||||
|
'auth.forgot.email': 'Email address',
|
||||||
|
'auth.forgot.emailPlaceholder': 'you@example.com',
|
||||||
|
'auth.forgot.emailHint':
|
||||||
|
'Use the email address associated with your KanbanGit account.',
|
||||||
|
'auth.forgot.submit': 'Send reset link',
|
||||||
|
'auth.forgot.pending': 'Sending reset link…',
|
||||||
|
'auth.forgot.sentTitle': 'Reset link sent',
|
||||||
|
'auth.reset.title': 'Choose a new password',
|
||||||
|
'auth.reset.description':
|
||||||
|
'Create a new password for the account linked below.',
|
||||||
|
'auth.reset.email': 'Account email',
|
||||||
|
'auth.reset.password': 'New password',
|
||||||
|
'auth.reset.passwordHint':
|
||||||
|
'Use a password you do not use for another account.',
|
||||||
|
'auth.reset.passwordConfirmation': 'Confirm new password',
|
||||||
|
'auth.reset.submit': 'Save new password',
|
||||||
|
'auth.reset.pending': 'Updating your password…',
|
||||||
|
'auth.reset.invalidTitle': 'This reset link is no longer valid',
|
||||||
|
'auth.reset.requestNew': 'Request a new reset link',
|
||||||
|
'auth.reset.footer':
|
||||||
|
'For your security, reset links expire and can only be used once.',
|
||||||
|
'auth.confirm.title': 'Confirm it is you',
|
||||||
|
'auth.confirm.description':
|
||||||
|
'This action affects sensitive account information. Enter your password to continue.',
|
||||||
|
'auth.confirm.password': 'Current password',
|
||||||
|
'auth.confirm.passwordHint':
|
||||||
|
'Your password is checked securely and is never saved in a draft.',
|
||||||
|
'auth.confirm.submit': 'Confirm and continue',
|
||||||
|
'auth.confirm.pending': 'Confirming your password…',
|
||||||
|
'auth.confirm.footer':
|
||||||
|
'Password confirmation expires automatically after a short time.',
|
||||||
|
'auth.forceChange.title': 'Secure your account',
|
||||||
|
'auth.forceChange.description':
|
||||||
|
'You need to replace your temporary password before continuing to KanbanGit.',
|
||||||
|
'auth.forceChange.currentPassword': 'Temporary password',
|
||||||
|
'auth.forceChange.password': 'New password',
|
||||||
|
'auth.forceChange.passwordHint':
|
||||||
|
'Choose a password that differs from your temporary password.',
|
||||||
|
'auth.forceChange.passwordConfirmation': 'Confirm new password',
|
||||||
|
'auth.forceChange.submit': 'Save password and continue',
|
||||||
|
'auth.forceChange.pending': 'Securing your account…',
|
||||||
|
'auth.forceChange.statusTitle': 'Password update',
|
||||||
|
'auth.forceChange.wrongAccount': 'Not the account you expected?',
|
||||||
|
'auth.forceChange.footer':
|
||||||
|
'Your password entries are kept in memory only and are cleared when you leave.',
|
||||||
'auth.verify.title': 'Check your inbox',
|
'auth.verify.title': 'Check your inbox',
|
||||||
'auth.verify.description':
|
'auth.verify.description':
|
||||||
'Open the verification link in your email to finish confirming your account.',
|
'Open the verification link in your email to finish confirming your account.',
|
||||||
@@ -130,6 +347,15 @@ export const en = {
|
|||||||
'tickets.noPullRequests': 'No matching pull requests',
|
'tickets.noPullRequests': 'No matching pull requests',
|
||||||
'tickets.noPullRequestsDescription':
|
'tickets.noPullRequestsDescription':
|
||||||
'No matching pull requests were found across the linked repositories.',
|
'No matching pull requests were found across the linked repositories.',
|
||||||
|
'tickets.open': 'Open {key}: {title}',
|
||||||
|
'tickets.blocked': 'Blocked',
|
||||||
|
'tickets.blockerComment': 'Blocker: {comment}',
|
||||||
|
'tickets.typeLabel': 'Type: {name}',
|
||||||
|
'tickets.priorityLabel': 'Priority: {name}',
|
||||||
|
'tickets.statusLabel': 'Status: {name}',
|
||||||
|
'tickets.noStatus': 'No status',
|
||||||
|
'tickets.assignedTo': 'Assigned to {name}',
|
||||||
|
'tickets.unassigned': 'Unassigned',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.error': 'Something went wrong.',
|
'common.error': 'Something went wrong.',
|
||||||
|
|||||||
@@ -1,47 +1,211 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
FolderGit2,
|
||||||
|
ListTodo,
|
||||||
|
OctagonAlert,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import { TicketCard } from '@/components/tickets';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { dashboard } from '@/routes/index';
|
import { dashboard } from '@/routes/index';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import { index as projectsIndex, show as showProject } from '@/routes/projects';
|
||||||
|
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const props = defineProps<DashboardPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.dashboard'), href: dashboard() },
|
||||||
|
]);
|
||||||
|
const assignedTickets = computed(() =>
|
||||||
|
props.assignedTicketsByProject.flatMap((group) => group.tickets),
|
||||||
|
);
|
||||||
|
const summaryItems = computed(() => [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
label: t('dashboard.summary.assignedTickets'),
|
||||||
href: dashboard(),
|
value: assignedTickets.value.length,
|
||||||
|
icon: ListTodo,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
label: t('dashboard.summary.projects'),
|
||||||
|
value: props.assignedTicketsByProject.length,
|
||||||
|
icon: FolderGit2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('dashboard.summary.blockedTickets'),
|
||||||
|
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
|
||||||
|
.length,
|
||||||
|
icon: OctagonAlert,
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
|
||||||
|
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<div
|
<Head :title="t('navigation.dashboard')" />
|
||||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
|
||||||
>
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
<header class="gap-stack grid min-w-0">
|
||||||
<div
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
{{ t('dashboard.eyebrow') }}
|
||||||
>
|
</p>
|
||||||
<PlaceholderPattern />
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
</div>
|
{{
|
||||||
<div
|
t('dashboard.greeting', {
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
name: $page.props.auth.user.name,
|
||||||
>
|
})
|
||||||
<PlaceholderPattern />
|
}}
|
||||||
</div>
|
</h1>
|
||||||
<div
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
{{ t('dashboard.description') }}
|
||||||
>
|
</p>
|
||||||
<PlaceholderPattern />
|
</header>
|
||||||
</div>
|
|
||||||
</div>
|
<section :aria-label="t('dashboard.summary')">
|
||||||
<div
|
<ul class="grid gap-3 sm:grid-cols-3">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
<li
|
||||||
|
v-for="item in summaryItems"
|
||||||
|
:key="item.label"
|
||||||
|
class="min-w-0"
|
||||||
|
>
|
||||||
|
<Card class="h-full gap-0 rounded-md py-0">
|
||||||
|
<CardContent
|
||||||
|
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-words"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</p>
|
||||||
|
<p class="text-title mt-1 tabular-nums">
|
||||||
|
{{ item.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="assigned-work-title"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
>
|
>
|
||||||
<PlaceholderPattern />
|
<header class="gap-stack grid min-w-0">
|
||||||
</div>
|
<h2 id="assigned-work-title" class="text-title">
|
||||||
|
{{ t('dashboard.assignedWork') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('dashboard.assignedWorkDescription') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-if="assignedTicketsByProject.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('dashboard.empty.title')"
|
||||||
|
:description="t('dashboard.empty.description')"
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="projectsIndex()">
|
||||||
|
<FolderGit2 aria-hidden="true" />
|
||||||
|
{{ t('dashboard.empty.action') }}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
|
<div v-else class="gap-page grid min-w-0">
|
||||||
|
<section
|
||||||
|
v-for="group in assignedTicketsByProject"
|
||||||
|
:key="group.project.id"
|
||||||
|
:aria-labelledby="`dashboard-project-${group.project.id}`"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="max-w-full shrink-0 whitespace-normal break-all"
|
||||||
|
>
|
||||||
|
{{ group.project.key }}
|
||||||
|
</Badge>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3
|
||||||
|
:id="`dashboard-project-${group.project.id}`"
|
||||||
|
class="text-body break-words font-semibold"
|
||||||
|
>
|
||||||
|
{{ group.project.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('tickets.count', {
|
||||||
|
count: group.tickets.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="link"
|
||||||
|
class="self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
:href="showProject(group.project.id)"
|
||||||
|
:aria-label="
|
||||||
|
t('dashboard.openProject', {
|
||||||
|
name: group.project.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ t('dashboard.openProjectAction') }}
|
||||||
|
<ArrowUpRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
:aria-label="
|
||||||
|
t('dashboard.projectTickets', {
|
||||||
|
name: group.project.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="ticket in group.tickets"
|
||||||
|
:key="ticket.id"
|
||||||
|
class="min-w-0"
|
||||||
|
>
|
||||||
|
<TicketCard
|
||||||
|
:project-id="group.project.id"
|
||||||
|
:ticket="ticket"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,52 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ShieldCheck } from 'lucide-vue-next';
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
import { store } from '@/routes/password/confirm';
|
import { store } from '@/routes/password/confirm';
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({ password: '' }).dontRemember('password');
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
|
const submit = (): void =>
|
||||||
|
protection.submit(store(), { onSuccess: () => form.reset() });
|
||||||
|
onUnmounted(() => form.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Confirm your password"
|
:title="t('auth.confirm.title')"
|
||||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
:description="t('auth.confirm.description')"
|
||||||
|
:icon="ShieldCheck"
|
||||||
>
|
>
|
||||||
<Head title="Confirm password" />
|
<Head :title="t('auth.confirm.title')" />
|
||||||
|
<form
|
||||||
<Form
|
:action="store.url()"
|
||||||
v-bind="store.form()"
|
method="post"
|
||||||
reset-on-success
|
class="grid gap-5"
|
||||||
v-slot="{ errors, processing }"
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
<div class="space-y-6">
|
<AuthFormFeedback
|
||||||
<div class="grid gap-2">
|
:state="protection.state.value"
|
||||||
<Label htmlFor="password">Password</Label>
|
:response-status="protection.responseStatus.value"
|
||||||
<PasswordInput
|
:loading-title="t('auth.confirm.pending')"
|
||||||
id="password"
|
@retry="submit"
|
||||||
name="password"
|
/>
|
||||||
class="mt-1 block w-full"
|
<div class="gap-stack grid">
|
||||||
required
|
<Label for="password">{{ t('auth.confirm.password') }}</Label>
|
||||||
autocomplete="current-password"
|
<PasswordInput
|
||||||
autofocus
|
id="password"
|
||||||
/>
|
v-model="form.password"
|
||||||
|
name="password"
|
||||||
<InputError :message="errors.password" />
|
required
|
||||||
</div>
|
autocomplete="current-password"
|
||||||
|
autofocus
|
||||||
<div class="flex items-center">
|
:disabled="form.processing"
|
||||||
<Button
|
:invalid="Boolean(form.errors.password)"
|
||||||
class="w-full"
|
aria-describedby="password-hint password-error"
|
||||||
:disabled="processing"
|
/>
|
||||||
data-test="confirm-password-button"
|
<p
|
||||||
>
|
id="password-hint"
|
||||||
<Spinner v-if="processing" />
|
class="text-caption text-muted-foreground"
|
||||||
Confirm password
|
>
|
||||||
</Button>
|
{{ t('auth.confirm.passwordHint') }}
|
||||||
</div>
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="password-error"
|
||||||
|
:message="form.errors.password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<Button
|
||||||
</AuthLayout>
|
type="submit"
|
||||||
|
class="w-full"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('auth.confirm.pending')"
|
||||||
|
data-test="confirm-password-button"
|
||||||
|
>{{ t('auth.confirm.submit') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<template #footer>{{ t('auth.confirm.footer') }}</template>
|
||||||
|
</AuthFlowLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,115 +1,170 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { LogOut } from 'lucide-vue-next';
|
import { KeyRound } from 'lucide-vue-next';
|
||||||
|
import { computed, onUnmounted } from 'vue';
|
||||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { logout } from '@/routes';
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
|
import { logout } from '@/routes/index';
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
|
||||||
defineProps<{
|
const { t } = useLocalization();
|
||||||
status?: string | null;
|
const form = useForm({
|
||||||
}>(),
|
current_password: '',
|
||||||
{
|
password: '',
|
||||||
status: null,
|
password_confirmation: '',
|
||||||
},
|
}).dontRemember('current_password', 'password', 'password_confirmation');
|
||||||
);
|
const logoutForm = useForm({});
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
|
const logoutProtection = useFormProtection(logoutForm);
|
||||||
|
const busy = computed(() => form.processing || logoutForm.processing);
|
||||||
|
const submit = (): void =>
|
||||||
|
protection.submit(SecurityController.update(), {
|
||||||
|
onSuccess: () => form.reset(),
|
||||||
|
});
|
||||||
|
const signOut = (): void => {
|
||||||
|
if (busy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
logoutProtection.submit(logout());
|
||||||
|
};
|
||||||
|
onUnmounted(() => form.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Change your password"
|
:title="t('auth.forceChange.title')"
|
||||||
description="You must update your password before you can continue using the application."
|
:description="t('auth.forceChange.description')"
|
||||||
|
:icon="KeyRound"
|
||||||
>
|
>
|
||||||
<Head title="Change your password" />
|
<Head :title="t('auth.forceChange.title')" />
|
||||||
|
<FeedbackState
|
||||||
<div class="mb-6 flex justify-end">
|
|
||||||
<Button as-child variant="ghost" size="sm">
|
|
||||||
<Link :href="logout()" as="button">
|
|
||||||
<LogOut class="size-4" />
|
|
||||||
Log out
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
|
state="success"
|
||||||
|
:title="t('auth.forceChange.statusTitle')"
|
||||||
|
:description="status"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
:action="SecurityController.update.url()"
|
||||||
|
method="post"
|
||||||
|
class="grid gap-5"
|
||||||
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<AuthFormFeedback
|
||||||
</div>
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
<Form
|
:loading-title="t('auth.forceChange.pending')"
|
||||||
v-bind="SecurityController.update.form()"
|
@retry="submit"
|
||||||
reset-on-success
|
/>
|
||||||
:reset-on-error="[
|
<div class="gap-stack grid">
|
||||||
'password',
|
<Label for="current_password">{{
|
||||||
'password_confirmation',
|
t('auth.forceChange.currentPassword')
|
||||||
'current_password',
|
}}</Label>
|
||||||
]"
|
<PasswordInput
|
||||||
v-slot="{ errors, processing }"
|
id="current_password"
|
||||||
>
|
v-model="form.current_password"
|
||||||
<div class="space-y-6">
|
name="current_password"
|
||||||
<div class="grid gap-2">
|
required
|
||||||
<Label htmlFor="current_password">Current password</Label>
|
autocomplete="current-password"
|
||||||
<PasswordInput
|
autofocus
|
||||||
id="current_password"
|
:disabled="busy"
|
||||||
name="current_password"
|
:invalid="Boolean(form.errors.current_password)"
|
||||||
class="mt-1 block w-full"
|
aria-describedby="current-password-error"
|
||||||
required
|
/>
|
||||||
autocomplete="current-password"
|
<InputError
|
||||||
autofocus
|
id="current-password-error"
|
||||||
placeholder="Current password"
|
:message="form.errors.current_password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError :message="errors.current_password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label htmlFor="password">New password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="New password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputError :message="errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label htmlFor="password_confirmation">
|
|
||||||
Confirm new password
|
|
||||||
</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputError :message="errors.password_confirmation" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Button
|
|
||||||
class="w-full"
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="force-password-change-button"
|
|
||||||
>
|
|
||||||
<Spinner v-if="processing" />
|
|
||||||
Save new password
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div class="gap-stack grid">
|
||||||
</AuthLayout>
|
<Label for="password">{{
|
||||||
|
t('auth.forceChange.password')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:disabled="busy"
|
||||||
|
:invalid="Boolean(form.errors.password)"
|
||||||
|
aria-describedby="new-password-hint new-password-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="new-password-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('auth.forceChange.passwordHint') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="new-password-error"
|
||||||
|
:message="form.errors.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="password_confirmation">{{
|
||||||
|
t('auth.forceChange.passwordConfirmation')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password_confirmation"
|
||||||
|
v-model="form.password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:disabled="busy"
|
||||||
|
:invalid="Boolean(form.errors.password_confirmation)"
|
||||||
|
aria-describedby="new-password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="new-password-confirmation-error"
|
||||||
|
:message="form.errors.password_confirmation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="logoutForm.processing"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('auth.forceChange.pending')"
|
||||||
|
data-test="force-password-change-button"
|
||||||
|
>{{ t('auth.forceChange.submit') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<div class="gap-stack pt-column grid border-t">
|
||||||
|
<p class="text-body text-muted-foreground">
|
||||||
|
{{ t('auth.forceChange.wrongAccount') }}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
:action="logout.url()"
|
||||||
|
method="post"
|
||||||
|
class="gap-card grid"
|
||||||
|
@submit.prevent="signOut"
|
||||||
|
>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="logoutProtection.state.value"
|
||||||
|
:response-status="logoutProtection.responseStatus.value"
|
||||||
|
:loading-title="t('auth.signingOut')"
|
||||||
|
@retry="signOut"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:loading="logoutForm.processing"
|
||||||
|
:loading-label="t('auth.signingOut')"
|
||||||
|
>{{ t('auth.signOut') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<template #footer>{{ t('auth.forceChange.footer') }}</template>
|
||||||
|
</AuthFlowLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,65 +1,89 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { Mail } from 'lucide-vue-next';
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
import TextLink from '@/components/TextLink.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
import { login } from '@/routes/index';
|
import { login } from '@/routes/index';
|
||||||
import { email } from '@/routes/password';
|
import { email } from '@/routes/password';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{ status?: string }>();
|
||||||
status?: string;
|
const { t } = useLocalization();
|
||||||
}>();
|
const form = useForm({ email: '' }).dontRemember('email');
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
|
const submit = (): void => protection.submit(email());
|
||||||
|
onUnmounted(() => form.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Forgot password"
|
:title="t('auth.forgot.title')"
|
||||||
description="Enter your email to receive a password reset link"
|
:description="t('auth.forgot.description')"
|
||||||
|
:icon="Mail"
|
||||||
>
|
>
|
||||||
<Head title="Forgot password" />
|
<Head :title="t('auth.forgot.title')" />
|
||||||
|
<FeedbackState
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
state="success"
|
||||||
|
:title="t('auth.forgot.sentTitle')"
|
||||||
|
:description="status"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
:action="email.url()"
|
||||||
|
method="post"
|
||||||
|
class="grid gap-5"
|
||||||
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<AuthFormFeedback
|
||||||
</div>
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
<div class="space-y-6">
|
:loading-title="t('auth.forgot.pending')"
|
||||||
<Form v-bind="email.form()" v-slot="{ errors, processing }">
|
@retry="submit"
|
||||||
<div class="grid gap-2">
|
/>
|
||||||
<Label for="email">Email address</Label>
|
<div class="gap-stack grid">
|
||||||
<Input
|
<Label for="email">{{ t('auth.forgot.email') }}</Label>
|
||||||
id="email"
|
<Input
|
||||||
type="email"
|
id="email"
|
||||||
name="email"
|
v-model="form.email"
|
||||||
autocomplete="off"
|
name="email"
|
||||||
autofocus
|
type="email"
|
||||||
placeholder="email@example.com"
|
inputmode="email"
|
||||||
/>
|
autocomplete="email"
|
||||||
<InputError :message="errors.email" />
|
autocapitalize="none"
|
||||||
</div>
|
:spellcheck="false"
|
||||||
|
required
|
||||||
<div class="my-6 flex items-center justify-start">
|
autofocus
|
||||||
<Button
|
:disabled="form.processing"
|
||||||
class="w-full"
|
:placeholder="t('auth.forgot.emailPlaceholder')"
|
||||||
:disabled="processing"
|
:invalid="Boolean(form.errors.email)"
|
||||||
data-test="email-password-reset-link-button"
|
aria-describedby="email-hint email-error"
|
||||||
>
|
/>
|
||||||
<Spinner v-if="processing" />
|
<p id="email-hint" class="text-caption text-muted-foreground">
|
||||||
Email password reset link
|
{{ t('auth.forgot.emailHint') }}
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
<InputError id="email-error" :message="form.errors.email" />
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="text-muted-foreground space-x-1 text-center text-sm">
|
|
||||||
<span>Or, return to</span>
|
|
||||||
<TextLink :href="login()">log in</TextLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
</AuthLayout>
|
type="submit"
|
||||||
|
class="w-full"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('auth.forgot.pending')"
|
||||||
|
data-test="email-password-reset-link-button"
|
||||||
|
>{{ t('auth.forgot.submit') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<template #footer
|
||||||
|
><TextLink :href="login()">{{
|
||||||
|
t('auth.backToLogin')
|
||||||
|
}}</TextLink></template
|
||||||
|
>
|
||||||
|
</AuthFlowLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,88 +1,136 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ref } from 'vue';
|
import { KeyRound } from 'lucide-vue-next';
|
||||||
|
import { computed, onUnmounted } from 'vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { update } from '@/routes/password';
|
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||||
|
import { request, update } from '@/routes/password';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ token: string; email: string }>();
|
||||||
token: string;
|
const { t } = useLocalization();
|
||||||
email: string;
|
const form = useForm({
|
||||||
}>();
|
token: props.token,
|
||||||
|
email: props.email,
|
||||||
const inputEmail = ref(props.email);
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
}).dontRemember('token', 'email', 'password', 'password_confirmation');
|
||||||
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
|
const invalidLink = computed(() => Boolean(form.errors.email));
|
||||||
|
const feedbackState = computed(() =>
|
||||||
|
invalidLink.value ? 'idle' : protection.state.value,
|
||||||
|
);
|
||||||
|
const submit = (): void =>
|
||||||
|
protection.submit(update(), {
|
||||||
|
onSuccess: () => form.reset('password', 'password_confirmation'),
|
||||||
|
});
|
||||||
|
onUnmounted(() => form.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLayout
|
<AuthFlowLayout
|
||||||
title="Reset password"
|
:title="t('auth.reset.title')"
|
||||||
description="Please enter your new password below"
|
:description="t('auth.reset.description')"
|
||||||
|
:icon="KeyRound"
|
||||||
>
|
>
|
||||||
<Head title="Reset password" />
|
<Head :title="t('auth.reset.title')" />
|
||||||
|
<FeedbackState
|
||||||
<Form
|
v-if="invalidLink"
|
||||||
v-bind="update.form()"
|
state="error"
|
||||||
:transform="(data) => ({ ...data, token, email })"
|
:title="t('auth.reset.invalidTitle')"
|
||||||
:reset-on-success="['password', 'password_confirmation']"
|
:description="form.errors.email"
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-6">
|
<Button as-child variant="outline" class="w-full">
|
||||||
<div class="grid gap-2">
|
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
|
||||||
<Label for="email">Email</Label>
|
</Button>
|
||||||
<Input
|
</FeedbackState>
|
||||||
id="email"
|
<form
|
||||||
type="email"
|
:action="update.url()"
|
||||||
name="email"
|
method="post"
|
||||||
autocomplete="email"
|
class="grid gap-5"
|
||||||
v-model="inputEmail"
|
@submit.prevent="submit"
|
||||||
class="mt-1 block w-full"
|
>
|
||||||
readonly
|
<AuthFormFeedback
|
||||||
/>
|
:state="feedbackState"
|
||||||
<InputError :message="errors.email" class="mt-2" />
|
:response-status="protection.responseStatus.value"
|
||||||
</div>
|
:loading-title="t('auth.reset.pending')"
|
||||||
|
@retry="submit"
|
||||||
<div class="grid gap-2">
|
/>
|
||||||
<Label for="password">Password</Label>
|
<input type="hidden" name="token" :value="form.token" />
|
||||||
<PasswordInput
|
<div class="gap-stack grid">
|
||||||
id="password"
|
<Label for="email">{{ t('auth.reset.email') }}</Label>
|
||||||
name="password"
|
<Input
|
||||||
autocomplete="new-password"
|
id="email"
|
||||||
class="mt-1 block w-full"
|
v-model="form.email"
|
||||||
autofocus
|
name="email"
|
||||||
placeholder="Password"
|
type="email"
|
||||||
/>
|
autocomplete="username"
|
||||||
<InputError :message="errors.password" />
|
readonly
|
||||||
</div>
|
:disabled="form.processing"
|
||||||
|
:invalid="Boolean(form.errors.email)"
|
||||||
<div class="grid gap-2">
|
aria-describedby="email-error"
|
||||||
<Label for="password_confirmation">
|
/>
|
||||||
Confirm password
|
<InputError id="email-error" :message="form.errors.email" />
|
||||||
</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
autocomplete="new-password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password_confirmation" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
class="mt-4 w-full"
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="reset-password-button"
|
|
||||||
>
|
|
||||||
<Spinner v-if="processing" />
|
|
||||||
Reset password
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div class="gap-stack grid">
|
||||||
</AuthLayout>
|
<Label for="password">{{ t('auth.reset.password') }}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
:disabled="form.processing"
|
||||||
|
:invalid="Boolean(form.errors.password)"
|
||||||
|
aria-describedby="password-hint password-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="password-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('auth.reset.passwordHint') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="password-error"
|
||||||
|
:message="form.errors.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="password_confirmation">{{
|
||||||
|
t('auth.reset.passwordConfirmation')
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password_confirmation"
|
||||||
|
v-model="form.password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
:disabled="form.processing"
|
||||||
|
:invalid="Boolean(form.errors.password_confirmation)"
|
||||||
|
aria-describedby="password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="password-confirmation-error"
|
||||||
|
:message="form.errors.password_confirmation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="w-full"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('auth.reset.pending')"
|
||||||
|
data-test="reset-password-button"
|
||||||
|
>{{ t('auth.reset.submit') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<template #footer>{{ t('auth.reset.footer') }}</template>
|
||||||
|
</AuthFlowLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import { TicketListItem } from '@/components/tickets';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -12,13 +13,11 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import {
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
create as createTicket,
|
|
||||||
show as showTicket,
|
|
||||||
} from '@/routes/projects/tickets';
|
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
@@ -40,25 +39,6 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
|
||||||
if (ticketStatus === null) {
|
|
||||||
return 'Unknown status';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
ticketStatus.board_column === null ||
|
|
||||||
ticketStatus.board_column === undefined
|
|
||||||
) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||||
@@ -132,163 +112,98 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
No backlog tickets exist yet.
|
No backlog tickets exist yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<ul v-else class="gap-column grid p-4">
|
||||||
<table
|
<TicketListItem
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
v-for="ticket in tickets"
|
||||||
|
:key="ticket.id"
|
||||||
|
:project-id="project.id"
|
||||||
|
:ticket="ticket"
|
||||||
>
|
>
|
||||||
<thead
|
<template v-if="canEditTickets" #actions>
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
<Form
|
||||||
>
|
v-bind="
|
||||||
<tr>
|
ProjectBoardController.move.form({
|
||||||
<th class="px-4 py-3 font-medium">Key</th>
|
project: project.id,
|
||||||
<th class="px-4 py-3 font-medium">Title</th>
|
ticket: ticket.id,
|
||||||
<th class="px-4 py-3 font-medium">Type</th>
|
})
|
||||||
<th class="px-4 py-3 font-medium">
|
"
|
||||||
Status
|
class="flex w-full flex-col gap-2"
|
||||||
</th>
|
v-slot="{ processing }"
|
||||||
<th class="px-4 py-3 font-medium">
|
>
|
||||||
Priority
|
<input
|
||||||
</th>
|
type="hidden"
|
||||||
<th
|
name="redirect_to"
|
||||||
v-if="canEditTickets"
|
value="backlog"
|
||||||
class="px-4 py-3 font-medium"
|
/>
|
||||||
|
<label
|
||||||
|
:for="`backlog-ticket-status-${ticket.id}`"
|
||||||
|
class="text-label text-muted-foreground uppercase"
|
||||||
>
|
>
|
||||||
Move to status
|
Move to status
|
||||||
</th>
|
</label>
|
||||||
</tr>
|
<div
|
||||||
</thead>
|
class="flex flex-col gap-2 sm:flex-row"
|
||||||
<tbody class="divide-border divide-y">
|
>
|
||||||
<tr
|
<select
|
||||||
v-for="ticket in tickets"
|
:id="`backlog-ticket-status-${ticket.id}`"
|
||||||
:key="ticket.id"
|
name="ticket_status_id"
|
||||||
class="bg-background"
|
:disabled="
|
||||||
>
|
allowedTargetStatuses(ticket)
|
||||||
<td class="px-4 py-4">
|
.length === 0 || processing
|
||||||
<Link
|
|
||||||
:href="
|
|
||||||
showTicket({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
"
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<Badge variant="outline">
|
<option
|
||||||
{{ ticket.ticket_key }}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Link
|
|
||||||
:href="
|
|
||||||
showTicket({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
|
||||||
>
|
|
||||||
{{ ticket.title }}
|
|
||||||
</Link>
|
|
||||||
<div
|
|
||||||
v-if="ticket.description"
|
|
||||||
class="text-muted-foreground mt-1 line-clamp-2"
|
|
||||||
>
|
|
||||||
{{ ticket.description }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
|
||||||
{{ ticket.ticket_type.name }}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(
|
|
||||||
ticket.ticket_status,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
|
||||||
{{ ticket.ticket_priority.name }}
|
|
||||||
</td>
|
|
||||||
<td v-if="canEditTickets" class="px-4 py-4">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardController.move.form(
|
|
||||||
{
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="redirect_to"
|
|
||||||
value="backlog"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
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-w-44 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,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
:disabled="
|
|
||||||
processing ||
|
|
||||||
allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</Button>
|
|
||||||
<p
|
|
||||||
v-if="
|
v-if="
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0
|
).length === 0
|
||||||
"
|
"
|
||||||
class="text-muted-foreground text-xs sm:basis-full"
|
value=""
|
||||||
>
|
>
|
||||||
This status does not allow
|
No allowed moves
|
||||||
moving anywhere else.
|
</option>
|
||||||
</p>
|
<option
|
||||||
</Form>
|
v-for="ticketStatus in allowedTargetStatuses(
|
||||||
</td>
|
ticket,
|
||||||
</tr>
|
)"
|
||||||
</tbody>
|
:key="ticketStatus.id"
|
||||||
</table>
|
:value="ticketStatus.id"
|
||||||
</div>
|
>
|
||||||
|
{{
|
||||||
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,91 +1,248 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { TicketCard } from '@/components/tickets';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useBoardDrag } from '@/composables/useBoardDrag';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
|
||||||
import { board as boardSettings } from '@/routes/projects/settings';
|
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import {
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
create as createTicket,
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
show as showTicket,
|
|
||||||
} from '@/routes/projects/tickets';
|
|
||||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectBoardPageProps>();
|
const props = defineProps<ProjectBoardPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: projectIndex(),
|
{ title: t('navigation.board'), href: boardIndex(props.project.id) },
|
||||||
|
]);
|
||||||
|
const selectedColumnId = ref<number>();
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
props.project.id,
|
||||||
|
props.boardColumns.map((column) => column.id),
|
||||||
|
] as const,
|
||||||
|
([projectId, columnIds], previous) => {
|
||||||
|
if (
|
||||||
|
projectId !== previous?.[0] ||
|
||||||
|
!columnIds.includes(selectedColumnId.value ?? -1)
|
||||||
|
) {
|
||||||
|
selectedColumnId.value = columnIds[0];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{ immediate: true },
|
||||||
title: props.project.name,
|
);
|
||||||
href: show(props.project.id),
|
const columns = computed(() =>
|
||||||
},
|
props.boardColumns.map((column) => {
|
||||||
{
|
const ticketsByStatus = new Map<number | null, TicketModel[]>();
|
||||||
title: 'Board',
|
for (const ticket of column.tickets ?? []) {
|
||||||
href: boardIndex(props.project.id),
|
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
|
||||||
},
|
tickets.push(ticket);
|
||||||
];
|
ticketsByStatus.set(ticket.ticket_status_id, tickets);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
count: column.tickets?.length ?? 0,
|
||||||
|
groups: (column.ticket_statuses ?? []).map((status) => ({
|
||||||
|
status,
|
||||||
|
tickets: ticketsByStatus.get(status.id) ?? [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ticketCount = computed(() =>
|
||||||
|
columns.value.reduce((count, column) => count + column.count, 0),
|
||||||
|
);
|
||||||
|
const allowedTargetStatuses = (ticket: TicketModel) =>
|
||||||
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
|
);
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
|
||||||
if (ticketStatus === null) {
|
const feedback = useFormProtection(moveForm, { isDirty: () => false });
|
||||||
return 'Unknown status';
|
const menuPending = ref<number | null>(null);
|
||||||
}
|
const busy = computed(() => moveForm.processing || menuPending.value !== null);
|
||||||
|
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
|
||||||
if (
|
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
|
||||||
ticketStatus.board_column === null ||
|
const findTicket = (id: number) =>
|
||||||
ticketStatus.board_column === undefined
|
props.boardColumns
|
||||||
) {
|
.flatMap((column) => column.tickets ?? [])
|
||||||
return ticketStatus.name;
|
.find((ticket) => ticket.id === id);
|
||||||
}
|
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
|
||||||
|
const statusIds =
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
props.boardColumns
|
||||||
return ticketStatus.name;
|
.find((column) => column.id === columnId)
|
||||||
}
|
?.ticket_statuses?.map((status) => status.id) ?? [];
|
||||||
|
return allowedTargetStatuses(ticket).filter((status) =>
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
statusIds.includes(status.id),
|
||||||
};
|
|
||||||
|
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const choiceTicket = computed(() =>
|
||||||
|
choice.value ? findTicket(choice.value.ticketId) : undefined,
|
||||||
|
);
|
||||||
|
const choiceTargets = computed(() =>
|
||||||
|
choice.value && choiceTicket.value
|
||||||
|
? targetsForColumn(choiceTicket.value, choice.value.columnId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const choiceOpen = computed({
|
||||||
|
get: () => choice.value !== null,
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
choice.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitMove(ticketId: number, statusId: number): void {
|
||||||
|
const ticket = findTicket(ticketId);
|
||||||
|
if (
|
||||||
|
busy.value ||
|
||||||
|
!props.canEditTickets ||
|
||||||
|
!ticket ||
|
||||||
|
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMove.value = { ticketId, statusId };
|
||||||
|
moveForm.ticket_status_id = statusId;
|
||||||
|
moveForm.clearErrors();
|
||||||
|
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
|
||||||
|
errorBag: `board-ticket-${ticketId}`,
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const destination = props.boardColumns.find((column) =>
|
||||||
|
column.ticket_statuses?.some(
|
||||||
|
(status) => status.id === statusId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (destination) {
|
||||||
|
selectedColumnId.value = destination.id;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
document
|
||||||
|
.getElementById(`ticket-status-${ticketId}`)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTicket(ticket: TicketModel, columnId: number): void {
|
||||||
|
const current = findTicket(ticket.id);
|
||||||
|
if (!current || busy.value || !props.canEditTickets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = targetsForColumn(current, columnId);
|
||||||
|
if (targets.length === 1) {
|
||||||
|
submitMove(current.id, targets[0].id);
|
||||||
|
} else if (targets.length > 1) {
|
||||||
|
moveForm.ticket_status_id = targets[0].id;
|
||||||
|
choice.value = { ticketId: current.id, columnId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
draggedTicket,
|
||||||
|
columnId: dropColumnId,
|
||||||
|
x: dragX,
|
||||||
|
y: dragY,
|
||||||
|
start: startDrag,
|
||||||
|
cancel: cancelDrag,
|
||||||
|
} = useBoardDrag({
|
||||||
|
canStart: (ticket) =>
|
||||||
|
props.canEditTickets &&
|
||||||
|
!busy.value &&
|
||||||
|
!choice.value &&
|
||||||
|
allowedTargetStatuses(ticket).length > 0,
|
||||||
|
drop: dropTicket,
|
||||||
|
});
|
||||||
|
const canDrop = (columnId: number) =>
|
||||||
|
!!draggedTicket.value &&
|
||||||
|
targetsForColumn(draggedTicket.value, columnId).length > 0;
|
||||||
|
const dropClasses = (columnId: number) =>
|
||||||
|
!draggedTicket.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'border-primary border-dashed': canDrop(columnId),
|
||||||
|
'ring-2 ring-primary bg-primary/10':
|
||||||
|
canDrop(columnId) && dropColumnId.value === columnId,
|
||||||
|
'opacity-40': !canDrop(columnId),
|
||||||
|
};
|
||||||
|
function confirmMove(): void {
|
||||||
|
if (
|
||||||
|
!choice.value ||
|
||||||
|
!choiceTargets.value.some(
|
||||||
|
(status) => status.id === moveForm.ticket_status_id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticketId = choice.value.ticketId;
|
||||||
|
choice.value = null;
|
||||||
|
submitMove(ticketId, moveForm.ticket_status_id);
|
||||||
|
}
|
||||||
|
function focusStatus(ticketId: number): void {
|
||||||
|
document.getElementById(`ticket-status-${ticketId}`)?.focus();
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => [props.project.id, props.boardColumns, props.canEditTickets],
|
||||||
|
() => {
|
||||||
|
cancelDrag();
|
||||||
|
choice.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} board`" />
|
<Head :title="t('board.title', { name: project.name })" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
{{ project.key }}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
<Heading
|
class="wrap-anywhere max-w-full whitespace-normal"
|
||||||
:title="`${project.name} board`"
|
>{{ project.key }}</Badge
|
||||||
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
>
|
||||||
/>
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('tickets.count', { count: ticketCount }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="text-headline-mobile md:text-headline wrap-anywhere"
|
||||||
|
>
|
||||||
|
{{ t('board.title', { name: project.name }) }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-caption text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('board.description') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -95,245 +252,427 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Create ticket
|
<Plus aria-hidden="true" />{{
|
||||||
|
t('navigation.createTicket')
|
||||||
|
}}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="backlogIndex(project.id)"
|
<Link :href="backlogIndex(project.id)"
|
||||||
>Open backlog</Link
|
><ListTodo aria-hidden="true" />{{
|
||||||
|
t('board.openBacklog')
|
||||||
|
}}</Link
|
||||||
>
|
>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Button as-child variant="outline">
|
<Link :href="show(project.id)"
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
><ArrowLeft aria-hidden="true" />{{
|
||||||
|
t('board.backToProject')
|
||||||
|
}}</Link
|
||||||
|
>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
v-if="status"
|
<FormFeedback
|
||||||
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"
|
:state="feedback.state.value"
|
||||||
>
|
:description="moveForm.errors.ticket_status_id"
|
||||||
{{ status }}
|
@retry="
|
||||||
</div>
|
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<FeedbackState
|
||||||
|
v-if="columns.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('board.empty.title')"
|
||||||
|
:description="t('board.empty.description')"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card v-if="boardColumns.length === 0">
|
<template v-else>
|
||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
<div class="grid min-w-0 gap-2 md:hidden">
|
||||||
<p class="text-muted-foreground">
|
<label for="board-column" class="text-body font-semibold">{{
|
||||||
The board has no workflow columns yet, so no tickets can
|
t('board.selectColumn')
|
||||||
appear here.
|
}}</label>
|
||||||
</p>
|
<select
|
||||||
<div v-if="canEditTickets">
|
id="board-column"
|
||||||
<Button as-child variant="outline">
|
v-model="selectedColumnId"
|
||||||
<Link :href="boardSettings(project.id)">
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
Open board settings
|
:aria-controls="`board-column-${selectedColumnId}`"
|
||||||
</Link>
|
>
|
||||||
</Button>
|
<option
|
||||||
</div>
|
v-for="column in columns"
|
||||||
</CardContent>
|
:key="column.id"
|
||||||
</Card>
|
:value="column.id"
|
||||||
|
|
||||||
<div v-else class="grid gap-4 xl:grid-cols-3">
|
|
||||||
<Card
|
|
||||||
v-for="boardColumn in boardColumns"
|
|
||||||
:key="boardColumn.id"
|
|
||||||
class="flex h-full flex-col gap-0 overflow-hidden"
|
|
||||||
>
|
|
||||||
<CardHeader class="bg-muted/20 border-b">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<CardTitle>{{ boardColumn.name }}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{{ boardColumn.tickets?.length ?? 0 }}
|
|
||||||
active ticket(s)
|
|
||||||
</CardDescription>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
(boardColumn.ticket_statuses?.length ??
|
|
||||||
0) > 0
|
|
||||||
"
|
|
||||||
class="mt-3 flex flex-wrap gap-2"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
v-for="ticketStatus in boardColumn.ticket_statuses"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{{ ticketStatus.name }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Badge variant="secondary">
|
|
||||||
#{{ boardColumn.position }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="flex flex-1 flex-col gap-4 p-4">
|
|
||||||
<div
|
|
||||||
v-if="(boardColumn.tickets?.length ?? 0) === 0"
|
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
||||||
>
|
>
|
||||||
No tickets are currently in this column.
|
{{
|
||||||
</div>
|
t('board.columnOption', {
|
||||||
|
name: column.name,
|
||||||
<div v-else class="flex flex-col gap-4">
|
count: column.count,
|
||||||
<Card
|
})
|
||||||
v-for="ticket in boardColumn.tickets"
|
}}
|
||||||
:key="ticket.id"
|
</option>
|
||||||
:class="[
|
</select>
|
||||||
'gap-0',
|
</div>
|
||||||
ticket.is_blocked
|
<p
|
||||||
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
id="board-scroll-hint"
|
||||||
: '',
|
class="text-caption text-muted-foreground hidden md:block"
|
||||||
]"
|
>
|
||||||
|
{{ t('board.scrollHint') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTickets"
|
||||||
|
id="board-drag-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.dragHint') }}
|
||||||
|
</p>
|
||||||
|
<section
|
||||||
|
:aria-label="t('board.columns')"
|
||||||
|
aria-describedby="board-scroll-hint"
|
||||||
|
tabindex="0"
|
||||||
|
data-board-drag-scroll
|
||||||
|
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
v-for="column in columns"
|
||||||
|
:id="`board-column-${column.id}`"
|
||||||
|
:key="`${project.id}-${column.id}`"
|
||||||
|
:aria-labelledby="`board-column-title-${column.id}`"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
|
:class="[
|
||||||
|
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
||||||
|
selectedColumnId === column.id ? 'flex' : 'hidden',
|
||||||
|
dropClasses(column.id),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
:id="`board-column-title-${column.id}`"
|
||||||
|
class="text-title wrap-anywhere"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-4">
|
{{ column.name }}
|
||||||
<div class="flex flex-wrap gap-2">
|
</h2>
|
||||||
<Badge variant="outline">
|
<Badge
|
||||||
{{ ticket.ticket_key }}
|
variant="secondary"
|
||||||
</Badge>
|
class="shrink-0 tabular-nums"
|
||||||
<Badge
|
:aria-label="
|
||||||
v-if="ticket.is_blocked"
|
t('tickets.count', { count: column.count })
|
||||||
variant="destructive"
|
"
|
||||||
>
|
>{{ column.count }}</Badge
|
||||||
Blocked
|
>
|
||||||
</Badge>
|
</header>
|
||||||
<Badge variant="secondary">
|
<p
|
||||||
{{ ticket.ticket_type.name }}
|
v-if="column.count === 0"
|
||||||
</Badge>
|
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
|
||||||
<Badge variant="secondary">
|
>
|
||||||
{{ ticket.ticket_priority.name }}
|
{{ t('board.emptyColumn') }}
|
||||||
</Badge>
|
</p>
|
||||||
<Badge variant="outline">
|
<p
|
||||||
{{
|
v-if="column.groups.length === 0"
|
||||||
ticketStatusLabel(
|
class="text-caption text-muted-foreground"
|
||||||
ticket.ticket_status,
|
>
|
||||||
)
|
{{ t('board.noStatuses') }}
|
||||||
}}
|
</p>
|
||||||
</Badge>
|
<section
|
||||||
</div>
|
v-for="group in column.groups"
|
||||||
<CardTitle class="text-base">
|
:key="group.status.id"
|
||||||
<Link
|
:aria-labelledby="`board-status-${group.status.id}`"
|
||||||
:href="
|
class="grid min-w-0 gap-3"
|
||||||
showTicket({
|
>
|
||||||
project: project.id,
|
<div
|
||||||
ticket: ticket.id,
|
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
|
||||||
})
|
>
|
||||||
"
|
<h3
|
||||||
class="hover:text-primary transition"
|
:id="`board-status-${group.status.id}`"
|
||||||
>
|
class="wrap-anywhere font-semibold"
|
||||||
{{ ticket.title }}
|
>
|
||||||
</Link>
|
{{ group.status.name }}
|
||||||
</CardTitle>
|
</h3>
|
||||||
<CardDescription
|
<span
|
||||||
v-if="ticket.description"
|
class="shrink-0 tabular-nums"
|
||||||
class="line-clamp-3"
|
:aria-label="
|
||||||
|
t('tickets.count', {
|
||||||
|
count: group.tickets.length,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>{{ group.tickets.length }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="group.tickets.length === 0"
|
||||||
|
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
|
||||||
|
>
|
||||||
|
{{ t('board.emptyStatus') }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
|
<li
|
||||||
|
v-for="ticket in group.tickets"
|
||||||
|
:key="ticket.id"
|
||||||
|
class="min-w-0"
|
||||||
|
:class="{
|
||||||
|
'opacity-50':
|
||||||
|
draggedTicket?.id === ticket.id,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<TicketCard
|
||||||
|
:project-id="project.id"
|
||||||
|
:ticket="ticket"
|
||||||
|
:show-status="false"
|
||||||
>
|
>
|
||||||
{{ ticket.description }}
|
<template
|
||||||
</CardDescription>
|
v-if="canEditTickets"
|
||||||
<CardDescription
|
#actions
|
||||||
v-if="
|
|
||||||
ticket.is_blocked &&
|
|
||||||
ticket.blocked_comment
|
|
||||||
"
|
|
||||||
class="mt-2 text-amber-900 dark:text-amber-200"
|
|
||||||
>
|
|
||||||
{{ ticket.blocked_comment }}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent v-if="canEditTickets" class="pt-0">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardController.move.form({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="flex flex-col gap-2"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="redirect_to"
|
|
||||||
value="board"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
:for="`ticket-status-${ticket.id}`"
|
|
||||||
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
|
|
||||||
>
|
>
|
||||||
Move ticket
|
<Button
|
||||||
</label>
|
type="button"
|
||||||
<div
|
variant="ghost"
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
>
|
|
||||||
<select
|
|
||||||
:id="`ticket-status-${ticket.id}`"
|
|
||||||
name="ticket_status_id"
|
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0 || processing
|
).length === 0
|
||||||
"
|
"
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
:aria-label="
|
||||||
|
t('board.dragTicket', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
aria-describedby="board-drag-hint"
|
||||||
|
@pointerdown="
|
||||||
|
startDrag($event, ticket)
|
||||||
|
"
|
||||||
|
@click.prevent
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
><GripVertical
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ t('board.drag') }}</Button
|
||||||
>
|
>
|
||||||
<option
|
<Form
|
||||||
|
v-bind="
|
||||||
|
move.form({
|
||||||
|
project: project.id,
|
||||||
|
ticket: ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-slot="{ processing, errors }"
|
||||||
|
:error-bag="`board-ticket-${ticket.id}`"
|
||||||
|
:options="{
|
||||||
|
preserveScroll: true,
|
||||||
|
}"
|
||||||
|
:on-before="
|
||||||
|
() =>
|
||||||
|
!busy && !draggedTicket
|
||||||
|
"
|
||||||
|
@start="menuPending = ticket.id"
|
||||||
|
@finish="menuPending = null"
|
||||||
|
class="grid min-w-0 gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="redirect_to"
|
||||||
|
value="board"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="`ticket-status-${ticket.id}`"
|
||||||
|
class="text-caption text-muted-foreground font-semibold"
|
||||||
|
>{{
|
||||||
|
t('board.moveTicket', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
})
|
||||||
|
}}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
:id="`ticket-status-${ticket.id}`"
|
||||||
|
name="ticket_status_id"
|
||||||
|
:disabled="
|
||||||
|
busy ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errors.ticket_status_id ||
|
||||||
|
undefined
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
errors.ticket_status_id
|
||||||
|
? `ticket-status-error-${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(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
{{ t('board.noMoves') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="target in allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
)"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t(
|
||||||
|
'tickets.noStatus',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:id="`ticket-status-error-${ticket.id}`"
|
||||||
|
:message="
|
||||||
|
errors.ticket_status_id
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
busy ||
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
processing
|
||||||
|
? 'board.updatingStatus'
|
||||||
|
: 'board.updateStatus',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0
|
).length === 0
|
||||||
"
|
"
|
||||||
value=""
|
class="text-caption text-muted-foreground"
|
||||||
>
|
|
||||||
No allowed moves
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
)"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="ticketStatus.id"
|
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
ticketStatusLabel(
|
t(
|
||||||
ticketStatus,
|
'board.noMovesDescription',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</option>
|
</p>
|
||||||
</select>
|
</Form>
|
||||||
<Button
|
</template>
|
||||||
type="submit"
|
</TicketCard>
|
||||||
size="sm"
|
</li>
|
||||||
:disabled="
|
</ul>
|
||||||
processing ||
|
</section>
|
||||||
allowedTargetStatuses(
|
</section>
|
||||||
ticket,
|
</section>
|
||||||
).length === 0
|
</template>
|
||||||
"
|
<div
|
||||||
>
|
v-if="draggedTicket"
|
||||||
Update status
|
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
|
||||||
</Button>
|
data-board-mobile-targets
|
||||||
</div>
|
>
|
||||||
<p
|
<p class="text-caption mb-2 font-semibold">
|
||||||
v-if="
|
{{ t('board.dropHint') }}
|
||||||
allowedTargetStatuses(ticket)
|
</p>
|
||||||
.length === 0
|
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
|
||||||
"
|
<div
|
||||||
class="text-muted-foreground text-xs"
|
v-for="column in columns"
|
||||||
>
|
:key="column.id"
|
||||||
This status does not allow moving
|
:data-board-drop-column="column.id"
|
||||||
anywhere else.
|
:class="dropClasses(column.id)"
|
||||||
</p>
|
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
|
||||||
</Form>
|
>
|
||||||
</CardContent>
|
{{ column.name }}
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.max(0, dragX - 80)}px`,
|
||||||
|
top: `${Math.max(0, dragY - 56)}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ draggedTicket.ticket_key }}
|
||||||
|
</div>
|
||||||
|
<p class="sr-only" aria-live="polite">
|
||||||
|
{{
|
||||||
|
draggedTicket
|
||||||
|
? t('board.dragging', { key: draggedTicket.ticket_key })
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Dialog v-model:open="choiceOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('board.chooseStatusDescription', {
|
||||||
|
key: choiceTicket?.ticket_key ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form class="grid gap-4" @submit.prevent="confirmMove">
|
||||||
|
<label
|
||||||
|
for="board-drop-status"
|
||||||
|
class="text-body font-semibold"
|
||||||
|
>{{ t('board.targetStatus') }}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="board-drop-status"
|
||||||
|
v-model="moveForm.ticket_status_id"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="target in choiceTargets"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="choiceOpen = false"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || choiceTargets.length === 0"
|
||||||
|
>{{ t('board.updateStatus') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,349 +1,633 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
ArrowLeft,
|
||||||
CardContent,
|
Check,
|
||||||
CardDescription,
|
Copy,
|
||||||
CardHeader,
|
FolderPlus,
|
||||||
CardTitle,
|
LayoutTemplate,
|
||||||
} from '@/components/ui/card';
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { store } from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index } from '@/routes/projects/index';
|
import { create, index } from '@/routes/projects';
|
||||||
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectsCreatePageProps>();
|
const props = defineProps<ProjectsCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: t('projects.create'), href: create() },
|
||||||
href: index(),
|
]);
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'New project',
|
|
||||||
href: create(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultTemplateId =
|
const defaultTemplateId =
|
||||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||||
|
const form = useForm(
|
||||||
const form = useForm({
|
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
|
||||||
copy_areas: [] as string[],
|
{
|
||||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||||
key: '',
|
name: '',
|
||||||
name: '',
|
key: '',
|
||||||
source_project_id: props.sourceProjects[0]?.id
|
source_project_id: props.sourceProjects[0]?.id
|
||||||
? String(props.sourceProjects[0].id)
|
? String(props.sourceProjects[0].id)
|
||||||
: '',
|
: '',
|
||||||
source_template_id:
|
source_template_id:
|
||||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||||
});
|
copy_areas: [] as string[],
|
||||||
|
},
|
||||||
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
);
|
||||||
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
const protection = useFormProtection(form);
|
||||||
|
const modes = computed(() => [
|
||||||
const copyAreasError = computed(() => {
|
{
|
||||||
return (
|
value: 'scratch',
|
||||||
|
icon: FolderPlus,
|
||||||
|
title: t('projectCreation.scratch'),
|
||||||
|
description: t('projectCreation.scratchDescription'),
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'duplicate',
|
||||||
|
icon: Copy,
|
||||||
|
title: t('projectCreation.duplicate'),
|
||||||
|
description: t(
|
||||||
|
props.sourceProjects.length
|
||||||
|
? 'projectCreation.duplicateDescription'
|
||||||
|
: 'projectCreation.noProjects',
|
||||||
|
),
|
||||||
|
disabled: props.sourceProjects.length === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'template',
|
||||||
|
icon: LayoutTemplate,
|
||||||
|
title: t('projectCreation.template'),
|
||||||
|
description: t(
|
||||||
|
props.sourceTemplates.length
|
||||||
|
? 'projectCreation.templateDescription'
|
||||||
|
: 'projectCreation.noTemplates',
|
||||||
|
),
|
||||||
|
disabled: props.sourceTemplates.length === 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const areaKeys = {
|
||||||
|
project_settings: 'projectCreation.areas.projectSettings',
|
||||||
|
board_columns: 'projectCreation.areas.boardColumns',
|
||||||
|
ticket_statuses: 'projectCreation.areas.ticketStatuses',
|
||||||
|
status_transitions: 'projectCreation.areas.statusTransitions',
|
||||||
|
ticket_types: 'projectCreation.areas.ticketTypes',
|
||||||
|
ticket_priorities: 'projectCreation.areas.ticketPriorities',
|
||||||
|
ticket_fields: 'projectCreation.areas.ticketFields',
|
||||||
|
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
|
||||||
|
repository_connections: 'projectCreation.areas.repositories',
|
||||||
|
notification_actions: 'projectCreation.areas.notifications',
|
||||||
|
} as const;
|
||||||
|
function areaText(value: string, part: 'label' | 'description'): string {
|
||||||
|
const key = areaKeys[value as keyof typeof areaKeys];
|
||||||
|
return key
|
||||||
|
? t(`${key}.${part}`)
|
||||||
|
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
|
||||||
|
value);
|
||||||
|
}
|
||||||
|
const selectedProject = computed(() =>
|
||||||
|
props.sourceProjects.find(
|
||||||
|
(project) => String(project.id) === form.source_project_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedTemplate = computed(() =>
|
||||||
|
props.sourceTemplates.find(
|
||||||
|
(template) => String(template.id) === form.source_template_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedMode = computed(() =>
|
||||||
|
modes.value.find((mode) => mode.value === form.creation_mode),
|
||||||
|
);
|
||||||
|
const ticketKey = computed(
|
||||||
|
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
|
||||||
|
);
|
||||||
|
const copyAreasError = computed(
|
||||||
|
() =>
|
||||||
form.errors.copy_areas ??
|
form.errors.copy_areas ??
|
||||||
Object.entries(form.errors).find(([key]) =>
|
Object.entries(form.errors).find(([key]) =>
|
||||||
key.startsWith('copy_areas.'),
|
key.startsWith('copy_areas.'),
|
||||||
)?.[1] ??
|
)?.[1],
|
||||||
''
|
);
|
||||||
);
|
const selectClass =
|
||||||
});
|
'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||||
|
|
||||||
const submit = (): void => {
|
watch(
|
||||||
if (form.creation_mode !== 'duplicate') {
|
() => form.creation_mode,
|
||||||
form.source_project_id = '';
|
() => {
|
||||||
form.copy_areas = [];
|
form.clearErrors(
|
||||||
}
|
'creation_mode',
|
||||||
|
'source_project_id',
|
||||||
|
'source_template_id',
|
||||||
|
'copy_areas',
|
||||||
|
);
|
||||||
|
for (const key of Object.keys(form.errors)) {
|
||||||
|
if (key.startsWith('copy_areas.')) {
|
||||||
|
form.clearErrors(key as 'copy_areas');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (form.creation_mode !== 'template') {
|
function submit(): void {
|
||||||
form.source_template_id = '';
|
form.transform((data) => ({
|
||||||
}
|
...data,
|
||||||
|
source_project_id:
|
||||||
form.post(ProjectController.store.url(), {
|
data.creation_mode === 'duplicate' ? data.source_project_id : '',
|
||||||
preserveScroll: true,
|
source_template_id:
|
||||||
});
|
data.creation_mode === 'template' ? data.source_template_id : '',
|
||||||
};
|
copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
|
||||||
|
}));
|
||||||
|
protection.submit(store(), { preserveScroll: true });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="New project" />
|
<Head :title="t('projects.create')" />
|
||||||
|
<div
|
||||||
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||||
|
>
|
||||||
|
<header class="gap-stack grid">
|
||||||
|
<Link
|
||||||
|
:href="index()"
|
||||||
|
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||||
|
t('projects.back')
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
|
{{ t('projects.workspace') }}
|
||||||
|
</p>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
|
{{ t('projectCreation.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('projectCreation.description') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<form
|
||||||
<Heading
|
class="gap-page grid min-w-0"
|
||||||
title="Create project"
|
:aria-busy="form.processing"
|
||||||
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
@submit.prevent="submit"
|
||||||
/>
|
>
|
||||||
|
<FormFeedback
|
||||||
<Card>
|
:state="protection.state.value"
|
||||||
<CardHeader>
|
:title="
|
||||||
<CardTitle>Project details</CardTitle>
|
form.processing
|
||||||
<CardDescription>
|
? t('projectCreation.creating')
|
||||||
The key is uppercased automatically, must be unique, and
|
: undefined
|
||||||
can contain up to 5 characters.
|
"
|
||||||
</CardDescription>
|
@retry="submit"
|
||||||
</CardHeader>
|
/>
|
||||||
|
<fieldset
|
||||||
<CardContent>
|
class="gap-column grid min-w-0"
|
||||||
<form class="space-y-6" @submit.prevent="submit">
|
:disabled="form.processing"
|
||||||
<div class="grid gap-3">
|
aria-describedby="creation-mode-error"
|
||||||
<Label>How do you want to start?</Label>
|
>
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<legend class="text-title mb-3">
|
||||||
<Label
|
{{ t('projectCreation.start') }}
|
||||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
</legend>
|
||||||
>
|
<div class="gap-column grid md:grid-cols-3">
|
||||||
<input
|
<label
|
||||||
v-model="form.creation_mode"
|
v-for="mode in modes"
|
||||||
type="radio"
|
:key="mode.value"
|
||||||
value="scratch"
|
class="relative min-w-0"
|
||||||
class="mt-1"
|
|
||||||
/>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="font-medium">
|
|
||||||
Create from scratch
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Start with the default project
|
|
||||||
setup.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label
|
|
||||||
class="flex items-start gap-3 rounded-xl border p-4"
|
|
||||||
:class="
|
|
||||||
hasSourceProjects
|
|
||||||
? 'cursor-pointer'
|
|
||||||
: 'cursor-not-allowed opacity-60'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.creation_mode"
|
|
||||||
type="radio"
|
|
||||||
value="duplicate"
|
|
||||||
class="mt-1"
|
|
||||||
:disabled="!hasSourceProjects"
|
|
||||||
/>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="font-medium">
|
|
||||||
Create from existing project
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Copy only the settings you
|
|
||||||
explicitly select. Tickets are never
|
|
||||||
duplicated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label
|
|
||||||
class="flex items-start gap-3 rounded-xl border p-4"
|
|
||||||
:class="
|
|
||||||
hasSourceTemplates
|
|
||||||
? 'cursor-pointer'
|
|
||||||
: 'cursor-not-allowed opacity-60'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.creation_mode"
|
|
||||||
type="radio"
|
|
||||||
value="template"
|
|
||||||
class="mt-1"
|
|
||||||
:disabled="!hasSourceTemplates"
|
|
||||||
/>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="font-medium">
|
|
||||||
Create from template
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Reuse a saved configuration
|
|
||||||
template.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Project name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
v-model="form.name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="Kanban foundation"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="key">Project key</Label>
|
|
||||||
<Input
|
|
||||||
id="key"
|
|
||||||
v-model="form.key"
|
|
||||||
required
|
|
||||||
maxlength="5"
|
|
||||||
placeholder="CORE"
|
|
||||||
class="uppercase"
|
|
||||||
/>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Example ticket keys will look like
|
|
||||||
<span class="text-foreground font-medium"
|
|
||||||
>CORE-1</span
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<InputError :message="form.errors.key" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="form.creation_mode === 'duplicate'"
|
|
||||||
class="space-y-6 rounded-xl border border-dashed p-4"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<input
|
||||||
<Label for="source_project_id"
|
v-model="form.creation_mode"
|
||||||
>Source project</Label
|
type="radio"
|
||||||
|
name="creation_mode"
|
||||||
|
:value="mode.value"
|
||||||
|
:disabled="mode.disabled"
|
||||||
|
:aria-invalid="!!form.errors.creation_mode"
|
||||||
|
:aria-labelledby="`mode-${mode.value}-title`"
|
||||||
|
:aria-describedby="`mode-${mode.value}-description`"
|
||||||
|
class="peer sr-only"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
|
||||||
|
:class="
|
||||||
|
!mode.disabled
|
||||||
|
? 'hover:border-primary/50'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
>
|
>
|
||||||
|
<component
|
||||||
|
:is="mode.icon"
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
v-if="form.creation_mode === mode.value"
|
||||||
|
class="text-primary size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:id="`mode-${mode.value}-title`"
|
||||||
|
class="font-semibold"
|
||||||
|
>{{ mode.title }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:id="`mode-${mode.value}-description`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>{{ mode.description }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="creation-mode-error"
|
||||||
|
:message="form.errors.creation_mode"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
|
||||||
|
>
|
||||||
|
<div class="gap-page grid min-w-0">
|
||||||
|
<section
|
||||||
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="project-details-title"
|
||||||
|
>
|
||||||
|
<h2 id="project-details-title" class="text-title">
|
||||||
|
{{ t('projectCreation.details') }}
|
||||||
|
</h2>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="name">{{
|
||||||
|
t('projectCreation.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:placeholder="
|
||||||
|
t('projectCreation.namePlaceholder')
|
||||||
|
"
|
||||||
|
:invalid="!!form.errors.name"
|
||||||
|
aria-describedby="name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="key">{{
|
||||||
|
t('projectCreation.key')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="key"
|
||||||
|
v-model="form.key"
|
||||||
|
name="key"
|
||||||
|
required
|
||||||
|
maxlength="5"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:placeholder="
|
||||||
|
t('projectCreation.keyPlaceholder')
|
||||||
|
"
|
||||||
|
class="uppercase sm:max-w-48"
|
||||||
|
:invalid="!!form.errors.key"
|
||||||
|
aria-describedby="key-help key-error"
|
||||||
|
autocapitalize="characters"
|
||||||
|
:spellcheck="false"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="key-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.keyHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="key-error"
|
||||||
|
:message="form.errors.key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="form.creation_mode === 'duplicate'"
|
||||||
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="copy-title"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<h2 id="copy-title" class="text-title">
|
||||||
|
{{ t('projectCreation.copyTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.copyHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<Label for="source_project_id">{{
|
||||||
|
t('projectCreation.sourceProject')
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_project_id"
|
id="source_project_id"
|
||||||
v-model="form.source_project_id"
|
v-model="form.source_project_id"
|
||||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
required
|
||||||
:disabled="!hasSourceProjects"
|
:class="selectClass"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.source_project_id
|
||||||
|
"
|
||||||
|
aria-describedby="source-project-error"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Select a source project
|
{{ t('projectCreation.selectProject') }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="sourceProject in sourceProjects"
|
v-for="project in sourceProjects"
|
||||||
:key="sourceProject.id"
|
:key="project.id"
|
||||||
:value="String(sourceProject.id)"
|
:value="String(project.id)"
|
||||||
>
|
>
|
||||||
{{ sourceProject.name }} ({{
|
{{ project.name }} ({{ project.key }})
|
||||||
sourceProject.key
|
|
||||||
}})
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
The new project keeps its own identity, key,
|
|
||||||
and future ticket numbering.
|
|
||||||
</p>
|
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-project-error"
|
||||||
:message="form.errors.source_project_id"
|
:message="form.errors.source_project_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset
|
||||||
<div class="grid gap-3">
|
class="min-w-0"
|
||||||
<div>
|
:disabled="form.processing"
|
||||||
<Label>Copy options</Label>
|
aria-describedby="copy-help copy-areas-error"
|
||||||
<p class="text-muted-foreground text-sm">
|
>
|
||||||
Choose which settings should be copied
|
<legend class="mb-2 font-semibold">
|
||||||
into the new project.
|
{{ t('projectCreation.copyOptions') }}
|
||||||
</p>
|
</legend>
|
||||||
</div>
|
<p
|
||||||
|
id="copy-help"
|
||||||
<div class="grid gap-3">
|
class="text-caption text-muted-foreground mb-3"
|
||||||
<Label
|
>
|
||||||
v-for="copyAreaOption in copyAreaOptions"
|
{{ t('projectCreation.copyDefaults') }}
|
||||||
:key="copyAreaOption.value"
|
</p>
|
||||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
<div class="gap-stack grid sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="area in copyAreaOptions"
|
||||||
|
:key="area.value"
|
||||||
|
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.copy_areas"
|
v-model="form.copy_areas"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="copyAreaOption.value"
|
:value="area.value"
|
||||||
class="mt-1"
|
:aria-labelledby="`copy-${area.value}-label`"
|
||||||
|
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
|
||||||
|
:aria-invalid="!!copyAreasError"
|
||||||
|
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<span class="gap-stack grid min-w-0">
|
||||||
<div class="font-medium">
|
<span
|
||||||
{{ copyAreaOption.label }}
|
:id="`copy-${area.value}-label`"
|
||||||
</div>
|
class="text-body font-semibold"
|
||||||
<p
|
>{{
|
||||||
class="text-muted-foreground text-sm"
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'label',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
{{ copyAreaOption.description }}
|
<span
|
||||||
</p>
|
:id="`copy-${area.value}-description`"
|
||||||
</div>
|
class="text-caption text-muted-foreground"
|
||||||
</Label>
|
>{{
|
||||||
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'description',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="copy-areas-error"
|
||||||
|
class="mt-3"
|
||||||
|
:message="copyAreasError"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="text-muted-foreground text-sm">
|
<section
|
||||||
Operational ticket data is always left
|
|
||||||
behind so the new project starts clean.
|
|
||||||
</p>
|
|
||||||
<InputError :message="copyAreasError" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="form.creation_mode === 'template'"
|
v-else-if="form.creation_mode === 'template'"
|
||||||
class="space-y-4 rounded-xl border border-dashed p-4"
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="template-title"
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<h2 id="template-title" class="text-title">
|
||||||
<Label for="source_template_id">Template</Label>
|
{{ t('projectCreation.chooseTemplate') }}
|
||||||
|
</h2>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<Label for="source_template_id">{{
|
||||||
|
t('projectCreation.templateLabel')
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="form.source_template_id"
|
v-model="form.source_template_id"
|
||||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
required
|
||||||
:disabled="!hasSourceTemplates"
|
:class="selectClass"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.source_template_id
|
||||||
|
"
|
||||||
|
aria-describedby="template-help source-template-error"
|
||||||
>
|
>
|
||||||
<option value="">Select a template</option>
|
<option value="">
|
||||||
|
{{
|
||||||
|
t('projectCreation.selectTemplate')
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="sourceTemplate in sourceTemplates"
|
v-for="template in sourceTemplates"
|
||||||
:key="sourceTemplate.id"
|
:key="template.id"
|
||||||
:value="String(sourceTemplate.id)"
|
:value="String(template.id)"
|
||||||
>
|
>
|
||||||
{{ sourceTemplate.name }} ({{
|
{{ template.name }} ({{
|
||||||
sourceTemplate.slug
|
template.slug
|
||||||
}})
|
}})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p
|
||||||
Templates apply their saved configuration
|
id="template-help"
|
||||||
groups to the new project.
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.templateHelp') }}
|
||||||
</p>
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-template-error"
|
||||||
:message="form.errors.source_template_id"
|
:message="form.errors.source_template_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
v-if="selectedTemplate"
|
||||||
|
class="gap-stack grid"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<h3 class="text-body font-semibold">
|
||||||
|
{{ t('projectCreation.includedAreas') }}
|
||||||
|
</h3>
|
||||||
|
<ul
|
||||||
|
v-if="selectedTemplate.copy_areas.length"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="area in selectedTemplate.copy_areas"
|
||||||
|
:key="area"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="whitespace-normal"
|
||||||
|
>{{
|
||||||
|
areaText(area, 'label')
|
||||||
|
}}</Badge
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.templateDefaults') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<aside
|
||||||
v-else-if="
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
!hasSourceProjects && !hasSourceTemplates
|
aria-labelledby="summary-title"
|
||||||
"
|
>
|
||||||
class="text-muted-foreground text-sm"
|
<h2 id="summary-title" class="text-title">
|
||||||
|
{{ t('projectCreation.summary') }}
|
||||||
|
</h2>
|
||||||
|
<dl class="gap-column grid min-w-0">
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.name') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words font-semibold">
|
||||||
|
{{
|
||||||
|
form.name.trim() ||
|
||||||
|
t('projectCreation.untitled')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.ticketPreview') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
ticketKey
|
||||||
|
}}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.setup') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words">
|
||||||
|
{{ selectedMode?.title }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.creation_mode === 'duplicate'"
|
||||||
|
class="gap-stack grid"
|
||||||
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.sourceProject') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words">
|
||||||
|
{{
|
||||||
|
selectedProject?.name ||
|
||||||
|
t('projectCreation.selectProject')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
<dd class="text-caption text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t('projectCreation.selectedCount', {
|
||||||
|
count: form.copy_areas.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="form.creation_mode === 'template'"
|
||||||
|
class="gap-stack grid"
|
||||||
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.templateLabel') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words">
|
||||||
|
{{
|
||||||
|
selectedTemplate?.name ||
|
||||||
|
t('projectCreation.selectTemplate')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground border-t pt-4"
|
||||||
>
|
>
|
||||||
No accessible source projects or saved templates are
|
{{ t('projectCreation.cleanStart') }}
|
||||||
available yet.
|
</p>
|
||||||
</div>
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<footer
|
||||||
<Button type="submit" :disabled="form.processing">
|
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
|
||||||
Create project
|
>
|
||||||
</Button>
|
<Button
|
||||||
|
as-child
|
||||||
<Button as-child variant="outline">
|
variant="outline"
|
||||||
<Link :href="index()">Cancel</Link>
|
:disabled="form.processing"
|
||||||
</Button>
|
><Link :href="index()">{{
|
||||||
</div>
|
t('common.cancel')
|
||||||
</form>
|
}}</Link></Button
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'projectCreation.creating'
|
||||||
|
: 'projectCreation.submit',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:loading-label="t('projectCreation.creating')"
|
||||||
|
><FolderPlus
|
||||||
|
v-if="!form.processing"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'projectCreation.creating'
|
||||||
|
: 'projectCreation.submit',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,103 +1,184 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { Folder, Plus } from 'lucide-vue-next';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowUpRight,
|
||||||
|
Columns3,
|
||||||
|
FolderGit2,
|
||||||
|
ListTodo,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index, show } from '@/routes/projects/index';
|
import { create, index, show } from '@/routes/projects';
|
||||||
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
||||||
|
|
||||||
defineProps<ProjectsIndexPageProps>();
|
defineProps<ProjectsIndexPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
]);
|
||||||
href: index(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Projects" />
|
<Head :title="t('navigation.projects')" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<Heading
|
<div class="gap-stack grid min-w-0">
|
||||||
title="Projects"
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
description="Create and browse project spaces that will later host boards, backlog, and settings."
|
{{ t('projects.workspace') }}
|
||||||
/>
|
</p>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
|
{{ t('navigation.projects') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('projects.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="$page.props.auth.permissions.projectCreate"
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
as-child
|
as-child
|
||||||
|
class="self-start"
|
||||||
>
|
>
|
||||||
<Link :href="create()">
|
<Link :href="create()"
|
||||||
<Plus class="size-4" />
|
><Plus aria-hidden="true" />{{
|
||||||
New project
|
t('projects.create')
|
||||||
</Link>
|
}}</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"
|
|
||||||
>
|
|
||||||
{{ status }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="projects.length === 0"
|
|
||||||
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
|
|
||||||
>
|
|
||||||
No projects exist yet. Create the first one to start building
|
|
||||||
out board, backlog, and settings areas.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<Link
|
|
||||||
v-for="project in projects"
|
|
||||||
:key="project.id"
|
|
||||||
:href="show(project.id)"
|
|
||||||
class="group block"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
|
|
||||||
>
|
>
|
||||||
<CardHeader class="gap-3">
|
</Button>
|
||||||
<div class="flex items-start justify-between gap-3">
|
</header>
|
||||||
<div class="space-y-1">
|
|
||||||
<CardTitle>{{ project.name }}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Project workspace entry point
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Badge variant="outline" class="px-2.5 py-1">
|
<FeedbackState
|
||||||
{{ project.key }}
|
v-if="status"
|
||||||
</Badge>
|
state="success"
|
||||||
</div>
|
:title="t('projects.updated')"
|
||||||
</CardHeader>
|
:description="status"
|
||||||
|
/>
|
||||||
|
|
||||||
<CardContent
|
<FeedbackState
|
||||||
class="text-muted-foreground flex items-center gap-2 text-sm"
|
v-if="projects.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('projects.empty.title')"
|
||||||
|
:description="
|
||||||
|
$page.props.auth.permissions.projectCreate
|
||||||
|
? t('projects.empty.createDescription')
|
||||||
|
: t('projects.empty.accessDescription')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link :href="create()"
|
||||||
|
><Plus aria-hidden="true" />{{
|
||||||
|
t('projects.empty.create')
|
||||||
|
}}</Link
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else
|
||||||
|
aria-labelledby="project-list-title"
|
||||||
|
class="gap-column grid"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="project-list-title"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projects.available', { count: projects.length }) }}
|
||||||
|
</h2>
|
||||||
|
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<li
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
class="min-w-0"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
|
||||||
>
|
>
|
||||||
<Folder class="size-4" />
|
<Link
|
||||||
Open board, backlog, and settings hub
|
:href="show(project.id)"
|
||||||
</CardContent>
|
:aria-label="
|
||||||
</Card>
|
t('projects.open', { name: project.name })
|
||||||
</Link>
|
"
|
||||||
</div>
|
class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<FolderGit2
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="max-w-full justify-self-start whitespace-normal break-all"
|
||||||
|
>{{ project.key }}</Badge
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-title group-hover:text-primary break-words"
|
||||||
|
>
|
||||||
|
{{ project.name }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>{{ t('projects.overview') }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight
|
||||||
|
class="text-muted-foreground mt-1 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
class="gap-stack p-card grid grid-cols-2 border-t"
|
||||||
|
>
|
||||||
|
<Button as-child variant="secondary">
|
||||||
|
<Link
|
||||||
|
:href="boardIndex(project.id)"
|
||||||
|
:aria-label="
|
||||||
|
t('projects.openBoard', {
|
||||||
|
name: project.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Columns3 aria-hidden="true" />{{
|
||||||
|
t('navigation.board')
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
|
<Link
|
||||||
|
:href="backlogIndex(project.id)"
|
||||||
|
:aria-label="
|
||||||
|
t('projects.openBacklog', {
|
||||||
|
name: project.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ListTodo aria-hidden="true" />{{
|
||||||
|
t('navigation.backlog')
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,206 +1,227 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Columns3,
|
||||||
|
Layers3,
|
||||||
|
ListTodo,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { access, index, show } from '@/routes/projects/index';
|
import { projectSettingsNavigation } from '@/lib/navigation';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index, show } from '@/routes/projects';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import {
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
board as boardSettings,
|
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectShowPageProps>();
|
const props = defineProps<ProjectShowPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.projects'), href: index() },
|
||||||
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
|
]);
|
||||||
|
const projectAreas = computed(() => [
|
||||||
{
|
{
|
||||||
title: 'Projects',
|
title: t('navigation.board'),
|
||||||
href: index(),
|
description: t('projects.boardDescription'),
|
||||||
|
action: t('projects.boardAction'),
|
||||||
|
href: boardIndex(props.project.id),
|
||||||
|
icon: Columns3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: props.project.name,
|
title: t('navigation.backlog'),
|
||||||
href: show(props.project.id),
|
description: t('projects.backlogDescription'),
|
||||||
},
|
action: t('projects.backlogAction'),
|
||||||
];
|
href: backlogIndex(props.project.id),
|
||||||
|
|
||||||
const projectAreas = [
|
|
||||||
{
|
|
||||||
title: 'Backlog',
|
|
||||||
description:
|
|
||||||
'Create tickets, review incoming work, and move ready items into the board workflow.',
|
|
||||||
icon: ListTodo,
|
icon: ListTodo,
|
||||||
},
|
},
|
||||||
{
|
]);
|
||||||
title: 'Board',
|
const settingsItems = computed(() =>
|
||||||
description:
|
projectSettingsNavigation(
|
||||||
'Track active work across the project-defined Kanban columns and update ticket status.',
|
{
|
||||||
icon: LayoutGrid,
|
id: props.project.id,
|
||||||
},
|
name: props.project.name,
|
||||||
{
|
key: props.project.key,
|
||||||
title: 'Project settings',
|
canManage: props.canManageAccess,
|
||||||
description:
|
canCreateTickets: false,
|
||||||
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
|
},
|
||||||
icon: Settings2,
|
t,
|
||||||
},
|
),
|
||||||
{
|
);
|
||||||
title: 'Repositories',
|
|
||||||
description:
|
|
||||||
'Link project repositories so tickets can later discover related branches and pull requests.',
|
|
||||||
icon: FolderGit2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="project.name" />
|
<Head :title="project.name" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div
|
<Link
|
||||||
|
:href="index()"
|
||||||
|
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||||
|
t('projects.back')
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="gap-stack grid min-w-0">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<Badge
|
||||||
{{ project.key }}
|
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="project.name"
|
|
||||||
description="Project hub for the upcoming backlog, board, and settings work."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="backlogIndex(project.id)">
|
|
||||||
Open backlog
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
v-if="$page.props.auth.permissions.projectCreate"
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
class="max-w-full justify-self-start whitespace-normal break-all"
|
||||||
|
>{{ project.key }}</Badge
|
||||||
>
|
>
|
||||||
<Link :href="projectTemplateCreate(project.id)">
|
<h1
|
||||||
Save as new template
|
class="text-headline-mobile md:text-headline break-words"
|
||||||
</Link>
|
>
|
||||||
</Button>
|
{{ project.name }}
|
||||||
|
</h1>
|
||||||
<Button as-child variant="outline">
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
<Link :href="index()">Back to projects</Link>
|
{{ t('projects.hubDescription') }}
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
class="self-start"
|
||||||
|
>
|
||||||
|
<Link :href="projectTemplateCreate(project.id)"
|
||||||
|
><Layers3 aria-hidden="true" />{{
|
||||||
|
t('projects.saveTemplate')
|
||||||
|
}}</Link
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Card v-if="templateState?.inheritsFromTemplate">
|
<section
|
||||||
<CardHeader>
|
:aria-label="t('projects.workAreas')"
|
||||||
<CardTitle>
|
class="gap-column grid md:grid-cols-2"
|
||||||
Built from template {{ templateState.templateName }}
|
>
|
||||||
</CardTitle>
|
<Link
|
||||||
<CardDescription>
|
v-for="area in projectAreas"
|
||||||
This project keeps its template link. Inherited areas
|
:key="area.href.url"
|
||||||
follow the template baseline; overridden areas were
|
:href="area.href"
|
||||||
customized locally.
|
:aria-label="area.action"
|
||||||
</CardDescription>
|
class="gap-column bg-card hover:border-primary/50 hover:shadow-card-hover group flex min-w-0 flex-col rounded-md border p-5 transition-[border-color,box-shadow]"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent class="flex flex-wrap gap-2">
|
<span
|
||||||
|
class="bg-accent text-primary flex size-11 items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="area.icon"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<h2 class="text-title">{{ area.title }}</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ area.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
|
||||||
|
>
|
||||||
|
{{ area.action
|
||||||
|
}}<ArrowRight class="size-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="settingsItems.length"
|
||||||
|
aria-labelledby="project-settings-title"
|
||||||
|
class="gap-column grid"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<h2 id="project-settings-title" class="text-title">
|
||||||
|
{{ t('navigation.projectSettings') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('projects.settingsDescription') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<li
|
||||||
|
v-for="item in settingsItems"
|
||||||
|
:key="item.title"
|
||||||
|
class="min-w-0"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
:href="item.href"
|
||||||
|
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="text-muted-foreground size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 break-words font-medium"
|
||||||
|
>{{ item.title }}</span
|
||||||
|
>
|
||||||
|
<ArrowRight
|
||||||
|
class="text-muted-foreground size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="templateState?.inheritsFromTemplate"
|
||||||
|
aria-labelledby="project-template-title"
|
||||||
|
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
|
||||||
|
>
|
||||||
|
<div class="gap-card flex items-start">
|
||||||
|
<Layers3
|
||||||
|
class="text-muted-foreground mt-0.5 size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<h2
|
||||||
|
id="project-template-title"
|
||||||
|
class="text-title break-words"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projects.templateTitle', {
|
||||||
|
name: templateState.templateName,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('projects.templateDescription') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack flex flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
v-for="area in templateState.inheritedAreas"
|
v-for="area in templateState.inheritedAreas"
|
||||||
:key="`inherited-${area.value}`"
|
:key="`inherited-${area.value}`"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
class="max-w-full whitespace-normal break-words"
|
||||||
>
|
>
|
||||||
{{ area.label }} · inherited
|
{{ t('projects.inheritedArea', { name: area.label }) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-for="area in templateState.overriddenAreas"
|
v-for="area in templateState.overriddenAreas"
|
||||||
:key="`overridden-${area.value}`"
|
:key="`overridden-${area.value}`"
|
||||||
|
variant="secondary"
|
||||||
|
class="max-w-full whitespace-normal break-words"
|
||||||
>
|
>
|
||||||
{{ area.label }} · overridden
|
{{ t('projects.overriddenArea', { name: area.label }) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
|
|
||||||
<Button as-child>
|
|
||||||
<Link :href="boardSettings(project.id)">
|
|
||||||
Board settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
v-if="$page.props.auth.permissions.globalAdmin"
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link :href="generalSettings(project.id)">
|
|
||||||
General settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="access(project.id)">Manage access</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card
|
|
||||||
v-for="area in projectAreas"
|
|
||||||
:key="area.title"
|
|
||||||
class="h-full"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div
|
|
||||||
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
|
|
||||||
>
|
|
||||||
<component :is="area.icon" class="size-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle>{{ area.title }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
area.description
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="text-muted-foreground text-sm">
|
|
||||||
Use the project shortcuts above to open this area.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AssignedTicketsProjectGroup = {
|
||||||
|
project: ProjectModel;
|
||||||
|
tickets: TicketModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardPageProps = {
|
||||||
|
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectsIndexPageProps = {
|
export type ProjectsIndexPageProps = {
|
||||||
projects: ProjectModel[];
|
projects: ProjectModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
|
||||||
|
export type TicketPresentationProps = {
|
||||||
|
projectId: number;
|
||||||
|
showDescription?: boolean;
|
||||||
|
showStatus?: boolean;
|
||||||
|
ticket: TicketModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketPresentationEmits = {
|
||||||
|
open: [ticketId: number];
|
||||||
|
};
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -17,7 +18,7 @@ Route::get('api/docs/openapi.json', function (): Response {
|
|||||||
})->name('api.docs.openapi');
|
})->name('api.docs.openapi');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||||
|
|
||||||
require __DIR__.'/web/admin.php';
|
require __DIR__.'/web/admin.php';
|
||||||
require __DIR__.'/web/project-templates.php';
|
require __DIR__.'/web/project-templates.php';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_confirm_password_screen_can_be_rendered()
|
public function test_confirm_password_screen_can_be_rendered(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
@@ -24,10 +24,32 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_password_confirmation_requires_authentication()
|
public function test_password_confirmation_requires_authentication(): void
|
||||||
{
|
{
|
||||||
$response = $this->get(route('password.confirm'));
|
$response = $this->get(route('password.confirm'));
|
||||||
|
|
||||||
$response->assertRedirect(route('login'));
|
$response->assertRedirect(route('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_password_can_be_confirmed(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('password.confirm'), ['password' => 'password'])
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertNotNull(session('auth.password_confirmed_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_confirmation_rejects_an_invalid_password(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('password.confirm'), ['password' => 'incorrect'])
|
||||||
|
->assertSessionHasErrors('password');
|
||||||
|
|
||||||
|
$this->assertNull(session('auth.password_confirmed_at'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -20,11 +21,16 @@ class PasswordResetTest extends TestCase
|
|||||||
$this->skipUnlessFortifyFeature(Features::resetPasswords());
|
$this->skipUnlessFortifyFeature(Features::resetPasswords());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_reset_password_link_screen_can_be_rendered()
|
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||||
{
|
{
|
||||||
$response = $this->get(route('password.request'));
|
$response = $this->get(route('password.request'));
|
||||||
|
|
||||||
$response->assertOk();
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('auth/ForgotPassword')
|
||||||
|
->where('status', null),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_reset_password_link_can_be_requested()
|
public function test_reset_password_link_can_be_requested()
|
||||||
@@ -38,7 +44,7 @@ class PasswordResetTest extends TestCase
|
|||||||
Notification::assertSentTo($user, ResetPassword::class);
|
Notification::assertSentTo($user, ResetPassword::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_reset_password_screen_can_be_rendered()
|
public function test_reset_password_screen_can_be_rendered(): void
|
||||||
{
|
{
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
|
|
||||||
@@ -46,10 +52,19 @@ class PasswordResetTest extends TestCase
|
|||||||
|
|
||||||
$this->post(route('password.email'), ['email' => $user->email]);
|
$this->post(route('password.email'), ['email' => $user->email]);
|
||||||
|
|
||||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||||
$response = $this->get(route('password.reset', $notification->token));
|
$response = $this->get(route('password.reset', [
|
||||||
|
'token' => $notification->token,
|
||||||
|
'email' => $user->email,
|
||||||
|
]));
|
||||||
|
|
||||||
$response->assertOk();
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('auth/ResetPassword')
|
||||||
|
->where('email', $user->email)
|
||||||
|
->where('token', $notification->token),
|
||||||
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,26 +2,117 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class DashboardTest extends TestCase
|
class DashboardTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_guests_are_redirected_to_the_login_page()
|
public function test_guests_are_redirected_to_the_login_page(): void
|
||||||
{
|
{
|
||||||
$response = $this->get(route('dashboard'));
|
$response = $this->get(route('dashboard'));
|
||||||
$response->assertRedirect(route('login'));
|
$response->assertRedirect(route('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_authenticated_users_can_visit_the_dashboard()
|
public function test_authenticated_users_see_an_empty_dashboard_without_assigned_tickets(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$response = $this->get(route('dashboard'));
|
$this->get(route('dashboard'))
|
||||||
$response->assertOk();
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('assignedTicketsByProject', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dashboard_only_contains_the_users_tickets_from_visible_projects(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$directProject = Project::factory()->create([
|
||||||
|
'key' => 'WEB',
|
||||||
|
'name' => 'Web platform',
|
||||||
|
]);
|
||||||
|
$teamProject = Project::factory()->create([
|
||||||
|
'key' => 'OPS',
|
||||||
|
'name' => 'Operations',
|
||||||
|
]);
|
||||||
|
$hiddenProject = Project::factory()->create([
|
||||||
|
'key' => 'SEC',
|
||||||
|
'name' => 'Private security',
|
||||||
|
]);
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
|
||||||
|
$team->users()->attach($user);
|
||||||
|
$this->syncProjectPermissions($directProject, $user, ['view']);
|
||||||
|
$this->syncProjectPermissions($teamProject, $team, ['view']);
|
||||||
|
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $user->id,
|
||||||
|
'project_id' => $directProject->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'WEB-1',
|
||||||
|
'title' => 'Older assigned ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $user->id,
|
||||||
|
'project_id' => $directProject->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'WEB-2',
|
||||||
|
'title' => 'Newest assigned ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $user->id,
|
||||||
|
'project_id' => $teamProject->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'OPS-1',
|
||||||
|
'title' => 'Team-visible assigned ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $otherUser->id,
|
||||||
|
'project_id' => $directProject->id,
|
||||||
|
'sequence_number' => 3,
|
||||||
|
'ticket_key' => 'WEB-3',
|
||||||
|
'title' => 'Another users ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $user->id,
|
||||||
|
'project_id' => $hiddenProject->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'SEC-1',
|
||||||
|
'title' => 'Hidden project ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('dashboard'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('assignedTicketsByProject', 2)
|
||||||
|
->where('assignedTicketsByProject.0.project.id', $teamProject->id)
|
||||||
|
->has('assignedTicketsByProject.0.tickets', 1)
|
||||||
|
->where('assignedTicketsByProject.0.tickets.0.ticket_key', 'OPS-1')
|
||||||
|
->where('assignedTicketsByProject.1.project.id', $directProject->id)
|
||||||
|
->has('assignedTicketsByProject.1.tickets', 2)
|
||||||
|
->where('assignedTicketsByProject.1.tickets.0.ticket_key', 'WEB-2')
|
||||||
|
->where('assignedTicketsByProject.1.tickets.1.ticket_key', 'WEB-1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dashboard_presentation_uses_real_data_and_handles_empty_state(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/dashboard.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Dashboard behavior checks passed.', $process->getOutput());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class ProjectBacklogTest extends TestCase
|
|||||||
public function test_users_with_view_permission_can_view_a_projects_backlog(): void
|
public function test_users_with_view_permission_can_view_a_projects_backlog(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$assignee = User::factory()->create([
|
||||||
|
'name' => 'Taylor Reed',
|
||||||
|
]);
|
||||||
$project = Project::factory()->create([
|
$project = Project::factory()->create([
|
||||||
'key' => 'AUTH',
|
'key' => 'AUTH',
|
||||||
]);
|
]);
|
||||||
@@ -39,6 +42,7 @@ class ProjectBacklogTest extends TestCase
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
'ticket_status_id' => $backlogStatus->id,
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
@@ -59,6 +63,10 @@ class ProjectBacklogTest extends TestCase
|
|||||||
->where('canEditTickets', false)
|
->where('canEditTickets', false)
|
||||||
->where('project.id', $project->id)
|
->where('project.id', $project->id)
|
||||||
->where('tickets.0.ticket_key', $ticket->ticket_key)
|
->where('tickets.0.ticket_key', $ticket->ticket_key)
|
||||||
|
->where('tickets.0.assignee.id', $assignee->id)
|
||||||
|
->where('tickets.0.assignee.name', $assignee->name)
|
||||||
|
->where('tickets.0.ticket_priority.name', $priority->name)
|
||||||
|
->where('tickets.0.ticket_status.name', $backlogStatus->name)
|
||||||
->where('canCreateTickets', false),
|
->where('canCreateTickets', false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
public function test_users_with_view_permission_can_view_a_projects_board(): void
|
public function test_users_with_view_permission_can_view_a_projects_board(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$assignee = User::factory()->create([
|
||||||
|
'name' => 'Taylor Reed',
|
||||||
|
]);
|
||||||
$project = Project::factory()->create([
|
$project = Project::factory()->create([
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
@@ -58,6 +61,7 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$boardTicket = Ticket::factory()->create([
|
$boardTicket = Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 2,
|
'sequence_number' => 2,
|
||||||
'ticket_key' => 'CORE-2',
|
'ticket_key' => 'CORE-2',
|
||||||
@@ -81,6 +85,10 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
->has('boardColumns.1.tickets', 1)
|
->has('boardColumns.1.tickets', 1)
|
||||||
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
|
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
|
||||||
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
|
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
|
||||||
|
->where('boardColumns.1.tickets.0.assignee.id', $assignee->id)
|
||||||
|
->where('boardColumns.1.tickets.0.assignee.name', $assignee->name)
|
||||||
|
->where('boardColumns.1.tickets.0.ticket_priority.name', $priority->name)
|
||||||
|
->where('boardColumns.1.tickets.0.ticket_status.name', $doingStatus->name)
|
||||||
->where('canCreateTickets', false)
|
->where('canCreateTickets', false)
|
||||||
->where('canEditTickets', false),
|
->where('canEditTickets', false),
|
||||||
);
|
);
|
||||||
@@ -204,6 +212,19 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'entry_type' => 'status_changed',
|
'entry_type' => 'status_changed',
|
||||||
'summary' => 'Moved from Todo to Done.',
|
'summary' => 'Moved from Todo to Done.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $ticket->worklogEntries()->count());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
|
'redirect_to' => 'board',
|
||||||
|
'ticket_status_id' => $doneStatus->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.board.index', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame($doneStatus->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
$this->assertSame(1, $ticket->worklogEntries()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectBoardPresentationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_board_renders_configured_status_groups_counts_and_permission_aware_actions(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/board.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Board behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectCreationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[DataProvider('missingSources')]
|
||||||
|
public function test_creation_requires_the_selected_source(string $mode, string $field): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->admin()->create();
|
||||||
|
$this->actingAs($user)->from(route('projects.create'))
|
||||||
|
->post(route('projects.store'), ['creation_mode' => $mode, 'name' => 'New project', 'key' => 'NEW'])
|
||||||
|
->assertRedirect(route('projects.create'))->assertSessionHasErrors($field);
|
||||||
|
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string}> */
|
||||||
|
public static function missingSources(): array
|
||||||
|
{
|
||||||
|
return ['duplicate' => ['duplicate', 'source_project_id'], 'template' => ['template', 'source_template_id']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unpublished_template_cannot_be_used_to_create_a_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->admin()->create();
|
||||||
|
$template = ProjectTemplate::factory()->create(['is_published' => false]);
|
||||||
|
$this->actingAs($user)->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'template', 'source_template_id' => $template->id, 'name' => 'New project', 'key' => 'NEW',
|
||||||
|
])->assertSessionHasErrors('source_template_id');
|
||||||
|
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creation_page_rendering_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-creation.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project creation rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectOverviewTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_projects_without_access_do_not_appear_in_the_empty_overview(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
Project::factory()->create(['name' => 'Private workspace']);
|
||||||
|
|
||||||
|
$this->actingAs($user)->get(route('projects.index'))
|
||||||
|
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Index')
|
||||||
|
->has('projects', 0)
|
||||||
|
->where('auth.permissions.projectCreate', false));
|
||||||
|
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
$this->get(route('projects.index'))
|
||||||
|
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('projects', 0)
|
||||||
|
->where('auth.permissions.projectCreate', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('projectRoles')]
|
||||||
|
public function test_project_entry_actions_follow_effective_permissions(string $role, bool $teamAccess, bool $canManage): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$assignee = $user;
|
||||||
|
if ($teamAccess) {
|
||||||
|
$assignee = Team::factory()->create();
|
||||||
|
$assignee->users()->attach($user);
|
||||||
|
}
|
||||||
|
$this->syncProjectPermissions($project, $assignee, [$role]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->get(route('projects.show', $project))
|
||||||
|
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Show')
|
||||||
|
->where('project.id', $project->id)
|
||||||
|
->where('canManageAccess', $canManage)
|
||||||
|
->where('auth.permissions.projectCreate', false));
|
||||||
|
|
||||||
|
foreach (['projects.board.index', 'projects.backlog.index'] as $route) {
|
||||||
|
$this->get(route($route, $project))->assertOk();
|
||||||
|
}
|
||||||
|
foreach (['projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) {
|
||||||
|
$this->get(route($route, $project))->assertStatus($canManage ? 200 : 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, bool, bool}> */
|
||||||
|
public static function projectRoles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reader' => ['read', false, false],
|
||||||
|
'writer' => ['write', false, false],
|
||||||
|
'project admin' => ['admin', false, true],
|
||||||
|
'team reader' => ['read', true, false],
|
||||||
|
'team admin' => ['admin', true, true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_overview_rendering_and_actions(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-overview.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project overview behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketPresentationsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_cards_and_list_items_share_accessible_data_driven_behavior(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/ticket-presentations.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Ticket presentation behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { TicketCard, TicketListItem } from '@/components/tickets';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
fullTicket: Object,
|
||||||
|
optionalTicket: Object,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="mx-auto grid max-w-5xl gap-8 p-4 sm:p-8">
|
||||||
|
<section class="grid gap-4" aria-labelledby="cards-title">
|
||||||
|
<h1 id="cards-title" class="text-headline-mobile md:text-headline">
|
||||||
|
Ticket cards
|
||||||
|
</h1>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<TicketCard :project-id="7" :ticket="fullTicket" />
|
||||||
|
<TicketCard :project-id="7" :ticket="optionalTicket" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4" aria-labelledby="rows-title">
|
||||||
|
<h2 id="rows-title" class="text-title">Ticket list items</h2>
|
||||||
|
<ul class="grid gap-4">
|
||||||
|
<TicketListItem :project-id="7" :ticket="fullTicket" />
|
||||||
|
<TicketListItem :project-id="7" :ticket="optionalTicket" />
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||||
|
const { chromium } = playwright.default ?? playwright;
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const base = 'http://127.0.0.1:4181/projects/7/board';
|
||||||
|
const content = () => page.locator('#main-content');
|
||||||
|
const visibleColumns = () =>
|
||||||
|
content().locator('[id^="board-column-title-"]:visible');
|
||||||
|
const visit = async (query = '') => {
|
||||||
|
await page.goto(base + query);
|
||||||
|
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
};
|
||||||
|
const tabTo = async (locator) => {
|
||||||
|
await content().focus();
|
||||||
|
for (let step = 0; step < 40; step++) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
if (
|
||||||
|
await locator.evaluate(
|
||||||
|
(element) => element === document.activeElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
assert.equal(
|
||||||
|
await locator.evaluate((element) =>
|
||||||
|
element.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.fail('Board control is unreachable by keyboard');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 768, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
});
|
||||||
|
for (const [query, state] of [
|
||||||
|
['', 'filled'],
|
||||||
|
['?empty', 'empty'],
|
||||||
|
['?one&noTickets', 'one-empty'],
|
||||||
|
]) {
|
||||||
|
await visit(query);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((el) => el.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`${state} overflow at ${width}/${theme}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await visibleColumns().count(),
|
||||||
|
state === 'empty'
|
||||||
|
? 0
|
||||||
|
: width < 768 || state === 'one-empty'
|
||||||
|
? 1
|
||||||
|
: 8,
|
||||||
|
);
|
||||||
|
if (width < 768 && state !== 'empty') {
|
||||||
|
assert.ok(
|
||||||
|
(await page.getByLabel('Choose a column').boundingBox())
|
||||||
|
.height >= 44,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'filled' && width >= 768) {
|
||||||
|
const region = content().getByRole('region', {
|
||||||
|
name: 'Workflow columns',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await region.evaluate(
|
||||||
|
(el) => el.scrollWidth > el.clientWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await region.focus();
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await content()
|
||||||
|
.locator('#board-column-8')
|
||||||
|
.scrollIntoViewIfNeeded();
|
||||||
|
assert.ok(await region.evaluate((el) => el.scrollLeft > 0));
|
||||||
|
await region.evaluate((el) => {
|
||||||
|
el.scrollLeft = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan27-board-${state}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await visit();
|
||||||
|
const chooseColumn = page.getByLabel('Choose a column');
|
||||||
|
await tabTo(chooseColumn);
|
||||||
|
// Native popup selection is handled by macOS outside the browser DOM.
|
||||||
|
await chooseColumn.selectOption('2');
|
||||||
|
assert.equal(await chooseColumn.inputValue(), '2');
|
||||||
|
assert.equal(await visibleColumns().innerText(), 'In development');
|
||||||
|
await chooseColumn.selectOption('8');
|
||||||
|
assert.equal(await visibleColumns().innerText(), 'Column without statuses');
|
||||||
|
await content()
|
||||||
|
.getByText('No statuses configured for this column.')
|
||||||
|
.waitFor();
|
||||||
|
await chooseColumn.selectOption('1');
|
||||||
|
await content()
|
||||||
|
.getByRole('heading', { name: 'Ready · Planned', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await content()
|
||||||
|
.getByRole('heading', { name: 'Ready · Active', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
const terminalSelect = page.getByLabel('Move ENG-43', { exact: true });
|
||||||
|
assert.equal(await terminalSelect.isDisabled(), true);
|
||||||
|
const moveSelect = page.getByLabel('Move ENG-42', { exact: true });
|
||||||
|
assert.deepEqual(
|
||||||
|
await moveSelect
|
||||||
|
.locator('option')
|
||||||
|
.evaluateAll((options) => options.map((option) => option.value)),
|
||||||
|
['20', '11', '99'],
|
||||||
|
);
|
||||||
|
await tabTo(moveSelect);
|
||||||
|
await moveSelect.selectOption('20');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(await page.locator(':focus').innerText(), 'Update status');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await chooseColumn.inputValue(), '1');
|
||||||
|
assert.match(
|
||||||
|
await chooseColumn.locator('option').first().innerText(),
|
||||||
|
/\(1\)/,
|
||||||
|
);
|
||||||
|
await chooseColumn.selectOption('2');
|
||||||
|
await content()
|
||||||
|
.getByRole('link', {
|
||||||
|
name: 'Open ENG-42: Ship accessible board navigation',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('99');
|
||||||
|
await content()
|
||||||
|
.locator('article')
|
||||||
|
.filter({ hasText: 'ENG-42' })
|
||||||
|
.getByRole('button', { name: 'Update status' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.getByRole('link', {
|
||||||
|
name: 'Open ENG-42: Ship accessible board navigation',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await chooseColumn.locator('option').first().innerText(),
|
||||||
|
/\(1\)/,
|
||||||
|
);
|
||||||
|
|
||||||
|
await visit('?reject');
|
||||||
|
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('20');
|
||||||
|
await content()
|
||||||
|
.locator('article')
|
||||||
|
.filter({ hasText: 'ENG-42' })
|
||||||
|
.getByRole('button', { name: 'Update status' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByText('This transition is no longer allowed.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Move ENG-42', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.getByText('This transition is no longer allowed.', { exact: true })
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Move ENG-43', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
await visit('?readonly');
|
||||||
|
assert.equal(await content().locator('form').count(), 0);
|
||||||
|
const ticketLink = content().getByRole('link', {
|
||||||
|
name: 'Open ENG-42: Ship accessible board navigation',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await tabTo(ticketLink);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/tickets/42');
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await visit('?empty');
|
||||||
|
await tabTo(
|
||||||
|
content().getByRole('link', { name: 'Open backlog', exact: true }),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/backlog');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Board browser checks passed: responsive columns, keyboard, status changes and validation, permissions, empty states and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||||
|
const { chromium } = playwright.default ?? playwright;
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 1440, height: 1200 },
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const failures = [];
|
||||||
|
const requests = [];
|
||||||
|
page.on('pageerror', (error) => failures.push(error.message));
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (['POST', 'PATCH'].includes(request.method())) {
|
||||||
|
requests.push(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const base = 'http://127.0.0.1:4181/projects/7/board';
|
||||||
|
const handle = () =>
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'Drag ENG-42 to change status',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const card = () => page.locator('article').filter({ hasText: 'ENG-42' });
|
||||||
|
const visit = async (query = '') => {
|
||||||
|
await page.goto(base + query);
|
||||||
|
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||||
|
requests.length = 0;
|
||||||
|
};
|
||||||
|
const center = async (locator) => {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
assert.ok(box);
|
||||||
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
};
|
||||||
|
const begin = async () => {
|
||||||
|
await handle().scrollIntoViewIfNeeded();
|
||||||
|
const from = await center(handle());
|
||||||
|
await page.mouse.move(from.x, from.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(from.x + 12, from.y, { steps: 3 });
|
||||||
|
};
|
||||||
|
const moveToColumn = async (id) => {
|
||||||
|
const to = await center(page.locator(`#board-column-title-${id}`));
|
||||||
|
await page.mouse.move(to.x, to.y, { steps: 10 });
|
||||||
|
};
|
||||||
|
const dragToColumn = async (id) => {
|
||||||
|
await begin();
|
||||||
|
await moveToColumn(id);
|
||||||
|
await page.mouse.up();
|
||||||
|
};
|
||||||
|
const originalCard = async () => {
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#board-column-1 article')
|
||||||
|
.filter({ hasText: 'ENG-42' })
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
});
|
||||||
|
await visit();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Drag ENG-43 to change status' })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await begin();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#board-column-2')
|
||||||
|
.evaluate((el) => el.classList.contains('border-primary')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#board-column-3')
|
||||||
|
.evaluate((el) => el.classList.contains('opacity-40')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await moveToColumn(3);
|
||||||
|
await page.mouse.up();
|
||||||
|
await originalCard();
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
|
||||||
|
await begin();
|
||||||
|
await moveToColumn(2);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.mouse.up();
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
await originalCard();
|
||||||
|
|
||||||
|
// Pointer capture cancellation must never become a drop.
|
||||||
|
await begin();
|
||||||
|
await handle().dispatchEvent('pointercancel', { pointerId: 1 });
|
||||||
|
await page.mouse.up();
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
|
||||||
|
await visit('?multiple');
|
||||||
|
await dragToColumn(2);
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.waitFor();
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
await dialog
|
||||||
|
.locator('option')
|
||||||
|
.evaluateAll((options) =>
|
||||||
|
options.map((option) => option.value),
|
||||||
|
),
|
||||||
|
['20', '21'],
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog.waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(
|
||||||
|
await handle().evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
await originalCard();
|
||||||
|
await dragToColumn(2);
|
||||||
|
const select = dialog.getByLabel('Destination status');
|
||||||
|
await select.focus();
|
||||||
|
await select.selectOption('21');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(await page.locator(':focus').innerText(), 'Update status');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.deepEqual(requests[0].postDataJSON(), {
|
||||||
|
ticket_status_id: 21,
|
||||||
|
redirect_to: 'board',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#board-column-2 article')
|
||||||
|
.filter({ hasText: 'ENG-42' })
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await visit('?reject');
|
||||||
|
await dragToColumn(2);
|
||||||
|
await page
|
||||||
|
.getByText('This transition is no longer allowed.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await originalCard();
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
|
||||||
|
await visit('?slow');
|
||||||
|
await dragToColumn(2);
|
||||||
|
await page.locator('[data-form-state="loading"]').waitFor();
|
||||||
|
assert.equal(await handle().isDisabled(), true);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Move ENG-42', { exact: true }).isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Attempt a competing form submit even though its controls are disabled.
|
||||||
|
await card()
|
||||||
|
.locator('form')
|
||||||
|
.evaluate((form) => form.requestSubmit());
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
|
||||||
|
await visit('?slow');
|
||||||
|
await card()
|
||||||
|
.getByRole('button', { name: 'Update status', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.equal(await handle().isDisabled(), true);
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await page.route('**/tickets/42/status', (route) =>
|
||||||
|
route.abort('failed'),
|
||||||
|
);
|
||||||
|
await dragToColumn(2);
|
||||||
|
await page.locator('[data-form-state="network"]').waitFor();
|
||||||
|
await originalCard();
|
||||||
|
assert.equal(await handle().isEnabled(), true);
|
||||||
|
await page.unroute('**/tickets/42/status');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await page.route('**/tickets/42/status', (route) =>
|
||||||
|
route.fulfill({ status: 500, body: 'Unavailable' }),
|
||||||
|
);
|
||||||
|
await dragToColumn(2);
|
||||||
|
await page.locator('[data-form-state="server"]').waitFor();
|
||||||
|
await originalCard();
|
||||||
|
await page.unroute('**/tickets/42/status');
|
||||||
|
|
||||||
|
// Normal touch gestures still scroll; only the explicit handle captures a drag.
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await visit('?multiple');
|
||||||
|
await handle().scrollIntoViewIfNeeded();
|
||||||
|
assert.equal(
|
||||||
|
await handle().evaluate((el) => getComputedStyle(el).touchAction),
|
||||||
|
'none',
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
await card().evaluate((el) => getComputedStyle(el).touchAction),
|
||||||
|
'none',
|
||||||
|
);
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
const from = await center(handle());
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [{ ...from, id: 1 }],
|
||||||
|
});
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ x: from.x + 16, y: from.y, id: 1 }],
|
||||||
|
});
|
||||||
|
const tray = page.locator('[data-board-mobile-targets]');
|
||||||
|
await tray.waitFor();
|
||||||
|
const to = await center(tray.locator('[data-board-drop-column="2"]'));
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ ...to, id: 1 }],
|
||||||
|
});
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan28-mobile-drag-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchEnd',
|
||||||
|
touchPoints: [],
|
||||||
|
});
|
||||||
|
await page.getByRole('dialog').waitFor();
|
||||||
|
await page.getByLabel('Destination status').selectOption('20');
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('button', { name: 'Update status' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByText('ENG-42 moved successfully.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Choose a column').inputValue(),
|
||||||
|
'2',
|
||||||
|
);
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
await cdp.detach();
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan28-mobile-result-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await visit('?readonly');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByRole('button', { name: /Drag ENG/ }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||||
|
}
|
||||||
|
assert.deepEqual(failures, []);
|
||||||
|
console.log(
|
||||||
|
'Board drag checks passed: mouse, touch, workflow restrictions, selection, cancellation, focus, request guards and recovery in both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Board from '../../resources/js/pages/projects/Board.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'Board'
|
||||||
|
? Board
|
||||||
|
: { render: () => h('h1', 'Destination reached') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/board-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4181, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const project = {
|
||||||
|
id: 7,
|
||||||
|
key: 'ENG',
|
||||||
|
name: 'Engineering · Platform and developer experience',
|
||||||
|
};
|
||||||
|
const columnNames = [
|
||||||
|
'Ready',
|
||||||
|
'In development',
|
||||||
|
'Review',
|
||||||
|
'Testing',
|
||||||
|
'Release',
|
||||||
|
'Done',
|
||||||
|
'Archived',
|
||||||
|
'Column without statuses',
|
||||||
|
];
|
||||||
|
const columns = columnNames.map((name, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name,
|
||||||
|
position: index + 1,
|
||||||
|
project_id: 7,
|
||||||
|
tickets: [],
|
||||||
|
ticket_statuses: [],
|
||||||
|
}));
|
||||||
|
const statuses = columns.slice(0, -1).flatMap((column) =>
|
||||||
|
['Planned', 'Active'].map((name, index) => ({
|
||||||
|
id: column.id * 10 + index,
|
||||||
|
name: `${column.name} · ${name}`,
|
||||||
|
position: index + 1,
|
||||||
|
project_id: 7,
|
||||||
|
board_column_id: column.id,
|
||||||
|
board_column: { ...column },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const backlog = {
|
||||||
|
id: 99,
|
||||||
|
name: 'Backlog',
|
||||||
|
board_column_id: null,
|
||||||
|
board_column: null,
|
||||||
|
};
|
||||||
|
for (const column of columns) {
|
||||||
|
column.ticket_statuses = statuses.filter(
|
||||||
|
(status) => status.board_column_id === column.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function ticket(id, status, title) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
ticket_key: `ENG-${id}`,
|
||||||
|
project_id: 7,
|
||||||
|
sequence_number: id,
|
||||||
|
description:
|
||||||
|
'A real ticket card in the isolated board behavior fixture.',
|
||||||
|
ticket_status_id: status.id,
|
||||||
|
ticket_status: {
|
||||||
|
...status,
|
||||||
|
allowed_target_statuses:
|
||||||
|
id === 42 ? [status, statuses[2], statuses[1], backlog] : [],
|
||||||
|
},
|
||||||
|
ticket_priority: { id: 1, name: 'Urgent custom priority', position: 1 },
|
||||||
|
ticket_type: { id: 1, name: 'Development', is_parent: false },
|
||||||
|
assignee: null,
|
||||||
|
assignee_user_id: null,
|
||||||
|
is_blocked: id === 43,
|
||||||
|
blocked_comment: id === 43 ? 'Awaiting the external review.' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePage(url) {
|
||||||
|
const parsed = new URL(url, 'http://localhost');
|
||||||
|
const params = parsed.searchParams;
|
||||||
|
const isBoard = parsed.pathname === '/projects/7/board';
|
||||||
|
const boardColumns = structuredClone(
|
||||||
|
params.has('empty')
|
||||||
|
? []
|
||||||
|
: params.has('one')
|
||||||
|
? columns.slice(0, 1)
|
||||||
|
: columns,
|
||||||
|
);
|
||||||
|
if (!params.has('noTickets')) {
|
||||||
|
const target =
|
||||||
|
[...statuses, backlog].find(
|
||||||
|
(status) => status.id === Number(params.get('target')),
|
||||||
|
) ?? statuses[0];
|
||||||
|
const tickets = [
|
||||||
|
ticket(42, target, 'Ship accessible board navigation'),
|
||||||
|
ticket(
|
||||||
|
43,
|
||||||
|
statuses[1],
|
||||||
|
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (params.has('multiple')) {
|
||||||
|
tickets[0].ticket_status.allowed_target_statuses.push(statuses[3]);
|
||||||
|
}
|
||||||
|
for (const column of boardColumns) {
|
||||||
|
column.tickets = tickets.filter(
|
||||||
|
(item) => item.ticket_status.board_column_id === column.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: isBoard ? 'Board' : 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: params.has('rejected')
|
||||||
|
? {
|
||||||
|
'board-ticket-42': {
|
||||||
|
ticket_status_id:
|
||||||
|
'This transition is no longer allowed.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
canManage: false,
|
||||||
|
canCreateTickets: !params.has('readonly'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
boardColumns,
|
||||||
|
canCreateTickets: !params.has('readonly'),
|
||||||
|
canEditTickets: !params.has('readonly'),
|
||||||
|
status: params.has('target') ? 'ENG-42 moved successfully.' : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response) => {
|
||||||
|
if (request.method === 'POST' || request.method === 'PATCH') {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
assert.equal(
|
||||||
|
request.url,
|
||||||
|
request.method === 'POST'
|
||||||
|
? '/projects/7/tickets/42/status?_method=PATCH'
|
||||||
|
: '/projects/7/tickets/42/status',
|
||||||
|
);
|
||||||
|
assert.deepEqual(Object.keys(data).sort(), [
|
||||||
|
'redirect_to',
|
||||||
|
'ticket_status_id',
|
||||||
|
]);
|
||||||
|
assert.equal(data.redirect_to, 'board');
|
||||||
|
assert.ok([11, 20, 21, 99].includes(Number(data.ticket_status_id)));
|
||||||
|
const previous = new URL(request.headers.referer);
|
||||||
|
const reject = previous.searchParams.has('reject');
|
||||||
|
if (previous.searchParams.has('slow')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
response.writeHead(303, {
|
||||||
|
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = makePage(request.url);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/board-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Board fixture ready at http://127.0.0.1:4181/projects/7/board',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { default: Board } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/Board.vue',
|
||||||
|
);
|
||||||
|
const render = async (query = '') => {
|
||||||
|
const result = await createInertiaApp({
|
||||||
|
page: makePage(`/projects/7/board${query}`),
|
||||||
|
resolve: () => Board,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return result.body.split('id="main-content"')[1];
|
||||||
|
};
|
||||||
|
const board = await render();
|
||||||
|
assert.equal((board.match(/id="board-column-title-/g) ?? []).length, 8);
|
||||||
|
assert.equal((board.match(/id="board-status-/g) ?? []).length, 14);
|
||||||
|
assert.match(board, /Ready · Planned/);
|
||||||
|
assert.match(board, /Ready · Active/);
|
||||||
|
assert.match(board, /No statuses configured for this column/);
|
||||||
|
assert.match(board, /No tickets in this column/);
|
||||||
|
assert.match(board, /No tickets in this status/);
|
||||||
|
assert.match(board, /2 tickets/);
|
||||||
|
assert.match(board, /Urgent custom priority/);
|
||||||
|
assert.match(board, /href="\/projects\/7\/tickets\/42"/);
|
||||||
|
assert.match(
|
||||||
|
board,
|
||||||
|
/action="\/projects\/7\/tickets\/42\/status\?_method=PATCH"/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
board,
|
||||||
|
/href="\/projects\/7\/tickets\/create\?source=board"/,
|
||||||
|
);
|
||||||
|
assert.match(board, /href="\/projects\/7\/backlog"/);
|
||||||
|
assert.match(board, /No allowed moves/);
|
||||||
|
assert.equal((board.match(/<article/g) ?? []).length, 2);
|
||||||
|
const readonly = await render('?readonly');
|
||||||
|
assert.doesNotMatch(readonly, /<form|Move ENG-|New ticket/);
|
||||||
|
const empty = await render('?empty');
|
||||||
|
assert.match(empty, /No workflow columns yet/);
|
||||||
|
assert.doesNotMatch(empty, /id="board-column"|<article/);
|
||||||
|
const one = await render('?one&noTickets');
|
||||||
|
assert.equal((one.match(/id="board-column-title-/g) ?? []).length, 1);
|
||||||
|
assert.match(one, /0 tickets/);
|
||||||
|
assert.doesNotMatch(one, /<article/);
|
||||||
|
console.log('Board behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||||
|
const { chromium } = playwright.default ?? playwright;
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4180';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const content = () => page.locator('#main-content');
|
||||||
|
const focusWithTab = async (link) => {
|
||||||
|
await content().focus();
|
||||||
|
for (let step = 0; step < 40; step++) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
if (
|
||||||
|
await link.evaluate((element) => element === document.activeElement)
|
||||||
|
) {
|
||||||
|
assert.equal(
|
||||||
|
await link.evaluate((element) =>
|
||||||
|
element.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.fail('Dashboard action was not reachable by keyboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
for (const [path, state] of [
|
||||||
|
['/dashboard', 'filled'],
|
||||||
|
['/dashboard?empty', 'empty'],
|
||||||
|
]) {
|
||||||
|
await page.goto(base + path);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', {
|
||||||
|
name: 'Welcome back, Alex Morgan',
|
||||||
|
exact: true,
|
||||||
|
level: 1,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((element) =>
|
||||||
|
element.classList.contains('dark'),
|
||||||
|
),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`${state} dashboard overflows at ${width} in ${theme}`,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan25-dashboard-${state}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await page.goto(`${base}/dashboard`);
|
||||||
|
const ticket = content().getByRole('link', {
|
||||||
|
name: 'Open ENG-42: Ship the dashboard with real assigned work',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(ticket);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/tickets/42');
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await page.goto(`${base}/dashboard?empty`);
|
||||||
|
const projects = content().getByRole('link', {
|
||||||
|
name: 'Browse projects',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(projects);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Dashboard browser checks passed: keyboard, empty and filled states, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Dashboard from '../../resources/js/pages/Dashboard.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
({ Dashboard })[name] ?? {
|
||||||
|
render: () => h('h1', 'Destination reached'),
|
||||||
|
},
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/dashboard-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4180, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: 8,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#22c55e',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
email_verified_at: '2026-09-11T00:00:00Z',
|
||||||
|
must_change_password_on_next_login: false,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
id: 7,
|
||||||
|
key: 'ENG',
|
||||||
|
name: 'Engineering · Platform and developer experience',
|
||||||
|
show_author: true,
|
||||||
|
show_story_points: true,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
};
|
||||||
|
const secondProject = {
|
||||||
|
...project,
|
||||||
|
id: 9,
|
||||||
|
key: 'OPS',
|
||||||
|
name: 'Operations',
|
||||||
|
};
|
||||||
|
const ticketDefaults = {
|
||||||
|
acceptance_criteria: [],
|
||||||
|
assignee: user,
|
||||||
|
assignee_user_id: user.id,
|
||||||
|
author: null,
|
||||||
|
author_user_id: null,
|
||||||
|
blocked_comment: null,
|
||||||
|
description: 'Real work loaded from the server.',
|
||||||
|
is_blocked: false,
|
||||||
|
parent_ticket_id: null,
|
||||||
|
story_points: 3,
|
||||||
|
ticket_priority: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Medium',
|
||||||
|
position: 3,
|
||||||
|
project_id: 7,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
},
|
||||||
|
ticket_priority_id: 1,
|
||||||
|
ticket_status: {
|
||||||
|
id: 3,
|
||||||
|
board_column_id: 2,
|
||||||
|
name: 'In Progress',
|
||||||
|
position: 1,
|
||||||
|
project_id: 7,
|
||||||
|
slug: 'in-progress',
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
board_column: {
|
||||||
|
id: 2,
|
||||||
|
name: 'In Progress',
|
||||||
|
position: 2,
|
||||||
|
project_id: 7,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ticket_status_id: 3,
|
||||||
|
ticket_type: {
|
||||||
|
id: 1,
|
||||||
|
is_parent: false,
|
||||||
|
name: 'Dev task',
|
||||||
|
position: 1,
|
||||||
|
project_id: 7,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
},
|
||||||
|
ticket_type_id: 1,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
};
|
||||||
|
const tickets = [
|
||||||
|
{
|
||||||
|
...ticketDefaults,
|
||||||
|
id: 42,
|
||||||
|
project_id: project.id,
|
||||||
|
sequence_number: 42,
|
||||||
|
ticket_key: 'ENG-42',
|
||||||
|
title: 'Ship the dashboard with real assigned work',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...ticketDefaults,
|
||||||
|
id: 41,
|
||||||
|
project_id: project.id,
|
||||||
|
sequence_number: 41,
|
||||||
|
ticket_key: 'ENG-41',
|
||||||
|
title: 'Verify keyboard navigation',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const blockedTicket = {
|
||||||
|
...ticketDefaults,
|
||||||
|
id: 12,
|
||||||
|
blocked_comment: 'Waiting for review.',
|
||||||
|
is_blocked: true,
|
||||||
|
project_id: secondProject.id,
|
||||||
|
sequence_number: 12,
|
||||||
|
ticket_key: 'OPS-12',
|
||||||
|
title: 'Resolve the deployment blocker',
|
||||||
|
};
|
||||||
|
const assignedTicketsByProject = [
|
||||||
|
{ project: secondProject, tickets: [blockedTicket] },
|
||||||
|
{ project, tickets },
|
||||||
|
];
|
||||||
|
|
||||||
|
function makePage(url) {
|
||||||
|
const parsedUrl = new URL(url, 'http://localhost');
|
||||||
|
const isDashboard = parsedUrl.pathname === '/dashboard';
|
||||||
|
return {
|
||||||
|
component: isDashboard ? 'Dashboard' : 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...(isDashboard
|
||||||
|
? {
|
||||||
|
assignedTicketsByProject: parsedUrl.searchParams.has(
|
||||||
|
'empty',
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: assignedTicketsByProject,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response) => {
|
||||||
|
const page = makePage(request.url);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/dashboard-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Dashboard fixture ready at http://127.0.0.1:4180/dashboard');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { default: Dashboard } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/Dashboard.vue',
|
||||||
|
);
|
||||||
|
const render = async (url) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: makePage(url),
|
||||||
|
resolve: () => Dashboard,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return response.body.split('<main')[1].split('</main>')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboard = await render('/dashboard');
|
||||||
|
assert.match(dashboard, /Welcome back, Alex Morgan/);
|
||||||
|
assert.match(dashboard, /Assigned tickets/);
|
||||||
|
assert.match(dashboard, /Projects with assigned work/);
|
||||||
|
assert.match(dashboard, /Blocked tickets/);
|
||||||
|
assert.match(dashboard, /Ship the dashboard with real assigned work/);
|
||||||
|
assert.match(dashboard, /Resolve the deployment blocker/);
|
||||||
|
assert.match(dashboard, /href="\/projects\/7\/tickets\/42"/);
|
||||||
|
assert.match(dashboard, /href="\/projects\/9"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
dashboard,
|
||||||
|
/API-integration|Finished this week|Recent activity|PlaceholderPattern/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const empty = await render('/dashboard?empty');
|
||||||
|
assert.match(empty, /No tickets assigned to you/);
|
||||||
|
assert.match(empty, /href="\/projects"/);
|
||||||
|
assert.doesNotMatch(empty, /ENG-42|OPS-12|<article/);
|
||||||
|
|
||||||
|
console.log('Dashboard behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
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 base = 'http://127.0.0.1:4179';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'creation_test_result', value, url: base }]);
|
||||||
|
const content = () => page.locator('#main-content');
|
||||||
|
const state = (value) =>
|
||||||
|
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
||||||
|
const choose = async (value) => {
|
||||||
|
await page.locator(`input[name="creation_mode"][value="${value}"]`).focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
};
|
||||||
|
const fillDetails = async () => {
|
||||||
|
await page
|
||||||
|
.getByLabel('Project name', { exact: true })
|
||||||
|
.fill('Frontend workspace');
|
||||||
|
await page.getByLabel('Project key', { exact: true }).fill('web');
|
||||||
|
};
|
||||||
|
const submit = () =>
|
||||||
|
page.getByRole('button', { name: 'Create project', exact: true }).click();
|
||||||
|
const focusWithTab = async (target) => {
|
||||||
|
await content().focus();
|
||||||
|
for (let step = 0; step < 40; step++) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
if (
|
||||||
|
await target.evaluate(
|
||||||
|
(element) => element === document.activeElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
assert.equal(
|
||||||
|
await target.evaluate((element) =>
|
||||||
|
element.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.fail('Form control was not reachable by keyboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 page.goto(
|
||||||
|
`${base}/projects/create?viewport=${width}&theme=${theme}`,
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Create a project' })
|
||||||
|
.waitFor();
|
||||||
|
for (const mode of ['scratch', 'duplicate', 'template']) {
|
||||||
|
await choose(mode);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((element) =>
|
||||||
|
element.classList.contains('dark'),
|
||||||
|
),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`${mode} overflows at ${width} in ${theme}`,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan24-${mode}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await choose('scratch');
|
||||||
|
const scratch = page.getByRole('radio', {
|
||||||
|
name: 'Start from scratch',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(scratch);
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', {
|
||||||
|
name: 'Duplicate a project',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const board = page.getByRole('checkbox', {
|
||||||
|
name: 'Board columns',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(board);
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await board.isChecked(), true);
|
||||||
|
const source = page.getByLabel('Source project', { exact: true });
|
||||||
|
await focusWithTab(source);
|
||||||
|
await page.keyboard.press('o');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.querySelector('#source_project_id').value === '8',
|
||||||
|
);
|
||||||
|
assert.equal(await source.inputValue(), '8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create?empty`);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', { name: 'Start from scratch', exact: true })
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', { name: 'Duplicate a project', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', { name: 'Use a template', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await fillDetails();
|
||||||
|
const scratchRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
assert.deepEqual((await scratchRequest).postDataJSON(), {
|
||||||
|
name: 'Frontend workspace',
|
||||||
|
key: 'web',
|
||||||
|
creation_mode: 'scratch',
|
||||||
|
source_project_id: '',
|
||||||
|
source_template_id: '',
|
||||||
|
copy_areas: [],
|
||||||
|
});
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create?template=4`);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Template', { exact: true }).inputValue(),
|
||||||
|
'4',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByText('This template uses the default project setup.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
await fillDetails();
|
||||||
|
const templateRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
const templateData = (await templateRequest).postDataJSON();
|
||||||
|
assert.equal(templateData.creation_mode, 'template');
|
||||||
|
assert.equal(templateData.source_template_id, '4');
|
||||||
|
assert.equal(templateData.source_project_id, '');
|
||||||
|
assert.deepEqual(templateData.copy_areas, []);
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create`);
|
||||||
|
await choose('duplicate');
|
||||||
|
await fillDetails();
|
||||||
|
await page.getByLabel('Source project', { exact: true }).selectOption('8');
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.check();
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Custom ticket fields', exact: true })
|
||||||
|
.check();
|
||||||
|
await page.getByText('2 areas selected', { exact: true }).waitFor();
|
||||||
|
await page.getByText('WEB-1', { exact: true }).waitFor();
|
||||||
|
await choose('template');
|
||||||
|
await choose('scratch');
|
||||||
|
await choose('duplicate');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Source project', { exact: true }).inputValue(),
|
||||||
|
'8',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', {
|
||||||
|
name: 'Custom ticket fields',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await setResult('validation');
|
||||||
|
const duplicateRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
const duplicateData = (await duplicateRequest).postDataJSON();
|
||||||
|
assert.equal(duplicateData.source_project_id, '8');
|
||||||
|
assert.equal(duplicateData.source_template_id, '');
|
||||||
|
assert.deepEqual(duplicateData.copy_areas, [
|
||||||
|
'board_columns',
|
||||||
|
'ticket_fields',
|
||||||
|
]);
|
||||||
|
await state('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Project name', { exact: true }).inputValue(),
|
||||||
|
'Frontend workspace',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Project key', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByText('The key has already been taken.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await page
|
||||||
|
.getByText('Select a valid configuration area.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
|
||||||
|
await choose('scratch');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByText('Select a valid configuration area.', { exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Project key', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await choose('duplicate');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route('**/projects', (route) => route.abort('failed'));
|
||||||
|
await submit();
|
||||||
|
await state('network');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Project name', { exact: true }).inputValue(),
|
||||||
|
'Frontend workspace',
|
||||||
|
);
|
||||||
|
await page.unroute('**/projects');
|
||||||
|
await setResult('server');
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
await state('server');
|
||||||
|
await setResult('success');
|
||||||
|
let pending;
|
||||||
|
let submissions = 0;
|
||||||
|
await page.route('**/projects', (route) => {
|
||||||
|
submissions++;
|
||||||
|
pending = route;
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
await state('loading');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Creating project…', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Project name', { exact: true }).isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.locator('form').evaluate((form) => form.requestSubmit());
|
||||||
|
assert.equal(submissions, 1);
|
||||||
|
await pending.continue();
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
await page.unroute('**/projects');
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create`);
|
||||||
|
await choose('duplicate');
|
||||||
|
await fillDetails();
|
||||||
|
const emptyCopyRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
assert.deepEqual((await emptyCopyRequest).postDataJSON().copy_areas, []);
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project creation browser checks passed: all modes, preselection, payloads, retained input, validation, retry, duplicate submission protection, keyboard, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Create from '../../resources/js/pages/projects/Create.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'Create'
|
||||||
|
? Create
|
||||||
|
: { render: () => h('h1', 'Project created') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-creation-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const areas = [
|
||||||
|
'project_settings',
|
||||||
|
'board_columns',
|
||||||
|
'ticket_statuses',
|
||||||
|
'status_transitions',
|
||||||
|
'ticket_types',
|
||||||
|
'ticket_priorities',
|
||||||
|
'ticket_fields',
|
||||||
|
'ticket_link_types',
|
||||||
|
'repository_connections',
|
||||||
|
'notification_actions',
|
||||||
|
];
|
||||||
|
function makePage(url, errors = {}) {
|
||||||
|
const query = new URL(url, 'http://localhost').searchParams;
|
||||||
|
return {
|
||||||
|
component: url.startsWith('/projects/create')
|
||||||
|
? 'Create'
|
||||||
|
: 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors,
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: true,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
copyAreaOptions: areas.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value,
|
||||||
|
description: 'Server description',
|
||||||
|
})),
|
||||||
|
sourceProjects: query.has('empty')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
key: 'ENG',
|
||||||
|
name: 'Engineering · Platform and developer experience',
|
||||||
|
},
|
||||||
|
{ id: 8, key: 'OPS', name: 'Operations' },
|
||||||
|
],
|
||||||
|
sourceTemplates:
|
||||||
|
query.has('empty') || query.has('no-templates')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Team baseline',
|
||||||
|
slug: 'team-baseline',
|
||||||
|
copy_areas: ['board_columns', 'ticket_types'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Minimal setup',
|
||||||
|
slug: 'minimal',
|
||||||
|
copy_areas: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedSourceTemplateId: query.has('template')
|
||||||
|
? Number(query.get('template'))
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
// Real Vue/Inertia form with isolated transport; PHPUnit covers persisted projects.
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
let url = request.url;
|
||||||
|
let errors = {};
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(
|
||||||
|
/creation_test_result=(\w+)/,
|
||||||
|
)?.[1] ?? 'success';
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
if (result === 'server') {
|
||||||
|
response.statusCode = 503;
|
||||||
|
response.end('Simulated server failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url = result === 'validation' ? '/projects/create' : '/projects/99';
|
||||||
|
if (result === 'validation') {
|
||||||
|
errors = {
|
||||||
|
key: 'The key has already been taken.',
|
||||||
|
...(data.creation_mode === 'duplicate'
|
||||||
|
? {
|
||||||
|
'copy_areas.0':
|
||||||
|
'Select a valid configuration area.',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage(url, errors);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-creation-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Project creation fixture ready at http://127.0.0.1:4179/projects/create',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const Create = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/Create.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (
|
||||||
|
url,
|
||||||
|
errors = {},
|
||||||
|
locale = createLocalization(),
|
||||||
|
) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: makePage(url, errors),
|
||||||
|
resolve: () => Create,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return response.body.split('<main')[1].split('</main>')[0];
|
||||||
|
};
|
||||||
|
const empty = await render('/projects/create?empty');
|
||||||
|
assert.match(empty, /value="scratch"[^>]*checked/);
|
||||||
|
assert.match(empty, /value="duplicate"[^>]*disabled/);
|
||||||
|
assert.match(empty, /value="template"[^>]*disabled/);
|
||||||
|
assert.match(empty, /No accessible projects/);
|
||||||
|
assert.match(empty, /No published templates/);
|
||||||
|
assert.doesNotMatch(empty, /<select/);
|
||||||
|
const scratch = await render('/projects/create?no-templates');
|
||||||
|
assert.match(scratch, /value="duplicate"[^>]*aria-invalid="false"/);
|
||||||
|
assert.doesNotMatch(scratch, /value="duplicate"[^>]*disabled/);
|
||||||
|
const template = await render('/projects/create');
|
||||||
|
assert.match(template, /value="template"[^>]*checked/);
|
||||||
|
assert.match(template, /Team baseline/);
|
||||||
|
assert.match(template, /Board columns/);
|
||||||
|
assert.match(template, /Ticket types/);
|
||||||
|
assert.match(template, /The project stays linked to this template/);
|
||||||
|
assert.doesNotMatch(template, /Server description/);
|
||||||
|
const preselected = await render('/projects/create?template=4');
|
||||||
|
assert.match(preselected, /Minimal setup/);
|
||||||
|
assert.match(
|
||||||
|
preselected,
|
||||||
|
/This template uses the default project setup/,
|
||||||
|
);
|
||||||
|
for (const id of [
|
||||||
|
'name-error',
|
||||||
|
'key-help',
|
||||||
|
'key-error',
|
||||||
|
'template-help',
|
||||||
|
'source-template-error',
|
||||||
|
'creation-mode-error',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
template.includes(`id="${id}"`),
|
||||||
|
`Missing accessible feedback target: ${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const translated = await render(
|
||||||
|
'/projects/create',
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'projectCreation.title': 'Projekt anlegen',
|
||||||
|
'projectCreation.areas.boardColumns.label':
|
||||||
|
'Boardspalten',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Projekt anlegen/);
|
||||||
|
assert.match(translated, /Boardspalten/);
|
||||||
|
console.log('Project creation rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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 base = 'http://127.0.0.1:4178';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const setRole = (value) =>
|
||||||
|
context.addCookies([{ name: 'project_test_role', value, url: base }]);
|
||||||
|
const content = () => page.locator('#main-content');
|
||||||
|
const focusWithTab = async (link) => {
|
||||||
|
await content().focus();
|
||||||
|
for (let step = 0; step < 30; step++) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
if (
|
||||||
|
await link.evaluate((element) => element === document.activeElement)
|
||||||
|
) {
|
||||||
|
assert.equal(
|
||||||
|
await link.evaluate((element) =>
|
||||||
|
element.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.fail('Project action was not reachable by keyboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 setRole('admin');
|
||||||
|
for (const [path, title, label] of [
|
||||||
|
['/projects', 'Projects', 'index'],
|
||||||
|
[
|
||||||
|
'/projects/7?template',
|
||||||
|
'Engineering · Platform and developer experience',
|
||||||
|
'hub',
|
||||||
|
],
|
||||||
|
['/projects?empty', 'Projects', 'empty'],
|
||||||
|
]) {
|
||||||
|
await page.goto(base + path);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', {
|
||||||
|
name: title,
|
||||||
|
exact: true,
|
||||||
|
level: 1,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((element) =>
|
||||||
|
element.classList.contains('dark'),
|
||||||
|
),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`${label} overflows at ${width} in ${theme}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await content().locator('a a, a button').count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan23-${label}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/projects/7`);
|
||||||
|
const board = content().getByRole('link', {
|
||||||
|
name: 'Open board',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(board);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/board');
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Destination reached' })
|
||||||
|
.waitFor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await setRole('reader');
|
||||||
|
await page.goto(`${base}/projects`);
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.getByRole('link', { name: 'New project', exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const overview = content().getByRole('link', {
|
||||||
|
name: 'Open Engineering · Platform and developer experience overview',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(overview);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7');
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.locator(
|
||||||
|
'a[href*="/settings/"], a[href$="/access"], a[href*="/templates/"]',
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await content()
|
||||||
|
.getByRole('link', { name: 'Open backlog', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/projects/7/backlog');
|
||||||
|
|
||||||
|
await setRole('admin');
|
||||||
|
await page.goto(`${base}/projects/7`);
|
||||||
|
const settings = content().getByRole('region', {
|
||||||
|
name: 'Project settings',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assert.equal(await settings.getByRole('link').count(), 6);
|
||||||
|
const general = settings.getByRole('link', {
|
||||||
|
name: 'General',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(general);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/7/settings/general');
|
||||||
|
|
||||||
|
await setRole('creator');
|
||||||
|
await page.goto(`${base}/projects?empty`);
|
||||||
|
const create = content().getByRole('link', {
|
||||||
|
name: 'Create your first project',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(create);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForURL('**/projects/create');
|
||||||
|
await page.goto(`${base}/projects/7`);
|
||||||
|
await content()
|
||||||
|
.getByRole('link', { name: 'Save as new template', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/projects/7/templates/create');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project overview browser checks passed: keyboard, navigation, permissions, empty states, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Index from '../../resources/js/pages/projects/Index.vue';
|
||||||
|
import Show from '../../resources/js/pages/projects/Show.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
({ Index, Show })[name] ?? {
|
||||||
|
render: () => h('h1', 'Destination reached'),
|
||||||
|
},
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-overview-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4178, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const project = {
|
||||||
|
id: 7,
|
||||||
|
key: 'ENG',
|
||||||
|
name: 'Engineering · Platform and developer experience',
|
||||||
|
};
|
||||||
|
const secondProject = { id: 8, key: 'OPS', name: 'Operations' };
|
||||||
|
const templateState = {
|
||||||
|
inheritsFromTemplate: true,
|
||||||
|
templateName: 'Team baseline',
|
||||||
|
inheritedAreas: [{ value: 'board_columns', label: 'Board columns' }],
|
||||||
|
overriddenAreas: [{ value: 'ticket_types', label: 'Ticket types' }],
|
||||||
|
};
|
||||||
|
function makePage(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
manage = false,
|
||||||
|
create = false,
|
||||||
|
empty = false,
|
||||||
|
template = false,
|
||||||
|
status = null,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const path = new URL(url, 'http://localhost').pathname;
|
||||||
|
const isIndex = path === '/projects';
|
||||||
|
const isShow = path === '/projects/7';
|
||||||
|
return {
|
||||||
|
component: isIndex ? 'Index' : isShow ? 'Show' : 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: create,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
project: isIndex
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
...project,
|
||||||
|
canManage: manage,
|
||||||
|
canCreateTickets: manage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(isIndex
|
||||||
|
? { projects: empty ? [] : [project, secondProject], status }
|
||||||
|
: {
|
||||||
|
project,
|
||||||
|
canManageAccess: manage,
|
||||||
|
templateState: template ? templateState : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
// Isolated transport for real Vue pages. PHPUnit covers actual permissions and resources.
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (!url.pathname.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const role = request.headers.cookie?.match(
|
||||||
|
/project_test_role=(\w+)/,
|
||||||
|
)?.[1];
|
||||||
|
const page = makePage(request.url, {
|
||||||
|
manage: role === 'admin',
|
||||||
|
create: role === 'creator',
|
||||||
|
empty: url.searchParams.has('empty'),
|
||||||
|
template: url.searchParams.has('template'),
|
||||||
|
});
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-overview-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Project overview fixture ready at http://127.0.0.1:4178/projects',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const pages = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
['Index', 'Show'].map(async (name) => [
|
||||||
|
name,
|
||||||
|
(
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
`/resources/js/pages/projects/${name}.vue`,
|
||||||
|
)
|
||||||
|
).default,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const render = async (
|
||||||
|
url,
|
||||||
|
options = {},
|
||||||
|
locale = createLocalization(),
|
||||||
|
) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: makePage(url, options),
|
||||||
|
resolve: (name) => pages[name],
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return response.body.split('<main')[1].split('</main>')[0];
|
||||||
|
};
|
||||||
|
const index = await render('/projects');
|
||||||
|
assert.match(index, /2 projects available/);
|
||||||
|
for (const id of [7, 8]) {
|
||||||
|
for (const suffix of ['', '/board', '/backlog']) {
|
||||||
|
assert.ok(index.includes(`href="/projects/${id}${suffix}"`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(
|
||||||
|
index,
|
||||||
|
/href="\/projects\/create"|upcoming|will later/,
|
||||||
|
);
|
||||||
|
const empty = await render('/projects', { empty: true });
|
||||||
|
assert.match(empty, /No projects to show/);
|
||||||
|
assert.match(empty, /Ask a workspace or project administrator/);
|
||||||
|
assert.doesNotMatch(empty, /href="\/projects\/create"|<article/);
|
||||||
|
const creator = await render('/projects', {
|
||||||
|
empty: true,
|
||||||
|
create: true,
|
||||||
|
status: 'Project deleted.',
|
||||||
|
});
|
||||||
|
assert.match(creator, /href="\/projects\/create"/);
|
||||||
|
assert.match(creator, /Create your first project/);
|
||||||
|
assert.match(creator, /role="status"[^>]*data-feedback="success"/);
|
||||||
|
assert.match(creator, /Project deleted\./);
|
||||||
|
const reader = await render('/projects/7');
|
||||||
|
assert.match(reader, /aria-label="Open board"/);
|
||||||
|
assert.match(reader, /aria-label="Open backlog"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
reader,
|
||||||
|
/href="\/projects\/7\/(settings|access|templates)/,
|
||||||
|
);
|
||||||
|
const admin = await render('/projects/7', {
|
||||||
|
manage: true,
|
||||||
|
template: true,
|
||||||
|
});
|
||||||
|
for (const suffix of [
|
||||||
|
'settings/general',
|
||||||
|
'settings/board',
|
||||||
|
'settings/metadata',
|
||||||
|
'settings/repositories',
|
||||||
|
'settings/notifications',
|
||||||
|
'access',
|
||||||
|
]) {
|
||||||
|
assert.ok(admin.includes(`href="/projects/7/${suffix}"`));
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(admin, /href="\/projects\/7\/templates/);
|
||||||
|
assert.match(admin, /Based on Team baseline/);
|
||||||
|
assert.match(admin, /Board columns · inherited/);
|
||||||
|
assert.match(admin, /Ticket types · overridden/);
|
||||||
|
assert.match(
|
||||||
|
await render('/projects/7', { create: true }),
|
||||||
|
/href="\/projects\/7\/templates\/create"/,
|
||||||
|
);
|
||||||
|
const translated = await render(
|
||||||
|
'/projects',
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'navigation.projects': 'Projekte',
|
||||||
|
'projects.overview': 'Projektübersicht',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Projekte/);
|
||||||
|
assert.match(translated, /Projektübersicht/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
index + reader + admin,
|
||||||
|
/upcoming|will later|Use the project shortcuts/,
|
||||||
|
);
|
||||||
|
let insideLink = false;
|
||||||
|
for (const [tag] of (index + reader + admin).matchAll(
|
||||||
|
/<\/?a\b[^>]*>/g,
|
||||||
|
)) {
|
||||||
|
if (tag.startsWith('</')) {
|
||||||
|
insideLink = false;
|
||||||
|
} else {
|
||||||
|
assert.equal(insideLink, false, 'Links must not be nested');
|
||||||
|
insideLink = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Project overview behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import TicketPresentationsPreview from './TicketPresentationsPreview.vue';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: () => TicketPresentationsPreview,
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp(App, props).use(plugin).use(createLocalization()).mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/ticket-presentations-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: 8,
|
||||||
|
name: 'Taylor Reed',
|
||||||
|
email: 'taylor@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#22c55e',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
email_verified_at: null,
|
||||||
|
must_change_password_on_next_login: false,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullTicket = {
|
||||||
|
id: 42,
|
||||||
|
acceptance_criteria: [],
|
||||||
|
assignee: user,
|
||||||
|
assignee_user_id: user.id,
|
||||||
|
author: null,
|
||||||
|
author_user_id: null,
|
||||||
|
blocked_comment: 'Waiting for the security review.',
|
||||||
|
description: 'Keep the complete summary available on every surface.',
|
||||||
|
is_blocked: true,
|
||||||
|
parent_ticket_id: null,
|
||||||
|
project_id: 7,
|
||||||
|
sequence_number: 42,
|
||||||
|
story_points: 5,
|
||||||
|
ticket_key: 'ENG-42',
|
||||||
|
ticket_priority: {
|
||||||
|
id: 502,
|
||||||
|
name: 'Critical from project',
|
||||||
|
position: 4,
|
||||||
|
project_id: 7,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
},
|
||||||
|
ticket_priority_id: 502,
|
||||||
|
ticket_status: {
|
||||||
|
id: 602,
|
||||||
|
board_column_id: 12,
|
||||||
|
name: 'Review from project',
|
||||||
|
position: 2,
|
||||||
|
project_id: 7,
|
||||||
|
slug: 'review',
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
board_column: {
|
||||||
|
id: 12,
|
||||||
|
name: 'Delivery',
|
||||||
|
position: 2,
|
||||||
|
project_id: 7,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ticket_status_id: 602,
|
||||||
|
ticket_type: {
|
||||||
|
id: 702,
|
||||||
|
is_parent: false,
|
||||||
|
name: 'Incident from project',
|
||||||
|
position: 1,
|
||||||
|
project_id: 7,
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
},
|
||||||
|
ticket_type_id: 702,
|
||||||
|
title: 'A very long ticket title with an_uninterrupted_identifier_that_must_remain_readable_and_actionable_on_small_screens',
|
||||||
|
created_at: '2026-09-11T00:00:00Z',
|
||||||
|
updated_at: '2026-09-11T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionalTicket = {
|
||||||
|
...fullTicket,
|
||||||
|
id: 43,
|
||||||
|
assignee: null,
|
||||||
|
assignee_user_id: null,
|
||||||
|
blocked_comment: null,
|
||||||
|
description: null,
|
||||||
|
is_blocked: false,
|
||||||
|
ticket_key: 'ENG-43',
|
||||||
|
ticket_status: null,
|
||||||
|
ticket_status_id: null,
|
||||||
|
title: 'Ticket without optional data',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (url.pathname !== '/ticket-presentations') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = {
|
||||||
|
component: 'TicketPresentationsPreview',
|
||||||
|
url: request.url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
auth: { user },
|
||||||
|
errors: {},
|
||||||
|
fullTicket,
|
||||||
|
optionalTicket,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
`<!doctype html><html lang="en"${url.searchParams.has('dark') ? ' class="dark"' : ''}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="${escaped}"></div><script type="module" src="/tests/Fixtures/ticket-presentations-preview.mjs"></script></body></html>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Ticket presentation fixture ready at http://127.0.0.1:4179/ticket-presentations',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { default: TicketCard } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/TicketCard.vue',
|
||||||
|
);
|
||||||
|
const { default: TicketListItem } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/TicketListItem.vue',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(TicketCard.emits, ['open']);
|
||||||
|
assert.deepEqual(TicketListItem.emits, ['open']);
|
||||||
|
for (const component of [TicketCard, TicketListItem]) {
|
||||||
|
assert.ok(component.props.projectId);
|
||||||
|
assert.ok(component.props.ticket);
|
||||||
|
assert.ok(component.props.showDescription);
|
||||||
|
assert.ok(component.props.showStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = async (component, ticket) => {
|
||||||
|
const Page = {
|
||||||
|
render: () =>
|
||||||
|
component === TicketListItem
|
||||||
|
? h('ul', {}, [h(component, { projectId: 7, ticket })])
|
||||||
|
: h(component, { projectId: 7, ticket }),
|
||||||
|
};
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: {
|
||||||
|
component: 'TicketPresentation',
|
||||||
|
url: '/projects/7/board',
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
auth: { user },
|
||||||
|
errors: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: () => Page,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization()),
|
||||||
|
});
|
||||||
|
return response.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const component of [TicketCard, TicketListItem]) {
|
||||||
|
const html = await render(component, fullTicket);
|
||||||
|
assert.match(html, /href="\/projects\/7\/tickets\/42"/);
|
||||||
|
assert.match(html, /aria-label="Open ENG-42:/);
|
||||||
|
assert.match(html, /min-h-control/);
|
||||||
|
assert.match(html, /focus-visible:ring-2/);
|
||||||
|
assert.match(html, /wrap-anywhere/);
|
||||||
|
assert.match(html, new RegExp(fullTicket.title));
|
||||||
|
assert.match(html, /data-ticket-type-id="702"/);
|
||||||
|
assert.match(html, /Incident from project/);
|
||||||
|
assert.match(html, /data-ticket-priority-id="502"/);
|
||||||
|
assert.match(html, /Critical from project/);
|
||||||
|
assert.match(html, /data-ticket-status-id="602"/);
|
||||||
|
assert.match(html, /Delivery \/ Review from project/);
|
||||||
|
assert.match(html, /Assigned to Taylor Reed/);
|
||||||
|
assert.match(html, /Blocked/);
|
||||||
|
assert.match(html, /Blocker: Waiting for the security review\./);
|
||||||
|
|
||||||
|
const optionalHtml = await render(component, optionalTicket);
|
||||||
|
assert.match(optionalHtml, /href="\/projects\/7\/tickets\/43"/);
|
||||||
|
assert.match(optionalHtml, /Ticket without optional data/);
|
||||||
|
assert.match(optionalHtml, /No status/);
|
||||||
|
assert.match(optionalHtml, /Unassigned/);
|
||||||
|
assert.doesNotMatch(optionalHtml, /Blocked|Blocker:/);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Ticket presentation behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user