refactor(dnd): remove drag-and-drop reorder for stories and tasks

- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction
- Delete REST route app/api/stories/[id]/tasks/reorder/route.ts
- Remove DnD from backlog story-panel and task-panel (flat list)
- Remove reorder-within-sprint branch from sprint-board-client handleDragEnd
- Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept)
- Remove all DnD from task-list (status toggle + edit kept)
- Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers
- Remove related tests for deleted reorder route; fix sprint store tests
This commit is contained in:
Scrum4Me Agent 2026-05-14 16:29:56 +02:00
parent b816cbe710
commit f68d985c2c
16 changed files with 52 additions and 816 deletions

View file

@ -1,111 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
story: {
findFirst: vi.fn(),
},
task: {
update: vi.fn(),
},
$transaction: vi.fn(),
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
const mockPrisma = prisma as unknown as {
story: { findFirst: ReturnType<typeof vi.fn> }
task: { update: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
function makeStory(taskIds: string[]) {
return {
id: 'story-1',
product_id: 'prod-1',
tasks: taskIds.map(id => ({ id })),
}
}
function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] {
return [
new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, {
method: 'PATCH',
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id: storyId }) },
]
}
describe('PATCH /api/stories/:id/tasks/reorder', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.$transaction.mockResolvedValue([])
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 })
})
// TC-RO-06 — body validation fires before story lookup
it('returns 422 when task_ids is an empty array', async () => {
const res = await patchReorder(...makeRequest({ task_ids: [] }))
expect(res.status).toBe(422)
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
})
// TC-RO-07
it('returns 422 when task_ids is not an array', async () => {
const res = await patchReorder(...makeRequest({ task_ids: 'task-1' }))
expect(res.status).toBe(422)
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
})
it('returns 422 when task_ids is missing entirely', async () => {
const res = await patchReorder(...makeRequest({}))
expect(res.status).toBe(422)
})
// TC-RO-08
it('returns 422 when task_ids contains an ID not belonging to the story', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] }))
const data = await res.json()
expect(res.status).toBe(422)
expect(data.error).toContain('task-from-other-story')
})
// TC-RO-09
it('reorders tasks and returns 200 with success: true', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3']))
const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] }))
const data = await res.json()
expect(res.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mockPrisma.$transaction).toHaveBeenCalled()
})
it('updates each task with its new sort_order index', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] }))
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } })
)
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } })
)
})
})

View file

@ -54,7 +54,6 @@ import { authenticateApiRequest } from '@/lib/api-auth'
import { GET as getProducts } from '@/app/api/products/route' import { GET as getProducts } from '@/app/api/products/route'
import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route'
import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route'
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
@ -276,56 +275,6 @@ describe('GET /api/sprints/:id/tasks', () => {
}) })
}) })
// ─── PATCH /api/stories/:id/tasks/reorder ────────────────────────────────────
describe('PATCH /api/stories/:id/tasks/reorder', () => {
const VALID_BODY = { task_ids: ['task-x'] }
// TC-RO-01
it('returns 401 when no valid token provided', async () => {
mockAuth.mockResolvedValue(UNAUTHORIZED)
const res = await patchReorder(
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
routeCtx('story-1')
)
expect(res.status).toBe(401)
})
// TC-RO-03
it('returns 403 for demo users', async () => {
mockAuth.mockResolvedValue(DEMO_AUTH)
const res = await patchReorder(
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
routeCtx('story-1')
)
expect(res.status).toBe(403)
const data = await res.json()
expect(data.error).toBe('Niet beschikbaar in demo-modus')
})
// TC-RO-04 / TC-RO-05
it('returns 404 when story is not accessible to the authenticated user', async () => {
mockAuth.mockResolvedValue(USER_2_AUTH)
mockPrisma.story.findFirst.mockResolvedValue(null)
const res = await patchReorder(
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
routeCtx('story-1')
)
expect(res.status).toBe(404)
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'story-1',
product: expect.objectContaining({
OR: expect.arrayContaining([{ user_id: 'user-2' }]),
}),
}),
})
)
})
})
// ─── POST /api/stories/:id/log ──────────────────────────────────────────────── // ─── POST /api/stories/:id/log ────────────────────────────────────────────────
describe('POST /api/stories/:id/log', () => { describe('POST /api/stories/:id/log', () => {

View file

@ -852,56 +852,6 @@ describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
describe('optimistic mutations', () => { describe('optimistic mutations', () => {
it('rollback herstelt vorige sprint-story-order', () => {
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
[
makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 1 }),
makeStory({ id: 'b', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 2 }),
],
),
)
const prevOrder = [
...useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1'],
]
const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({
kind: 'sprint-story-order',
sprintId: 'sp-1',
prevStoryIds: prevOrder,
})
useSprintWorkspaceStore.setState((s) => {
s.relations.storyIdsBySprint['sp-1'] = ['b', 'a']
})
useSprintWorkspaceStore.getState().rollbackMutation(id)
expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual(
prevOrder,
)
expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
})
it('settle ruimt pending op zonder state te wijzigen', () => {
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [
makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1' }),
]),
)
const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({
kind: 'sprint-story-order',
sprintId: 'sp-1',
prevStoryIds: ['a'],
})
expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeDefined()
useSprintWorkspaceStore.getState().settleMutation(id)
expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([
'a',
])
})
it('SSE-echo van een al-bestaande sprint is idempotent', () => { it('SSE-echo van een al-bestaande sprint is idempotent', () => {
useSprintWorkspaceStore.setState((s) => { useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' } s.context.activeProduct = { id: 'prod-1', name: 'P' }

View file

@ -562,32 +562,6 @@ export async function removeStoryFromSprintAction(storyId: string) {
return { success: true } return { success: true }
} }
export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' }
const sprint = await prisma.sprint.findFirst({
where: { id: sprintId, product: productAccessFilter(session.userId) },
})
if (!sprint) return { error: 'Sprint niet gevonden' }
const stories = await prisma.story.findMany({
where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id },
select: { id: true },
})
if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' }
await prisma.$transaction(
orderedIds.map((id, i) =>
prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } })
)
)
revalidatePath(`/products/${sprint.product_id}/sprint`)
return { success: true }
}
export async function completeSprintAction( export async function completeSprintAction(
sprintId: string, sprintId: string,

View file

@ -357,43 +357,3 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string)
return { success: true, count: result.count } return { success: true, count: result.count }
} }
export async function reorderStoriesAction(
pbiId: string,
productId: string,
orderedIds: string[],
newPriority?: number
) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' }
if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) {
return { error: 'Ongeldige prioriteit' }
}
const pbi = await prisma.pbi.findFirst({
where: { id: pbiId, product: productAccessFilter(session.userId) },
})
if (!pbi) return { error: 'PBI niet gevonden' }
const stories = await prisma.story.findMany({
where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id },
select: { id: true },
})
if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' }
await prisma.$transaction(
orderedIds.map((id, i) =>
prisma.story.update({
where: { id },
data: {
sort_order: i + 1.0,
...(newPriority !== undefined ? { priority: newPriority } : {}),
},
})
)
)
revalidatePath(`/products/${pbi.product_id}`)
return { success: true }
}

View file

@ -322,22 +322,3 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im
return { success: true } return { success: true }
} }
export async function reorderTasksAction(storyId: string, orderedIds: string[]) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const story = await prisma.story.findFirst({
where: { id: storyId, product: productAccessFilter(session.userId) },
})
if (!story) return { error: 'Story niet gevonden' }
await prisma.$transaction(
orderedIds.map((id, i) =>
prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } })
)
)
revalidatePath(`/products/${story.product_id}/sprint/planning`)
return { success: true }
}

View file

