feat: destination calendar settings for CalendarSettings atom (#16133)

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Rajiv Sahal
2024-08-15 14:31:53 -07:00
committed by GitHub
co-authored by Morgan Morgan Vernay Peer Richelsen
parent 5df14c344e
commit f81074c4f5
28 changed files with 1137 additions and 282 deletions
@@ -105,7 +105,7 @@ class Primary {
credentialId!: number;
}
class Calendar {
export class Calendar {
@IsEmail()
externalId!: string;
@@ -135,7 +135,7 @@ class Calendar {
credentialId!: number;
}
class ConnectedCalendar {
export class ConnectedCalendar {
@ValidateNested()
@IsObject()
integration!: Integration;
@@ -0,0 +1,171 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { DestinationCalendarsOutputResponseDto } from "@/modules/destination-calendars/outputs/destination-calendars.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
import * as request from "supertest";
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
describe("Platform Destination Calendar Endpoints", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let tokensRepositoryFixture: TokensRepositoryFixture;
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
let appleCalendarCredentials: Credential;
let user: User;
let accessTokenSecret: string;
let refreshTokenSecret: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
appleCalendarCredentials = await credentialsRepositoryFixture.create(
APPLE_CALENDAR_TYPE,
{},
user.id,
APPLE_CALENDAR_ID
);
jest.spyOn(CalendarsService.prototype, "getCalendars").mockReturnValue(
Promise.resolve({
connectedCalendars: [
{
integration: {
installed: false,
type: "apple_calendar",
title: "",
name: "",
description: "",
variant: "calendar",
slug: "",
locationOption: null,
categories: ["calendar"],
logo: "",
publisher: "",
url: "",
email: "",
},
calendars: {
externalId:
"https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
readOnly: false,
integration: "apple_calendar",
credentialId: appleCalendarCredentials.id,
primary: true,
email: user.email,
},
error: { message: "" },
},
],
})
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: [CLIENT_REDIRECT_URI],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient).toBeDefined();
expect(accessTokenSecret).toBeDefined();
expect(refreshTokenSecret).toBeDefined();
expect(user).toBeDefined();
});
it(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
const body = {
integration: appleCalendarCredentials.type,
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
};
return request(app.getHttpServer())
.put("/v2/destination-calendars")
.set("Authorization", `Bearer ${accessTokenSecret}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: DestinationCalendarsOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.credentialId).toEqual(appleCalendarCredentials.id);
expect(responseBody.data.integration).toEqual(body.integration);
expect(responseBody.data.externalId).toEqual(body.externalId);
expect(responseBody.data.userId).toEqual(user.id);
});
});
it(`POST /v2/destination-calendars: should fail 400 if calendar type is invalid`, async () => {
const body = {
integration: "not-supported-calendar",
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
};
return request(app.getHttpServer())
.put("/v2/destination-calendars")
.set("Authorization", `Bearer ${accessTokenSecret}`)
.send(body)
.expect(400);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
});
@@ -0,0 +1,46 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { DestinationCalendarsInputBodyDto } from "@/modules/destination-calendars/inputs/destination-calendars.input";
import {
DestinationCalendarsOutputDto,
DestinationCalendarsOutputResponseDto,
} from "@/modules/destination-calendars/outputs/destination-calendars.output";
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Body, Controller, Put, UseGuards } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/destination-calendars",
version: API_VERSIONS_VALUES,
})
@DocsTags("Destination-Calendars")
export class DestinationCalendarsController {
constructor(private readonly destinationCalendarsService: DestinationCalendarsService) {}
@Put("/")
@UseGuards(ApiAuthGuard)
@DocsTags("Select a third party destination calendar where events will be created")
async updateDestinationCalendars(
@Body() input: DestinationCalendarsInputBodyDto,
@GetUser() user: UserWithProfile
): Promise<DestinationCalendarsOutputResponseDto> {
const { integration, externalId } = input;
const updatedDestinationCalendar = await this.destinationCalendarsService.updateDestinationCalendars(
integration,
externalId,
user.id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(DestinationCalendarsOutputDto, updatedDestinationCalendar, {
strategy: "excludeAll",
}),
};
}
}
@@ -0,0 +1,26 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { DestinationCalendarsController } from "@/modules/destination-calendars/controllers/destination-calendars.controller";
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
CalendarsRepository,
CalendarsService,
DestinationCalendarsService,
DestinationCalendarsRepository,
UsersRepository,
CredentialsRepository,
AppsRepository,
],
controllers: [DestinationCalendarsController],
exports: [DestinationCalendarsRepository],
})
export class DestinationCalendarsModule {}
@@ -0,0 +1,34 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class DestinationCalendarsRepository {
constructor(private readonly dbWrite: PrismaWriteService) {}
async updateCalendar(
integration: string,
externalId: string,
credentialId: number,
userId: number,
primaryEmail: string | null
) {
return await this.dbWrite.prisma.destinationCalendar.upsert({
update: {
integration,
externalId,
credentialId,
primaryEmail,
},
create: {
integration,
externalId,
credentialId,
primaryEmail,
userId,
},
where: {
userId: userId,
},
});
}
}
@@ -0,0 +1,36 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString, IsEnum } from "class-validator";
import {
APPLE_CALENDAR_TYPE,
GOOGLE_CALENDAR_TYPE,
OFFICE_365_CALENDAR_TYPE,
} from "@calcom/platform-constants";
export class DestinationCalendarsInputBodyDto {
@IsString()
@Expose()
@ApiProperty({
example: APPLE_CALENDAR_TYPE,
description: "The calendar service you want to integrate, as returned by the /calendars endpoint",
enum: [APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE],
required: true,
})
@IsEnum([APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE])
readonly integration!:
| typeof APPLE_CALENDAR_TYPE
| typeof GOOGLE_CALENDAR_TYPE
| typeof OFFICE_365_CALENDAR_TYPE;
@IsString()
@Expose()
@ApiProperty({
example: "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/",
description:
"Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint",
type: "string",
required: true,
})
readonly externalId!: string;
}
@@ -0,0 +1,34 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsInt, IsString, ValidateNested, IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class DestinationCalendarsOutputDto {
@IsInt()
@Expose()
readonly userId!: number;
@IsString()
@Expose()
readonly integration!: string;
@IsString()
@Expose()
readonly externalId!: string;
@IsInt()
@Expose()
readonly credentialId!: number | null;
}
export class DestinationCalendarsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => DestinationCalendarsOutputDto)
data!: DestinationCalendarsOutputDto;
}
@@ -0,0 +1,50 @@
import { ConnectedCalendar, Calendar } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class DestinationCalendarsService {
constructor(
private readonly calendarsService: CalendarsService,
private readonly destinationCalendarsRepository: DestinationCalendarsRepository
) {}
async updateDestinationCalendars(integration: string, externalId: string, userId: number) {
const userCalendars = await this.calendarsService.getCalendars(userId);
const allCalendars = userCalendars.connectedCalendars
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
.flat();
const credentialId = allCalendars.find(
(cal: Calendar) =>
cal.externalId === externalId && cal.integration === integration && cal.readOnly === false
)?.credentialId;
if (!credentialId) {
throw new NotFoundException(`Could not find calendar ${externalId}`);
}
const primaryEmail =
allCalendars.find((cal: Calendar) => cal.primary && cal.credentialId === credentialId)?.email ?? null;
const {
integration: updatedCalendarIntegration,
externalId: updatedCalendarExternalId,
credentialId: updatedCalendarCredentialId,
userId: updatedCalendarUserId,
} = await this.destinationCalendarsRepository.updateCalendar(
integration,
externalId,
credentialId,
userId,
primaryEmail
);
return {
userId: updatedCalendarUserId,
integration: updatedCalendarIntegration,
externalId: updatedCalendarExternalId,
credentialId: updatedCalendarCredentialId,
};
}
}
@@ -1,5 +1,6 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { BillingModule } from "@/modules/billing/billing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -16,6 +17,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
TimezoneModule,
UsersModule,
WebhooksModule,
DestinationCalendarsModule,
],
})
export class EndpointsModule implements NestModule {
+100
View File
@@ -3794,6 +3794,38 @@
"OAuthClients Webhooks"
]
}
},
"/v2/destination-calendars": {
"put": {
"operationId": "DestinationCalendarsController_updateDestinationCalendars",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DestinationCalendarsInputBodyDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DestinationCalendarsOutputResponseDto"
}
}
}
}
},
"tags": [
"Destination-Calendars",
"Select a third party destination calendar where events will be created"
]
}
}
},
"info": {
@@ -9163,6 +9195,74 @@
"status",
"data"
]
},
"DestinationCalendarsInputBodyDto": {
"type": "object",
"properties": {
"integration": {
"type": "string",
"example": "apple_calendar",
"description": "The calendar service you want to integrate, as returned by the /calendars endpoint",
"enum": [
"apple_calendar",
"google_calendar",
"office365_calendar"
]
},
"externalId": {
"type": "string",
"example": "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/",
"description": "Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint"
}
},
"required": [
"integration",
"externalId"
]
},
"DestinationCalendarsOutputDto": {
"type": "object",
"properties": {
"userId": {
"type": "number"
},
"integration": {
"type": "string"
},
"externalId": {
"type": "string"
},
"credentialId": {
"type": "number",
"nullable": true
}
},
"required": [
"userId",
"integration",
"externalId",
"credentialId"
]
},
"DestinationCalendarsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/DestinationCalendarsOutputDto"
}
},
"required": [
"status",
"data"
]
}
}
}
@@ -1,14 +1,15 @@
import { Fragment, useEffect } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { CalendarSettingsWebWrapper } from "@calcom/atoms/monorepo";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import {
SelectedCalendarSettingsWebWrapper,
DestinationCalendarSettingsWebWrapper,
} from "@calcom/atoms/monorepo";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
EmptyScreen,
Label,
List,
ShellSubHeading,
AppSkeletonLoader as SkeletonLoader,
@@ -105,31 +106,8 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
<>
{heading && (
<>
<div className="border-subtle mb-6 mt-8 rounded-lg border">
<div className="p-6">
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
{t("add_to_calendar")}
</h2>
<p className="text-subtle text-sm leading-tight">
{t("add_to_calendar_description")}
</p>
</div>
<div className="border-t">
<div className="border-subtle flex w-full flex-col space-y-3 border-y-0 p-6">
<div>
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
<DestinationCalendarSelector
hidePlaceholder
value={data.destinationCalendar?.externalId}
onChange={mutation.mutate}
isPending={mutation.isPending}
/>
</div>
</div>
</div>
</div>
<CalendarSettingsWebWrapper
<DestinationCalendarSettingsWebWrapper />
<SelectedCalendarSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
@@ -25,7 +25,7 @@ interface Option {
subtitle: string;
}
const SingleValueComponent = ({ ...props }: SingleValueProps<Option>) => {
export const SingleValueComponent = ({ ...props }: SingleValueProps<Option>) => {
const { label, subtitle } = props.data;
return (
<components.SingleValue {...props} className="flex space-x-1">
@@ -34,7 +34,7 @@ const SingleValueComponent = ({ ...props }: SingleValueProps<Option>) => {
);
};
const OptionComponent = ({ ...props }: OptionProps<Option>) => {
export const OptionComponent = ({ ...props }: OptionProps<Option>) => {
const { label } = props.data;
return (
<components.Option {...props}>
@@ -1,5 +0,0 @@
import type { ReactNode } from "react";
export const CalendarSettings = (props: { children: ReactNode }) => {
return <div className="border-subtle mt-6 rounded-lg border">{props.children}</div>;
};
@@ -1,242 +1,24 @@
import { useState } from "react";
import { DestinationCalendarSettingsPlatformWrapper } from "../../destination-calendar/index";
import { SelectedCalendarSettingsPlatformWrapper } from "../../selected-calendar/index";
import type { ICalendarSwitchProps } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { CALENDARS } from "@calcom/platform-constants";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import type { ButtonProps } from "@calcom/ui";
import {
CalendarSwitchComponent,
AppListCard,
List,
DisconnectIntegrationComponent,
Alert,
} from "@calcom/ui";
import { useAddSelectedCalendar } from "../../hooks/calendars/useAddSelectedCalendar";
import { useDeleteCalendarCredentials } from "../../hooks/calendars/useDeleteCalendarCredentials";
import { useRemoveSelectedCalendar } from "../../hooks/calendars/useRemoveSelectedCalendar";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { Switch } from "../../src/components/ui/switch";
import { useToast } from "../../src/components/ui/use-toast";
import { CalendarSettings } from "../CalendarSettings";
export const CalendarSettingsPlatformWrapper = () => {
const { t } = useLocale();
const query = useConnectedCalendars({});
return (
<AtomsWrapper>
<div>
<QueryCell
query={query}
success={({ data }) => {
const destinationCalendarId = data.destinationCalendar.externalId;
if (!data.connectedCalendars.length) {
return null;
}
return (
<CalendarSettings>
<CalendarSettingsHeading />
<List noBorderTreatment className="p-6 pt-2">
{data.connectedCalendars.map((connectedCalendar) => {
if (!!connectedCalendar.calendars && connectedCalendar.calendars.length > 0) {
return (
<AppListCard
key={`list-${connectedCalendar.credentialId}-${connectedCalendar.integration.slug}`}
shouldHighlight
slug={connectedCalendar.integration.slug}
title={connectedCalendar.integration.name}
logo={`https://app.cal.com${connectedCalendar.integration.logo}`}
description={
connectedCalendar.primary?.email ?? connectedCalendar.integration.description
}
className="border-subtle mt-4 rounded-lg border"
actions={
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
/>
</div>
}>
<div className="border-subtle border-t">
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-4 px-5 py-4">
{connectedCalendar.calendars?.map((cal) => {
return (
<PlatformCalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={connectedCalendar.integration.type}
isChecked={cal.isSelected}
destination={cal.externalId === destinationCalendarId}
credentialId={cal.credentialId}
/>
);
})}
</ul>
</div>
</AppListCard>
);
}
return (
<Alert
key={`alert-${connectedCalendar.credentialId}`}
severity="warning"
title={t("something_went_wrong")}
message={<span>{t("calendar_error")}</span>}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
/>
</div>
}
/>
);
})}
</List>
</CalendarSettings>
);
}}
/>
</div>
</AtomsWrapper>
);
type CalendarSettingsPlatformWrapperProps = {
classNames?: {
calendarSettingsCustomClassnames?: string;
destinationCalendarSettingsCustomClassnames?: string;
selectedCalendarSettingsCustomClassnames?: string;
};
};
const CalendarSettingsHeading = () => {
const { t } = useLocale();
export const CalendarSettingsPlatformWrapper = ({ classNames }: CalendarSettingsPlatformWrapperProps) => {
return (
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
<div>
<h4 className="text-emphasis text-base font-semibold leading-5">{t("check_for_conflicts")}</h4>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
</div>
<div className={classNames?.calendarSettingsCustomClassnames}>
<DestinationCalendarSettingsPlatformWrapper
statusLoader={<></>}
classNames={classNames?.destinationCalendarSettingsCustomClassnames}
/>
<SelectedCalendarSettingsPlatformWrapper
classNames={classNames?.selectedCalendarSettingsCustomClassnames}
/>
</div>
);
};
const PlatformDisconnectIntegration = (props: {
credentialId: number;
label?: string;
slug?: string;
trashIcon?: boolean;
isGlobal?: boolean;
onSuccess?: () => void;
buttonProps?: ButtonProps;
}) => {
const { t } = useLocale();
const { onSuccess, credentialId, slug } = props;
const [modalOpen, setModalOpen] = useState(false);
const { toast } = useToast();
const { mutate: deleteCalendarCredentials } = useDeleteCalendarCredentials({
onSuccess: () => {
toast({
description: t("app_removed_successfully"),
});
setModalOpen(false);
onSuccess && onSuccess();
},
onError: () => {
toast({
description: t("error_removing_app"),
});
setModalOpen(false);
},
});
return (
<DisconnectIntegrationComponent
onDeletionConfirmation={async () => {
slug &&
(await deleteCalendarCredentials({
calendar: slug.split("-")[0] as unknown as (typeof CALENDARS)[number],
id: credentialId,
}));
}}
{...props}
isModalOpen={modalOpen}
onModalOpen={() => setModalOpen((prevValue) => !prevValue)}
/>
);
};
const PlatformCalendarSwitch = (props: ICalendarSwitchProps) => {
const { isChecked, title, credentialId, type, externalId } = props;
const [checkedInternal, setCheckedInternal] = useState(isChecked);
const { toast } = useToast();
const { mutate: addSelectedCalendar, isPending: isAddingSelectedCalendar } = useAddSelectedCalendar({
onError: (err) => {
toast({
description: `Something went wrong while adding calendar - ${title}. ${err}`,
});
},
});
const { mutate: removeSelectedCalendar, isPending: isRemovingSelectedCalendar } = useRemoveSelectedCalendar(
{
onError: (err) => {
toast({
description: `Something went wrong while removing calendar - ${title}. ${err}`,
});
},
}
);
const toggleSelectedCalendars = async ({
isOn,
credentialId,
integration,
externalId,
}: {
isOn: boolean;
credentialId: number;
integration: string;
externalId: string;
}) => {
if (isOn) {
await addSelectedCalendar({ credentialId, integration, externalId });
} else {
await removeSelectedCalendar({ credentialId, integration, externalId });
}
};
return (
<CalendarSwitchComponent
destination={props.destination}
{...props}
isChecked={checkedInternal}
isLoading={isAddingSelectedCalendar || isRemovingSelectedCalendar}>
<Switch
checked={checkedInternal}
id={externalId}
onCheckedChange={async () => {
setCheckedInternal((prevValue) => !prevValue);
await toggleSelectedCalendars({
isOn: !checkedInternal,
credentialId,
externalId,
integration: type,
});
}}
/>
</CalendarSwitchComponent>
);
};
@@ -0,0 +1,37 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label } from "@calcom/ui";
import { cn } from "../src/lib/utils";
import type { DestinationCalendarProps } from "./DestinationCalendarSelector";
import { DestinationCalendarSelector } from "./DestinationCalendarSelector";
export const DestinationCalendarSettings = (props: DestinationCalendarProps & { classNames?: string }) => {
const { t } = useLocale();
return (
<div className={cn("border-subtle mb-6 mt-8 rounded-lg border", props?.classNames)}>
<DestinationCalendarSettingsHeading />
<div className="border-t">
<div className="border-subtle flex w-full flex-col space-y-3 border-y-0 p-6">
<div>
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
<DestinationCalendarSelector {...props} />
</div>
</div>
</div>
</div>
);
};
const DestinationCalendarSettingsHeading = () => {
const { t } = useLocale();
return (
<div className="p-6">
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
{t("add_to_calendar")}
</h2>
<p className="text-subtle text-sm leading-tight">{t("add_to_calendar_description")}</p>
</div>
);
};
@@ -0,0 +1,148 @@
import { useState, useEffect, useMemo } from "react";
import { SingleValueComponent } from "@calcom/features/calendars/DestinationCalendarSelector";
import { OptionComponent } from "@calcom/features/calendars/DestinationCalendarSelector";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
import { Badge, Select } from "@calcom/ui";
import { getPlaceholderContent } from "../lib/getPlaceholderContent";
export type DestinationCalendarProps = {
connectedCalendars: ConnectedDestinationCalendars["connectedCalendars"];
destinationCalendar: ConnectedDestinationCalendars["destinationCalendar"];
onChange: (value: { externalId: string; integration: string }) => void;
isPending?: boolean;
hidePlaceholder?: boolean;
value: string | undefined;
maxWidth?: number;
hideAdvancedText?: boolean;
};
export const DestinationCalendarSelector = ({
connectedCalendars,
destinationCalendar,
onChange,
isPending,
value,
hidePlaceholder,
hideAdvancedText,
maxWidth,
}: DestinationCalendarProps): JSX.Element | null => {
const { t } = useLocale();
const [selectedOption, setSelectedOption] = useState<{
value: string;
label: string;
subtitle: string;
} | null>(null);
useEffect(() => {
const selected = connectedCalendars
.map((connected) => connected.calendars ?? [])
.flat()
.find((cal) => cal.externalId === value);
if (selected) {
const selectedIntegration = connectedCalendars.find((integration) =>
integration.calendars?.some((calendar) => calendar.externalId === selected.externalId)
);
setSelectedOption({
value: `${selected.integration}:${selected.externalId}`,
label: `${selected.name} ` || "",
subtitle: `(${selectedIntegration?.integration.title?.replace(/calendar/i, "")} - ${
selectedIntegration?.primary?.name
})`,
});
}
}, [connectedCalendars]);
const options = useMemo(() => {
return (
connectedCalendars.map((selectedCalendar) => ({
key: selectedCalendar.credentialId,
label: `${selectedCalendar.integration.title?.replace(/calendar/i, "")} (${
selectedCalendar.primary?.integration === "office365_calendar"
? selectedCalendar.primary?.email
: selectedCalendar.primary?.name
})`,
options: (selectedCalendar.calendars ?? [])
.filter((cal) => cal.readOnly === false)
.map((cal) => ({
label: ` ${cal.name} `,
subtitle: `(${selectedCalendar?.integration.title?.replace(/calendar/i, "")} - ${
selectedCalendar?.primary?.name
})`,
value: `${cal.integration}:${cal.externalId}`,
})),
})) ?? []
);
}, [connectedCalendars]);
return (
<div
className="relative table w-full table-fixed"
title={`${t("create_events_on")}: ${selectedOption?.label || ""}`}>
<Select
name="primarySelectedCalendar"
placeholder={
!hidePlaceholder ? (
`${t("create_events_on")}`
) : (
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
<Badge variant="blue">Default</Badge>{" "}
{destinationCalendar?.primaryEmail &&
`${destinationCalendar.name} (${destinationCalendar?.integrationTitle} - ${destinationCalendar.primaryEmail})`}
</span>
)
}
options={options}
styles={{
placeholder: (styles) => ({
...styles,
...getPlaceholderContent(hidePlaceholder, `'${t("create_events_on")}:'`),
}),
singleValue: (styles) => ({
...styles,
...getPlaceholderContent(hidePlaceholder, `'${t("create_events_on")}:'`),
}),
control: (defaultStyles) => {
return {
...defaultStyles,
"@media only screen and (min-width: 640px)": {
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
maxWidth,
},
};
},
}}
isSearchable={false}
className={classNames(
"border-default my-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm text-sm"
)}
onChange={(newValue) => {
setSelectedOption(newValue);
if (!newValue) {
return;
}
/* Split only the first `:`, since Apple uses the full URL as externalId */
const [integration, externalId] = newValue.value.split(/:(.+)/);
onChange({
integration,
externalId,
});
}}
isLoading={isPending}
value={selectedOption}
components={{ SingleValue: SingleValueComponent, Option: OptionComponent }}
isMulti={false}
/>
{hideAdvancedText ? null : (
<p className="text-sm leading-tight">{t("you_can_override_calendar_in_advanced_tab")}</p>
)}
</div>
);
};
@@ -0,0 +1 @@
export { DestinationCalendarSettingsPlatformWrapper } from "./wrappers/DestinationCalendarSettingsPlatformWrapper";
@@ -0,0 +1,48 @@
import { useUpdateDestinationCalendars } from "../../hooks/calendars/useUpdateDestinationCalendars";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { DestinationCalendarSettings } from "../DestinationCalendar";
export const DestinationCalendarSettingsPlatformWrapper = ({
statusLoader,
classNames = "mx-5",
}: {
statusLoader?: JSX.Element;
classNames?: string;
}) => {
const calendars = useConnectedCalendars({});
const { mutate: updateDestinationCalendars, isPending: isUpdatingCalendar } =
useUpdateDestinationCalendars();
if (calendars.isLoading) {
return (
<AtomsWrapper>
<>
{statusLoader}
{!statusLoader && <h1 className="m-5 text-xl font-semibold">Loading...</h1>}
</>
</AtomsWrapper>
);
}
if (!calendars.data?.connectedCalendars || calendars.data.connectedCalendars.length < 1) {
return null;
}
return (
<AtomsWrapper>
<DestinationCalendarSettings
classNames={classNames}
connectedCalendars={calendars.data.connectedCalendars}
destinationCalendar={calendars.data.destinationCalendar}
value={calendars.data.destinationCalendar.externalId}
hidePlaceholder
hideAdvancedText
onChange={async ({ externalId, integration }) => {
await updateDestinationCalendars({ integration, externalId });
}}
isPending={isUpdatingCalendar}
/>
</AtomsWrapper>
);
};
@@ -0,0 +1,31 @@
import { trpc } from "@calcom/trpc/react";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { DestinationCalendarSettings } from "../DestinationCalendar";
export const DestinationCalendarSettingsWebWrapper = () => {
const calendars = trpc.viewer.connectedCalendars.useQuery();
const utils = trpc.useUtils();
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
onSuccess: () => {
utils.viewer.connectedCalendars.invalidate();
},
});
if (!calendars.data?.connectedCalendars || calendars.data.connectedCalendars.length < 1) {
return null;
}
return (
<AtomsWrapper>
<DestinationCalendarSettings
connectedCalendars={calendars.data.connectedCalendars}
isPending={mutation.isPending}
destinationCalendar={calendars.data.destinationCalendar}
value={calendars.data.destinationCalendar.externalId}
hidePlaceholder
onChange={mutation.mutate}
/>
</AtomsWrapper>
);
};
@@ -0,0 +1,59 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
import { QUERY_KEY } from "../useConnectedCalendars";
interface IUseUpdateDestinationCalendars {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useUpdateDestinationCalendars = (
{ onSuccess, onError }: IUseUpdateDestinationCalendars = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const queryClient = useQueryClient();
const updatedDestinationCalendar = useMutation<
ApiResponse<{
status: string;
data: {
userId: number;
integration: string;
externalId: string;
credentialId: number | null;
};
}>,
unknown,
{ integration: string; externalId: string }
>({
mutationFn: (data) => {
return http.put(`/destination-calendars`, data).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
return updatedDestinationCalendar;
};
+3 -1
View File
@@ -20,4 +20,6 @@ export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalenda
export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar";
export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";
export { useTeams } from "./hooks/teams/useTeams";
export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/wrappers/CalendarSettingsPlatformWrapper";
export { SelectedCalendarSettingsPlatformWrapper as SelectedCalendarSettings } from "./selected-calendar/index";
export { DestinationCalendarSettingsPlatformWrapper as DestinationCalendarSettings } from "./destination-calendar/index";
export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/index";
@@ -0,0 +1,16 @@
// Extra styles to show prefixed text in react-select
export const getPlaceholderContent = (hidePlaceholder = false, contentText = "Create events on") => {
if (!hidePlaceholder) {
return {
alignItems: "center",
width: "100%",
display: "flex",
":before": {
content: contentText,
display: "block",
marginRight: 8,
},
};
}
return {};
};
+2 -1
View File
@@ -7,4 +7,5 @@ export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useHandleBookEvent } from "./hooks/useHandleBookEvent";
export * as Dialog from "./src/components/ui/dialog";
export { Timezone } from "./timezone";
export { CalendarSettingsWebWrapper } from "./calendar-settings/wrappers/CalendarSettingsWebWrapper";
export { SelectedCalendarSettingsWebWrapper } from "./selected-calendar/wrappers/SelectedCalendarSettingsWebWrapper";
export { DestinationCalendarSettingsWebWrapper } from "./destination-calendar/wrappers/DestinationCalendarSettingsWebWrapper";
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { cn } from "../src/lib/utils";
export const SelectedCalendarSettings = (props: { children: ReactNode; classNames?: string }) => {
return <div className={cn("border-subtle mt-6 rounded-lg border", props.classNames)}>{props.children}</div>;
};
@@ -0,0 +1 @@
export { SelectedCalendarSettingsPlatformWrapper } from "./wrappers/SelectedCalendarSettingsPlatformWrapper";
@@ -0,0 +1,246 @@
import { useState } from "react";
import type { ICalendarSwitchProps } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { CALENDARS } from "@calcom/platform-constants";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import type { ButtonProps } from "@calcom/ui";
import {
CalendarSwitchComponent,
AppListCard,
List,
DisconnectIntegrationComponent,
Alert,
} from "@calcom/ui";
import { useAddSelectedCalendar } from "../../hooks/calendars/useAddSelectedCalendar";
import { useDeleteCalendarCredentials } from "../../hooks/calendars/useDeleteCalendarCredentials";
import { useRemoveSelectedCalendar } from "../../hooks/calendars/useRemoveSelectedCalendar";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { Switch } from "../../src/components/ui/switch";
import { useToast } from "../../src/components/ui/use-toast";
import { SelectedCalendarSettings } from "../SelectedCalendarSettings";
export const SelectedCalendarSettingsPlatformWrapper = ({
classNames = "mx-5 mb-6",
}: {
classNames?: string;
}) => {
const { t } = useLocale();
const query = useConnectedCalendars({});
return (
<AtomsWrapper>
<div>
<QueryCell
query={query}
success={({ data }) => {
const destinationCalendarId = data.destinationCalendar.externalId;
if (!data.connectedCalendars.length) {
return null;
}
return (
<SelectedCalendarSettings classNames={classNames}>
<SelectedCalendarSettingsHeading />
<List noBorderTreatment className="p-6 pt-2">
{data.connectedCalendars.map((connectedCalendar) => {
if (!!connectedCalendar.calendars && connectedCalendar.calendars.length > 0) {
return (
<AppListCard
key={`list-${connectedCalendar.credentialId}-${connectedCalendar.integration.slug}`}
shouldHighlight
slug={connectedCalendar.integration.slug}
title={connectedCalendar.integration.name}
logo={`https://app.cal.com${connectedCalendar.integration.logo}`}
description={
connectedCalendar.primary?.email ?? connectedCalendar.integration.description
}
className="border-subtle mt-4 rounded-lg border"
actions={
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
/>
</div>
}>
<div className="border-subtle border-t">
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-4 px-5 py-4">
{connectedCalendar.calendars?.map((cal) => {
return (
<PlatformCalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={connectedCalendar.integration.type}
isChecked={cal.isSelected}
destination={cal.externalId === destinationCalendarId}
credentialId={cal.credentialId}
/>
);
})}
</ul>
</div>
</AppListCard>
);
}
return (
<Alert
key={`alert-${connectedCalendar.credentialId}`}
severity="warning"
title={t("something_went_wrong")}
message={<span>{t("calendar_error")}</span>}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
/>
</div>
}
/>
);
})}
</List>
</SelectedCalendarSettings>
);
}}
/>
</div>
</AtomsWrapper>
);
};
const SelectedCalendarSettingsHeading = () => {
const { t } = useLocale();
return (
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
<div>
<h4 className="text-emphasis text-base font-semibold leading-5">{t("check_for_conflicts")}</h4>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
</div>
</div>
);
};
const PlatformDisconnectIntegration = (props: {
credentialId: number;
label?: string;
slug?: string;
trashIcon?: boolean;
isGlobal?: boolean;
onSuccess?: () => void;
buttonProps?: ButtonProps;
}) => {
const { t } = useLocale();
const { onSuccess, credentialId, slug } = props;
const [modalOpen, setModalOpen] = useState(false);
const { toast } = useToast();
const { mutate: deleteCalendarCredentials } = useDeleteCalendarCredentials({
onSuccess: () => {
toast({
description: t("app_removed_successfully"),
});
setModalOpen(false);
onSuccess && onSuccess();
},
onError: () => {
toast({
description: t("error_removing_app"),
});
setModalOpen(false);
},
});
return (
<DisconnectIntegrationComponent
onDeletionConfirmation={async () => {
slug &&
(await deleteCalendarCredentials({
calendar: slug.split("-")[0] as unknown as (typeof CALENDARS)[number],
id: credentialId,
}));
}}
{...props}
isModalOpen={modalOpen}
onModalOpen={() => setModalOpen((prevValue) => !prevValue)}
/>
);
};
const PlatformCalendarSwitch = (props: ICalendarSwitchProps) => {
const { isChecked, title, credentialId, type, externalId } = props;
const [checkedInternal, setCheckedInternal] = useState(isChecked);
const { toast } = useToast();
const { mutate: addSelectedCalendar, isPending: isAddingSelectedCalendar } = useAddSelectedCalendar({
onError: (err) => {
toast({
description: `Something went wrong while adding calendar - ${title}. ${err}`,
});
},
});
const { mutate: removeSelectedCalendar, isPending: isRemovingSelectedCalendar } = useRemoveSelectedCalendar(
{
onError: (err) => {
toast({
description: `Something went wrong while removing calendar - ${title}. ${err}`,
});
},
}
);
const toggleSelectedCalendars = async ({
isOn,
credentialId,
integration,
externalId,
}: {
isOn: boolean;
credentialId: number;
integration: string;
externalId: string;
}) => {
if (isOn) {
await addSelectedCalendar({ credentialId, integration, externalId });
} else {
await removeSelectedCalendar({ credentialId, integration, externalId });
}
};
return (
<CalendarSwitchComponent
destination={props.destination}
{...props}
isChecked={checkedInternal}
isLoading={isAddingSelectedCalendar || isRemovingSelectedCalendar}>
<Switch
checked={checkedInternal}
id={externalId}
onCheckedChange={async () => {
setCheckedInternal((prevValue) => !prevValue);
await toggleSelectedCalendars({
isOn: !checkedInternal,
credentialId,
externalId,
integration: type,
});
}}
/>
</CalendarSwitchComponent>
);
};
@@ -11,16 +11,17 @@ import { List } from "@calcom/ui";
import AppListCard from "@calcom/web/components/AppListCard";
import AdditionalCalendarSelector from "@calcom/web/components/apps/AdditionalCalendarSelector";
import { CalendarSettings } from "../CalendarSettings";
import { SelectedCalendarSettings } from "../SelectedCalendarSettings";
type CalendarSettingsWebWrapperProps = {
type SelectedCalendarSettingsWebWrapperProps = {
onChanged: () => unknown | Promise<unknown>;
fromOnboarding?: boolean;
destinationCalendarId?: string;
isPending?: boolean;
classNames?: string;
};
export const CalendarSettingsWebWrapper = (props: CalendarSettingsWebWrapperProps) => {
export const SelectedCalendarSettingsWebWrapper = (props: SelectedCalendarSettingsWebWrapperProps) => {
const { t } = useLocale();
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
suspense: true,
@@ -38,8 +39,8 @@ export const CalendarSettingsWebWrapper = (props: CalendarSettingsWebWrapperProp
}
return (
<CalendarSettings>
<CalendarSettingsHeading
<SelectedCalendarSettings classNames={props.classNames}>
<SelectedCalendarSettingsHeading
isConnectedCalendarsPresent={!!data.connectedCalendars.length}
isPending={isPending}
/>
@@ -121,7 +122,7 @@ export const CalendarSettingsWebWrapper = (props: CalendarSettingsWebWrapperProp
);
})}
</List>
</CalendarSettings>
</SelectedCalendarSettings>
);
}}
/>
@@ -129,7 +130,10 @@ export const CalendarSettingsWebWrapper = (props: CalendarSettingsWebWrapperProp
);
};
const CalendarSettingsHeading = (props: { isConnectedCalendarsPresent: boolean; isPending?: boolean }) => {
const SelectedCalendarSettingsHeading = (props: {
isConnectedCalendarsPresent: boolean;
isPending?: boolean;
}) => {
const { t } = useLocale();
return (
@@ -17,7 +17,7 @@ export default function Calendars(props: { calUsername: string; calEmail: string
You have not connected any calendars yet, please connect your Google, Outlook or Apple calendar.
</h1>
)}
<div className="mx-10 my-4">
<div>
<CalendarSettings />
</div>
</main>