feat(ST-1137): mobile Product Backlog-pagina (T-328/T-329/T-330)
- app/(mobile)/m/products/[id]/page.tsx — hergebruikt BacklogHydrationWrapper +
BacklogSplitPane + PbiList/StoryPanel/TaskPanel (1:1 zelfde data-fetch als
desktop-page; demo blijft read-only via PbiList/StoryPanel)
- Cookie-key gescheiden: `backlog-${id}-mobile` (beslissing C in
docs/plans/PBI-11-mobile-shell.md) — tab-mode-gebruikers vervuilen de
desktop-split-percentages niet
- closePath en redirect-targets blijven onder /m/products/
- Tab-mode rendert automatisch op <1024px via SplitPane (uit ST-1116)
- Tests: regressie-vangnet op cookie-key, /m/-paden, hergebruik
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a3dc401b7
commit
5b42740461
2 changed files with 182 additions and 0 deletions
38
__tests__/app/m-products-page.test.ts
Normal file
38
__tests__/app/m-products-page.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Lichte regressie-tests voor de mobile backlog-page. Server-component render
|
||||
// vereist te veel mocking; we asserten op statische source-eigenschappen die
|
||||
// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden).
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx')
|
||||
const src = readFileSync(PAGE, 'utf-8')
|
||||
|
||||
describe('mobile backlog page (ST-1137)', () => {
|
||||
it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => {
|
||||
// Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet.
|
||||
expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/)
|
||||
})
|
||||
|
||||
it('closePath en TaskDialog redirect blijven onder /m/products/', () => {
|
||||
expect(src).toContain('const closePath = `/m/products/${id}`')
|
||||
})
|
||||
|
||||
it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => {
|
||||
expect(src).toContain('BacklogHydrationWrapper')
|
||||
expect(src).toContain('BacklogSplitPane')
|
||||
expect(src).toContain('PbiList')
|
||||
expect(src).toContain('StoryPanel')
|
||||
expect(src).toContain('TaskPanel')
|
||||
})
|
||||
|
||||
it('auth via requireSession() (gedeelde guard)', () => {
|
||||
expect(src).toContain("from '@/lib/auth-guard'")
|
||||
expect(src).toContain('requireSession()')
|
||||
})
|
||||
|
||||
it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => {
|
||||
expect(src).toContain('{newTask &&')
|
||||
expect(src).toContain('{editTask && !newTask &&')
|
||||
})
|
||||
})
|
||||
144
app/(mobile)/m/products/[id]/page.tsx
Normal file
144
app/(mobile)/m/products/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// PBI-11 / ST-1137: Mobile Product Backlog. Wraps de 3-paneel-backlog in de
|
||||
// mobile-shell. BacklogSplitPane rendert automatisch tab-mode op <1024px
|
||||
// (uit ST-1116). Cookie-key gescheiden van desktop zodat tab-mode-gebruikers
|
||||
// de desktop-split niet vervuilen (beslissing C in docs/plans/PBI-11-mobile-shell.md).
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { pbiStatusToApi } from '@/lib/task-status'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||
import { PbiList } from '@/components/backlog/pbi-list'
|
||||
import { StoryPanel } from '@/components/backlog/story-panel'
|
||||
import type { Story } from '@/components/backlog/story-panel'
|
||||
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
|
||||
}
|
||||
|
||||
export default async function MobileProductBacklogPage({ params, searchParams }: Props) {
|
||||
const { id } = await params
|
||||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||
const closePath = `/m/products/${id}`
|
||||
|
||||
const session = await requireSession()
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const pbis = await prisma.pbi.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
})
|
||||
|
||||
const [stories, tasks] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
description: true,
|
||||
acceptance_criteria: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
pbi_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
}),
|
||||
prisma.task.findMany({
|
||||
where: { story: { pbi: { product_id: id } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
sort_order: true,
|
||||
story_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const storiesByPbi: Record<string, Story[]> = {}
|
||||
for (const story of stories) {
|
||||
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
|
||||
storiesByPbi[story.pbi_id].push(story)
|
||||
}
|
||||
|
||||
const tasksByStory: Record<string, typeof tasks> = {}
|
||||
for (const task of tasks) {
|
||||
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
|
||||
tasksByStory[task.story_id].push(task)
|
||||
}
|
||||
|
||||
const isDemo = session.isDemo ?? false
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<BacklogHydrationWrapper
|
||||
productId={id}
|
||||
initialData={{
|
||||
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
||||
storiesByPbi,
|
||||
tasksByStory,
|
||||
}}
|
||||
>
|
||||
<BacklogSplitPane
|
||||
cookieKey={`backlog-${id}-mobile`}
|
||||
defaultSplit={[20, 45, 35]}
|
||||
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
||||
panes={[
|
||||
<PbiList
|
||||
key="pbi"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
/>,
|
||||
<StoryPanel
|
||||
key="story"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
/>,
|
||||
<TaskPanel
|
||||
key="tasks"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
closePath={closePath}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</BacklogHydrationWrapper>
|
||||
|
||||
{newTask && (
|
||||
<TaskDialog
|
||||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue