diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index d48769e59f..233c77a057 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -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} @@ -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} diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index 1fa6caea34..662e18cf00 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -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"); diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 5f18c573d6..84128d6f96 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -247,7 +247,7 @@ const createUser = async ( opts?: CustomUserOpts | null ): Promise => { // 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, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9e8a7d3fbb..105bb1aa01 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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" } diff --git a/packages/features/eventtypes/components/CreateEventTypeButton.tsx b/packages/features/eventtypes/components/CreateEventTypeButton.tsx index 6e11a8d1ab..bdec4e63cc 100644 --- a/packages/features/eventtypes/components/CreateEventTypeButton.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeButton.tsx @@ -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 ( - + <> {!hasTeams || props.isIndividualTeam ? ( - - - - - +
+ + +
+ + + + )} + ); } diff --git a/packages/features/eventtypes/components/DuplicateDialog.tsx b/packages/features/eventtypes/components/DuplicateDialog.tsx new file mode 100644 index 0000000000..35e37b5eda --- /dev/null +++ b/packages/features/eventtypes/components/DuplicateDialog.tsx @@ -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 ( + + +
{ + duplicateMutation.mutate(values); + }}> +
+ { + 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 ? ( + /{pageSlug}/} + {...register("slug")} + onChange={(e) => { + form.setValue("slug", slugify(e?.target.value), { shouldTouch: true }); + }} + /> + ) : ( + + {process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/ + + } + {...register("slug")} + /> + )} + + + +
+ +
+
+
+ + +
+
+
+
+ ); +}; + +export { DuplicateDialog }; diff --git a/packages/trpc/server/routers/viewer/eventTypes.tsx b/packages/trpc/server/routers/viewer/eventTypes.tsx index 261984aeb9..95838c762f 100644 --- a/packages/trpc/server/routers/viewer/eventTypes.tsx +++ b/packages/trpc/server/routers/viewer/eventTypes.tsx @@ -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, + }; + }), }); diff --git a/packages/ui/v2/core/Dialog.tsx b/packages/ui/v2/core/Dialog.tsx index 57e441606b..8b296659b1 100644 --- a/packages/ui/v2/core/Dialog.tsx +++ b/packages/ui/v2/core/Dialog.tsx @@ -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";