feat(ST-1138): mobile Solo-pagina + verify TaskDetailDialog (T-331/T-332/T-333)
- app/(mobile)/m/products/[id]/solo/page.tsx — hergebruikt SoloBoard 1:1 met desktop. 3-koloms-kanban blijft, NoActiveSprint-fallback ongewijzigd - T-332 verify-only: TaskDetailDialog regel 383 gebruikt entityDialogContentClasses → mobile-fullscreen erft automatisch uit ST-1133 - Tests: regressie-vangnet op SoloBoard-hergebruik, requireSession, NoActiveSprint, en op TaskDetailDialog-className-wiring (geen override) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b42740461
commit
b327fbdf09
2 changed files with 155 additions and 0 deletions
35
__tests__/app/m-solo-page.test.ts
Normal file
35
__tests__/app/m-solo-page.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// ST-1138: regressie-vangnet voor mobile solo-page (server component).
|
||||||
|
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]/solo/page.tsx')
|
||||||
|
const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx')
|
||||||
|
|
||||||
|
describe('mobile solo page (ST-1138)', () => {
|
||||||
|
const src = readFileSync(PAGE, 'utf-8')
|
||||||
|
|
||||||
|
it('hergebruikt SoloBoard zonder content-aanpassingen', () => {
|
||||||
|
expect(src).toContain('SoloBoard')
|
||||||
|
expect(src).toContain("from '@/components/solo/solo-board'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auth via gedeelde requireSession()', () => {
|
||||||
|
expect(src).toContain("from '@/lib/auth-guard'")
|
||||||
|
expect(src).toContain('requireSession()')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => {
|
||||||
|
expect(src).toContain('NoActiveSprint')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => {
|
||||||
|
// Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes
|
||||||
|
// komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan.
|
||||||
|
const src = readFileSync(TASK_DETAIL, 'utf-8')
|
||||||
|
|
||||||
|
it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => {
|
||||||
|
expect(src).toContain('className={entityDialogContentClasses}')
|
||||||
|
})
|
||||||
|
})
|
||||||
120
app/(mobile)/m/products/[id]/solo/page.tsx
Normal file
120
app/(mobile)/m/products/[id]/solo/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
// PBI-11 / ST-1138: Mobile Solo Paneel — wraps de bestaande SoloBoard zonder
|
||||||
|
// content-aanpassingen. 3-koloms-kanban blijft (overflow-x scrollt zijwaarts).
|
||||||
|
// TaskDetailDialog krijgt full-screen-mobile via gedeelde
|
||||||
|
// entityDialogContentClasses (beslissing A in docs/plans/PBI-11-mobile-shell.md;
|
||||||
|
// ingebouwd via ST-1133/T-317).
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireSession } from '@/lib/auth-guard'
|
||||||
|
import { SoloBoard } from '@/components/solo/solo-board'
|
||||||
|
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
||||||
|
import type { SoloTask } from '@/components/solo/solo-board'
|
||||||
|
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MobileSoloProductPage({ params }: Props) {
|
||||||
|
const { id } = await params
|
||||||
|
const session = await requireSession()
|
||||||
|
|
||||||
|
const product = await getAccessibleProduct(id, session.userId)
|
||||||
|
if (!product) notFound()
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { product_id: id, status: 'ACTIVE' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sprint) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<NoActiveSprint productId={id} productName={product.name} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawTasks, rawUnassigned] = await Promise.all([
|
||||||
|
prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
story: {
|
||||||
|
sprint_id: sprint.id,
|
||||||
|
assignee_id: session.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
story: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ story: { pbi: { priority: 'asc' } } },
|
||||||
|
{ story: { pbi: { sort_order: 'asc' } } },
|
||||||
|
{ story: { sort_order: 'asc' } },
|
||||||
|
{ priority: 'asc' },
|
||||||
|
{ sort_order: 'asc' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
prisma.story.findMany({
|
||||||
|
where: { sprint_id: sprint.id, assignee_id: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
tasks: {
|
||||||
|
select: { id: true, title: true, description: true, priority: true, status: true },
|
||||||
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { sort_order: 'asc' },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const tasks: SoloTask[] = rawTasks.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
implementation_plan: t.implementation_plan,
|
||||||
|
priority: t.priority,
|
||||||
|
sort_order: t.sort_order,
|
||||||
|
status: t.status as SoloTask['status'],
|
||||||
|
verify_only: t.verify_only,
|
||||||
|
verify_required: t.verify_required as SoloTask['verify_required'],
|
||||||
|
story_id: t.story.id,
|
||||||
|
story_code: t.story.code,
|
||||||
|
story_title: t.story.title,
|
||||||
|
task_code: t.code,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
code: s.code,
|
||||||
|
title: s.title,
|
||||||
|
tasks: s.tasks.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
priority: t.priority,
|
||||||
|
status: t.status,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SoloBoard
|
||||||
|
productId={id}
|
||||||
|
sprintGoal={sprint.sprint_goal}
|
||||||
|
tasks={tasks}
|
||||||
|
unassignedStories={unassignedStories}
|
||||||
|
isDemo={session.isDemo ?? false}
|
||||||
|
currentUserId={session.userId}
|
||||||
|
repoUrl={product.repo_url}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue