diff --git a/src/core/utils/mutationAccess.test.ts b/src/core/utils/mutationAccess.test.ts index 1cce07f8..c53d19ef 100644 --- a/src/core/utils/mutationAccess.test.ts +++ b/src/core/utils/mutationAccess.test.ts @@ -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"), diff --git a/src/milestones/components/CopyMilestoneButton.test.tsx b/src/milestones/components/CopyMilestoneButton.test.tsx new file mode 100644 index 00000000..c8e5c0d4 --- /dev/null +++ b/src/milestones/components/CopyMilestoneButton.test.tsx @@ -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) => 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]) => toastPromise(...a) }, +})) +vi.mock("../mutations/copyMilestone", () => ({ default: {} })) + +import { CopyMilestoneButton } from "./CopyMilestoneButton" + +const openDialog = (taskCount = 3) => { + render() + 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() + }) +}) diff --git a/src/milestones/components/CopyMilestoneButton.tsx b/src/milestones/components/CopyMilestoneButton.tsx new file mode 100644 index 00000000..4893f06d --- /dev/null +++ b/src/milestones/components/CopyMilestoneButton.tsx @@ -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 ( + <> + + +

Copy milestone

+

+ The copy keeps this milestone's name (marked as a copy), description, tags and dates. +

+

+ 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. +

+
+ + + +
+
+ + ) +} diff --git a/src/milestones/mutations/copyMilestone.test.ts b/src/milestones/mutations/copyMilestone.test.ts new file mode 100644 index 00000000..176c5e64 --- /dev/null +++ b/src/milestones/mutations/copyMilestone.test.ts @@ -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() + }) +}) diff --git a/src/milestones/mutations/copyMilestone.ts b/src/milestones/mutations/copyMilestone.ts new file mode 100644 index 00000000..e4ad1202 --- /dev/null +++ b/src/milestones/mutations/copyMilestone.ts @@ -0,0 +1,94 @@ +import { resolver } from "@blitzjs/rpc" +import db from "db" +import { NotFoundError } from "blitz" +import { CopyMilestoneSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" + +// Makes a copy of a milestone in the same project: name (with " (Copy)"), description, tags and +// dates. +// +// By default its tasks stay with the original, because a task can belong to only one milestone. +// With `includeTasks` the milestone's tasks are copied too, as new tasks under the copy (the same +// way copying a whole project copies tasks): same details, form, roles, column and settings, +// back to "not completed", and with nobody assigned and no responses or comments, so a copy +// never carries someone else's progress or sends anyone a notification. +export default resolver.pipe( + resolver.zod(CopyMilestoneSchema), + resolver.authorize(), + async ({ id, includeTasks = false }, ctx) => { + await requireManagerOf(ctx, "milestone", [id]) + const userId = ctx.session.userId as number + + const original = await db.milestone.findUniqueOrThrow({ + where: { id }, + include: includeTasks + ? { task: { include: { roles: { select: { id: true } } } } } + : undefined, + }) + + return db.$transaction(async (tx) => { + const copy = await tx.milestone.create({ + data: { + name: `${original.name} (Copy)`, + description: original.description, + projectId: original.projectId, + tags: original.tags ?? undefined, + startDate: original.startDate ?? undefined, + endDate: original.endDate ?? undefined, + }, + }) + + let copiedTaskCount = 0 + const tasks = includeTasks ? (original as any).task ?? [] : [] + if (tasks.length > 0) { + // the copied tasks are "created by" the person copying, who is a member of this project + const me = await tx.projectMember.findFirst({ + where: { projectId: original.projectId, name: null, users: { some: { id: userId } } }, + select: { id: true }, + }) + if (!me) throw new NotFoundError() + + // new tasks go to the end of their column + const nextOrder = new Map() + for (const task of tasks) { + if (!nextOrder.has(task.containerId)) { + nextOrder.set( + task.containerId, + await tx.task.count({ where: { containerId: task.containerId } }) + ) + } + const order = nextOrder.get(task.containerId)! + nextOrder.set(task.containerId, order + 1) + + await tx.task.create({ + data: { + name: task.name, + description: task.description, + deadline: task.deadline ?? undefined, + startDate: task.startDate ?? undefined, + tags: task.tags ?? undefined, + containerTaskOrder: order, + containerId: task.containerId, + projectId: original.projectId, + milestoneId: copy.id, + formVersionId: task.formVersionId ?? undefined, + elementId: task.elementId ?? undefined, + status: "NOT_COMPLETED", + autoAssignNew: task.autoAssignNew, + anonymous: task.anonymous, + anonymousResponses: task.anonymousResponses, + createdById: me.id, + roles: + task.roles.length > 0 + ? { connect: task.roles.map((role: { id: number }) => ({ id: role.id })) } + : undefined, + }, + }) + copiedTaskCount += 1 + } + } + + return { ...copy, copiedTaskCount } + }) + } +) diff --git a/src/milestones/schemas.ts b/src/milestones/schemas.ts index 8c7a3fdd..4ddecb67 100644 --- a/src/milestones/schemas.ts +++ b/src/milestones/schemas.ts @@ -69,3 +69,9 @@ export const UpdateMilestoneDatesSchema = z.object({ export const DeleteMilestoneSchema = z.object({ id: z.number(), }) + +export const CopyMilestoneSchema = z.object({ + id: z.number(), + // also copy the milestone's tasks (as new, unassigned tasks under the copy) + includeTasks: z.boolean().optional(), +}) diff --git a/src/pages/projects/[projectId]/milestones/[milestoneId].tsx b/src/pages/projects/[projectId]/milestones/[milestoneId].tsx index c51bd529..8504b02b 100644 --- a/src/pages/projects/[projectId]/milestones/[milestoneId].tsx +++ b/src/pages/projects/[projectId]/milestones/[milestoneId].tsx @@ -10,6 +10,7 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" import Link from "next/link" import UpdateTasksMilestone from "src/milestones/components/UpdateTasksMilestone" +import { CopyMilestoneButton } from "src/milestones/components/CopyMilestoneButton" import { useState } from "react" import getTasks from "src/tasks/queries/getTasks" import router from "next/router" @@ -108,6 +109,12 @@ const ShowMilestonePage = () => { tasks={updateTasks} /> + +