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:
co-authored by
Morgan
Morgan Vernay
Peer Richelsen
parent
5df14c344e
commit
f81074c4f5
@@ -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;
|
||||
|
||||
+171
@@ -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();
|
||||
});
|
||||
});
|
||||
+46
@@ -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;
|
||||
}
|
||||
+50
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
+17
-235
@@ -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";
|
||||
+48
@@ -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>
|
||||
);
|
||||
};
|
||||
+31
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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";
|
||||
+246
@@ -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
-7
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user