fix/duplicate-event-types (#5888)

* Add new modal for duplicate and trpc action for it

* Adding event type duplication validations

* Fix type errors and id not being removed from rest obj

* Fix test for duplicating event

* Fix test for duplicating event

* Add connect to event type workflows

* fix button for adding new events

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
pull/5976/head
alannnc 2022-12-10 15:48:26 -07:00 committed by GitHub
parent e7ff5508d7
commit c1e23bfdc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 407 additions and 155 deletions

View File

@ -177,22 +177,17 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}
// inject selection data into url for correct router history
const openModal = (group: EventTypeGroup, type: EventType) => {
const openDuplicateModal = (eventType: EventType) => {
const query = {
...router.query,
dialog: "new-eventtype",
eventPage: group.profile.slug,
title: type.title,
slug: type.slug,
description: type.description,
length: type.length,
type: type.schedulingType,
teamId: group.teamId,
locations: encodeURIComponent(JSON.stringify(type.locations)),
dialog: "duplicate-event-type",
title: eventType.title,
description: eventType.description,
slug: eventType.slug,
id: eventType.id,
length: eventType.length,
};
if (!group.teamId) {
delete query.teamId;
}
router.push(
{
pathname: router.pathname,
@ -361,7 +356,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
type="button"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
onClick={() => openDuplicateModal(type)}>
{t("duplicate") as string}
</DropdownItem>
</DropdownMenuItem>
@ -469,7 +464,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
onClick={() => openDuplicateModal(type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>

View File

@ -8,10 +8,10 @@ import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types tests", () => {
test.describe("pro user", () => {
test.describe("user", () => {
test.beforeEach(async ({ page, users }) => {
const proUser = await users.create();
await proUser.login();
const user = await users.create();
await user.login();
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
@ -101,13 +101,13 @@ test.describe("Event Types tests", () => {
const params = new URLSearchParams(url);
expect(params.get("title")).toBe(firstTitle);
expect(params.get("slug")).toBe(firstSlug);
expect(params.get("slug")).toContain(firstSlug);
const formTitle = await page.inputValue("[name=title]");
const formSlug = await page.inputValue("[name=slug]");
expect(formTitle).toBe(firstTitle);
expect(formSlug).toBe(firstSlug);
expect(formSlug).toContain(firstSlug);
});
test("edit first event", async ({ page }) => {
const $eventTypes = page.locator("[data-testid=event-types] > li a");

View File

@ -247,7 +247,7 @@ const createUser = async (
opts?: CustomUserOpts | null
): Promise<PrismaType.UserCreateInput> => {
// build a unique name for our user
const uname = `${opts?.username}-${workerInfo.workerIndex}-${Date.now()}`;
const uname = `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
return {
username: uname,
name: opts?.name,

View File

@ -750,7 +750,7 @@
"toggle_calendars_conflict": "Toggle the calendars you want to check for conflicts to prevent double bookings.",
"select_destination_calendar": "Create events on",
"connect_additional_calendar": "Connect additional calendar",
"calendar_updated_successfully":"Calendar updated successfully",
"calendar_updated_successfully": "Calendar updated successfully",
"conferencing": "Conferencing",
"calendar": "Calendar",
"payments": "Payments",
@ -1389,9 +1389,6 @@
"attendee_email_workflow": "Attendee email",
"attendee_email_info": "The person booking's email",
"kbar_search_placeholder": "Type a command or search...",
"free_to_use_apps": "Free",
"enterprise_license": "This is an enterprise feature",
"enterprise_license_description": "To enable this feature, get a deployment key at {{consoleUrl}} console and add it to your .env as CALCOM_LICENSE_KEY. If your team already has a license, please contact {{supportMail}} for help.",
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
@ -1412,7 +1409,7 @@
"add_one_fixed_attendee": "Add one fixed attendee and round robin through a number of attendees.",
"calcom_is_better_with_team": "Cal.com is better with teams",
"add_your_team_members": "Add your team members to your event types. Use collective scheduling to include everyone or find the most suitable person with round robin scheduling.",
"booking_limit_reached":"Booking Limit for this event type has been reached",
"booking_limit_reached": "Booking Limit for this event type has been reached",
"admin_has_disabled": "An admin has disabled {{appName}}",
"disabled_app_affects_event_type": "An admin has disabled {{appName}} which affects your event type {{eventType}}",
"disable_payment_app": "The admin has disabled {{appName}} which affects your event type {{title}}. Attendees are still able to book this type of event but will not be prompted to pay. You may hide hide the event type to prevent this until your admin renables your payment method.",
@ -1440,6 +1437,6 @@
"enter_option": "Enter Option {{index}}",
"add_an_option": "Add an option",
"radio": "Radio",
"kbar_search_placeholder" : "Start typing to search",
"event_type_duplicate_copy_text": "{{slug}}-copy",
"set_as_default": "Set as default"
}

View File

@ -34,6 +34,8 @@ import {
TextField,
} from "@calcom/ui";
import { DuplicateDialog } from "./DuplicateDialog";
// this describes the uniform data needed to create a new event type on Profile or Team
export interface EventTypeParent {
teamId: number | null | undefined; // if undefined, then it's a profile
@ -149,18 +151,7 @@ export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
};
return (
<Dialog
name="new-eventtype"
clearQueryParamsOnClose={[
"eventPage",
"teamId",
"type",
"description",
"title",
"length",
"slug",
"locations",
]}>
<>
{!hasTeams || props.isIndividualTeam ? (
<Button
onClick={() => openModal(props.options[0])}
@ -200,124 +191,140 @@ export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
</DropdownMenuContent>
</Dropdown>
)}
<DialogContent
type="creation"
className="overflow-y-auto"
title={teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
description={t("new_event_type_to_book_description")}>
<Form
form={form}
handleSubmit={(values) => {
createMutation.mutate(values);
}}>
<div className="mt-3 space-y-6">
{teamId && (
<TextField
type="hidden"
labelProps={{ style: { display: "none" } }}
{...register("teamId", { valueAsNumber: true })}
value={teamId}
/>
)}
<TextField
label={t("title")}
placeholder={t("quick_chat")}
{...register("title")}
onChange={(e) => {
form.setValue("title", e?.target.value);
if (form.formState.touchedFields["slug"] === undefined) {
form.setValue("slug", slugify(e?.target.value));
}
}}
/>
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{pageSlug}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
) : (
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</>
}
{...register("slug")}
/>
)}
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-20"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
{t("scheduling_type")}
</label>
{form.formState.errors.schedulingType && (
<Alert
className="mt-1"
severity="error"
message={form.formState.errors.schedulingType.message}
{/* Dialog for duplicate event type */}
{router.query.dialog === "duplicate-event-type" && <DuplicateDialog />}
{router.query.dialog === "new-eventtype" && (
<Dialog
name="new-eventtype"
clearQueryParamsOnClose={[
"eventPage",
"teamId",
"type",
"description",
"title",
"length",
"slug",
"locations",
]}>
<DialogContent
type="creation"
className="overflow-y-auto"
title={teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
description={t("new_event_type_to_book_description")}>
<Form
form={form}
handleSubmit={(values) => {
createMutation.mutate(values);
}}>
<div className="mt-3 space-y-6">
{teamId && (
<TextField
type="hidden"
labelProps={{ style: { display: "none" } }}
{...register("teamId", { valueAsNumber: true })}
value={teamId}
/>
)}
<RadioArea.Group
{...register("schedulingType")}
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
<RadioArea.Item
value={SchedulingType.COLLECTIVE}
defaultChecked={type === SchedulingType.COLLECTIVE}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value={SchedulingType.ROUND_ROBIN}
defaultChecked={type === SchedulingType.ROUND_ROBIN}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
<TextField
label={t("title")}
placeholder={t("quick_chat")}
{...register("title")}
onChange={(e) => {
form.setValue("title", e?.target.value);
if (form.formState.touchedFields["slug"] === undefined) {
form.setValue("slug", slugify(e?.target.value));
}
}}
/>
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{pageSlug}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
) : (
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</>
}
{...register("slug")}
/>
)}
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-20"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
{t("scheduling_type")}
</label>
{form.formState.errors.schedulingType && (
<Alert
className="mt-1"
severity="error"
message={form.formState.errors.schedulingType.message}
/>
)}
<RadioArea.Group
{...register("schedulingType")}
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
<RadioArea.Item
value={SchedulingType.COLLECTIVE}
defaultChecked={type === SchedulingType.COLLECTIVE}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value={SchedulingType.ROUND_ROBIN}
defaultChecked={type === SchedulingType.ROUND_ROBIN}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
)}
</div>
)}
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose />
</div>
</Form>
</DialogContent>
</Dialog>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose />
</div>
</Form>
</DialogContent>
</Dialog>
)}
</>
);
}

View File

@ -0,0 +1,137 @@
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
Form,
showToast,
TextAreaField,
TextField,
} from "@calcom/ui";
const DuplicateDialog = () => {
const { t } = useLocale();
const router = useRouter();
// react hook form
const form = useForm({
defaultValues: {
id: Number(router.query.id as string) || -1,
title: (router.query.title as string) || "",
slug: t("event_type_duplicate_copy_text", { slug: router.query.slug as string }),
description: (router.query.description as string) || "",
length: Number(router.query.length) || 30,
},
});
const { register } = form;
const duplicateMutation = trpc.viewer.eventTypes.duplicate.useMutation({
onSuccess: async ({ eventType }) => {
await router.replace("/event-types/" + eventType.id);
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "BAD_REQUEST") {
const message = `${err.data.code}: URL already exists.`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not able to create this event`;
showToast(message, "error");
}
},
});
const pageSlug = router.query.eventPage;
return (
<Dialog
name="duplicate-event-type"
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id"]}>
<DialogContent type="creation" className="overflow-y-auto" title="Duplicate Event Type">
<Form
form={form}
handleSubmit={(values) => {
duplicateMutation.mutate(values);
}}>
<div className="mt-3 space-y-6">
<TextField
label={t("title")}
placeholder={t("quick_chat")}
{...register("title")}
onChange={(e) => {
form.setValue("title", e?.target.value);
if (form.formState.touchedFields["slug"] === undefined) {
form.setValue("slug", slugify(e?.target.value));
}
}}
/>
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{pageSlug}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
) : (
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</>
}
{...register("slug")}
/>
)}
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-20"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit" loading={duplicateMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose />
</div>
</Form>
</DialogContent>
</Dialog>
);
};
export { DuplicateDialog };

View File

@ -99,6 +99,14 @@ const EventTypeUpdateInput = _EventTypeModel
})
);
const EventTypeDuplicateInput = z.object({
id: z.number(),
slug: z.string(),
title: z.string(),
description: z.string(),
length: z.number(),
});
const eventOwnerProcedure = authedProcedure.use(async ({ ctx, rawInput, next }) => {
// Prevent non-owners to update/delete a team event
const event = await ctx.prisma.eventType.findUnique({
@ -598,4 +606,112 @@ export const eventTypesRouter = router({
id,
};
}),
duplicate: eventOwnerProcedure.input(EventTypeDuplicateInput.strict()).mutation(async ({ ctx, input }) => {
const { id: originalEventTypeId, title: newEventTitle, slug: newSlug } = input;
const eventType = await ctx.prisma.eventType.findUnique({
where: {
id: originalEventTypeId,
},
include: {
customInputs: true,
schedule: true,
users: true,
team: true,
workflows: true,
webhooks: true,
},
});
if (!eventType) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Validate user is owner of event type or in the team
if (eventType.userId !== ctx.user.id) {
if (eventType.teamId) {
const isMember = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: eventType.teamId,
},
});
if (!isMember) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
throw new TRPCError({ code: "FORBIDDEN" });
}
const {
customInputs,
users,
locations,
team,
recurringEvent,
bookingLimits,
metadata,
workflows,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
id: _id,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
webhooks: _webhooks,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
schedule: _schedule,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - not typed correctly as its set on SSR
descriptionAsSafeHTML: _descriptionAsSafeHTML,
...rest
} = eventType;
const data: Prisma.EventTypeCreateInput = {
...rest,
title: newEventTitle,
slug: newSlug,
locations: locations ?? undefined,
team: team ? { connect: { id: team.id } } : undefined,
users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined,
recurringEvent: recurringEvent || undefined,
bookingLimits: bookingLimits ?? undefined,
metadata: metadata === null ? Prisma.DbNull : metadata,
};
const newEventType = await ctx.prisma.eventType.create({ data });
// Create custom inputs
if (customInputs) {
const customInputsData = customInputs.map((customInput) => {
const { id: _, options, ...rest } = customInput;
return {
options: options ?? undefined,
...rest,
eventTypeId: newEventType.id,
};
});
await ctx.prisma.eventTypeCustomInput.createMany({
data: customInputsData,
});
}
if (workflows.length > 0) {
const workflowIds = workflows.map((workflow) => {
return { id: workflow.workflowId };
});
const eventUpdateData: Prisma.EventTypeUpdateInput = {
workflows: {
connect: workflowIds,
},
};
await ctx.prisma.eventType.update({
where: {
id: newEventType.id,
},
data: eventUpdateData,
});
}
return {
eventType: newEventType,
};
}),
});

View File

@ -1,6 +1,6 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useRouter } from "next/router";
import React, { forwardRef, HTMLProps, ReactNode, useState } from "react";
import React, { ReactNode, useState } from "react";
import { Icon } from "react-feather";
import classNames from "@calcom/lib/classNames";