feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up
actions/sprints.ts:
- Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent,
storyOverrides).
- Server-side intent-resolve:
1. Voor elke PBI met intent='all': fetch child-story-IDs minus
storyOverrides[pbi].remove.
2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan).
- Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status
!= DONE; stories in andere OPEN sprint → conflicts.crossSprint).
- Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') +
task.updateMany (sprint_id cascade) — alles atomair.
- setActiveSprintInSettings na success.
- Return: { success, sprintId, affectedStoryIds, affectedPbiIds,
affectedTaskIds, conflicts: { notEligible, crossSprint } } of error.
components/backlog/sprint-definition-banner.tsx:
- 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction;
toast bij conflicts, success-toast anders, router.refresh() voor SSR
cycle. Pending draft wordt door de action zelf nog niet expliciet gewist
— dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden.
Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases)
dekken intent-resolve, override-respect, cross-sprint conflict, transactie-
binding van story.status + task.sprint_id, return-shape, en error-pad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
947d970231
commit
d21011cdfa
3 changed files with 480 additions and 4 deletions
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn().mockResolvedValue({
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||
}))
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||
getAccessibleProduct: vi.fn().mockResolvedValue({
|
||||
id: 'product-1',
|
||||
user_id: 'user-1',
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
vi.mock('@/lib/code-server', () => ({
|
||||
createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')),
|
||||
generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'),
|
||||
}))
|
||||
vi.mock('@/lib/active-sprint', () => ({
|
||||
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => {
|
||||
const txClient = {
|
||||
sprint: { create: vi.fn() },
|
||||
story: { updateMany: vi.fn() },
|
||||
task: { updateMany: vi.fn() },
|
||||
}
|
||||
return {
|
||||
prisma: {
|
||||
sprint: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
pbi: { findMany: vi.fn() },
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
|
||||
__txClient: txClient,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
createSprintWithSelectionAction,
|
||||
type CreateSprintWithSelectionInput,
|
||||
} from '@/actions/sprints'
|
||||
|
||||
type Mocked = {
|
||||
sprint: {
|
||||
create: ReturnType<typeof vi.fn>
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: {
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
task: {
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
__txClient: {
|
||||
sprint: { create: ReturnType<typeof vi.fn> }
|
||||
story: { updateMany: ReturnType<typeof vi.fn> }
|
||||
task: { updateMany: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
}
|
||||
const mockPrisma = prisma as unknown as Mocked
|
||||
|
||||
function baseInput(
|
||||
overrides: Partial<CreateSprintWithSelectionInput> = {},
|
||||
): CreateSprintWithSelectionInput {
|
||||
return {
|
||||
productId: 'product-1',
|
||||
metadata: { goal: 'Sprint 1' },
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.sprint.create.mockReset()
|
||||
mockPrisma.story.findMany.mockReset()
|
||||
mockPrisma.story.updateMany.mockReset()
|
||||
mockPrisma.task.findMany.mockReset()
|
||||
mockPrisma.task.updateMany.mockReset()
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
|
||||
fn(mockPrisma.__txClient),
|
||||
)
|
||||
mockPrisma.__txClient.sprint.create
|
||||
.mockReset()
|
||||
.mockResolvedValue({ id: 'sprint-1', code: 'SP-1' })
|
||||
mockPrisma.__txClient.story.updateMany
|
||||
.mockReset()
|
||||
.mockResolvedValue({ count: 0 })
|
||||
mockPrisma.__txClient.task.updateMany
|
||||
.mockReset()
|
||||
.mockResolvedValue({ count: 0 })
|
||||
})
|
||||
|
||||
describe('createSprintWithSelectionAction', () => {
|
||||
it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => {
|
||||
// Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch.
|
||||
mockPrisma.story.findMany
|
||||
// resolve step (only for pbis with intent='all')
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', pbi_id: 'pbiA' },
|
||||
{ id: 's2', pbi_id: 'pbiA' },
|
||||
{ id: 's3', pbi_id: 'pbiA' },
|
||||
])
|
||||
// partitionByEligibility — alle eligible
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
{ id: 's3', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
])
|
||||
// affectedStories
|
||||
.mockResolvedValueOnce([
|
||||
{ pbi_id: 'pbiA' },
|
||||
{ pbi_id: 'pbiA' },
|
||||
])
|
||||
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
||||
|
||||
const result = await createSprintWithSelectionAction(
|
||||
baseInput({
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
storyOverrides: { pbiA: { add: [], remove: ['s2'] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect('success' in result).toBe(true)
|
||||
if ('success' in result) {
|
||||
expect(result.affectedStoryIds).toEqual(['s1', 's3'])
|
||||
expect(result.conflicts.notEligible).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => {
|
||||
// Geen PBI met intent=all → stap 1 wordt niet uitgevoerd.
|
||||
mockPrisma.story.findMany
|
||||
// partition
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's10', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
])
|
||||
// affectedStories
|
||||
.mockResolvedValueOnce([{ pbi_id: 'pbiB' }])
|
||||
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
||||
|
||||
const result = await createSprintWithSelectionAction(
|
||||
baseInput({
|
||||
pbiIntent: { pbiB: 'none' },
|
||||
storyOverrides: { pbiB: { add: ['s10'], remove: [] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect('success' in result).toBe(true)
|
||||
if ('success' in result) {
|
||||
expect(result.affectedStoryIds).toEqual(['s10'])
|
||||
}
|
||||
})
|
||||
|
||||
it('eligibility-filter classificeert DONE en cross-sprint stories', async () => {
|
||||
mockPrisma.story.findMany
|
||||
// resolve
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', pbi_id: 'pbiA' },
|
||||
{ id: 's2', pbi_id: 'pbiA' },
|
||||
{ id: 's3', pbi_id: 'pbiA' },
|
||||
])
|
||||
// partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
{
|
||||
id: 's3',
|
||||
sprint_id: 'sprint-other',
|
||||
status: 'IN_SPRINT',
|
||||
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
// affectedStories
|
||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
||||
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
||||
|
||||
const result = await createSprintWithSelectionAction(
|
||||
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
||||
)
|
||||
|
||||
expect('success' in result).toBe(true)
|
||||
if ('success' in result) {
|
||||
expect(result.affectedStoryIds).toEqual(['s2'])
|
||||
expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual(
|
||||
['s1', 's3'],
|
||||
)
|
||||
expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3'])
|
||||
}
|
||||
})
|
||||
|
||||
it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => {
|
||||
mockPrisma.story.findMany
|
||||
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
])
|
||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
||||
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
||||
|
||||
await createSprintWithSelectionAction(
|
||||
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
||||
)
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
sprint_id: 'sprint-1',
|
||||
status: 'IN_SPRINT',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { sprint_id: 'sprint-1' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => {
|
||||
mockPrisma.story.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', pbi_id: 'pbiA' },
|
||||
{ id: 's2', pbi_id: 'pbiB' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
])
|
||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }])
|
||||
mockPrisma.task.findMany.mockResolvedValueOnce([
|
||||
{ id: 't1' },
|
||||
{ id: 't2' },
|
||||
])
|
||||
|
||||
const result = await createSprintWithSelectionAction(
|
||||
baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }),
|
||||
)
|
||||
|
||||
expect('success' in result).toBe(true)
|
||||
if ('success' in result) {
|
||||
expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2'])
|
||||
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
|
||||
expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2'])
|
||||
}
|
||||
})
|
||||
|
||||
it('returnt error wanneer geen eligible stories overblijven', async () => {
|
||||
mockPrisma.story.findMany
|
||||
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
|
||||
// s1 is DONE → notEligible
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||
])
|
||||
|
||||
const result = await createSprintWithSelectionAction(
|
||||
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
||||
)
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) {
|
||||
expect(result.code).toBe(422)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -15,8 +15,158 @@ import { enforceUserRateLimit } from '@/lib/rate-limit'
|
|||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
||||
import { setActiveSprintInSettings } from '@/lib/active-sprint'
|
||||
import { partitionByEligibility } from '@/lib/sprint-conflicts'
|
||||
import { z } from 'zod'
|
||||
|
||||
const StoryOverrideSchema = z.object({
|
||||
add: z.array(z.string()),
|
||||
remove: z.array(z.string()),
|
||||
})
|
||||
|
||||
const createSprintWithSelectionSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
metadata: z.object({
|
||||
goal: z.string().min(1).max(2000),
|
||||
startAt: z.string().date().optional(),
|
||||
endAt: z.string().date().optional(),
|
||||
}),
|
||||
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
|
||||
storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}),
|
||||
})
|
||||
|
||||
export type CreateSprintWithSelectionInput = z.infer<
|
||||
typeof createSprintWithSelectionSchema
|
||||
>
|
||||
|
||||
type SprintCreateConflicts = {
|
||||
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
|
||||
crossSprint: { storyId: string; sprintId: string; sprintName: string }[]
|
||||
}
|
||||
|
||||
export type CreateSprintWithSelectionResult =
|
||||
| {
|
||||
success: true
|
||||
sprintId: string
|
||||
affectedStoryIds: string[]
|
||||
affectedPbiIds: string[]
|
||||
affectedTaskIds: string[]
|
||||
conflicts: SprintCreateConflicts
|
||||
}
|
||||
| { error: string; code: number }
|
||||
|
||||
export async function createSprintWithSelectionAction(
|
||||
input: CreateSprintWithSelectionInput,
|
||||
): Promise<CreateSprintWithSelectionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('create-sprint', session.userId)
|
||||
if (limited) return { error: limited.error, code: limited.code }
|
||||
|
||||
const parsed = createSprintWithSelectionSchema.safeParse(input)
|
||||
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||
|
||||
// Resolveer intent + per-PBI overrides naar concrete story-IDs.
|
||||
const allPbiAllIds = Object.entries(parsed.data.pbiIntent)
|
||||
.filter(([, intent]) => intent === 'all')
|
||||
.map(([pbiId]) => pbiId)
|
||||
|
||||
// Stap 1: alle child-stories voor PBI's met intent='all'.
|
||||
let candidate: string[] = []
|
||||
if (allPbiAllIds.length > 0) {
|
||||
const rows = await prisma.story.findMany({
|
||||
where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId },
|
||||
select: { id: true, pbi_id: true },
|
||||
})
|
||||
const removedSet = new Set<string>()
|
||||
for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) {
|
||||
for (const id of override.remove) removedSet.add(`${pbiId}:${id}`)
|
||||
}
|
||||
candidate = rows
|
||||
.filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`))
|
||||
.map((row) => row.id)
|
||||
}
|
||||
|
||||
// Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra
|
||||
// toevoegingen). Dedupliceren met candidates uit stap 1.
|
||||
const candidateSet = new Set(candidate)
|
||||
for (const override of Object.values(parsed.data.storyOverrides)) {
|
||||
for (const id of override.add) candidateSet.add(id)
|
||||
}
|
||||
const candidateIds = Array.from(candidateSet)
|
||||
|
||||
// Eligibility-filter (incl. cross-sprint guard).
|
||||
const partition = await partitionByEligibility(prisma, candidateIds)
|
||||
|
||||
if (partition.eligible.length === 0) {
|
||||
return {
|
||||
error: 'Geen eligible stories voor deze sprint',
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
const sprint = await createWithCodeRetry(
|
||||
() => generateNextSprintCode(parsed.data.productId),
|
||||
(code) =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const created = await tx.sprint.create({
|
||||
data: {
|
||||
product_id: parsed.data.productId,
|
||||
code,
|
||||
sprint_goal: parsed.data.metadata.goal,
|
||||
status: 'OPEN',
|
||||
start_date: parseDate(parsed.data.metadata.startAt),
|
||||
end_date: parseDate(parsed.data.metadata.endAt),
|
||||
},
|
||||
})
|
||||
await tx.story.updateMany({
|
||||
where: { id: { in: partition.eligible } },
|
||||
data: { sprint_id: created.id, status: 'IN_SPRINT' },
|
||||
})
|
||||
await tx.task.updateMany({
|
||||
where: { story_id: { in: partition.eligible } },
|
||||
data: { sprint_id: created.id },
|
||||
})
|
||||
return created
|
||||
}),
|
||||
)
|
||||
|
||||
// Snapshot affected pbi/task IDs voor client-store patches.
|
||||
const affectedStories = await prisma.story.findMany({
|
||||
where: { id: { in: partition.eligible } },
|
||||
select: { pbi_id: true },
|
||||
})
|
||||
const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id)))
|
||||
const affectedTasks = await prisma.task.findMany({
|
||||
where: { story_id: { in: partition.eligible } },
|
||||
select: { id: true },
|
||||
})
|
||||
const affectedTaskIds = affectedTasks.map((t) => t.id)
|
||||
|
||||
await setActiveSprintInSettings(
|
||||
session.userId,
|
||||
parsed.data.productId,
|
||||
sprint.id,
|
||||
)
|
||||
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sprintId: sprint.id,
|
||||
affectedStoryIds: partition.eligible,
|
||||
affectedPbiIds,
|
||||
affectedTaskIds,
|
||||
conflicts: {
|
||||
notEligible: partition.notEligible,
|
||||
crossSprint: partition.crossSprint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type { PendingSprintDraft } from '@/lib/user-settings'
|
||||
import { createSprintWithSelectionAction } from '@/actions/sprints'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
interface SprintDefinitionBannerProps {
|
||||
|
|
@ -74,6 +76,7 @@ export function SprintDefinitionBanner({
|
|||
(s) => s.clearPendingSprintDraft,
|
||||
)
|
||||
const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary)
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [confirmCancel, setConfirmCancel] = useState(false)
|
||||
|
||||
|
|
@ -100,10 +103,33 @@ export function SprintDefinitionBanner({
|
|||
}
|
||||
|
||||
function handleCreate() {
|
||||
// PBI-79 ST-1339 wires de createSprintWithSelectionAction in.
|
||||
toast.info(
|
||||
'Sprint aanmaken is nog niet aangesloten (wordt afgerond in ST-1339).',
|
||||
)
|
||||
startTransition(async () => {
|
||||
const result = await createSprintWithSelectionAction({
|
||||
productId,
|
||||
metadata: {
|
||||
goal: draft.goal,
|
||||
startAt: draft.startAt,
|
||||
endAt: draft.endAt,
|
||||
},
|
||||
pbiIntent: draft.pbiIntent,
|
||||
storyOverrides: draft.storyOverrides,
|
||||
})
|
||||
if ('error' in result) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
const { conflicts } = result
|
||||
if (conflicts.notEligible.length > 0) {
|
||||
toast.warning(
|
||||
`${conflicts.notEligible.length} stor${
|
||||
conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn'
|
||||
} overgeslagen (al in een andere sprint of afgerond).`,
|
||||
)
|
||||
} else {
|
||||
toast.success('Sprint aangemaakt')
|
||||
}
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const storyLabel = counts.hasUnknownTotal
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue