feat(ST-1112): add Suspense skeleton for edit-mode task loading
Extracts task fetch into EditTaskLoader (async server component) so the sprint board renders immediately while the task loads. TaskDialogSkeleton shows 3 grey bars during the fetch. Invalid or out-of-scope task IDs redirect to closePath. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e27d25a662
commit
03c90c1fdd
3 changed files with 104 additions and 28 deletions
|
|
@ -1,14 +1,15 @@
|
|||
import { Suspense } from 'react'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
|
||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
import type { Task } from '@/components/sprint/task-list'
|
||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||
import type { TaskDialogTask } from '@/app/_components/tasks/task-dialog'
|
||||
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -116,30 +117,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
const isDemo = session.isDemo ?? false
|
||||
const closePath = `/products/${id}/sprint`
|
||||
|
||||
// Fetch task for edit mode (auth-scoped)
|
||||
let editTaskData: TaskDialogTask | null = null
|
||||
if (editTask) {
|
||||
const t = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: editTask,
|
||||
story: { product: productAccessFilter(session.userId) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
implementation_plan: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
if (!t) redirect(closePath)
|
||||
editTaskData = t
|
||||
}
|
||||
|
||||
const showDialog = !!newTask || !!editTaskData
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SprintHeader
|
||||
|
|
@ -170,15 +147,26 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
{showDialog && (
|
||||
{newTask && (
|
||||
<TaskDialog
|
||||
task={editTaskData ?? undefined}
|
||||
storyId={storyIdParam}
|
||||
productId={id}
|
||||
closePath={closePath}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTask && !newTask && (
|
||||
<Suspense fallback={<TaskDialogSkeleton />}>
|
||||
<EditTaskLoader
|
||||
taskId={editTask}
|
||||
userId={session.userId}
|
||||
productId={id}
|
||||
closePath={closePath}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
47
app/_components/tasks/edit-task-loader.tsx
Normal file
47
app/_components/tasks/edit-task-loader.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { TaskDialog } from './task-dialog'
|
||||
|
||||
interface EditTaskLoaderProps {
|
||||
taskId: string
|
||||
userId: string
|
||||
productId: string
|
||||
closePath: string
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
export async function EditTaskLoader({
|
||||
taskId,
|
||||
userId,
|
||||
productId,
|
||||
closePath,
|
||||
isDemo,
|
||||
}: EditTaskLoaderProps) {
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
story: { product: productAccessFilter(userId) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
implementation_plan: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!task) redirect(closePath)
|
||||
|
||||
return (
|
||||
<TaskDialog
|
||||
task={task}
|
||||
productId={productId}
|
||||
closePath={closePath}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
41
app/_components/tasks/task-dialog-skeleton.tsx
Normal file
41
app/_components/tasks/task-dialog-skeleton.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TaskDialogSkeleton() {
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'flex flex-col p-0 gap-0',
|
||||
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
|
||||
'sm:max-w-[90vw] sm:max-h-[85vh]',
|
||||
'lg:max-w-[50vw] lg:min-w-[480px]',
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Taak laden…</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-outline-variant shrink-0">
|
||||
<Skeleton className="h-7 w-40" />
|
||||
</div>
|
||||
|
||||
{/* Body — 3 bars mimicking title + description + plan */}
|
||||
<div className="flex-1 px-6 py-6 space-y-6">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-outline-variant px-6 py-4 shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue