From ae104a72213116284ea315779d474dec3ce50e7f Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 9 Oct 2026 02:12:13 +0200 Subject: [PATCH 1/2] Add a Copy Milestone button (#533) Co-Authored-By: Claude Sonnet 5.5 --- src/core/utils/mutationAccess.test.ts | 5 ++ .../components/CopyMilestoneButton.test.tsx | 50 +++++++++++ .../components/CopyMilestoneButton.tsx | 35 ++++++++ .../mutations/copyMilestone.test.ts | 85 +++++++++++++++++++ src/milestones/mutations/copyMilestone.ts | 28 ++++++ src/milestones/schemas.ts | 4 + .../[projectId]/milestones/[milestoneId].tsx | 3 + 7 files changed, 210 insertions(+) create mode 100644 src/milestones/components/CopyMilestoneButton.test.tsx create mode 100644 src/milestones/components/CopyMilestoneButton.tsx create mode 100644 src/milestones/mutations/copyMilestone.test.ts create mode 100644 src/milestones/mutations/copyMilestone.ts diff --git a/src/core/utils/mutationAccess.test.ts b/src/core/utils/mutationAccess.test.ts index 1cce07f8..8f4689b4 100644 --- a/src/core/utils/mutationAccess.test.ts +++ b/src/core/utils/mutationAccess.test.ts @@ -114,6 +114,11 @@ 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: "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..623d41c6 --- /dev/null +++ b/src/milestones/components/CopyMilestoneButton.test.tsx @@ -0,0 +1,50 @@ +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" + +describe("CopyMilestoneButton", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "error").mockImplementation(() => undefined) + }) + afterEach(cleanup) + + test("copies this milestone and opens the copy", async () => { + copyFn.mockResolvedValue({ id: 42 }) + render() + fireEvent.click(screen.getByRole("button", { name: "Copy Milestone" })) + + await waitFor(() => expect(push).toHaveBeenCalledWith("/projects/3/milestones/42")) + expect(copyFn).toHaveBeenCalledWith({ id: 5 }) + }) + + test("stays on the page if the copy fails", async () => { + copyFn.mockRejectedValue(new Error("nope")) + render() + fireEvent.click(screen.getByRole("button", { name: "Copy Milestone" })) + + 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..579af6a5 --- /dev/null +++ b/src/milestones/components/CopyMilestoneButton.tsx @@ -0,0 +1,35 @@ +import { useMutation } from "@blitzjs/rpc" +import { Routes } from "@blitzjs/next" +import { useRouter } from "next/router" +import toast from "react-hot-toast" +import copyMilestone from "../mutations/copyMilestone" + +type Props = { + milestoneId: number + projectId: number +} + +// Copies the milestone and opens the copy, ready to be renamed or adjusted +export const CopyMilestoneButton = ({ milestoneId, projectId }: Props) => { + const router = useRouter() + const [copyMilestoneMutation, { isLoading }] = useMutation(copyMilestone) + + const handleCopy = async () => { + try { + const copy = await toast.promise(copyMilestoneMutation({ id: milestoneId }), { + loading: "Copying milestone...", + success: "Milestone copied. Its tasks stay with the original.", + error: "Failed to copy the milestone...", + }) + await router.push(Routes.ShowMilestonePage({ projectId, milestoneId: copy.id })) + } catch (error) { + console.error(error) + } + } + + return ( + + ) +} diff --git a/src/milestones/mutations/copyMilestone.test.ts b/src/milestones/mutations/copyMilestone.test.ts new file mode 100644 index 00000000..b6637178 --- /dev/null +++ b/src/milestones/mutations/copyMilestone.test.ts @@ -0,0 +1,85 @@ +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 create = vi.fn() +vi.mock("db", () => ({ + default: { + projectPrivilege: { findMany: async () => privileges }, + milestone: { + findMany: (...a: unknown[]) => milestoneFindMany(...a), + findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrow(...a), + create: (...a: unknown[]) => create(...a), + }, + }, + MemberPrivileges: { PROJECT_MANAGER: "PROJECT_MANAGER", CONTRIBUTOR: "CONTRIBUTOR" }, +})) + +import copyMilestone from "./copyMilestone" + +const ctx: any = { session: { userId: 7, $isAuthorized: () => true, $authorize: () => undefined } } + +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"), + createdAt: new Date("2025-12-01"), + updatedAt: new Date("2025-12-02"), +} + +describe("copyMilestone", () => { + beforeEach(() => { + vi.clearAllMocks() + privileges = [{ projectId: 3, privilege: "PROJECT_MANAGER" }] + milestoneFindMany.mockResolvedValue([{ projectId: 3 }]) + findUniqueOrThrow.mockResolvedValue(original) + create.mockImplementation(async ({ data }: any) => ({ id: 6, ...data })) + }) + + test("copies the details into the same project, marked as a copy, without its tasks", async () => { + const copy = await copyMilestone({ id: 5 }, ctx) + expect(create).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.id).toBe(6) + // nothing about tasks is passed on: they stay with the original + expect(create.mock.calls[0]![0].data).not.toHaveProperty("task") + }) + + test("copes with a milestone that has no description, tags or dates", async () => { + findUniqueOrThrow.mockResolvedValue({ + ...original, + description: null, + tags: null, + startDate: null, + endDate: null, + }) + await copyMilestone({ id: 5 }, ctx) + const { data } = create.mock.calls[0]![0] + expect(data.description).toBeNull() + expect(data.tags).toBeUndefined() + expect(data.startDate).toBeUndefined() + expect(data.endDate).toBeUndefined() + }) + + test("only a project manager of that project may copy it", async () => { + for (const mine of [[], [{ projectId: 3, privilege: "CONTRIBUTOR" }]]) { + privileges = mine + await expect(copyMilestone({ id: 5 }, ctx)).rejects.toThrow() + } + expect(create).not.toHaveBeenCalled() + }) +}) diff --git a/src/milestones/mutations/copyMilestone.ts b/src/milestones/mutations/copyMilestone.ts new file mode 100644 index 00000000..0480a3ed --- /dev/null +++ b/src/milestones/mutations/copyMilestone.ts @@ -0,0 +1,28 @@ +import { resolver } from "@blitzjs/rpc" +import db from "db" +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. Its tasks stay with the original, because a task can belong to only one milestone; +// they can be moved across afterwards with "Update Tasks". +export default resolver.pipe( + resolver.zod(CopyMilestoneSchema), + resolver.authorize(), + async ({ id }, ctx) => { + await requireManagerOf(ctx, "milestone", [id]) + + const original = await db.milestone.findUniqueOrThrow({ where: { id } }) + + return db.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, + }, + }) + } +) diff --git a/src/milestones/schemas.ts b/src/milestones/schemas.ts index 8c7a3fdd..d8d4a476 100644 --- a/src/milestones/schemas.ts +++ b/src/milestones/schemas.ts @@ -69,3 +69,7 @@ export const UpdateMilestoneDatesSchema = z.object({ export const DeleteMilestoneSchema = z.object({ id: z.number(), }) + +export const CopyMilestoneSchema = z.object({ + id: z.number(), +}) diff --git a/src/pages/projects/[projectId]/milestones/[milestoneId].tsx b/src/pages/projects/[projectId]/milestones/[milestoneId].tsx index c51bd529..34eeb181 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,8 @@ const ShowMilestonePage = () => { tasks={updateTasks} /> + + From 40d9927ef5486535ea45ce7c1599f16ce8c87a7b Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 9 Oct 2026 02:14:23 +0200 Subject: [PATCH 2/2] Let a milestone be copied on its own or together with its tasks (#533) Co-Authored-By: Claude Sonnet 5.5 --- src/core/utils/mutationAccess.test.ts | 5 + .../components/CopyMilestoneButton.test.tsx | 55 +++++-- .../components/CopyMilestoneButton.tsx | 67 +++++++-- .../mutations/copyMilestone.test.ts | 134 ++++++++++++++---- src/milestones/mutations/copyMilestone.ts | 92 ++++++++++-- src/milestones/schemas.ts | 2 + .../[projectId]/milestones/[milestoneId].tsx | 6 +- 7 files changed, 305 insertions(+), 56 deletions(-) diff --git a/src/core/utils/mutationAccess.test.ts b/src/core/utils/mutationAccess.test.ts index 8f4689b4..c53d19ef 100644 --- a/src/core/utils/mutationAccess.test.ts +++ b/src/core/utils/mutationAccess.test.ts @@ -119,6 +119,11 @@ const CASES: Case[] = [ 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 index 623d41c6..c8e5c0d4 100644 --- a/src/milestones/components/CopyMilestoneButton.test.tsx +++ b/src/milestones/components/CopyMilestoneButton.test.tsx @@ -23,27 +23,66 @@ 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("copies this milestone and opens the copy", async () => { - copyFn.mockResolvedValue({ id: 42 }) - render() - fireEvent.click(screen.getByRole("button", { name: "Copy Milestone" })) + 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 }) + 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")) - render() - fireEvent.click(screen.getByRole("button", { name: "Copy Milestone" })) - + 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 index 579af6a5..4893f06d 100644 --- a/src/milestones/components/CopyMilestoneButton.tsx +++ b/src/milestones/components/CopyMilestoneButton.tsx @@ -1,26 +1,35 @@ +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 and opens the copy, ready to be renamed or adjusted -export const CopyMilestoneButton = ({ milestoneId, projectId }: Props) => { +// 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 () => { + const handleCopy = async (includeTasks: boolean) => { try { - const copy = await toast.promise(copyMilestoneMutation({ id: milestoneId }), { + const copy = await toast.promise(copyMilestoneMutation({ id: milestoneId, includeTasks }), { loading: "Copying milestone...", - success: "Milestone copied. Its tasks stay with the original.", + 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) @@ -28,8 +37,50 @@ export const CopyMilestoneButton = ({ milestoneId, projectId }: Props) => { } 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 index b6637178..176c5e64 100644 --- a/src/milestones/mutations/copyMilestone.test.ts +++ b/src/milestones/mutations/copyMilestone.test.ts @@ -4,23 +4,58 @@ import { beforeEach, describe, expect, test, vi } from "vitest" let privileges: Array<{ projectId: number; privilege: string }> = [] const milestoneFindMany = vi.fn() const findUniqueOrThrow = vi.fn() -const create = vi.fn() -vi.mock("db", () => ({ - default: { - projectPrivilege: { findMany: async () => privileges }, - milestone: { - findMany: (...a: unknown[]) => milestoneFindMany(...a), - findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrow(...a), - create: (...a: unknown[]) => create(...a), +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" }, -})) + 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", @@ -29,8 +64,7 @@ const original = { tags: [{ key: "a", value: "wave 1" }], startDate: new Date("2026-01-01"), endDate: new Date("2026-03-01"), - createdAt: new Date("2025-12-01"), - updatedAt: new Date("2025-12-02"), + task: [task(1, 10), task(2, 10), task(3, 11)], } describe("copyMilestone", () => { @@ -39,12 +73,15 @@ describe("copyMilestone", () => { privileges = [{ projectId: 3, privilege: "PROJECT_MANAGER" }] milestoneFindMany.mockResolvedValue([{ projectId: 3 }]) findUniqueOrThrow.mockResolvedValue(original) - create.mockImplementation(async ({ data }: any) => ({ id: 6, ...data })) + 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("copies the details into the same project, marked as a copy, without its tasks", async () => { + test("by default copies only the milestone's details, and leaves its tasks alone", async () => { const copy = await copyMilestone({ id: 5 }, ctx) - expect(create).toHaveBeenCalledWith({ + expect(milestoneCreate).toHaveBeenCalledWith({ data: { name: "Data collection (Copy)", description: "Collect the data", @@ -54,32 +91,77 @@ describe("copyMilestone", () => { endDate: original.endDate, }, }) - expect(copy.id).toBe(6) - // nothing about tasks is passed on: they stay with the original - expect(create.mock.calls[0]![0].data).not.toHaveProperty("task") + 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("copes with a milestone that has no description, tags or dates", async () => { + 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: [], }) - await copyMilestone({ id: 5 }, ctx) - const { data } = create.mock.calls[0]![0] + 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(data.endDate).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", async () => { + 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 - await expect(copyMilestone({ id: 5 }, ctx)).rejects.toThrow() + for (const includeTasks of [false, true]) { + await expect(copyMilestone({ id: 5, includeTasks }, ctx)).rejects.toThrow() + } } - expect(create).not.toHaveBeenCalled() + expect(milestoneCreate).not.toHaveBeenCalled() + expect(taskCreate).not.toHaveBeenCalled() }) }) diff --git a/src/milestones/mutations/copyMilestone.ts b/src/milestones/mutations/copyMilestone.ts index 0480a3ed..e4ad1202 100644 --- a/src/milestones/mutations/copyMilestone.ts +++ b/src/milestones/mutations/copyMilestone.ts @@ -1,28 +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. Its tasks stay with the original, because a task can belong to only one milestone; -// they can be moved across afterwards with "Update Tasks". +// 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 }, ctx) => { + 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 } }) + 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 db.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, - }, + return { ...copy, copiedTaskCount } }) } ) diff --git a/src/milestones/schemas.ts b/src/milestones/schemas.ts index d8d4a476..4ddecb67 100644 --- a/src/milestones/schemas.ts +++ b/src/milestones/schemas.ts @@ -72,4 +72,6 @@ export const DeleteMilestoneSchema = z.object({ 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 34eeb181..8504b02b 100644 --- a/src/pages/projects/[projectId]/milestones/[milestoneId].tsx +++ b/src/pages/projects/[projectId]/milestones/[milestoneId].tsx @@ -109,7 +109,11 @@ const ShowMilestonePage = () => { tasks={updateTasks} /> - +