Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/core/utils/mutationAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ const CASES: Case[] = [
load: () => import("src/milestones/mutations/createMilestone"),
input: { name: "x", projectId: 99 },
},
{
name: "copyMilestone",
load: () => import("src/milestones/mutations/copyMilestone"),
input: { id: 1 },
},
{
name: "copyMilestone (with its tasks)",
load: () => import("src/milestones/mutations/copyMilestone"),
input: { id: 1, includeTasks: true },
},
{
name: "deleteMilestone",
load: () => import("src/milestones/mutations/deleteMilestone"),
Expand Down
89 changes: 89 additions & 0 deletions src/milestones/components/CopyMilestoneButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from "react"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"

const copyFn = vi.fn()
const push = vi.fn()
const toastPromise = vi.fn((promise: Promise<unknown>) => promise)

vi.mock("@blitzjs/rpc", () => ({
useMutation: () => [(...a: unknown[]) => copyFn(...a), { isLoading: false }],
}))
vi.mock("@blitzjs/next", () => ({
Routes: {
ShowMilestonePage: ({ projectId, milestoneId }: any) =>
`/projects/${projectId}/milestones/${milestoneId}`,
},
}))
vi.mock("next/router", () => ({ useRouter: () => ({ push }) }))
vi.mock("react-hot-toast", () => ({
default: { promise: (...a: [Promise<unknown>]) => toastPromise(...a) },
}))
vi.mock("../mutations/copyMilestone", () => ({ default: {} }))

import { CopyMilestoneButton } from "./CopyMilestoneButton"

const openDialog = (taskCount = 3) => {
render(<CopyMilestoneButton milestoneId={5} projectId={3} taskCount={taskCount} />)
fireEvent.click(screen.getByRole("button", { name: "Copy Milestone" }))
}

describe("CopyMilestoneButton", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, "error").mockImplementation(() => undefined)
copyFn.mockResolvedValue({ id: 42 })
})
afterEach(cleanup)

test("nothing is copied until a choice is made", () => {
openDialog()
expect(screen.getByText("Copy milestone and 3 tasks")).toBeTruthy()
expect(copyFn).not.toHaveBeenCalled()
})

test("copy milestone only, then open the copy", async () => {
openDialog()
fireEvent.click(screen.getByRole("button", { name: "Copy milestone only" }))
await waitFor(() => expect(push).toHaveBeenCalledWith("/projects/3/milestones/42"))
expect(copyFn).toHaveBeenCalledWith({ id: 5, includeTasks: false })
})

test("copy the milestone and its tasks, then open the copy", async () => {
openDialog()
fireEvent.click(screen.getByRole("button", { name: "Copy milestone and 3 tasks" }))
await waitFor(() => expect(push).toHaveBeenCalledWith("/projects/3/milestones/42"))
expect(copyFn).toHaveBeenCalledWith({ id: 5, includeTasks: true })
})

test("says '1 task' for a single task", () => {
openDialog(1)
expect(screen.getByRole("button", { name: "Copy milestone and 1 task" })).toBeTruthy()
})

test("a milestone with no tasks can only be copied on its own", () => {
openDialog(0)
const withTasks = screen.getByRole("button", {
name: "Copy milestone and tasks",
}) as HTMLButtonElement
expect(withTasks.disabled).toBe(true)
expect(
(screen.getByRole("button", { name: "Copy milestone only" }) as HTMLButtonElement).disabled
).toBe(false)
})

test("cancel closes the dialog without copying", async () => {
openDialog()
fireEvent.click(screen.getByRole("button", { name: "Cancel" }))
await waitFor(() => expect(screen.queryByText("Copy milestone only")).toBeNull())
expect(copyFn).not.toHaveBeenCalled()
})

test("stays on the page if the copy fails", async () => {
copyFn.mockRejectedValue(new Error("nope"))
openDialog()
fireEvent.click(screen.getByRole("button", { name: "Copy milestone only" }))
await waitFor(() => expect(copyFn).toHaveBeenCalled())
expect(push).not.toHaveBeenCalled()
})
})
86 changes: 86 additions & 0 deletions src/milestones/components/CopyMilestoneButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState } from "react"
import { useMutation } from "@blitzjs/rpc"
import { Routes } from "@blitzjs/next"
import { useRouter } from "next/router"
import toast from "react-hot-toast"
import Modal from "src/core/components/Modal"
import copyMilestone from "../mutations/copyMilestone"

type Props = {
milestoneId: number
projectId: number
/** How many tasks the milestone has, so the choice can say what "and tasks" means */
taskCount: number
}

// Copies the milestone, either on its own or together with its tasks, and opens the copy so it
// can be renamed or adjusted
export const CopyMilestoneButton = ({ milestoneId, projectId, taskCount }: Props) => {
const router = useRouter()
const [open, setOpen] = useState(false)
const [copyMilestoneMutation, { isLoading }] = useMutation(copyMilestone)

const handleCopy = async (includeTasks: boolean) => {
try {
const copy = await toast.promise(copyMilestoneMutation({ id: milestoneId, includeTasks }), {
loading: "Copying milestone...",
success: includeTasks
? "Milestone and tasks copied."
: "Milestone copied. Its tasks stay with the original.",
error: "Failed to copy the milestone...",
})
setOpen(false)
await router.push(Routes.ShowMilestonePage({ projectId, milestoneId: copy.id }))
} catch (error) {
console.error(error)
}
}

return (
<>
<button type="button" className="btn btn-secondary" onClick={() => setOpen(true)}>
Copy Milestone
</button>
<Modal open={open} size="w-11/12 max-w-xl">
<h3 className="text-xl font-bold mb-2">Copy milestone</h3>
<p className="mb-2">
The copy keeps this milestone&apos;s name (marked as a copy), description, tags and dates.
</p>
<p className="mb-4">
Tasks can only belong to one milestone, so you can leave them where they are or copy them
too. Copied tasks are new, unassigned tasks under the copy, with the same details, form
and roles. Nothing about progress, responses or comments is copied.
</p>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
className="btn btn-secondary"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => void handleCopy(false)}
disabled={isLoading}
>
Copy milestone only
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void handleCopy(true)}
disabled={isLoading || taskCount === 0}
title={taskCount === 0 ? "This milestone has no tasks" : undefined}
>
{taskCount === 0
? "Copy milestone and tasks"
: `Copy milestone and ${taskCount} task${taskCount === 1 ? "" : "s"}`}
</button>
</div>
</Modal>
</>
)
}
167 changes: 167 additions & 0 deletions src/milestones/mutations/copyMilestone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, test, vi } from "vitest"

// vi.mock is hoisted above these, so the mock reaches them lazily at call time
let privileges: Array<{ projectId: number; privilege: string }> = []
const milestoneFindMany = vi.fn()
const findUniqueOrThrow = vi.fn()
const milestoneCreate = vi.fn()
const taskCreate = vi.fn()
const taskCount = vi.fn()
const memberFindFirst = vi.fn()

vi.mock("db", () => {
const tx = {
milestone: { create: (...a: unknown[]) => milestoneCreate(...a) },
task: {
create: (...a: unknown[]) => taskCreate(...a),
count: (...a: unknown[]) => taskCount(...a),
},
projectMember: { findFirst: (...a: unknown[]) => memberFindFirst(...a) },
}
return {
default: {
projectPrivilege: { findMany: async () => privileges },
milestone: {
findMany: (...a: unknown[]) => milestoneFindMany(...a),
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrow(...a),
},
$transaction: async (fn: (client: typeof tx) => unknown) => fn(tx),
},
MemberPrivileges: { PROJECT_MANAGER: "PROJECT_MANAGER", CONTRIBUTOR: "CONTRIBUTOR" },
}
})

import copyMilestone from "./copyMilestone"

const ctx: any = { session: { userId: 7, $isAuthorized: () => true, $authorize: () => undefined } }

const task = (id: number, containerId: number, over: any = {}) => ({
id,
name: `Task ${id}`,
description: `About ${id}`,
deadline: new Date("2026-02-01"),
startDate: new Date("2026-01-15"),
tags: [{ key: "t", value: "x" }],
containerId,
containerTaskOrder: 99,
milestoneId: 5,
formVersionId: 12,
elementId: null,
status: "COMPLETED",
autoAssignNew: "NONE",
anonymous: false,
anonymousResponses: true,
createdById: 400,
roles: [{ id: 31 }],
...over,
})

