From 975fb21d86857a68d648594abc32ff9dace21ad0 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Thu, 18 Dec 2025 12:24:41 -0300 Subject: [PATCH] feat(atoms): add ListEventTypes atom to enable event type management (#25047) (#25113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ListEventTypes atom (#25047) * feat: add ListEventTypes atom for Platform (#25047) - Add EventTypeListItem component in @calcom/features - Add ListEventTypesPlatformWrapper with delete functionality - Implement useAtomGetAllEventTypes hook - Update backend to include slug, description, length - Add 11 unit tests (65% coverage) - Add 3 i18n keys No breaking changes. Backward compatible. * refactor(eventtypes): improve EventTypeListItem readability - Extract EventTypeContent and EventTypeActions sub-components - Add formatEventTypeDuration helper (90min → '1h 30m') - Use Badge for duration display (consistency with EventTypeDescription) - Use polymorphic Wrapper component (DRY) - Add comprehensive tests - Reduce main component size Addresses @volnei feedback * refactor: replace window.confirm with ConfirmationDialog - Replace native window.confirm with ConfirmationDialogContent - Update test mocks for Dialog components - Fix HTML nesting (div instead of p for Badge container) - Follows Cal.com pattern from AvailabilitySettings Addresses @ThyMinimalDev feedback * fix: add aria-label to options button for accessibility * test: update getBulkEventTypes test to include new fields (description, slug, length) * refactor: move EventTypeListItem to atoms package --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- apps/web/public/static/locales/en/common.json | 3 + .../_utils/getBulkEventTypes.test.ts | 3 + .../app-store/_utils/getBulkEventTypes.ts | 6 + .../__tests__/EventTypeListItem.test.tsx | 194 ++++++++++++++++++ .../__tests__/formatEventTypeDuration.test.ts | 47 +++++ .../components/EventTypeListItem.tsx | 151 ++++++++++++++ .../hooks/useAtomGetAllEventTypes.ts | 30 +++ packages/platform/atoms/event-types/index.ts | 1 + .../lib/formatEventTypeDuration.ts | 25 +++ packages/platform/atoms/event-types/types.ts | 16 ++ .../ListEventTypesPlatformWrapper.tsx | 87 ++++++++ packages/platform/atoms/index.ts | 2 + 12 files changed, 565 insertions(+) create mode 100644 packages/platform/atoms/event-types/__tests__/EventTypeListItem.test.tsx create mode 100644 packages/platform/atoms/event-types/__tests__/formatEventTypeDuration.test.ts create mode 100644 packages/platform/atoms/event-types/components/EventTypeListItem.tsx create mode 100644 packages/platform/atoms/event-types/hooks/useAtomGetAllEventTypes.ts create mode 100644 packages/platform/atoms/event-types/index.ts create mode 100644 packages/platform/atoms/event-types/lib/formatEventTypeDuration.ts create mode 100644 packages/platform/atoms/event-types/types.ts create mode 100644 packages/platform/atoms/event-types/wrappers/ListEventTypesPlatformWrapper.tsx diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6426a56d10..e8bd920484 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4063,6 +4063,9 @@ "no_available_users_found_input": "No available users found", "repeats_num_times": "Repeats {{count}} times", "view_booking_details": "View Booking Details", + "cannot_delete_event_type": "This event type cannot be deleted", + "delete_event_type_confirmation": "Are you sure you want to delete this event type?", + "error_loading_event_types": "Error loading event types", "recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences", "team_bio": "Team Bio", "team_bio_placeholder": "Tell us about your team...", diff --git a/packages/app-store/_utils/getBulkEventTypes.test.ts b/packages/app-store/_utils/getBulkEventTypes.test.ts index c5af114a89..77b0b96790 100644 --- a/packages/app-store/_utils/getBulkEventTypes.test.ts +++ b/packages/app-store/_utils/getBulkEventTypes.test.ts @@ -265,6 +265,9 @@ describe("getBulkUserEventTypes", () => { select: { id: true, title: true, + description: true, + length: true, + slug: true, locations: true, metadata: true, parentId: true, diff --git a/packages/app-store/_utils/getBulkEventTypes.ts b/packages/app-store/_utils/getBulkEventTypes.ts index 9876d33466..d38b893e2f 100644 --- a/packages/app-store/_utils/getBulkEventTypes.ts +++ b/packages/app-store/_utils/getBulkEventTypes.ts @@ -40,6 +40,9 @@ export const getBulkUserEventTypes = async (userId: number) => { select: { id: true, title: true, + slug: true, + description: true, + length: true, locations: true, metadata: true, parentId: true, @@ -65,6 +68,9 @@ export const getBulkTeamEventTypes = async (teamId: number) => { select: { id: true, title: true, + slug: true, + description: true, + length: true, locations: true, }, }); diff --git a/packages/platform/atoms/event-types/__tests__/EventTypeListItem.test.tsx b/packages/platform/atoms/event-types/__tests__/EventTypeListItem.test.tsx new file mode 100644 index 0000000000..f70f780a6f --- /dev/null +++ b/packages/platform/atoms/event-types/__tests__/EventTypeListItem.test.tsx @@ -0,0 +1,194 @@ +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; + +import type { AtomEventTypeListItem } from "../types"; +import { EventTypeListItem } from "../components/EventTypeListItem"; + +// Mock auto-animate +vi.mock("@formkit/auto-animate/react", () => ({ + useAutoAnimate: () => [null], +})); + +// Mock Next.js Link +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, +})); + +// Mock useLocale +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => key, + }), +})); + +// Mock toast +vi.mock("@calcom/ui/components/toast", () => ({ + showToast: vi.fn(), +})); + +// Mock dialog components +vi.mock("@calcom/ui/components/dialog", () => ({ + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + ConfirmationDialogContent: ({ + children, + onConfirm, + }: { + children: React.ReactNode; + onConfirm: () => void; + }) => ( +
+ {children} + +
+ ), +})); + +describe("EventTypeListItem", () => { + const mockEventType: AtomEventTypeListItem = { + id: 1, + title: "30 Min Meeting", + slug: "30min", + description: "Quick meeting", + length: 30, + locations: null, + logo: "/test-logo.svg", + }; + + const mockDeleteFunction = vi.fn(); + + const renderComponent = ({ + eventType = mockEventType, + deleteFunction = mockDeleteFunction, + isDeletable = true, + getEventTypeUrl, + }: { + eventType?: typeof mockEventType; + deleteFunction?: typeof mockDeleteFunction; + isDeletable?: boolean; + getEventTypeUrl?: (id: number) => string; + } = {}) => { + return render( + + ); + }; + + it("should render event type details correctly", () => { + renderComponent(); + + expect(screen.getByText("30 Min Meeting")).toBeInTheDocument(); + expect(screen.getByText("Quick meeting")).toBeInTheDocument(); + expect(screen.getByText(/30m/)).toBeInTheDocument(); + }); + + it("should render as link when getEventTypeUrl is provided", () => { + const getEventTypeUrl = (id: number) => `/event-types/${id}`; + renderComponent({ getEventTypeUrl }); + + const link = screen.getByRole("link", { name: /30 Min Meeting/i }); + expect(link).toHaveAttribute("href", "/event-types/1"); + }); + + it("should render without link when getEventTypeUrl is not provided", () => { + renderComponent(); + + const links = screen.queryAllByRole("link"); + expect(links.length).toBe(0); + }); + + it("should display event type without description when description is null", () => { + renderComponent({ + eventType: { ...mockEventType, description: null }, + }); + + expect(screen.getByText("30 Min Meeting")).toBeInTheDocument(); + expect(screen.queryByText("Quick meeting")).not.toBeInTheDocument(); + expect(screen.getByText(/30m/)).toBeInTheDocument(); + }); + + it("should render dropdown menu trigger", () => { + renderComponent(); + + const dropdownTrigger = screen.getByTestId("event-type-options-1"); + expect(dropdownTrigger).toBeInTheDocument(); + }); + + it("should render with unique data-testid based on event type ID", () => { + const customEventType = { + ...mockEventType, + id: 999, + }; + + renderComponent({ eventType: customEventType }); + + const dropdownTrigger = screen.getByTestId("event-type-options-999"); + expect(dropdownTrigger).toBeInTheDocument(); + }); + + it("should display different event durations correctly", () => { + const shortEvent = { ...mockEventType, length: 15 }; + const { rerender } = renderComponent({ eventType: shortEvent }); + + expect(screen.getByText(/15m/)).toBeInTheDocument(); + + const longEvent = { ...mockEventType, length: 120 }; + rerender( + + ); + + expect(screen.getByText(/2h/)).toBeInTheDocument(); + }); + + it("should render event type with long title without breaking layout", () => { + const longTitleEvent = { + ...mockEventType, + title: "This is a very long event type title that should be truncated properly in the UI", + }; + + renderComponent({ eventType: longTitleEvent }); + + expect( + screen.getByText("This is a very long event type title that should be truncated properly in the UI") + ).toBeInTheDocument(); + expect(screen.getByText("Quick meeting")).toBeInTheDocument(); + }); + + it("should render with locations as null", () => { + const eventType: AtomEventTypeListItem = { + ...mockEventType, + locations: null, + }; + + renderComponent({ eventType }); + expect(screen.getByText("30 Min Meeting")).toBeInTheDocument(); + }); + + it("should render without logo", () => { + const eventType: AtomEventTypeListItem = { + ...mockEventType, + logo: undefined, + }; + + renderComponent({ eventType }); + expect(screen.getByText("30 Min Meeting")).toBeInTheDocument(); + }); + + it("should render with locations object", () => { + const eventType: AtomEventTypeListItem = { + ...mockEventType, + locations: [{ type: "integrations:zoom" }], + logo: "/app-store/zoomvideo/icon.svg", + }; + + renderComponent({ eventType }); + expect(screen.getByText("30 Min Meeting")).toBeInTheDocument(); + }); +}); diff --git a/packages/platform/atoms/event-types/__tests__/formatEventTypeDuration.test.ts b/packages/platform/atoms/event-types/__tests__/formatEventTypeDuration.test.ts new file mode 100644 index 0000000000..5e3128ebb3 --- /dev/null +++ b/packages/platform/atoms/event-types/__tests__/formatEventTypeDuration.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { formatEventTypeDuration } from "../lib/formatEventTypeDuration"; + +describe("formatEventTypeDuration", () => { + it("should format single digit minutes", () => { + expect(formatEventTypeDuration(5)).toBe("5m"); + }); + + it("should format double digit minutes", () => { + expect(formatEventTypeDuration(15)).toBe("15m"); + expect(formatEventTypeDuration(30)).toBe("30m"); + expect(formatEventTypeDuration(45)).toBe("45m"); + }); + + it("should format edge case at 59 minutes", () => { + expect(formatEventTypeDuration(59)).toBe("59m"); + }); + + it("should handle edge case at 61 minutes", () => { + expect(formatEventTypeDuration(61)).toBe("1h 1m"); + }); + + it("should format single hour", () => { + expect(formatEventTypeDuration(60)).toBe("1h"); + }); + + it("should format multiple hours", () => { + expect(formatEventTypeDuration(120)).toBe("2h"); + expect(formatEventTypeDuration(180)).toBe("3h"); + expect(formatEventTypeDuration(240)).toBe("4h"); + }); + + it("should format hours with minutes", () => { + expect(formatEventTypeDuration(75)).toBe("1h 15m"); + expect(formatEventTypeDuration(150)).toBe("2h 30m"); + expect(formatEventTypeDuration(195)).toBe("3h 15m"); + }); + + it("should handle 1 minute", () => { + expect(formatEventTypeDuration(1)).toBe("1m"); + }); + + it("should handle zero minutes", () => { + expect(formatEventTypeDuration(0)).toBe("0m"); + }); +}); diff --git a/packages/platform/atoms/event-types/components/EventTypeListItem.tsx b/packages/platform/atoms/event-types/components/EventTypeListItem.tsx new file mode 100644 index 0000000000..787efba10d --- /dev/null +++ b/packages/platform/atoms/event-types/components/EventTypeListItem.tsx @@ -0,0 +1,151 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, ConfirmationDialogContent } from "@calcom/ui/components/dialog"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; +import { showToast } from "@calcom/ui/components/toast"; + +import type { AtomEventTypeListItem } from "../types"; +import { formatEventTypeDuration } from "../lib/formatEventTypeDuration"; + +const EventTypeContent = ({ eventType }: { eventType: AtomEventTypeListItem }) => { + return ( +
+
+ {eventType.title} +
+
+ {eventType.description && {eventType.description}} + + {formatEventTypeDuration(eventType.length)} + +
+
+ ); +}; + +type EventTypeActionsProps = { + eventType: AtomEventTypeListItem; + isDeletable: boolean; + getEventTypeUrl?: (eventTypeId: number) => string; + deleteFunction: ({ eventTypeId }: { eventTypeId: number }) => void; +}; + +const EventTypeActions = ({ + eventType, + isDeletable, + getEventTypeUrl, + deleteFunction, +}: EventTypeActionsProps) => { + const { t } = useLocale(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + return ( + <> + + +