@ -1,56 +0,0 @@
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { z } from 'zod'
const bodySchema = z.object({
task_ids: z.array(z.string()).min(1),
})
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
if (auth.isDemo) {
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
}
const { id: storyId } = await params
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = bodySchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
const story = await prisma.story.findFirst({
where: { id: storyId, product: productAccessFilter(auth.userId) },
include: { tasks: { select: { id: true } } },
})
if (!story) {
return Response.json({ error: 'Story niet gevonden' }, { status: 404 })
}
const storyTaskIds = new Set(story.tasks.map(t => t.id))
const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id))
if (invalidId) {
return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 })
}
await prisma.$transaction(
parsed.data.task_ids.map((id, i) =>
prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } })
)
)
return Response.json({ success: true })
}

View file

@ -1,26 +1,6 @@
'use client' 'use client'
import { useState, useTransition } from 'react' import { useState } from 'react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
rectSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react' import { CheckSquare, Square } from 'lucide-react'
import { import {
Tooltip, Tooltip,
@ -40,7 +20,6 @@ import {
selectStoryIsBlocked, selectStoryIsBlocked,
} from '@/stores/product-workspace/selectors' } from '@/stores/product-workspace/selectors'
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types' import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
import { reorderStoriesAction } from '@/actions/stories'
import { StoryDialog, type StoryDialogState } from './story-dialog' import { StoryDialog, type StoryDialogState } from './story-dialog'
import { debugProps } from '@/lib/debug' import { debugProps } from '@/lib/debug'
import { BacklogCard } from './backlog-card' import { BacklogCard } from './backlog-card'
@ -80,8 +59,7 @@ interface StoryPanelProps {
activeSprintId?: string | null activeSprintId?: string | null
} }
// --- Sortable story block --- function StoryBlock({
function SortableStoryBlock({
story, story,
isSelected, isSelected,
cherrypick, cherrypick,
@ -98,26 +76,11 @@ function SortableStoryBlock({
onSelect: () => void onSelect: () => void
onEdit: () => void onEdit: () => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: story.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return ( return (
<BacklogCard <BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
title={story.title} title={story.title}
code={story.code} code={story.code}
priority={story.priority} priority={story.priority}
isDragging={isDragging}
isSelected={isSelected} isSelected={isSelected}
onClick={onSelect} onClick={onSelect}
badge={ badge={
@ -196,8 +159,6 @@ function StoryCherrypickButton({
} }
// --- Main component --- // --- Main component ---
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
// (useShallow). DnD via applyOptimisticMutation('story-order').
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) { export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
@ -210,14 +171,8 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
const setPref = useUserSettingsStore((s) => s.setPref) const setPref = useUserSettingsStore((s) => s.setPref)
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v) const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null) const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
// rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. const base = rawStories
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
const orderedStories = rawStories
const base = orderedStories
.filter(s => !filterStatus || s.status === filterStatus) .filter(s => !filterStatus || s.status === filterStatus)
.filter(s => !filterPriority || s.priority === filterPriority) .filter(s => !filterPriority || s.priority === filterPriority)
@ -231,74 +186,6 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
return a.priority !== b.priority ? a.priority - b.priority : 0 return a.priority !== b.priority ? a.priority - b.priority : 0
}) })
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id || !selectedPbiId) return
const activeStory = storyMap[active.id as string]
const overStory = storyMap[over.id as string]
if (!activeStory || !overStory) return
const store = useProductWorkspaceStore.getState()
const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])]
const oldIndex = prevOrder.indexOf(active.id as string)
const newIndex = prevOrder.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)
const orderMutationId = store.applyOptimisticMutation({
kind: 'story-order',
pbiId: selectedPbiId,
prevStoryIds: prevOrder,
})
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi[selectedPbiId] = newOrder
})
const priorityChanged = activeStory.priority !== overStory.priority
let priorityMutationId: string | null = null
if (priorityChanged) {
priorityMutationId = store.applyOptimisticMutation({
kind: 'entity-patch',
entity: 'story',
id: active.id as string,
prev: store.entities.storiesById[active.id as string],
})
useProductWorkspaceStore.setState((s) => {
const story = s.entities.storiesById[active.id as string]
if (story) story.priority = overStory.priority
})
}
startTransition(async () => {
const result = await reorderStoriesAction(
selectedPbiId,
productId,
newOrder,
priorityChanged ? overStory.priority : undefined
)
const st = useProductWorkspaceStore.getState()
if (result.success) {
if (priorityMutationId) st.settleMutation(priorityMutationId)
st.settleMutation(orderMutationId)
} else {
if (priorityMutationId) st.rollbackMutation(priorityMutationId)
st.rollbackMutation(orderMutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
const hasActiveFilters = filterStatus !== null || filterPriority !== null const hasActiveFilters = filterStatus !== null || filterPriority !== null
return ( return (
@ -361,39 +248,19 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }} action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
/> />
) : ( ) : (
<DndContext <div className="grid grid-cols-3 gap-2">
id="story-panel" {filtered.map(story => (
sensors={sensors} <StoryBlockWithCherrypick
collisionDetection={closestCenter} key={story.id}
onDragStart={handleDragStart} story={story}
onDragEnd={handleDragEnd} productId={productId}
> activeSprintId={activeSprintId}
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}> isSelected={selectedStoryId === story.id}
<div className="grid grid-cols-3 gap-2"> onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
{filtered.map(story => ( onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
<StoryBlockWithCherrypick />
key={story.id} ))}
story={story} </div>
productId={productId}
activeSprintId={activeSprintId}
isSelected={selectedStoryId === story.id}
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<BacklogCard
title={storyMap[activeDragId].title}
priority={storyMap[activeDragId].priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
)} )}
</div> </div>
@ -406,9 +273,7 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
) )
} }
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling. // PBI-79 / ST-1337: wrapper rond StoryBlock met cherrypick-handling.
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
// crossSprintBlocks-mutaties.
function StoryBlockWithCherrypick({ function StoryBlockWithCherrypick({
story, story,
productId, productId,
@ -443,7 +308,6 @@ function StoryBlockWithCherrypick({
} | null = null } | null = null
if (draft) { if (draft) {
// State A: muteer draft via per-PBI overrides.
const intent = draft.pbiIntent[story.pbi_id] ?? 'none' const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
const override = draft.storyOverrides[story.pbi_id] ?? { const override = draft.storyOverrides[story.pbi_id] ?? {
add: [], add: [],
@ -474,7 +338,6 @@ function StoryBlockWithCherrypick({
}, },
} }
} else if (activeSprintId) { } else if (activeSprintId) {
// State B: muteer pending buffer via toggleStorySprintMembership.
const inSprintDb = story.sprint_id === activeSprintId const inSprintDb = story.sprint_id === activeSprintId
const inAdds = pending.adds.includes(story.id) const inAdds = pending.adds.includes(story.id)
const inRemoves = pending.removes.includes(story.id) const inRemoves = pending.removes.includes(story.id)
@ -489,7 +352,7 @@ function StoryBlockWithCherrypick({
} }
return ( return (
<SortableStoryBlock <StoryBlock
story={story} story={story}
isSelected={isSelected} isSelected={isSelected}
cherrypick={cherrypick} cherrypick={cherrypick}

View file

@ -1,27 +1,6 @@
'use client' 'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
rectSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
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 { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PanelNavBar } from '@/components/shared/panel-nav-bar'
@ -33,7 +12,6 @@ import type {
BacklogTask, BacklogTask,
TaskDetail, TaskDetail,
} from '@/stores/product-workspace/types' } from '@/stores/product-workspace/types'
import { reorderTasksAction } from '@/actions/tasks'
import { BacklogCard } from './backlog-card' import { BacklogCard } from './backlog-card'
import { debugProps } from '@/lib/debug' import { debugProps } from '@/lib/debug'
import { EmptyPanel } from './empty-panel' import { EmptyPanel } from './empty-panel'
@ -52,32 +30,17 @@ const STATUS_LABELS: Record<string, string> = {
DONE: 'Klaar', DONE: 'Klaar',
} }
function SortableTaskCard({ function TaskCard({
task, task,
isDemo,
onClick, onClick,
}: { }: {
task: BacklogTask | TaskDetail task: BacklogTask | TaskDetail
isDemo: boolean
onClick: () => void onClick: () => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return ( return (
<BacklogCard <BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...(isDemo ? {} : listeners)}
title={task.title} title={task.title}
priority={task.priority} priority={task.priority}
isDragging={isDragging}
onClick={onClick} onClick={onClick}
badge={ badge={
<Badge <Badge
@ -99,64 +62,17 @@ interface TaskPanelProps {
closePath: string closePath: string
} }
// PBI-74 / T-851: leest tasks voor active story via selectTasksForActiveStory // PBI-74 / T-851: leest tasks voor active story via selectTasksForActiveStory.
// (useShallow). DnD via applyOptimisticMutation('task-order'). Detail-view
// (ensureTaskLoaded + isDetail()) zit in de task-dialog, niet in deze lijst.
export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
const router = useRouter() const router = useRouter()
const [, startTransition] = useTransition()
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as
| (BacklogTask | TaskDetail)[] | (BacklogTask | TaskDetail)[]
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId
? rawTasks ? rawTasks
: null : null
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
if (!selectedStoryId || !tasks) return
const { active, over } = event
if (!over || active.id === over.id) return
const store = useProductWorkspaceStore.getState()
const prevOrder = [...(store.relations.taskIdsByStory[selectedStoryId] ?? [])]
const oldIndex = prevOrder.indexOf(active.id as string)
const newIndex = prevOrder.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)
const orderMutationId = store.applyOptimisticMutation({
kind: 'task-order',
storyId: selectedStoryId,
prevTaskIds: prevOrder,
})
useProductWorkspaceStore.setState((s) => {
s.relations.taskIdsByStory[selectedStoryId] = newOrder
})
startTransition(async () => {
const result = await reorderTasksAction(selectedStoryId, newOrder)
const st = useProductWorkspaceStore.getState()
if (result?.error) {
st.rollbackMutation(orderMutationId)
toast.error(result.error)
} else {
st.settleMutation(orderMutationId)
}
})
}
const navActions = ( const navActions = (
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<Button <Button
@ -201,42 +117,19 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
) )
} }
const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null
return ( return (
<div className="flex flex-col h-full" {...dp}> <div className="flex flex-col h-full" {...dp}>
<PanelNavBar title="Taken" actions={navActions} /> <PanelNavBar title="Taken" actions={navActions} />
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
<DndContext <div className="grid grid-cols-2 gap-2">
id="task-panel" {tasks.map((task) => (
sensors={sensors} <TaskCard
collisionDetection={closestCenter} key={task.id}
onDragStart={handleDragStart} task={task}
onDragEnd={handleDragEnd} onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
> />
<SortableContext items={tasks.map((t) => t.id)} strategy={rectSortingStrategy}> ))}
<div className="grid grid-cols-2 gap-2"> </div>
{tasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
isDemo={isDemo}
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeTask && (
<BacklogCard
title={activeTask.title}
priority={activeTask.priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
</div> </div>
</div> </div>
) )

View file

@ -3,8 +3,6 @@
import { useMemo, useState, useTransition } from 'react' import { useMemo, useState, useTransition } from 'react'
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react' import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
import { useDroppable, useDraggable } from '@dnd-kit/core' import { useDroppable, useDraggable } from '@dnd-kit/core'
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -76,7 +74,7 @@ export interface PbiWithStories {
// --- Left panel: Sprint Backlog --- // --- Left panel: Sprint Backlog ---
function SortableSprintRow({ function SprintRow({
story, isDemo, onRemove, onSelect, onEdit, isSelected, story, isDemo, onRemove, onSelect, onEdit, isSelected,
currentUserId, productId, members, onAssigneeChange, currentUserId, productId, members, onAssigneeChange,
}: { }: {
@ -91,8 +89,8 @@ function SortableSprintRow({
members: ProductMember[] members: ProductMember[]
onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: story.id }) const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: story.id })
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } const style = { opacity: isDragging ? 0.4 : 1 }
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
function handleClaim(e: React.MouseEvent) { function handleClaim(e: React.MouseEvent) {
@ -312,9 +310,9 @@ export function SprintBacklogLeft({
{isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'} {isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'}
</p> </p>
) : ( ) : (
<SortableContext items={orderedStories.map(s => s.id)} strategy={verticalListSortingStrategy}> <>
{orderedStories.map(story => ( {orderedStories.map(story => (
<SortableSprintRow <SprintRow
key={story.id} key={story.id}
story={story} story={story}
isDemo={isDemo} isDemo={isDemo}
@ -328,7 +326,7 @@ export function SprintBacklogLeft({
onAssigneeChange={onAssigneeChange} onAssigneeChange={onAssigneeChange}
/> />
))} ))}
</SortableContext> </>
)} )}
</div> </div>
<StoryDialog <StoryDialog

View file

@ -5,7 +5,7 @@ import {
DndContext, DragEndEvent, DragStartEvent, DragOverlay, DndContext, DragEndEvent, DragStartEvent, DragOverlay,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core' } from '@dnd-kit/core'
import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable' import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { SplitPane } from '@/components/split-pane/split-pane' import { SplitPane } from '@/components/split-pane/split-pane'
@ -18,7 +18,6 @@ import type { SprintWorkspaceStory } from '@/stores/sprint-workspace/types'
import { import {
addStoryToSprintAction, addStoryToSprintAction,
removeStoryFromSprintAction, removeStoryFromSprintAction,
reorderSprintStoriesAction,
} from '@/actions/sprints' } from '@/actions/sprints'
import { debugProps } from '@/lib/debug' import { debugProps } from '@/lib/debug'
@ -106,11 +105,6 @@ export function SprintBoardClient({
handleRemove(activeId) handleRemove(activeId)
return return
} }
// Reorder within sprint
if (activeId !== overId && !activeId.startsWith('pb:')) {
handleReorder(activeId, overId)
}
} }
function handleAdd(storyId: string, storyData: SprintStory) { function handleAdd(storyId: string, storyData: SprintStory) {
@ -173,35 +167,6 @@ export function SprintBoardClient({
}) })
} }
function handleReorder(activeId: string, overId: string) {
const store = useSprintWorkspaceStore.getState()
const order = store.relations.storyIdsBySprint[sprintId] ?? []
const prevOrder = [...order]
const newOrder = order.includes(overId)
? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId))
: [...order.filter(id => id !== activeId), activeId]
const mutationId = store.applyOptimisticMutation({
kind: 'sprint-story-order',
sprintId,
prevStoryIds: prevOrder,
})
useSprintWorkspaceStore.setState((s) => {
s.relations.storyIdsBySprint[sprintId] = newOrder
})
startTransition(async () => {
const result = await reorderSprintStoriesAction(sprintId, newOrder)
const st = useSprintWorkspaceStore.getState()
if (result.success) {
st.settleMutation(mutationId)
} else {
st.rollbackMutation(mutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) {
useSprintWorkspaceStore.setState((s) => { useSprintWorkspaceStore.setState((s) => {
const story = s.entities.storiesById[storyId] const story = s.entities.storiesById[storyId]

View file

@ -1,18 +1,8 @@
'use client' 'use client'
import { useState, useTransition } from 'react' import { useTransition } from 'react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import {
DndContext, DragEndEvent, DragOverlay,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import {
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Pencil } from 'lucide-react' import { Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -25,7 +15,7 @@ import type {
SprintWorkspaceTask, SprintWorkspaceTask,
SprintWorkspaceTaskDetail, SprintWorkspaceTaskDetail,
} from '@/stores/sprint-workspace/types' } from '@/stores/sprint-workspace/types'
import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks' import { updateTaskStatusAction } from '@/actions/tasks'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { debugProps } from '@/lib/debug' import { debugProps } from '@/lib/debug'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -53,7 +43,6 @@ const STATUS_LABELS: Record<string, string> = {
EXCLUDED: 'Uitgesloten', EXCLUDED: 'Uitgesloten',
} }
// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra // Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra
// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883). // SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883).
export interface Task { export interface Task {
@ -75,7 +64,7 @@ interface TaskListProps {
isDemo: boolean isDemo: boolean
} }
function SortableTaskRow({ function TaskRow({
task, code, isDemo, onStatusToggle, onEdit, task, code, isDemo, onStatusToggle, onEdit,
}: { }: {
task: WorkspaceTask task: WorkspaceTask
@ -84,11 +73,8 @@ function SortableTaskRow({
onStatusToggle: () => void onStatusToggle: () => void
onEdit: () => void onEdit: () => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
return ( return (
<div ref={setNodeRef} style={style} className="group px-2 py-1"> <div className="group px-2 py-1">
<div <div
className={cn( className={cn(
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer', 'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer',
@ -105,17 +91,6 @@ function SortableTaskRow({
} }
}} }}
> >
{!isDemo && (
<span
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"
aria-hidden="true"
>
</span>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className={cn( <p className={cn(
@ -162,55 +137,12 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }:
const orderedTasks = useSprintWorkspaceStore( const orderedTasks = useSprintWorkspaceStore(
useShallow(selectTasksForActiveStory), useShallow(selectTasksForActiveStory),
) )
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const taskMap: Record<string, WorkspaceTask> = {}
for (const t of orderedTasks) taskMap[t.id] = t
const doneCount = orderedTasks.filter(t => t.status === 'DONE').length const doneCount = orderedTasks.filter(t => t.status === 'DONE').length
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!storyId) return
if (!over || active.id === over.id) return
const store = useSprintWorkspaceStore.getState()
const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])]
const newOrder = arrayMove(
[...prevOrder],
prevOrder.indexOf(active.id as string),
prevOrder.indexOf(over.id as string),
)
const mutationId = store.applyOptimisticMutation({
kind: 'sprint-task-order',
storyId,
prevTaskIds: prevOrder,
})
useSprintWorkspaceStore.setState((s) => {
s.relations.taskIdsByStory[storyId] = newOrder
})
setActiveDragId(null)
startTransition(async () => {
const result = await reorderTasksAction(storyId, newOrder)
const st = useSprintWorkspaceStore.getState()
if (result.success) {
st.settleMutation(mutationId)
} else {
st.rollbackMutation(mutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
function handleStatusToggle(task: WorkspaceTask) { function handleStatusToggle(task: WorkspaceTask) {
startTransition(async () => { startTransition(async () => {
await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO') await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO')
@ -263,36 +195,18 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }:
</DemoTooltip> </DemoTooltip>
</div> </div>
) : ( ) : (
<DndContext <>
id="task-list" {orderedTasks.map((task) => (
sensors={sensors} <TaskRow
collisionDetection={closestCenter} key={task.id}
onDragStart={e => setActiveDragId(e.active.id as string)} task={task}
onDragEnd={handleDragEnd} code={task.code}
> isDemo={isDemo}
<SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}> onStatusToggle={() => handleStatusToggle(task)}
{orderedTasks.map((task) => ( onEdit={() => openEditDialog(task.id)}
<SortableTaskRow />
key={task.id} ))}
task={task} </>
code={task.code}
isDemo={isDemo}
onStatusToggle={() => handleStatusToggle(task)}
onEdit={() => openEditDialog(task.id)}
/>
))}
</SortableContext>
<DragOverlay>
{activeDragId && taskMap[activeDragId] && (
<div className={cn(
'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm',
PRIORITY_BORDER[taskMap[activeDragId].priority],
)}>
{taskMap[activeDragId].title}
</div>
)}
</DragOverlay>
</DndContext>
)} )}
</div> </div>
</div> </div>

View file

@ -557,10 +557,6 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
case 'pbi-order': case 'pbi-order':
// store-call passes new order via separate set, snapshot is prevPbiIds // store-call passes new order via separate set, snapshot is prevPbiIds
break break
case 'story-order':
break
case 'task-order':
break
case 'entity-patch': case 'entity-patch':
break break
} }
@ -577,12 +573,6 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
case 'pbi-order': case 'pbi-order':
s.relations.pbiIds = [...mutation.prevPbiIds] s.relations.pbiIds = [...mutation.prevPbiIds]
break break
case 'story-order':
s.relations.storyIdsByPbi[mutation.pbiId] = [...mutation.prevStoryIds]
break
case 'task-order':
s.relations.taskIdsByStory[mutation.storyId] = [...mutation.prevTaskIds]
break
case 'entity-patch': { case 'entity-patch': {
const { entity, id, prev } = mutation const { entity, id, prev } = mutation
if (prev) { if (prev) {

View file

@ -109,18 +109,6 @@ export interface OptimisticPbiOrderMutation {
prevPbiIds: string[] prevPbiIds: string[]
} }
export interface OptimisticStoryOrderMutation {
kind: 'story-order'
pbiId: string
prevStoryIds: string[]
}
export interface OptimisticTaskOrderMutation {
kind: 'task-order'
storyId: string
prevTaskIds: string[]
}
export interface OptimisticEntityPatchMutation { export interface OptimisticEntityPatchMutation {
kind: 'entity-patch' kind: 'entity-patch'
entity: 'pbi' | 'story' | 'task' entity: 'pbi' | 'story' | 'task'
@ -130,8 +118,6 @@ export interface OptimisticEntityPatchMutation {
export type OptimisticMutation = export type OptimisticMutation =
| OptimisticPbiOrderMutation | OptimisticPbiOrderMutation
| OptimisticStoryOrderMutation
| OptimisticTaskOrderMutation
| OptimisticEntityPatchMutation | OptimisticEntityPatchMutation
export interface PendingOptimisticMutation { export interface PendingOptimisticMutation {

View file

@ -519,12 +519,6 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
const { mutation } = pending const { mutation } = pending
set((s) => { set((s) => {
switch (mutation.kind) { switch (mutation.kind) {
case 'sprint-story-order':
s.relations.storyIdsBySprint[mutation.sprintId] = [...mutation.prevStoryIds]
break
case 'sprint-task-order':
s.relations.taskIdsByStory[mutation.storyId] = [...mutation.prevTaskIds]
break
case 'entity-patch': { case 'entity-patch': {
const { entity, id, prev } = mutation const { entity, id, prev } = mutation
if (prev) { if (prev) {

View file

@ -122,18 +122,6 @@ export type ResyncReason =
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
export interface OptimisticSprintStoryOrderMutation {
kind: 'sprint-story-order'
sprintId: string
prevStoryIds: string[]
}
export interface OptimisticSprintTaskOrderMutation {
kind: 'sprint-task-order'
storyId: string
prevTaskIds: string[]
}
export interface OptimisticEntityPatchMutation { export interface OptimisticEntityPatchMutation {
kind: 'entity-patch' kind: 'entity-patch'
entity: 'sprint' | 'story' | 'task' entity: 'sprint' | 'story' | 'task'
@ -147,8 +135,6 @@ export interface OptimisticEntityPatchMutation {
} }
export type OptimisticMutation = export type OptimisticMutation =
| OptimisticSprintStoryOrderMutation
| OptimisticSprintTaskOrderMutation
| OptimisticEntityPatchMutation | OptimisticEntityPatchMutation
export interface PendingOptimisticMutation { export interface PendingOptimisticMutation {