feat(atoms): add ListEventTypes atom to enable event type management (#25047) (#25113)

* 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>
This commit is contained in:
Pedro Castro
2025-12-18 12:24:41 -03:00
committed by GitHub
co-authored by Morgan
parent b20b52a1f8
commit 975fb21d86
12 changed files with 565 additions and 0 deletions
@@ -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...",
@@ -265,6 +265,9 @@ describe("getBulkUserEventTypes", () => {
select: {
id: true,
title: true,
description: true,
length: true,
slug: true,
locations: true,
metadata: true,
parentId: true,
@@ -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,
},
});
@@ -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 }) => <a href={href}>{children}</a>,
}));
// 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 ? <div data-testid="dialog">{children}</div> : null,
ConfirmationDialogContent: ({
children,
onConfirm,
}: {
children: React.ReactNode;
onConfirm: () => void;
}) => (
<div data-testid="confirmation-dialog">
{children}
<button onClick={onConfirm} data-testid="confirm-delete">
Confirm
</button>
</div>
),
}));
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(
<EventTypeListItem
eventType={eventType}
deleteFunction={deleteFunction}
isDeletable={isDeletable}
getEventTypeUrl={getEventTypeUrl}
/>
);
};
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(
<EventTypeListItem eventType={longEvent} deleteFunction={mockDeleteFunction} isDeletable={true} />
);
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();
});
});
@@ -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");
});
});
@@ -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 (
<div>
<div className="space-x-2 rtl:space-x-reverse">
<span className="text-emphasis truncate font-medium">{eventType.title}</span>
</div>
<div className="text-subtle mt-1">
{eventType.description && <span className="block">{eventType.description}</span>}
<Badge variant="gray" className="text-xs">
{formatEventTypeDuration(eventType.length)}
</Badge>
</div>
</div>
);
};
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 (
<>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
data-testid={`event-type-options-${eventType.id}`}
type="button"
variant="icon"
color="secondary"
StartIcon="ellipsis"
aria-label={t("options")}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{getEventTypeUrl && (
<DropdownMenuItem className="min-w-40 focus:ring-muted">
<DropdownItem type="button" StartIcon="pencil" href={getEventTypeUrl(eventType.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="min-w-40 focus:ring-muted">
<DropdownItem
type="button"
color="destructive"
StartIcon="trash"
data-testid={`delete-event-type-${eventType.id}`}
className="rounded-t-none"
onClick={() => {
if (!isDeletable) {
showToast(t("cannot_delete_event_type"), "error");
return;
}
setIsDeleteDialogOpen(true);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_event_type")}
confirmBtnText={t("delete")}
onConfirm={() => {
deleteFunction({ eventTypeId: eventType.id });
setIsDeleteDialogOpen(false);
}}>
{t("delete_event_type_confirmation")}
</ConfirmationDialogContent>
</Dialog>
</>
);
};
type EventTypeListItemProps = {
eventType: AtomEventTypeListItem;
deleteFunction: ({ eventTypeId }: { eventTypeId: number }) => void;
isDeletable?: boolean;
getEventTypeUrl?: (eventTypeId: number) => string;
};
export function EventTypeListItem({
eventType,
deleteFunction,
isDeletable = true,
getEventTypeUrl,
}: EventTypeListItemProps) {
const Wrapper: React.ElementType = getEventTypeUrl ? Link : "div";
const wrapperProps = getEventTypeUrl
? {
href: getEventTypeUrl(eventType.id),
title: eventType.title,
}
: {};
return (
<li key={eventType.id}>
<div className="hover:bg-muted flex items-center justify-between px-3 py-5 transition sm:px-4">
<div className="group flex w-full items-center justify-between">
<Wrapper className="flex-grow truncate text-sm" {...wrapperProps}>
<EventTypeContent eventType={eventType} />
</Wrapper>
</div>
<EventTypeActions
eventType={eventType}
isDeletable={isDeletable}
getEventTypeUrl={getEventTypeUrl}
deleteFunction={deleteFunction}
/>
</div>
</li>
);
}
@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse } from "@calcom/platform-types";
import { useAtomsContext } from "../../hooks/useAtomsContext";
import http from "../../lib/http";
import type { AtomEventTypeListItem, AtomEventTypesResponse } from "../types";
export const QUERY_KEY = "use-atom-all-event-types";
export const useAtomGetAllEventTypes = () => {
const pathname = `/atoms/${V2_ENDPOINTS.eventTypes}`;
const { isInit, accessToken } = useAtomsContext();
const { isLoading, error, data, refetch } = useQuery<AtomEventTypeListItem[]>({
queryKey: [QUERY_KEY],
queryFn: () => {
return http.get<ApiResponse<AtomEventTypesResponse>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data.data.eventTypes;
}
throw new Error(res.data.error?.message);
});
},
enabled: isInit && !!accessToken,
});
return { isLoading, error, data, refetch };
};
@@ -0,0 +1 @@
export { ListEventTypesPlatformWrapper } from "./wrappers/ListEventTypesPlatformWrapper";
@@ -0,0 +1,25 @@
/**
* Formats event type duration in a human-readable format.
*
* @param minutes - Duration in minutes (always an integer for event types)
* @returns Formatted duration string (e.g., "30m", "1h", "1h 30m")
*
* @example
* formatEventTypeDuration(30) // "30m"
* formatEventTypeDuration(60) // "1h"
* formatEventTypeDuration(90) // "1h 30m"
*/
export function formatEventTypeDuration(minutes: number): string {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours}h`;
}
return `${hours}h ${remainingMinutes}m`;
}
@@ -0,0 +1,16 @@
/**
* Event type item as returned by the atoms API
*/
export type AtomEventTypeListItem = {
id: number;
title: string;
slug: string;
description: string | null;
length: number;
locations: unknown | null;
logo?: string;
};
export type AtomEventTypesResponse = {
eventTypes: AtomEventTypeListItem[];
};
@@ -0,0 +1,87 @@
import { AtomsWrapper } from "@/components/atoms-wrapper";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { EventTypeListItem } from "../components/EventTypeListItem";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { useDeleteEventTypeById } from "../../hooks/event-types/private/useDeleteEventTypeById";
import { useToast } from "../../src/components/ui/use-toast";
import { useAtomGetAllEventTypes } from "../hooks/useAtomGetAllEventTypes";
interface ListEventTypesPlatformWrapperProps {
getEventTypeUrl?: (eventTypeId: number) => string;
}
export const ListEventTypesPlatformWrapper = ({
getEventTypeUrl,
}: ListEventTypesPlatformWrapperProps = {}) => {
const [animationParentRef] = useAutoAnimate<HTMLUListElement>();
const { toast } = useToast();
const { t } = useLocale();
const {
data: eventTypes,
isLoading: isLoadingEventTypes,
error,
refetch: refetchEventTypes,
} = useAtomGetAllEventTypes();
const { mutate: deleteEventType } = useDeleteEventTypeById({
onSuccess: () => {
toast({
description: t("event_type_deleted_successfully"),
});
refetchEventTypes();
},
onError: (err) => {
toast({
description: err.message,
});
},
});
if (isLoadingEventTypes) {
return <>{t("loading")}</>;
}
if (error) {
return (
<EmptyScreen
Icon="alert-circle"
headline={t("error_loading_event_types")}
description={error.message}
className="w-full"
/>
);
}
if (!isLoadingEventTypes && (!eventTypes || eventTypes.length === 0)) {
return (
<EmptyScreen
Icon="calendar"
headline={t("no_event_types")}
description={t("no_event_types_have_been_setup")}
className="w-full"
/>
);
}
return (
<AtomsWrapper>
<div className="border-subtle bg-default overflow-hidden rounded-md border">
<ul className="divide-subtle divide-y" data-testid="event-types" ref={animationParentRef}>
{eventTypes?.map((eventType) => (
<EventTypeListItem
key={eventType.id}
eventType={eventType}
deleteFunction={({ eventTypeId }) => deleteEventType(eventTypeId)}
isDeletable={true}
getEventTypeUrl={getEventTypeUrl}
/>
))}
</ul>
</div>
</AtomsWrapper>
);
};
+2
View File
@@ -49,4 +49,6 @@ export { CreateScheduleForm } from "./create-schedule/CreateScheduleForm";
export { ListSchedulesPlatformWrapper as ListSchedules } from "./list-schedules/index";
export { ListEventTypesPlatformWrapper as ListEventTypes } from "./event-types/index";
export { useAvailableSlots } from "./hooks/useAvailableSlots";