feat: Troubleshooter atom (#27497)
This commit is contained in:
1 parent
a71d62dc33
commit
8a17ebc2ef
31 files changed
+821
-305
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
interface TroubleshooterListItemContainerProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
suffixSlot?: React.ReactNode;
|
||||
prefixSlot?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TroubleshooterListItemHeader({
|
||||
prefixSlot,
|
||||
title,
|
||||
subtitle,
|
||||
suffixSlot,
|
||||
className,
|
||||
}: TroubleshooterListItemContainerProps) {
|
||||
return (
|
||||
<div className={classNames("border-subtle flex max-w-full gap-3 border border-b-0 px-4 py-2", className)}>
|
||||
{prefixSlot}
|
||||
<div className="flex h-full max-w-full flex-1 flex-col flex-nowrap truncate text-sm leading-4">
|
||||
<p className="font-medium">{title}</p>
|
||||
{subtitle && <p className="font-normal">{subtitle}</p>}
|
||||
</div>
|
||||
{suffixSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TroubleshooterListItemContainer({
|
||||
children,
|
||||
...rest
|
||||
}: PropsWithChildren<TroubleshooterListItemContainerProps>) {
|
||||
return (
|
||||
<div className="[&>*:first-child]:rounded-t-md ">
|
||||
<TroubleshooterListItemHeader {...rest} />
|
||||
<div className="border-subtle flex flex-col stack-y-3 rounded-b-md border p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user