feat: add duplicate functionality for managed event types (#26792)
* feat: add duplicate functionality for managed event types Co-Authored-By: peer@cal.com <peer@cal.com> * feat: add disclaimer and redirect for managed event type duplication - Add info alert disclaimer in duplicate modal for managed event types - Pass schedulingType to duplicate dialog via query params - Redirect to assignment tab after duplicating managed event types - Add translation key for managed event type duplicate disclaimer Co-Authored-By: peer@cal.com <peer@cal.com> * test: add tests for managed event type duplication Co-Authored-By: peer@cal.com <peer@cal.com> * refactor: replace PrismaMock unit test with Prisma integration test for duplicate handler (#27973) * refactor: replace PrismaMock unit test with Prisma integration test for duplicate handler Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * fix: re-throw TRPCErrors in duplicate handler catch block and assert specific error codes in tests Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
This commit is contained in:
co-authored by
peer@cal.com
ali@cal.com
Devin AI
Hariom Balhara
Syed Ali Shahbaz
parent
8908a66c23
commit
eef47ddefd
@@ -15,6 +15,8 @@ import { md } from "@calcom/lib/markdownIt";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
@@ -32,6 +34,7 @@ const querySchema = z.object({
|
||||
pageSlug: z.string(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
parentId: z.coerce.number().optional().nullable(),
|
||||
schedulingType: z.nativeEnum(SchedulingType).optional().nullable(),
|
||||
});
|
||||
|
||||
const DuplicateDialog = () => {
|
||||
@@ -42,8 +45,9 @@ const DuplicateDialog = () => {
|
||||
const router = useRouter();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const {
|
||||
data: { pageSlug, slug, ...defaultValues },
|
||||
data: { pageSlug, slug, schedulingType, ...defaultValues },
|
||||
} = useTypedQuery(querySchema);
|
||||
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
|
||||
@@ -72,7 +76,11 @@ const DuplicateDialog = () => {
|
||||
|
||||
const duplicateMutation = trpc.viewer.eventTypesHeavy.duplicate.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace(`/event-types/${eventType.id}`);
|
||||
// For managed event types, redirect to the assignment tab so the user can add members
|
||||
const redirectUrl = isManagedEventType
|
||||
? `/event-types/${eventType.id}?tabName=team`
|
||||
: `/event-types/${eventType.id}`;
|
||||
await router.replace(redirectUrl);
|
||||
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
revalidateEventTypesList();
|
||||
@@ -117,7 +125,7 @@ const DuplicateDialog = () => {
|
||||
return (
|
||||
<Dialog
|
||||
name="duplicate"
|
||||
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug"]}>
|
||||
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug", "schedulingType"]}>
|
||||
<DialogContent type="creation" className="overflow-y-auto" title={t("duplicate_event_type")}>
|
||||
<Form
|
||||
form={form}
|
||||
@@ -125,6 +133,12 @@ const DuplicateDialog = () => {
|
||||
duplicateMutation.mutate(values);
|
||||
}}>
|
||||
<div className="-mt-2 stack-y-5">
|
||||
{isManagedEventType && (
|
||||
<Alert
|
||||
severity="info"
|
||||
message={t("managed_event_type_duplicate_disclaimer")}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label={t("title")}
|
||||
placeholder={t("quick_chat")}
|
||||
|
||||
@@ -432,14 +432,15 @@ export const InfiniteEventTypeList = ({
|
||||
if (value) newSearchParams.set(key, value.toString());
|
||||
if (value === null) newSearchParams.delete(key);
|
||||
}
|
||||
setParamsIfDefined("dialog", "duplicate");
|
||||
setParamsIfDefined("title", eventType.title);
|
||||
setParamsIfDefined("description", eventType.description);
|
||||
setParamsIfDefined("slug", eventType.slug);
|
||||
setParamsIfDefined("id", eventType.id);
|
||||
setParamsIfDefined("length", eventType.length);
|
||||
setParamsIfDefined("pageSlug", group.profile.slug);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
setParamsIfDefined("dialog", "duplicate");
|
||||
setParamsIfDefined("title", eventType.title);
|
||||
setParamsIfDefined("description", eventType.description);
|
||||
setParamsIfDefined("slug", eventType.slug);
|
||||
setParamsIfDefined("id", eventType.id);
|
||||
setParamsIfDefined("length", eventType.length);
|
||||
setParamsIfDefined("pageSlug", group.profile.slug);
|
||||
setParamsIfDefined("schedulingType", eventType.schedulingType);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
@@ -695,18 +696,16 @@ export const InfiniteEventTypeList = ({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
StartIcon="copy"
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
StartIcon="copy"
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
@@ -812,7 +811,7 @@ export const InfiniteEventTypeList = ({
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"time_left": "Time left",
|
||||
"event_type": "Event type",
|
||||
"duplicate_event_type": "Duplicate event type",
|
||||
"managed_event_type_duplicate_disclaimer": "User assignments are not copied when duplicating a managed event type. You will need to add team members manually after duplication.",
|
||||
"enter_meeting": "Enter meeting",
|
||||
"video_call_provider": "Video call provider",
|
||||
"meeting_id": "Meeting ID",
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { EventType, Team, User } from "@calcom/prisma/client";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { duplicateHandler } from "./duplicate.handler";
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
function createCtx(user: User) {
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
profile: {
|
||||
id: null,
|
||||
},
|
||||
} as NonNullable<TrpcSessionUser>,
|
||||
};
|
||||
}
|
||||
|
||||
let user1: User;
|
||||
let user2: User;
|
||||
let user3: User;
|
||||
let team1: Team;
|
||||
let individualEventType: EventType;
|
||||
let managedParentEventType: EventType;
|
||||
const duplicatedEventTypeIds: number[] = [];
|
||||
|
||||
describe("duplicateHandler - integration", () => {
|
||||
beforeAll(async () => {
|
||||
user1 = await prisma.user.create({
|
||||
data: {
|
||||
username: `dup-test-user1-${timestamp}`,
|
||||
email: `dup-test-user1-${timestamp}@example.com`,
|
||||
name: "Dup Test User 1",
|
||||
},
|
||||
});
|
||||
|
||||
user2 = await prisma.user.create({
|
||||
data: {
|
||||
username: `dup-test-user2-${timestamp}`,
|
||||
email: `dup-test-user2-${timestamp}@example.com`,
|
||||
name: "Dup Test User 2",
|
||||
},
|
||||
});
|
||||
|
||||
user3 = await prisma.user.create({
|
||||
data: {
|
||||
username: `dup-test-user3-${timestamp}`,
|
||||
email: `dup-test-user3-${timestamp}@example.com`,
|
||||
name: "Dup Test User 3",
|
||||
},
|
||||
});
|
||||
|
||||
team1 = await prisma.team.create({
|
||||
data: {
|
||||
name: `Dup Test Team ${timestamp}`,
|
||||
slug: `dup-test-team-${timestamp}`,
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{ userId: user1.id, role: MembershipRole.ADMIN, accepted: true },
|
||||
{ userId: user2.id, role: MembershipRole.MEMBER, accepted: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
individualEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Individual Event",
|
||||
slug: `dup-individual-${timestamp}`,
|
||||
length: 30,
|
||||
userId: user1.id,
|
||||
},
|
||||
});
|
||||
|
||||
managedParentEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Managed Parent Event",
|
||||
slug: `dup-managed-parent-${timestamp}`,
|
||||
length: 45,
|
||||
teamId: team1.id,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
hosts: {
|
||||
create: {
|
||||
userId: user1.id,
|
||||
isFixed: false,
|
||||
priority: 2,
|
||||
weight: 100,
|
||||
weightAdjustment: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (duplicatedEventTypeIds.length > 0) {
|
||||
await prisma.host.deleteMany({ where: { eventTypeId: { in: duplicatedEventTypeIds } } });
|
||||
await prisma.eventType.deleteMany({ where: { id: { in: duplicatedEventTypeIds } } });
|
||||
}
|
||||
|
||||
const sourceIds = [individualEventType?.id, managedParentEventType?.id].filter(Boolean);
|
||||
if (sourceIds.length > 0) {
|
||||
await prisma.host.deleteMany({ where: { eventTypeId: { in: sourceIds } } });
|
||||
await prisma.eventType.deleteMany({ where: { id: { in: sourceIds } } });
|
||||
}
|
||||
|
||||
if (team1?.id) {
|
||||
await prisma.membership.deleteMany({ where: { teamId: team1.id } });
|
||||
await prisma.team.deleteMany({ where: { id: team1.id } });
|
||||
}
|
||||
|
||||
const userIds = [user1?.id, user2?.id, user3?.id].filter(Boolean);
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Test cleanup failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
it("should duplicate an individual event type", async () => {
|
||||
const result = await duplicateHandler({
|
||||
ctx: createCtx(user1),
|
||||
input: {
|
||||
id: individualEventType.id,
|
||||
slug: `dup-individual-copy-${timestamp}`,
|
||||
title: "Individual Event Copy",
|
||||
description: "A copy of the individual event",
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
|
||||
duplicatedEventTypeIds.push(result.eventType.id);
|
||||
|
||||
expect(result.eventType.id).not.toBe(individualEventType.id);
|
||||
expect(result.eventType.title).toBe("Individual Event Copy");
|
||||
expect(result.eventType.slug).toBe(`dup-individual-copy-${timestamp}`);
|
||||
expect(result.eventType.length).toBe(30);
|
||||
});
|
||||
|
||||
it("should preserve schedulingType MANAGED when duplicating", async () => {
|
||||
const result = await duplicateHandler({
|
||||
ctx: createCtx(user1),
|
||||
input: {
|
||||
id: managedParentEventType.id,
|
||||
slug: `dup-managed-copy-${timestamp}`,
|
||||
title: "Managed Event Copy",
|
||||
description: "A copy of managed event",
|
||||
length: 45,
|
||||
teamId: team1.id,
|
||||
},
|
||||
});
|
||||
|
||||
duplicatedEventTypeIds.push(result.eventType.id);
|
||||
|
||||
expect(result.eventType.schedulingType).toBe(SchedulingType.MANAGED);
|
||||
});
|
||||
|
||||
it("should copy hosts but not children when duplicating a managed event type", async () => {
|
||||
const result = await duplicateHandler({
|
||||
ctx: createCtx(user1),
|
||||
input: {
|
||||
id: managedParentEventType.id,
|
||||
slug: `dup-managed-hosts-${timestamp}`,
|
||||
title: "Managed Event Hosts Copy",
|
||||
description: "Copy to test hosts",
|
||||
length: 45,
|
||||
teamId: team1.id,
|
||||
},
|
||||
});
|
||||
|
||||
duplicatedEventTypeIds.push(result.eventType.id);
|
||||
|
||||
const duplicatedHosts = await prisma.host.findMany({
|
||||
where: { eventTypeId: result.eventType.id },
|
||||
});
|
||||
expect(duplicatedHosts.length).toBeGreaterThan(0);
|
||||
expect(duplicatedHosts[0].userId).toBe(user1.id);
|
||||
|
||||
const children = await prisma.eventType.findMany({
|
||||
where: { parentId: result.eventType.id },
|
||||
});
|
||||
expect(children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should set parentId to null for duplicated managed event type", async () => {
|
||||
const result = await duplicateHandler({
|
||||
ctx: createCtx(user1),
|
||||
input: {
|
||||
id: managedParentEventType.id,
|
||||
slug: `dup-managed-parentid-${timestamp}`,
|
||||
title: "Managed Event ParentId Check",
|
||||
description: "Check parentId is null",
|
||||
length: 45,
|
||||
teamId: team1.id,
|
||||
},
|
||||
});
|
||||
|
||||
duplicatedEventTypeIds.push(result.eventType.id);
|
||||
|
||||
const duplicated = await prisma.eventType.findUnique({
|
||||
where: { id: result.eventType.id },
|
||||
});
|
||||
expect(duplicated?.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow team members to duplicate managed event types", async () => {
|
||||
const result = await duplicateHandler({
|
||||
ctx: createCtx(user2),
|
||||
input: {
|
||||
id: managedParentEventType.id,
|
||||
slug: `dup-managed-member-${timestamp}`,
|
||||
title: "Managed Event Member Copy",
|
||||
description: "Duplicated by team member",
|
||||
length: 45,
|
||||
teamId: team1.id,
|
||||
},
|
||||
});
|
||||
|
||||
duplicatedEventTypeIds.push(result.eventType.id);
|
||||
|
||||
expect(result.eventType).toBeDefined();
|
||||
expect(result.eventType.schedulingType).toBe(SchedulingType.MANAGED);
|
||||
});
|
||||
|
||||
it("should throw FORBIDDEN when non-team member tries to duplicate managed event type", async () => {
|
||||
await expect(
|
||||
duplicateHandler({
|
||||
ctx: createCtx(user3),
|
||||
input: {
|
||||
id: managedParentEventType.id,
|
||||
slug: `dup-managed-nonmember-${timestamp}`,
|
||||
title: "Should Not Duplicate",
|
||||
description: "Non-member attempting to duplicate",
|
||||
length: 45,
|
||||
teamId: team1.id,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw CONFLICT when duplicating with an existing slug", async () => {
|
||||
const conflictSlug = `dup-slug-conflict-${timestamp}`;
|
||||
const existingEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Existing Event",
|
||||
slug: conflictSlug,
|
||||
length: 30,
|
||||
userId: user1.id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
duplicateHandler({
|
||||
ctx: createCtx(user1),
|
||||
input: {
|
||||
id: individualEventType.id,
|
||||
slug: conflictSlug,
|
||||
title: "Should Conflict",
|
||||
description: "Slug conflict test",
|
||||
length: 30,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
} finally {
|
||||
await prisma.eventType.delete({ where: { id: existingEventType.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,9 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
|
||||
eventType: newEventType,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
if (Array.isArray(error.meta?.target) && error.meta?.target.includes("slug")) {
|
||||
throw new TRPCError({
|
||||
|
||||
Reference in New Issue
Block a user