feat: Troubleshooter atom (#27497)

This commit is contained in:
Rajiv Sahal
2026-02-06 17:34:58 +02:00
committed by GitHub
parent a71d62dc33
commit 8a17ebc2ef
31 changed files with 821 additions and 305 deletions
@@ -1,3 +1,20 @@
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { listWithTeamHandler, PublicEventType } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Query,
UseGuards,
VERSION_NEUTRAL,
Version,
} from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController, ApiTags as DocsTags } from "@nestjs/swagger";
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
@@ -16,24 +33,6 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Param,
ParseIntPipe,
UseGuards,
Version,
VERSION_NEUTRAL,
Patch,
Body,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { PublicEventType } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
/*
Event-types endpoints for atoms, split from AtomsController for clarity and maintainability.
@@ -49,6 +48,17 @@ These endpoints should not be recommended for use by third party and are exclude
export class AtomsEventTypesController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
@Get("/event-types/list-with-team")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listEventTypesWithTeam(@GetUser() user: UserWithProfile): Promise<ApiResponse<unknown>> {
const eventTypes = await listWithTeamHandler({ ctx: { user } });
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/event-types/:eventSlug/public")
async getPublicEventType(
@Param("eventSlug") eventSlug: string,
@@ -73,6 +73,21 @@ export class AtomsSchedulesController {
};
}
@Get("/schedules/event-type/:eventSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Permissions([SCHEDULE_READ])
async getScheduleByEventSlug(
@GetUser() user: UserWithProfile,
@Param("eventSlug") eventSlug: string
): Promise<ApiResponse<unknown>> {
const schedule = await this.schedulesService.getScheduleByEventSlug(user, eventSlug);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Get("/schedules/all")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -1,3 +1,4 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -5,6 +6,7 @@ import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ScheduleRepository, UpdateScheduleResponse } from "@calcom/platform-libraries/schedules";
import { getScheduleByEventSlugHandler } from "@calcom/platform-libraries/schedules";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { UpdateAtomScheduleDto } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -15,6 +17,7 @@ export class SchedulesAtomsService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
) {}
@@ -42,6 +45,13 @@ export class SchedulesAtomsService {
});
}
async getScheduleByEventSlug(user: UserWithProfile, eventSlug: string) {
return getScheduleByEventSlugHandler({
ctx: { user, prisma: this.dbRead.prisma as unknown as PrismaClient },
input: { eventSlug },
});
}
async updateUserSchedule({
input,
user,
@@ -3,7 +3,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
import { TroubleshooterListItemContainer } from "@calcom/features/troubleshooter/components/TroubleshooterListItemContainer";
function AvailabiltyItem() {
const { t } = useLocale();
@@ -1,124 +1,16 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CalendarToggleContainerComponent } from "@calcom/features/troubleshooter/components/CalendarToggleContainerComponent";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
const SELECTION_COLORS = ["#f97316", "#84cc16", "#06b6d4", "#8b5cf6", "#ec4899", "#f43f5e"];
interface CalendarToggleItemProps {
title: string;
subtitle: string;
colorDot?: string;
status: "connected" | "not_found";
calendars?: {
active?: boolean;
name?: string;
}[];
}
function CalendarToggleItem(props: CalendarToggleItemProps) {
const badgeStatus = props.status === "connected" ? "green" : "orange";
const badgeText = props.status === "connected" ? "Connected" : "Not found";
return (
<TroubleshooterListItemContainer
title={props.title}
subtitle={props.subtitle}
prefixSlot={
<>
<div
className="h-4 w-4 self-center rounded-[4px]"
style={{
backgroundColor: props.colorDot,
}}
/>
</>
}
suffixSlot={
<div>
<Badge variant={badgeStatus} withDot size="sm">
{badgeText}
</Badge>
</div>
}>
<div className="[&>*]:text-emphasis flex flex-col gap-3">
{props.calendars?.map((calendar) => {
return <Switch key={calendar.name} checked={calendar.active} label={calendar.name} disabled />;
})}
</div>
</TroubleshooterListItemContainer>
);
}
function EmptyCalendarToggleItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title={t("installed", { count: 0 })}
subtitle={t("please_install_a_calendar")}
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="orange" withDot size="sm">
{t("unavailable")}
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<Button color="secondary" className="justify-center gap-2" href="/apps/categories/calendar">
{t("install_calendar")}
</Button>
</div>
</TroubleshooterListItemContainer>
);
}
export function CalendarToggleContainer() {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.calendars.connectedCalendars.useQuery();
const hasConnectedCalendars = data && data?.connectedCalendars.length > 0;
const { data, isLoading } =
trpc.viewer.calendars.connectedCalendars.useQuery();
return (
<div className="flex flex-col stack-y-3">
<p className="text-sm font-medium leading-none">{t("calendars_were_checking_for_conflicts")}</p>
{hasConnectedCalendars && !isLoading ? (
<>
{data.connectedCalendars.map((calendar) => {
const foundPrimary = calendar.calendars?.find((item) => item.primary);
// Will be used when getAvailbility is modified to use externalId instead of appId for source.
// const color = SELECTION_COLORS[idx] || "#000000";
// // Add calendar to color map using externalId (what we use on the backend to determine source)
// addToColorMap(foundPrimary?.externalId, color);
return (
<CalendarToggleItem
key={calendar.credentialId}
title={calendar.integration.name}
colorDot="#000000"
subtitle={foundPrimary?.name ?? "Nameless Calendar"}
status={calendar.error ? "not_found" : "connected"}
calendars={calendar.calendars?.map((item) => {
return {
active: item.isSelected,
name: item.name,
};
})}
/>
);
})}
<Button color="secondary" className="justify-center gap-2" href="/settings/my-account/calendars">
{t("manage_calendars")}
</Button>
</>
) : (
<EmptyCalendarToggleItem />
)}
</div>
<CalendarToggleContainerComponent
connectedCalendars={data?.connectedCalendars ?? []}
isLoading={isLoading}
manageCalendarsAction={{ href: "/settings/my-account/calendars" }}
installCalendarAction={{ href: "/apps/categories/calendar" }}
/>
);
}
@@ -1,7 +1,7 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
import { TroubleshooterListItemHeader } from "@calcom/features/troubleshooter/components/TroubleshooterListItemContainer";
function ConnectedAppsItem() {
return (
@@ -1,43 +1,42 @@
import Link from "next/link";
import { EventScheduleItemComponent } from "@calcom/features/troubleshooter/components/EventScheduleItemComponent";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Label } from "@calcom/ui/components/form";
import Link from "next/link";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
export { EventScheduleItemComponent };
export function EventScheduleItem() {
export function EventScheduleItem(): JSX.Element {
const { t } = useLocale();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const { data: schedule } = trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
const { data: schedule } =
trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
return (
<div>
<Label>{t("availability_schedule")}</Label>
<TroubleshooterListItemHeader
className="group rounded-md border-b"
prefixSlot={<div className="w-4 rounded-[4px] bg-black" />}
title={schedule?.name ?? "Loading"}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge color="orange" size="sm" className="invisible hover:cursor-pointer group-hover:visible">
{t("edit")}
</Badge>
</Link>
)
}
/>
</div>
<EventScheduleItemComponent
schedule={schedule ?? null}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge
color="orange"
size="sm"
className="invisible hover:cursor-pointer group-hover:visible"
>
{t("edit")}
</Badge>
</Link>
)
}
/>
);
}
@@ -1,88 +1,16 @@
import { useMemo, useEffect, startTransition } from "react";
import { shallow } from "zustand/shallow";
import { EventTypeSelectComponent } from "@calcom/features/troubleshooter/components/EventTypeSelectComponent";
import { trpc } from "@calcom/trpc/react";
import { SelectField } from "@calcom/ui/components/form";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
export { EventTypeSelectComponent };
export function EventTypeSelect() {
const { data: eventTypes, isPending } = trpc.viewer.eventTypes.listWithTeam.useQuery();
const { event: selectedEventType, setEvent: setSelectedEventType } = useTroubleshooterStore(
(state) => ({
event: state.event,
setEvent: state.setEvent,
}),
shallow
);
const options = useMemo(() => {
if (!eventTypes) return [];
return eventTypes.map((e) => ({
label: e.title,
value: e.id.toString(),
id: e.id,
duration: e.length,
}));
}, [eventTypes]);
// Initialize event type from query param or default to first event
useEffect(() => {
if (!eventTypes || eventTypes.length === 0) return;
const selectedEventIdParam = getQueryParam("eventTypeId");
const eventTypeId = selectedEventIdParam ? parseInt(selectedEventIdParam, 10) : null;
// If we already have a selected event that matches the query param, don't do anything
if (selectedEventType?.id === eventTypeId) return;
// If there's a query param, try to find and set that event
if (eventTypeId && !isNaN(eventTypeId)) {
startTransition(() => {
const foundEventType = eventTypes.find((et) => et.id === eventTypeId);
if (foundEventType) {
setSelectedEventType({
id: foundEventType.id,
slug: foundEventType.slug,
duration: foundEventType.length,
teamId: foundEventType.team?.id ?? null,
});
return;
}
});
}
// If no event is selected and no valid query param, default to first event
if (!selectedEventType && !eventTypeId) {
const firstEvent = eventTypes[0];
setSelectedEventType({
id: firstEvent.id,
slug: firstEvent.slug,
duration: firstEvent.length,
teamId: firstEvent.team?.id ?? null,
});
}
}, [eventTypes, selectedEventType, setSelectedEventType]);
export function EventTypeSelect(): JSX.Element {
const { data: eventTypes, isPending } =
trpc.viewer.eventTypes.listWithTeam.useQuery();
return (
<SelectField
label="Event Type"
options={options}
isDisabled={isPending || options.length === 0}
value={options.find((option) => option.id === selectedEventType?.id) || options[0]}
onChange={(option) => {
if (!option) return;
const foundEventType = eventTypes?.find((et) => et.id === option.id);
if (foundEventType) {
setSelectedEventType({
id: foundEventType.id,
slug: foundEventType.slug,
duration: foundEventType.length,
teamId: foundEventType.team?.id ?? null,
});
}
}}
<EventTypeSelectComponent
eventTypes={eventTypes ?? []}
isPending={isPending}
/>
);
}
@@ -1247,6 +1247,7 @@
"invitees_can_schedule": "Invitees can schedule",
"date_range": "Date range",
"calendar_days": "calendar days",
"nameless_calendar": "Nameless Calendar",
"business_days": "business days",
"set_address_place": "Set an address or place",
"set_link_meeting": "Set a link to the meeting",
@@ -2925,6 +2926,7 @@
"directory_sync_delete_confirmation": "This action cannot be undone. This will permanently delete the directory sync connection.",
"event_setup_length_error": "Event Setup: The duration must be at least 1 minute.",
"availability_schedules": "Availability schedules",
"availability_schedule": "Availability Schedule",
"unauthorized": "Unauthorized",
"access_cal_account": "{{clientName}} wants to access your {{appName}} account",
"select_account_team": "Select account or team",
@@ -0,0 +1,174 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
interface CalendarToggleItemProps {
title: string;
subtitle: string;
colorDot?: string;
status: "connected" | "not_found";
calendars?: {
active?: boolean;
name?: string;
}[];
}
function CalendarToggleItem(props: CalendarToggleItemProps) {
const badgeStatus = props.status === "connected" ? "green" : "orange";
const badgeText = props.status === "connected" ? "Connected" : "Not found";
return (
<TroubleshooterListItemContainer
title={props.title}
subtitle={props.subtitle}
prefixSlot={
<>
<div
className="h-4 w-4 self-center rounded-[4px]"
style={{
backgroundColor: props.colorDot,
}}
/>
</>
}
suffixSlot={
<div>
<Badge variant={badgeStatus} withDot size="sm">
{badgeText}
</Badge>
</div>
}
>
<div className="[&>*]:text-emphasis flex flex-col gap-3">
{props.calendars?.map((calendar) => {
return (
<Switch
key={calendar.name}
checked={calendar.active}
label={calendar.name}
disabled
/>
);
})}
</div>
</TroubleshooterListItemContainer>
);
}
function EmptyCalendarToggleItem({
installCalendarAction,
}: {
installCalendarAction?: { href: string } | { onClick: () => void };
}) {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title={t("installed", { count: 0 })}
subtitle={t("please_install_a_calendar")}
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="orange" withDot size="sm">
{t("unavailable")}
</Badge>
</div>
}
>
{installCalendarAction && (
<div className="flex flex-col gap-3">
<Button
color="secondary"
className="justify-center gap-2"
{...installCalendarAction}
>
{t("install_calendar")}
</Button>
</div>
)}
</TroubleshooterListItemContainer>
);
}
interface ConnectedCalendarItem {
credentialId: number;
integration: {
name: string;
};
error?: {
message: string;
};
calendars?: {
primary: boolean | null;
isSelected: boolean;
name?: string;
}[];
}
interface CalendarToggleContainerComponentProps {
connectedCalendars: ConnectedCalendarItem[];
isLoading: boolean;
manageCalendarsAction?: { href: string } | { onClick: () => void };
installCalendarAction?: { href: string } | { onClick: () => void };
}
export function CalendarToggleContainerComponent({
connectedCalendars,
isLoading,
manageCalendarsAction,
installCalendarAction,
}: CalendarToggleContainerComponentProps): JSX.Element {
const { t } = useLocale();
const hasConnectedCalendars = connectedCalendars.length > 0;
return (
<div className="flex flex-col stack-y-3">
<p className="text-sm font-medium leading-none">
{t("calendars_were_checking_for_conflicts")}
</p>
{hasConnectedCalendars && !isLoading ? (
<>
{connectedCalendars.map((calendar) => {
const foundPrimary = calendar.calendars?.find(
(item) => item.primary
);
// Will be used when getAvailbility is modified to use externalId instead of appId for source.
// const color = SELECTION_COLORS[idx] || "#000000";
// // Add calendar to color map using externalId (what we use on the backend to determine source)
// addToColorMap(foundPrimary?.externalId, color);
return (
<CalendarToggleItem
key={calendar.credentialId}
title={calendar.integration.name}
colorDot="#000000"
subtitle={foundPrimary?.name ?? t("nameless_calendar")}
status={calendar.error ? "not_found" : "connected"}
calendars={calendar.calendars?.map((item) => {
return {
active: item.isSelected,
name: item.name,
};
})}
/>
);
})}
{manageCalendarsAction && (
<Button
color="secondary"
className="justify-center gap-2"
{...manageCalendarsAction}
>
{t("manage_calendars")}
</Button>
)}
</>
) : (
<EmptyCalendarToggleItem installCalendarAction={installCalendarAction} />
)}
</div>
);
}
@@ -0,0 +1,33 @@
import { Label } from "@calcom/ui/components/form";
import type React from "react";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
import { useLocale } from "@calcom/lib/hooks/useLocale";
interface ScheduleItem {
id: number;
name: string;
}
interface EventScheduleItemComponentProps {
schedule: ScheduleItem | null;
suffixSlot?: React.ReactNode;
}
export function EventScheduleItemComponent({
schedule,
suffixSlot,
}: EventScheduleItemComponentProps): JSX.Element {
const { t } = useLocale();
return (
<div>
<Label> {t("availability_schedule")}</Label>
<TroubleshooterListItemHeader
className="group rounded-md border-b"
prefixSlot={<div className="w-4 rounded-[4px] bg-black" />}
title={schedule?.name ?? t("loading")}
suffixSlot={suffixSlot}
/>
</div>
);
}
@@ -0,0 +1,115 @@
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SelectField } from "@calcom/ui/components/form";
import { startTransition, useEffect, useMemo } from "react";
import { shallow } from "zustand/shallow";
interface EventTypeItem {
id: number;
title: string;
slug: string;
length: number;
username?: string | null;
team?: {
id: number;
name: string;
} | null;
}
interface EventTypeSelectComponentProps {
eventTypes: EventTypeItem[];
isPending?: boolean;
}
export function EventTypeSelectComponent({
eventTypes,
isPending,
}: EventTypeSelectComponentProps): JSX.Element {
const { t } = useLocale();
const { event: selectedEventType, setEvent: setSelectedEventType } =
useTroubleshooterStore(
(state) => ({
event: state.event,
setEvent: state.setEvent,
}),
shallow
);
const options = useMemo(() => {
if (!eventTypes) return [];
return eventTypes.map((e) => ({
label: e.title,
value: e.id.toString(),
id: e.id,
duration: e.length,
}));
}, [eventTypes]);
// Initialize event type from query param or default to first event
useEffect(() => {
if (!eventTypes || eventTypes.length === 0) return;
const selectedEventIdParam = getQueryParam("eventTypeId");
const eventTypeId = selectedEventIdParam
? parseInt(selectedEventIdParam, 10)
: null;
// If we already have a selected event that matches the query param, don't do anything
if (selectedEventType?.id === eventTypeId) return;
// If there's a query param, try to find and set that event
if (eventTypeId && !isNaN(eventTypeId)) {
startTransition(() => {
const foundEventType = eventTypes.find((et) => et.id === eventTypeId);
if (foundEventType) {
setSelectedEventType({
id: foundEventType.id,
slug: foundEventType.slug,
duration: foundEventType.length,
teamId: foundEventType.team?.id ?? null,
username: foundEventType.username ?? null,
});
return;
}
});
}
// If no event is selected and no valid query param, default to first event
if (!selectedEventType && !eventTypeId) {
const firstEvent = eventTypes[0];
setSelectedEventType({
id: firstEvent.id,
slug: firstEvent.slug,
duration: firstEvent.length,
teamId: firstEvent.team?.id ?? null,
username: firstEvent.username ?? null,
});
}
}, [eventTypes, selectedEventType, setSelectedEventType]);
return (
<SelectField
label={t("event_type")}
options={options}
isDisabled={isPending || options.length === 0}
value={
options.find((option) => option.id === selectedEventType?.id) ||
options[0]
}
onChange={(option) => {
if (!option) return;
const foundEventType = eventTypes?.find((et) => et.id === option.id);
if (foundEventType) {
setSelectedEventType({
id: foundEventType.id,
slug: foundEventType.slug,
duration: foundEventType.length,
teamId: foundEventType.team?.id ?? null,
username: foundEventType.username ?? null,
});
}
}}
/>
);
}
+14 -4
View File
@@ -11,6 +11,7 @@ import { updateQueryParam, getQueryParam, removeQueryParam } from "../bookings/B
*/
type StoreInitializeType = {
month: string | null;
isPlatform?: boolean;
};
type EventType = {
@@ -18,9 +19,11 @@ type EventType = {
slug: string;
duration: number;
teamId?: number | null;
username?: string | null;
};
export type TroubleshooterStore = {
isPlatform: boolean;
event: EventType | null;
setEvent: (eventSlug: EventType) => void;
month: string | null;
@@ -41,6 +44,7 @@ export type TroubleshooterStore = {
* See comments in interface above for more information on it's specific values.
*/
export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) => ({
isPlatform: false,
selectedDate: getQueryParam("date") || null,
setSelectedDate: (selectedDate: string | null) => {
// unset selected date
@@ -77,7 +81,9 @@ export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) =>
event: null,
setEvent: (event: EventType) => {
set({ event });
updateQueryParam("eventTypeId", event.id.toString());
if (!get().isPlatform) {
updateQueryParam("eventTypeId", event.id.toString());
}
},
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
@@ -85,7 +91,10 @@ export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) =>
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
initialize: ({ month }: StoreInitializeType) => {
initialize: ({ month, isPlatform }: StoreInitializeType) => {
if (isPlatform) {
set({ isPlatform: true });
}
if (month) {
set({ month });
updateQueryParam("month", month);
@@ -101,11 +110,12 @@ export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) =>
},
}));
export const useInitalizeTroubleshooterStore = ({ month }: StoreInitializeType) => {
export const useInitalizeTroubleshooterStore = ({ month, isPlatform }: StoreInitializeType) => {
const initializeStore = useTroubleshooterStore((state) => state.initialize);
useEffect(() => {
initializeStore({
month,
isPlatform,
});
}, [initializeStore, month]);
}, [initializeStore, month, isPlatform]);
};
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
import { useAtomsContext } from "./useAtomsContext";
type EventTypeWithTeam = {
id: number;
team: { id: number; name: string } | null;
title: string;
slug: string;
length: number;
username: string | null;
};
type ListWithTeamData = EventTypeWithTeam[];
export const QUERY_KEY = "get-event-types-list-with-team";
export const useEventTypesList = (props: { enabled?: boolean }) => {
const { isInit } = useAtomsContext();
const eventTypes = useQuery({
queryKey: [QUERY_KEY],
queryFn: () => {
return http.get<ApiResponse<ListWithTeamData>>("/atoms/event-types/list-with-team").then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<ListWithTeamData>)?.data;
}
throw new Error(res.data.error.message);
});
},
enabled: props?.enabled !== undefined ? props.enabled && isInit : isInit,
staleTime: 5000,
});
return eventTypes;
};
@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
import { useAtomsContext } from "./useAtomsContext";
type ScheduleByEventSlugData = {
id: number;
name: string;
availability: unknown[][];
dateOverrides: unknown[];
timeZone: string;
workingHours: unknown[];
isDefault: boolean;
};
export const QUERY_KEY = "get-schedule-by-event-slug";
export const useScheduleByEventSlug = (props: { eventSlug?: string; enabled?: boolean }) => {
const { isInit } = useAtomsContext();
const schedule = useQuery({
queryKey: [QUERY_KEY, props.eventSlug],
queryFn: () => {
return http
.get<ApiResponse<ScheduleByEventSlugData>>(`/atoms/schedules/event-type/${props.eventSlug}`)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<ScheduleByEventSlugData>)?.data;
}
throw new Error(res.data.error.message);
});
},
enabled: !!props.eventSlug && (props?.enabled !== undefined ? props.enabled && isInit : isInit),
staleTime: 5000,
});
return schedule;
};
+40 -47
View File
@@ -1,55 +1,48 @@
export { CalProvider, CalOAuthProvider } from "./cal-provider";
export { GcalConnect } from "./connect/google/GcalConnect";
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
export type { AvailabilitySettingsPlatformWrapperProps as AvailabilitySettingsProps } from "./availability/wrappers/AvailabilitySettingsPlatformWrapper";
export type { AvailabilitySettingsScheduleType } from "./availability/AvailabilitySettings";
export { BookerPlatformWrapper as Booker } from "./booker/BookerPlatformWrapper";
export { useIsPlatform } from "./hooks/useIsPlatform";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
export { useEventTypes } from "./hooks/event-types/public/useEventTypes";
export { useTeamEventTypes } from "./hooks/event-types/public/useTeamEventTypes";
export { useEventType as useEvent } from "./hooks/event-types/public/useEventType";
export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useCancelBooking } from "./hooks/bookings/useCancelBooking";
export { useBooking } from "./hooks/bookings/useBooking";
export { useBookings } from "./hooks/bookings/useBookings";
export { useMe } from "./hooks/useMe";
export { OutlookConnect } from "./connect/outlook/OutlookConnect";
export * as Connect from "./connect";
export { BookerEmbed } from "./booker-embed";
export { Router } from "./router";
export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials";
export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar";
export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";
export { useTeams } from "./hooks/teams/useTeams";
export { SelectedCalendarsSettingsPlatformWrapper as SelectedCalendarsSettings } from "./selected-calendars/index";
export { DestinationCalendarSettingsPlatformWrapper as DestinationCalendarSettings } from "./destination-calendar/index";
export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/index";
export type { UpdateScheduleInput_2024_06_11 as UpdateScheduleBody } from "@calcom/platform-types";
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
export type { AvailabilitySettingsScheduleType } from "./availability/AvailabilitySettings";
export type { AvailabilitySettingsFormRef } from "./availability/types";
export type { AvailabilitySettingsPlatformWrapperProps as AvailabilitySettingsProps } from "./availability/wrappers/AvailabilitySettingsPlatformWrapper";
export { BookerPlatformWrapper as Booker } from "./booker/BookerPlatformWrapper";
export { BookerEmbed } from "./booker-embed";
export { CalOAuthProvider, CalProvider } from "./cal-provider";
export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/index";
export { CalendarViewPlatformWrapper as CalendarView } from "./calendar-view/index";
export * as Connect from "./connect";
export type { ConferencingAppsCustomClassNames } from "./connect/conferencing-apps/ConferencingAppsViewPlatformWrapper";
export { ConferencingAppsViewPlatformWrapper as ConferencingAppsSettings } from "./connect/conferencing-apps/ConferencingAppsViewPlatformWrapper";
export { GcalConnect } from "./connect/google/GcalConnect";
export { OutlookConnect } from "./connect/outlook/OutlookConnect";
export { StripeConnect } from "./connect/stripe/StripeConnect";
export { CreateScheduleForm } from "./create-schedule/CreateScheduleForm";
export { CreateSchedulePlatformWrapper as CreateSchedule } from "./create-schedule/index";
export { DestinationCalendarSettingsPlatformWrapper as DestinationCalendarSettings } from "./destination-calendar/index";
export { ListEventTypesPlatformWrapper as ListEventTypes } from "./event-types/index";
export { PaymentForm } from "./event-types/payments/PaymentForm";
export { CreateEventTypePlatformWrapper as CreateEventType } from "./event-types/wrappers/CreateEventTypePlatformWrapper";
export { EventTypePlatformWrapper as EventTypeSettings } from "./event-types/wrappers/EventTypePlatformWrapper";
export type { EventSettingsFromRef } from "./event-types/wrappers/types";
export type { AvailabilitySettingsFormRef } from "./availability/types";
export { ConferencingAppsViewPlatformWrapper as ConferencingAppsSettings } from "./connect/conferencing-apps/ConferencingAppsViewPlatformWrapper";
export type { ConferencingAppsCustomClassNames } from "./connect/conferencing-apps/ConferencingAppsViewPlatformWrapper";
export { StripeConnect } from "./connect/stripe/StripeConnect";
export { CreateEventTypePlatformWrapper as CreateEventType } from "./event-types/wrappers/CreateEventTypePlatformWrapper";
export { PaymentForm } from "./event-types/payments/PaymentForm";
export { useBooking } from "./hooks/bookings/useBooking";
export { useBookings } from "./hooks/bookings/useBookings";
export { useCancelBooking } from "./hooks/bookings/useCancelBooking";
export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar";
export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials";
export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";
export { useCreateEventType } from "./hooks/event-types/private/useCreateEventType";
export { useCreateTeamEventType } from "./hooks/event-types/private/useCreateTeamEventType";
export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useEventType as useEvent } from "./hooks/event-types/public/useEventType";
export { useEventTypes } from "./hooks/event-types/public/useEventTypes";
export { useTeamEventTypes } from "./hooks/event-types/public/useTeamEventTypes";
export { useOrganizationBookings } from "./hooks/organizations/bookings/useOrganizationBookings";
export { useOrganizationUserBookings } from "./hooks/organizations/bookings/useOrganizationUserBookings";
export { CalendarViewPlatformWrapper as CalendarView } from "./calendar-view/index";
export { CreateSchedulePlatformWrapper as CreateSchedule } from "./create-schedule/index";
export { CreateScheduleForm } from "./create-schedule/CreateScheduleForm";
export { ListSchedulesPlatformWrapper as ListSchedules } from "./list-schedules/index";
export { ListEventTypesPlatformWrapper as ListEventTypes } from "./event-types/index";
export { useTeams } from "./hooks/teams/useTeams";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useAvailableSlots } from "./hooks/useAvailableSlots";
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
export { useIsPlatform } from "./hooks/useIsPlatform";
export { useMe } from "./hooks/useMe";
export { ListSchedulesPlatformWrapper as ListSchedules } from "./list-schedules/index";
export { Router } from "./router";
export { SelectedCalendarsSettingsPlatformWrapper as SelectedCalendarsSettings } from "./selected-calendars/index";
export { TroubleshooterPlatformWrapper as TroubleShooter } from "./troubleshooter/index";
@@ -0,0 +1 @@
export { TroubleshooterPlatformWrapper } from "./wrappers/TroubleshooterPlatformWrapper";
@@ -0,0 +1,31 @@
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { CalendarViewPlatformWrapper } from "../../calendar-view/wrappers/CalendarViewPlatformWrapper";
export const LargeCalendar = (): JSX.Element | null => {
const event = useTroubleshooterStore((state) => state.event);
if (!event?.slug) return null;
if (event.teamId) {
return (
<CalendarViewPlatformWrapper
isEventTypeView={true}
teamId={event.teamId}
eventSlug={event.slug}
/>
);
}
if (event.username) {
return (
<CalendarViewPlatformWrapper
isEventTypeView={true}
username={event.username}
eventSlug={event.slug}
/>
);
}
return null;
};
@@ -0,0 +1,27 @@
import { CalendarToggleContainerComponent } from "@calcom/features/troubleshooter/components/CalendarToggleContainerComponent";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
interface CalendarToggleContainerProps {
onManageCalendarsClick?: () => void;
onInstallCalendarClick?: () => void;
}
export function CalendarToggleContainer({
onManageCalendarsClick,
onInstallCalendarClick,
}: CalendarToggleContainerProps): JSX.Element {
const calendars = useConnectedCalendars({});
return (
<CalendarToggleContainerComponent
connectedCalendars={calendars.data?.connectedCalendars ?? []}
isLoading={calendars.isLoading}
{...(onManageCalendarsClick
? { manageCalendarsAction: { onClick: onManageCalendarsClick } }
: {})}
{...(onInstallCalendarClick
? { installCalendarAction: { onClick: onInstallCalendarClick } }
: {})}
/>
);
}
@@ -0,0 +1,13 @@
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { EventScheduleItemComponent } from "@calcom/features/troubleshooter/components/EventScheduleItemComponent";
import { useScheduleByEventSlug } from "../../hooks/useScheduleByEventSlug";
export function EventScheduleItem(): JSX.Element {
const selectedEventType = useTroubleshooterStore((state) => state.event);
const { data: schedule } = useScheduleByEventSlug({
eventSlug: selectedEventType?.slug,
});
return <EventScheduleItemComponent schedule={schedule ?? null} />;
}
@@ -0,0 +1,13 @@
import { EventTypeSelectComponent } from "@calcom/features/troubleshooter/components/EventTypeSelectComponent";
import { useEventTypesList } from "../../hooks/useEventTypesList";
export function EventTypeSelect(): JSX.Element {
const { data: eventTypes, isPending } = useEventTypesList({});
return (
<EventTypeSelectComponent
eventTypes={eventTypes ?? []}
isPending={isPending}
/>
);
}
@@ -0,0 +1,42 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Skeleton } from "@calcom/ui/components/skeleton";
import { CalendarToggleContainer } from "./CalendarToggleContainer";
import { EventScheduleItem } from "./EventScheduleItem";
import { EventTypeSelect } from "./EventTypeSelect";
const SidebarHeading = ({ name }: { name: string }): JSX.Element => {
return (
<Skeleton
title={name}
as="p"
className="max-w-36 min-h-4 truncate font-semibold"
loadingClassName="ms-3"
>
{name}
</Skeleton>
);
};
interface TroubleshooterSidebarProps {
onManageCalendarsClick?: () => void;
onInstallCalendarClick?: () => void;
}
export const TroubleshooterSidebar = ({
onManageCalendarsClick,
onInstallCalendarClick,
}: TroubleshooterSidebarProps): JSX.Element => {
const { t } = useLocale();
return (
<div className="relative z-10 hidden h-screen w-full flex-col gap-6 overflow-y-auto py-6 pl-4 pr-6 sm:flex md:pl-0">
<SidebarHeading name={t("troubleshooter")} />
<EventTypeSelect />
<EventScheduleItem />
<CalendarToggleContainer
onManageCalendarsClick={onManageCalendarsClick}
onInstallCalendarClick={onInstallCalendarClick}
/>
</div>
);
};
@@ -0,0 +1,89 @@
import { useInitalizeTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import classNames from "@calcom/ui/classNames";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { TroubleshooterSidebar } from "../sidebar/TroubleshooterSidebar";
import { LargeCalendar } from "../large-calendar/LargeCalendar";
import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider";
interface TroubleshooterComponentProps {
month?: string | null;
onManageCalendarsClick?: () => void;
onInstallCalendarClick?: () => void;
}
export const TroubleshooterComponent = ({
month = null,
onManageCalendarsClick,
onInstallCalendarClick,
}: TroubleshooterComponentProps): JSX.Element => {
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
useInitalizeTroubleshooterStore({
month: month,
isPlatform: true,
});
return (
<AtomsWrapper>
<>
<div
className={classNames(
"text-default flex min-h-full w-full flex-col items-center overflow-clip",
isMobile
? "[--troubleshooter-meta-width:0px]"
: "[--troubleshooter-meta-width:250px] lg:[--troubleshooter-meta-width:430px]"
)}
>
<div
style={{
display: "grid",
width: "100%",
height: "auto",
gridTemplateAreas: `"meta main"`,
gridTemplateColumns: "var(--troubleshooter-meta-width) 1fr",
gridTemplateRows: "auto",
}}
className={classNames(
"bg-default dark:bg-cal-muted text-default min-h-full w-full sm:transition-[width] sm:duration-300"
)}
>
<div className="sticky top-0 z-10 self-start ps-6 [grid-area:meta]">
<TroubleshooterSidebar
onManageCalendarsClick={onManageCalendarsClick}
onInstallCalendarClick={onInstallCalendarClick}
/>
</div>
<div className="ml-[-1px] border-subtle [grid-area:main]">
<LargeCalendar />
</div>
</div>
</div>
</>
</AtomsWrapper>
);
};
interface TroubleshooterPlatformWrapperProps {
month?: string | null;
onManageCalendarsClick?: () => void;
onInstallCalendarClick?: () => void;
}
export const TroubleshooterPlatformWrapper = ({
month,
onManageCalendarsClick,
onInstallCalendarClick,
}: TroubleshooterPlatformWrapperProps): JSX.Element => {
return (
<BookerStoreProvider>
<TroubleshooterComponent
month={month}
onManageCalendarsClick={onManageCalendarsClick}
onInstallCalendarClick={onInstallCalendarClick}
/>
</BookerStoreProvider>
);
};
@@ -25,6 +25,9 @@ export function Navbar({ username }: { username?: string }) {
<li>
<Link href="/availability">Availability</Link>
</li>
<li>
<Link href="/troubleshooter">Troubleshooter</Link>
</li>
<li>
<Link href="/event-types">EventTypes</Link>
</li>
@@ -0,0 +1,30 @@
import { TroubleShooter } from "@calcom/atoms";
import { Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter } from "next/router";
const inter = Inter({ subsets: ["latin"] });
export default function Troubleshooter(props: {
calUsername: string;
calEmail: string;
}) {
const router = useRouter();
return (
<main className={`flex min-h-screen flex-col ${inter.className}`}>
<Navbar username={props.calUsername} />
<div data-testid="troubleshooter-atom">
<TroubleShooter
onManageCalendarsClick={() => {
router.push("/calendars");
}}
onInstallCalendarClick={() => {
router.push("/calendars");
}}
/>
</div>
</main>
);
}
@@ -7,6 +7,8 @@ export { getBulkUserEventTypes, getBulkTeamEventTypes } from "@calcom/app-store/
export { createHandler as createEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/heavy/create.handler";
export { updateHandler as updateEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/heavy/update.handler";
export { listWithTeamHandler } from "@calcom/trpc/server/routers/viewer/eventTypes/listWithTeam.handler";
export type { TUpdateInputSchema as TUpdateEventTypeInputSchema } from "@calcom/trpc/server/routers/viewer/eventTypes/heavy/update.schema";
export type { EventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
export { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
+2
View File
@@ -23,3 +23,5 @@ export {
duplicateHandler as duplicateScheduleHandler,
type DuplicateScheduleHandlerReturn,
} from "@calcom/trpc/server/routers/viewer/availability/schedule/duplicate.handler";
export { getScheduleByEventSlugHandler } from "@calcom/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler";
@@ -6,7 +6,7 @@ import type { TGetInputSchema } from "./get.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: Pick<NonNullable<TrpcSessionUser>, "id" | "timeZone" | "defaultScheduleId">;
};
input: TGetInputSchema;
};
@@ -6,7 +6,7 @@ import type { TGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.sc
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: Pick<NonNullable<TrpcSessionUser>, "id" | "timeZone" | "defaultScheduleId">;
prisma: PrismaClient;
};
input: TGetByEventSlugInputSchema;
@@ -5,18 +5,19 @@ import type { TrpcSessionUser } from "../../../types";
type ListWithTeamOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: Pick<NonNullable<TrpcSessionUser>, "id">;
};
};
export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
const userId = ctx.user.id;
const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName"
const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName", "u"."username" as "username"
FROM "public"."EventType"
LEFT JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
WHERE "public"."EventType"."userId" = ${userId}
LEFT JOIN "public"."users" AS "u" ON ("u"."id") = ("public"."EventType"."userId")
WHERE "public"."EventType"."userId" = ${userId} AND "public"."EventType"."teamId" IS NULL
UNION
SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName"
SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName", NULL as "username"
FROM "public"."EventType"
INNER JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
INNER JOIN "public"."Membership" AS "t2" ON "t2"."teamId" = "j1"."id"
@@ -30,6 +31,7 @@ export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
slug: string;
length: number;
teamName: string | null;
username: string | null;
}[]
>(query);
@@ -39,5 +41,6 @@ export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
title: row.title,
slug: row.slug,
length: row.length,
username: row.teamId ? null : row.username,
}));
};