const original = {
id: 5,
name: "Data collection",
description: "Collect the data",
projectId: 3,
tags: [{ key: "a", value: "wave 1" }],
startDate: new Date("2026-01-01"),
endDate: new Date("2026-03-01"),
task: [task(1, 10), task(2, 10), task(3, 11)],
}

describe("copyMilestone", () => {
beforeEach(() => {
vi.clearAllMocks()
privileges = [{ projectId: 3, privilege: "PROJECT_MANAGER" }]
milestoneFindMany.mockResolvedValue([{ projectId: 3 }])
findUniqueOrThrow.mockResolvedValue(original)
milestoneCreate.mockImplementation(async ({ data }: any) => ({ id: 6, ...data }))
taskCreate.mockResolvedValue({})
taskCount.mockImplementation(async ({ where }: any) => (where.containerId === 10 ? 4 : 0))
memberFindFirst.mockResolvedValue({ id: 77 })
})

test("by default copies only the milestone's details, and leaves its tasks alone", async () => {
const copy = await copyMilestone({ id: 5 }, ctx)
expect(milestoneCreate).toHaveBeenCalledWith({
data: {
name: "Data collection (Copy)",
description: "Collect the data",
projectId: 3,
tags: [{ key: "a", value: "wave 1" }],
startDate: original.startDate,
endDate: original.endDate,
},
})
expect(copy).toMatchObject({ id: 6, copiedTaskCount: 0 })
expect(taskCreate).not.toHaveBeenCalled()
// the tasks weren't even read
expect(findUniqueOrThrow.mock.calls[0]![0].include).toBeUndefined()
})

test("with tasks, copies each as a new, unassigned task under the copy, at the end of its column", async () => {
const copy = await copyMilestone({ id: 5, includeTasks: true }, ctx)
expect(copy.copiedTaskCount).toBe(3)
expect(taskCreate).toHaveBeenCalledTimes(3)

const created = taskCreate.mock.calls.map((call) => call[0].data)
// everything lands under the new milestone, in this project, created by the person copying
expect(
created.every((d) => d.milestoneId === 6 && d.projectId === 3 && d.createdById === 77)
).toBe(true)
// same details, form and roles; progress is reset
expect(created[0]).toMatchObject({
name: "Task 1",
description: "About 1",
deadline: new Date("2026-02-01"),
formVersionId: 12,
anonymousResponses: true,
status: "NOT_COMPLETED",
roles: { connect: [{ id: 31 }] },
})
// column 10 already holds 4 tasks, so the two copies take places 4 and 5; column 11 is empty
expect(created.map((d) => [d.containerId, d.containerTaskOrder])).toEqual([
[10, 4],
[10, 5],
[11, 0],
])
// nobody is assigned and nothing about logs or comments is carried over
for (const data of created) {
expect(data).not.toHaveProperty("assignedMembers")
expect(data).not.toHaveProperty("taskLogs")
}
})

test("copes with a milestone that has no tasks, description, tags or dates", async () => {
findUniqueOrThrow.mockResolvedValue({
...original,
description: null,
tags: null,
startDate: null,
endDate: null,
task: [],
})
const copy = await copyMilestone({ id: 5, includeTasks: true }, ctx)
const { data } = milestoneCreate.mock.calls[0]![0]
expect(data.description).toBeNull()
expect(data.tags).toBeUndefined()
expect(data.startDate).toBeUndefined()
expect(copy.copiedTaskCount).toBe(0)
expect(taskCreate).not.toHaveBeenCalled()
})

test("stops before copying any task if the caller isn't a member row of the project", async () => {
memberFindFirst.mockResolvedValue(null)
await expect(copyMilestone({ id: 5, includeTasks: true }, ctx)).rejects.toThrow()
expect(taskCreate).not.toHaveBeenCalled()
})

test("only a project manager of that project may copy it, with or without tasks", async () => {
for (const mine of [[], [{ projectId: 3, privilege: "CONTRIBUTOR" }]]) {
privileges = mine
for (const includeTasks of [false, true]) {
await expect(copyMilestone({ id: 5, includeTasks }, ctx)).rejects.toThrow()
}
}
expect(milestoneCreate).not.toHaveBeenCalled()
expect(taskCreate).not.toHaveBeenCalled()
})
})
Loading
Loading