Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685eadc30b | ||
|
|
c821f15a14 | ||
|
|
8a972a74c6 | ||
|
|
cf76cf7aae | ||
|
|
4dd762681c | ||
|
|
399a23a52f | ||
|
|
342f935aba | ||
|
|
27e4210693 | ||
|
|
66cb2bcc13 | ||
|
|
30c31d3585 | ||
|
|
e17303ea88 | ||
|
|
bebf1d80a7 | ||
|
|
5bcefb0b3e | ||
|
|
f52de7331f | ||
|
|
1690770d6a | ||
|
|
7759e6a029 |
@@ -34,6 +34,11 @@ jobs:
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-atoms:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/atoms-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Atoms production Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build atoms
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- name: Cache atoms production build
|
||||
uses: buildjet/cache@v4
|
||||
id: cache-atoms-build
|
||||
env:
|
||||
cache-name: atoms-build
|
||||
key-1: ${{ hashFiles('yarn.lock') }}
|
||||
key-2: ${{ hashFiles('packages/platform/atoms/**.[jt]s', 'packages/platform/atoms/**.[jt]sx', '!**/node_modules') }}
|
||||
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||
# Ensures production-build.yml will always be fresh
|
||||
key-4: ${{ github.sha }}
|
||||
with:
|
||||
path: |
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for atoms build. Skipping build."
|
||||
else
|
||||
rm -rf packages/platform/atoms/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/atoms run build
|
||||
fi
|
||||
shell: bash
|
||||
@@ -14,7 +14,15 @@ jobs:
|
||||
steps:
|
||||
- name: Label PR as ready for E2E
|
||||
if: github.event.review.state == 'approved'
|
||||
uses: actions-ecosystem/action-add-labels@v1
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
script: |
|
||||
await github.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: process.env.labels.split(', '),
|
||||
})
|
||||
env:
|
||||
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
|
||||
labels: 'ready-for-e2e'
|
||||
|
||||
@@ -131,6 +131,13 @@ jobs:
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-atoms:
|
||||
name: Production builds
|
||||
needs: [changes, check-label, deps]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/atoms-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-enums build",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -9,7 +10,11 @@ import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_04_15 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(
|
||||
userId: number,
|
||||
@@ -46,7 +51,7 @@ export class EventTypesRepository_2024_04_15 {
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
|
||||
+7
-5
@@ -6,6 +6,7 @@ import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@@ -24,7 +25,8 @@ export class EventTypesService_2024_04_15 {
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(
|
||||
@@ -53,11 +55,11 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
const isOrgAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
@@ -80,7 +82,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
@@ -153,7 +155,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return { ...user, profile: { id: profileId }, selectedCalendars };
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,13 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/dist/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -34,7 +35,11 @@ type InputEventTransformed = Omit<
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(userId: number, body: InputEventTransformed) {
|
||||
return this.dbWrite.prisma.eventType.create({
|
||||
@@ -80,7 +85,7 @@ export class EventTypesRepository_2024_06_14 {
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
|
||||
+4
-4
@@ -119,11 +119,11 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
const isOrgAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -159,7 +159,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
@@ -270,7 +270,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return { ...user, profile: { id: profileId }, selectedCalendars };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
@@ -12,13 +13,14 @@ export class OrganizationRolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private organizationsService: OrganizationsService,
|
||||
private membershipRepository: MembershipsRepository
|
||||
private membershipRepository: MembershipsRepository,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user: UserWithProfile = request.user;
|
||||
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
|
||||
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
|
||||
|
||||
if (!user || !organizationId) {
|
||||
throw new ForbiddenException("No organization associated with the user.");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
duration: 1000,
|
||||
},
|
||||
}),
|
||||
UsersModule,
|
||||
],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
|
||||
exports: [BillingService, BillingRepository],
|
||||
|
||||
+5
-3
@@ -13,6 +13,7 @@ import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-gu
|
||||
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
@@ -59,7 +60,8 @@ export class OAuthClientsController {
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
private readonly teamsRepository: OrganizationsRepository,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -74,7 +76,7 @@ export class OAuthClientsController {
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: CreateOAuthClientInput
|
||||
): Promise<CreateOAuthClientResponseDto> {
|
||||
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
this.logger.log(
|
||||
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
|
||||
);
|
||||
@@ -100,7 +102,7 @@ export class OAuthClientsController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
|
||||
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
|
||||
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
|
||||
return { status: SUCCESS_STATUS, data: clients };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
@@ -11,12 +12,12 @@ import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientGuard implements CanActivate {
|
||||
constructor(private oAuthClientRepository: OAuthClientRepository) {}
|
||||
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
|
||||
const user: GetUserReturnType = request.user;
|
||||
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
|
||||
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
|
||||
const oAuthClientId = request.params.clientId;
|
||||
|
||||
if (!oAuthClientId) {
|
||||
|
||||
+1
-1
@@ -17,6 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
HostPriority,
|
||||
SchedulingType,
|
||||
} from "@calcom/platform-types";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class InputOrganizationsEventTypesService {
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ export class OrganizationsEventTypesService {
|
||||
async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
|
||||
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId);
|
||||
const profileId =
|
||||
this.usersService.getUserProfileByOrgId(user, organizationId)?.id || user.movedToProfileId;
|
||||
this.usersService.getUserProfileByOrgId(user, organizationId)?.id ||
|
||||
this.usersService.getUserMainProfile(user)?.id;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
|
||||
@@ -30,6 +30,10 @@ export class UsersService {
|
||||
);
|
||||
}
|
||||
|
||||
getUserMainOrgId(user: UserWithProfile) {
|
||||
return this.getUserMainProfile(user)?.organizationId ?? user.organizationId;
|
||||
}
|
||||
|
||||
getUserProfileByOrgId(user: UserWithProfile, organizationId: number) {
|
||||
return user.profiles?.find((p) => p.organizationId === organizationId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import {
|
||||
CanActivate,
|
||||
@@ -17,7 +18,8 @@ import { PlatformOAuthClient, Webhook } from "@calcom/prisma/client";
|
||||
export class IsOAuthClientWebhookGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly webhooksService: WebhooksService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -27,7 +29,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
||||
const user = request.user as GetUserReturnType;
|
||||
const webhookId = request.params.webhookId;
|
||||
const oAuthClientId = request.params.clientId;
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException("User not authenticated");
|
||||
|
||||
@@ -4955,6 +4955,9 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Source"
|
||||
}
|
||||
},
|
||||
"disableOnPrefill": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5529,6 +5532,9 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Source"
|
||||
}
|
||||
},
|
||||
"disableOnPrefill": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import Page from "@calcom/features/ee/sso/page/teams-sso-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("sso_configuration"),
|
||||
(t) => t("sso_configuration_description")
|
||||
);
|
||||
|
||||
export default Page;
|
||||
@@ -8,8 +8,24 @@ export default function NoPlatformPlan() {
|
||||
description="You are not subscribed to a Platform plan."
|
||||
buttonRaw={
|
||||
<div className="flex gap-2">
|
||||
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
|
||||
<Button color="secondary" href="https://cal.com/pricing">
|
||||
<Button
|
||||
className="hover:bg-slate-300 hover:text-black"
|
||||
color="secondary"
|
||||
href="/settings/platform/new">
|
||||
Subscribe
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="hover:bg-slate-300 hover:text-black"
|
||||
href="https://cal.com/platform/pricing"
|
||||
target="_blank">
|
||||
Go to Pricing
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="hover:bg-slate-300 hover:text-black"
|
||||
href="https://cal.com/sales"
|
||||
target="_blank">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,10 @@ export const platformPlans: IndividualPlatformPlan[] = [
|
||||
pricing: 99,
|
||||
includes: [
|
||||
"Up to 100 bookings a month",
|
||||
"$0.99 overage beyond",
|
||||
"Community Support",
|
||||
"Cal Atoms (React Library)",
|
||||
"Platform APIs",
|
||||
"Admin APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -26,9 +26,9 @@ export const platformPlans: IndividualPlatformPlan[] = [
|
||||
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 299,
|
||||
includes: [
|
||||
"Up to 500 bookings a month. $0,60 overage beyond",
|
||||
"Everything in Starter",
|
||||
"Cal Atoms (React Library)",
|
||||
"Up to 500 bookings a month",
|
||||
"$0.60 overage beyond",
|
||||
"User Management and Analytics",
|
||||
"Technical Account Manager and Onboarding Support",
|
||||
],
|
||||
@@ -39,8 +39,9 @@ export const platformPlans: IndividualPlatformPlan[] = [
|
||||
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
|
||||
pricing: 2499,
|
||||
includes: [
|
||||
"Up to 5000 bookings a month. $0.50 overage beyond",
|
||||
"Everything in Essentials",
|
||||
"Up to 5000 bookings a month",
|
||||
"$0.50 overage beyond",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
@@ -51,7 +52,18 @@ export const platformPlans: IndividualPlatformPlan[] = [
|
||||
},
|
||||
{
|
||||
plan: "Enterprise",
|
||||
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
|
||||
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
|
||||
description:
|
||||
"Enterprise is perfect for institutions that focus on control, security and everything beyond.",
|
||||
includes: [
|
||||
"Everything in Scale",
|
||||
"No overages",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
"Help with Credentials Verification (Zoom, Google App Store)",
|
||||
"Expedited features and integrations",
|
||||
"SLA (99.999% uptime)",
|
||||
"Volume Discount",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -280,7 +280,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<p className="text-subtle">{t("current_username")}</p>
|
||||
<p className="text-emphasis mt-1" data-testid="current-username">
|
||||
<p className="text-emphasis mt-1 break-all" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
@@ -288,7 +288,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
<p className="text-subtle" data-testid="new-username">
|
||||
{t("new_username")}
|
||||
</p>
|
||||
<p className="text-emphasis">{inputUsernameValue}</p>
|
||||
<p className="text-emphasis break-all">{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
<p className="text-subtle">{t("current_username")}</p>
|
||||
<Tooltip content={currentUsername}>
|
||||
<p
|
||||
className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis"
|
||||
className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis break-all"
|
||||
data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
@@ -175,7 +175,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
{t("new_username")}
|
||||
</p>
|
||||
<Tooltip content={inputUsernameValue}>
|
||||
<p className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis">
|
||||
<p className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis break-all">
|
||||
{inputUsernameValue}
|
||||
</p>
|
||||
</Tooltip>
|
||||
|
||||
@@ -741,7 +741,7 @@ export default function Success(props: PageProps) {
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{userIsOwner && !isCancelled && isRejectionMode && (
|
||||
{!isCancelled && isRejectionMode && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<RejectBooking
|
||||
|
||||
@@ -170,7 +170,8 @@ const InfiniteMobileTeamsTab: FC<InfiniteMobileTeamsTabProps> = (props) => {
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.4.2",
|
||||
"version": "4.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -36,6 +36,7 @@
|
||||
"@calcom/embed-snippet": "workspace:*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
|
||||
@@ -321,15 +321,6 @@ export default function Signup({
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
{/* Left side */}
|
||||
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
|
||||
{/* Header */}
|
||||
{errors.apiError && (
|
||||
<Alert
|
||||
className="mb-3"
|
||||
severity="error"
|
||||
message={errors.apiError?.message}
|
||||
data-testid="signup-error-message"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-cal text-[28px] leading-none ">
|
||||
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
|
||||
@@ -408,6 +399,14 @@ export default function Signup({
|
||||
onChange={() => handleConsentChange(COOKIE_CONSENT)}
|
||||
description={t("cookie_consent_checkbox")}
|
||||
/>
|
||||
{errors.apiError && (
|
||||
<Alert
|
||||
className="mb-3"
|
||||
severity="error"
|
||||
message={errors.apiError?.message}
|
||||
data-testid="signup-error-message"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="my-2 w-full justify-center"
|
||||
|
||||
@@ -7,15 +7,10 @@ import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { moveUserToOrg } from "@lib/orgMigration";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { IS_STRIPE_ENABLED } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
);
|
||||
|
||||
const IS_SELF_HOSTED = !(
|
||||
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com")
|
||||
);
|
||||
|
||||
@@ -33,6 +33,10 @@ export const createRoutingFormsFixture = () => {
|
||||
label: string;
|
||||
identifier?: string;
|
||||
required: boolean;
|
||||
options?: {
|
||||
label: string;
|
||||
id: string | null;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return await prisma.app_RoutingForms_Form.create({
|
||||
|
||||
@@ -301,6 +301,10 @@ export const createUsersFixture = (
|
||||
}
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
const option2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
|
||||
const option1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
routes: [
|
||||
@@ -366,6 +370,26 @@ export const createUsersFixture = (
|
||||
},
|
||||
{
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: multiSelectLegacyFieldUuid,
|
||||
value: [["Option-2"]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
@@ -374,8 +398,8 @@ export const createUsersFixture = (
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "d4292635-9f12-17b1-9153-c3a854649182",
|
||||
value: [["Option-2"]],
|
||||
field: multiSelectFieldUuid,
|
||||
value: [[option2Uuid]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
@@ -399,13 +423,30 @@ export const createUsersFixture = (
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "d4292635-9f12-17b1-9153-c3a854649182",
|
||||
id: multiSelectLegacyFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
label: "Multi Select(with Legacy `selectText`)",
|
||||
identifier: "multi",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: multiSelectFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi-new-format",
|
||||
options: [
|
||||
{
|
||||
id: option1Uuid,
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: option2Uuid,
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
|
||||
@@ -7,19 +7,11 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { test, todo } from "./lib/fixtures";
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
import { IS_STRIPE_ENABLED, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.STRIPE_PRIVATE_KEY &&
|
||||
process.env.PAYMENT_FEE_FIXED &&
|
||||
process.env.PAYMENT_FEE_PERCENTAGE
|
||||
);
|
||||
|
||||
test.describe("Stripe integration skip true", () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
|
||||
@@ -26,6 +26,14 @@ export const testName = "Test Testson";
|
||||
export const teamEventTitle = "Team Event - 30min";
|
||||
export const teamEventSlug = "team-event-30min";
|
||||
|
||||
export const IS_STRIPE_ENABLED = !!(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.STRIPE_PRIVATE_KEY &&
|
||||
process.env.PAYMENT_FEE_FIXED &&
|
||||
process.env.PAYMENT_FEE_PERCENTAGE
|
||||
);
|
||||
|
||||
export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) {
|
||||
const {
|
||||
requestHandler = ({ res }) => {
|
||||
|
||||
@@ -12,14 +12,9 @@ import {
|
||||
bookTimeSlot,
|
||||
doOnOrgDomain,
|
||||
goToUrlWithErrorHandling,
|
||||
IS_STRIPE_ENABLED,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
);
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
@@ -164,6 +159,9 @@ test.describe("Reschedule Tests", async () => {
|
||||
});
|
||||
|
||||
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed");
|
||||
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await user.installStripePersonal({ skip: true });
|
||||
|
||||
@@ -647,6 +647,7 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
response: "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -673,14 +674,33 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
label: "Option-1",
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
label: "Option-2",
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
const textFieldIdentifier = "name";
|
||||
const multiSelectFieldIdentifier = "multi";
|
||||
await page.fill(`[data-testid="form-field-${textFieldIdentifier}"]`, "John Doe");
|
||||
await page.click(`[data-testid="form-field-${multiSelectFieldIdentifier}"]`); // Open dropdown
|
||||
await page.click("text=Option-2"); // Select option
|
||||
page.click('button[type="submit"]');
|
||||
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
@@ -699,10 +719,23 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
response: "John Doe",
|
||||
},
|
||||
multi: {
|
||||
value: ["Option-2"],
|
||||
response: [
|
||||
{
|
||||
id: "2",
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
/* Legacy fields Start */
|
||||
name: "John Doe",
|
||||
multi: ["Option-2"],
|
||||
/* Legacy fields End */
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
|
||||
@@ -92,6 +92,18 @@
|
||||
<path d="M3 10h18" ></path>
|
||||
<path d="m16 20 2 2 4-4" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-calendar-days" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="calendar-days">
|
||||
<path d="M8 2v4" ></path>
|
||||
<path d="M16 2v4" ></path>
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" ></rect>
|
||||
<path d="M3 10h18" ></path>
|
||||
<path d="M8 14h.01" ></path>
|
||||
<path d="M12 14h.01" ></path>
|
||||
<path d="M16 14h.01" ></path>
|
||||
<path d="M8 18h.01" ></path>
|
||||
<path d="M12 18h.01" ></path>
|
||||
<path d="M16 18h.01" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-calendar-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="calendar-heart">
|
||||
<path d="M3 10h18V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7" ></path>
|
||||
<path d="M8 2v4" ></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
@@ -81,15 +81,24 @@ declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toHaveEmail(expectedEmail: ExpectedEmail, to: string): R;
|
||||
toHaveEmail(expectedEmail: ExpectedEmail, to: string, subject?: string): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string) {
|
||||
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string, subject?: string) {
|
||||
const { isNot } = this;
|
||||
const testEmail = emails.get().find((email) => email.to.includes(to));
|
||||
const testEmail = emails.get().find((email) => {
|
||||
const filterConditions = [email.to.includes(to)];
|
||||
|
||||
if (subject) {
|
||||
filterConditions.push(email.subject === subject);
|
||||
}
|
||||
|
||||
return filterConditions.every((condition) => condition);
|
||||
});
|
||||
|
||||
const emailsToLog = emails
|
||||
.get()
|
||||
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
|
||||
@@ -742,10 +751,12 @@ export function expectSuccessfulBookingRescheduledEmails({
|
||||
export function expectAwaitingPaymentEmails({
|
||||
emails,
|
||||
booker,
|
||||
subject,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
subject?: string;
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
@@ -753,7 +764,8 @@ export function expectAwaitingPaymentEmails({
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
noIcs: true,
|
||||
},
|
||||
`${booker.email}`
|
||||
`${booker.email}`,
|
||||
subject
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -277,17 +277,16 @@ export default class Office365CalendarService implements Calendar {
|
||||
dateTime: dayjs(event.endTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: [
|
||||
// Add the calEvent organizer
|
||||
{
|
||||
emailAddress: {
|
||||
address: event.destinationCalendar
|
||||
? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ??
|
||||
event.organizer.email
|
||||
: event.organizer.email,
|
||||
name: event.organizer.name,
|
||||
},
|
||||
organizer: {
|
||||
emailAddress: {
|
||||
address: event.destinationCalendar
|
||||
? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ??
|
||||
event.organizer.email
|
||||
: event.organizer.email,
|
||||
name: event.organizer.name,
|
||||
},
|
||||
},
|
||||
attendees: [
|
||||
...event.attendees.map((attendee) => ({
|
||||
emailAddress: {
|
||||
address: attendee.email,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermis
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import type { Response, SerializableForm } from "../../types/types";
|
||||
import type { FormResponse, SerializableForm } from "../../types/types";
|
||||
|
||||
function escapeCsvText(str: string) {
|
||||
return str.replace(/,/, "%2C");
|
||||
@@ -32,7 +32,7 @@ async function* getResponses(
|
||||
) {
|
||||
const csv: string[] = [];
|
||||
responses.forEach((response) => {
|
||||
const fieldResponses = response.response as Response;
|
||||
const fieldResponses = response.response as FormResponse;
|
||||
const csvCells: string[] = [];
|
||||
headerFields.forEach((headerField) => {
|
||||
const fieldResponse = fieldResponses[headerField.id];
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import getFieldIdentifier from "../lib/getFieldIdentifier";
|
||||
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import transformResponse from "../lib/transformResponse";
|
||||
import type { SerializableForm, Response } from "../types/types";
|
||||
import { getUIOptionsForSelect } from "../lib/selectOptions";
|
||||
import { getFieldResponseForJsonLogic } from "../lib/transformResponse";
|
||||
import type { SerializableForm, FormResponse } from "../types/types";
|
||||
|
||||
type Props = {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
response: Response;
|
||||
setResponse: Dispatch<SetStateAction<Response>>;
|
||||
response: FormResponse;
|
||||
setResponse: Dispatch<SetStateAction<FormResponse>>;
|
||||
};
|
||||
|
||||
export default function FormInputFields(props: Props) {
|
||||
@@ -31,14 +32,7 @@ export default function FormInputFields(props: Props) {
|
||||
}
|
||||
const Component = widget.factory;
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
const options = getUIOptionsForSelect(field);
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
@@ -62,7 +56,7 @@ export default function FormInputFields(props: Props) {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value: transformResponse({ field, value }),
|
||||
value: getFieldResponseForJsonLogic({ field, value }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||
import { RoutingPages } from "../lib/RoutingPages";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { processRoute } from "../lib/processRoute";
|
||||
import type { Response, Route, SerializableForm } from "../types/types";
|
||||
import type { FormResponse, Route, SerializableForm } from "../types/types";
|
||||
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
|
||||
import FormInputFields from "./FormInputFields";
|
||||
import RoutingNavBar from "./RoutingNavBar";
|
||||
@@ -238,7 +238,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
|
||||
const { t } = useLocale();
|
||||
|
||||
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
|
||||
const [response, setResponse] = useState<Response>({});
|
||||
const [response, setResponse] = useState<FormResponse>({});
|
||||
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
|
||||
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
|
||||
const [eventTypeUrl, setEventTypeUrl] = useState("");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FieldTypes } from "../pages/form-edit/[...appPages]";
|
||||
import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types";
|
||||
import { InitialConfig } from "./InitialConfig";
|
||||
import { getUIOptionsForSelect } from "./selectOptions";
|
||||
|
||||
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
const fields: Record<
|
||||
@@ -22,14 +23,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
field = field.routerField;
|
||||
}
|
||||
if (FieldTypes.map((f) => f.value).includes(field.type)) {
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
const options = getUIOptionsForSelect(field);
|
||||
|
||||
const widget = InitialConfig.widgets[field.type];
|
||||
const widgetType = widget.type;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { zodFields, zodRoutes } from "../zod";
|
||||
import getConnectedForms from "./getConnectedForms";
|
||||
import isRouter from "./isRouter";
|
||||
import isRouterLinkedField from "./isRouterLinkedField";
|
||||
import { getFieldWithOptions } from "./selectOptions";
|
||||
|
||||
/**
|
||||
* Doesn't have deleted fields by default
|
||||
@@ -57,7 +58,9 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
}));
|
||||
const finalFields = fields;
|
||||
|
||||
const finalFields = fields.map((field) => getFieldWithOptions(field));
|
||||
|
||||
let teamMembers: SerializableFormTeamMembers[] = [];
|
||||
if (form.teamId) {
|
||||
teamMembers = await prisma.user.findMany({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { Response, Route, SerializableForm } from "../types/types";
|
||||
import type { FormResponse, Route, SerializableForm } from "../types/types";
|
||||
import type { zodNonRouterRoute } from "../zod";
|
||||
import { getQueryBuilderConfig } from "./getQueryBuilderConfig";
|
||||
import { isFallbackRoute } from "./isFallbackRoute";
|
||||
@@ -16,7 +16,7 @@ export function processRoute({
|
||||
response,
|
||||
}: {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
response: Record<string, Pick<Response[string], "value">>;
|
||||
response: Record<string, Pick<FormResponse[string], "value">>;
|
||||
}) {
|
||||
const queryBuilderConfig = getQueryBuilderConfig(form);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function processRoute({
|
||||
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
|
||||
const logic = jsonLogicQuery.logic;
|
||||
let result = false;
|
||||
const responseValues: Record<string, Response[string]["value"]> = {};
|
||||
const responseValues: Record<string, FormResponse[string]["value"]> = {};
|
||||
for (const [uuid, { value }] of Object.entries(response)) {
|
||||
responseValues[uuid] = value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
*
|
||||
* This file holds the utilities to build the options to render in the select field and it could be loaded on client side as well.
|
||||
*/
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { zodFieldView } from "../zod";
|
||||
|
||||
type Field = z.infer<typeof zodFieldView>;
|
||||
|
||||
const buildOptionsFromLegacySelectText = ({ legacySelectText }: { legacySelectText: string }) => {
|
||||
return legacySelectText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((fieldValue) => ({
|
||||
label: fieldValue,
|
||||
id: null,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getFieldWithOptions = <T extends Field>(field: T) => {
|
||||
const legacySelectText = field.selectText;
|
||||
if (field.options) {
|
||||
return {
|
||||
...field,
|
||||
options: field.options,
|
||||
};
|
||||
} else if (legacySelectText) {
|
||||
const options = buildOptionsFromLegacySelectText({ legacySelectText });
|
||||
return {
|
||||
...field,
|
||||
options,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
};
|
||||
};
|
||||
|
||||
export function getUIOptionsForSelect(field: Field) {
|
||||
return getFieldWithOptions(field).options?.map((option) => {
|
||||
// We prefer option.id as that doesn't change when we change the option text/label.
|
||||
// Fallback to option.label for fields saved in DB in old format which didn't have `options`
|
||||
const value = option.id ?? option.label;
|
||||
return {
|
||||
value,
|
||||
title: option.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
import type { Response, Route, Field } from "../types/types";
|
||||
import type { FormResponse, Route, Field } from "../types/types";
|
||||
import getFieldIdentifier from "./getFieldIdentifier";
|
||||
|
||||
export const substituteVariables = (
|
||||
routeValue: Route["action"]["value"],
|
||||
response: Response,
|
||||
response: FormResponse,
|
||||
fields: Field[]
|
||||
) => {
|
||||
const regex = /\{([^\}]+)\}/g;
|
||||
@@ -15,7 +15,11 @@ export const substituteVariables = (
|
||||
|
||||
variables.forEach((variable) => {
|
||||
for (const key in response) {
|
||||
const identifier = getFieldIdentifier(fields.find((field) => field.id === key));
|
||||
const field = fields.find((field) => field.id === key)
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
const identifier = getFieldIdentifier(field);
|
||||
if (identifier.toLowerCase() === variable.toLowerCase()) {
|
||||
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, slugify(response[key].value.toString() || ""));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getFieldResponseForJsonLogic } from "./transformResponse";
|
||||
|
||||
describe("getFieldResponseForJsonLogic", () => {
|
||||
it("should return an empty string if value is undefined", () => {
|
||||
const field = { type: "text", options: undefined };
|
||||
const value = undefined;
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should transform value for a number type field to number", () => {
|
||||
const field = { type: "number", options: undefined };
|
||||
const value = "123";
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
describe("multiselect", () => {
|
||||
describe("non-legacy options", () => {
|
||||
it("should return option ids for the field if the value is an array of option ids", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["1", "2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("should return ids if matching labels are provided in value", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["Option 1", "Option 2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("should return the value as it is if it doesn't match any of the labels or option ids", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["Option 10", "Option 11"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["Option 10", "Option 11"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy options", () => {
|
||||
it("should return what is given for a field that has legacy options but the values don't match labels", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["1", "2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("should return matching labels for a field that has legacy options", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["Option 1", "Option 2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["Option 1", "Option 2"]);
|
||||
});
|
||||
|
||||
it("should handle multiselect field with legacy options", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ label: "Option 1", id: null },
|
||||
{ label: "Option 2", id: null },
|
||||
],
|
||||
};
|
||||
const value = ["Option 1", "Option 2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["Option 1", "Option 2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("select", () => {
|
||||
it("should handle select field with options", () => {
|
||||
const field = {
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = "1";
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
|
||||
it("should handle select field with legacy options", () => {
|
||||
const field = {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Option 1", id: null },
|
||||
{ label: "Option 2", id: null },
|
||||
],
|
||||
};
|
||||
const value = "Option 1";
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("Option 1");
|
||||
});
|
||||
|
||||
it("should handle select field with number as string", () => {
|
||||
const field = {
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = 1;
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,59 @@
|
||||
import type { Field, Response } from "../types/types";
|
||||
import type { Field, FormResponse } from "../types/types";
|
||||
|
||||
export default function transformResponse({
|
||||
/**
|
||||
* It takes care of correctly transforming the input to label or id depending on various cases
|
||||
* - It allows us to prefill with ID or Label
|
||||
* - It allows backward compatibility with legacy routes(with labels for options)
|
||||
*/
|
||||
function transformSelectValue({
|
||||
field,
|
||||
idOrLabel,
|
||||
}: {
|
||||
field: Pick<Field, "options" | "type">;
|
||||
idOrLabel: string;
|
||||
}) {
|
||||
idOrLabel = idOrLabel.trim();
|
||||
const options = field.options;
|
||||
if (!options) {
|
||||
return idOrLabel;
|
||||
}
|
||||
const areOptionsInLegacyFormat = !!options.find((option) => !option.id);
|
||||
// Because for legacy saved options, routes must have labels in them instead of ids
|
||||
const shouldUseLabelAsValue = areOptionsInLegacyFormat;
|
||||
const foundOptionById = options.find((option) => option.id === idOrLabel);
|
||||
if (foundOptionById) {
|
||||
if (shouldUseLabelAsValue) {
|
||||
return foundOptionById.label;
|
||||
} else {
|
||||
// If shouldUseLabelAsValue is false, then we must use id as value
|
||||
// Because shouldUseLabelAsValue is false, id must be set already
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return foundOptionById.id!;
|
||||
}
|
||||
} else {
|
||||
// No option was found that matches ID
|
||||
// So check if the label is provided
|
||||
const foundOptionByLabel = options.find((option) => {
|
||||
return option.label === idOrLabel;
|
||||
});
|
||||
if (foundOptionByLabel) {
|
||||
if (!shouldUseLabelAsValue) {
|
||||
// If shouldUseLabelAsValue is false, then we must use id as value
|
||||
// Because shouldUseLabelAsValue is false, id must be set already
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return foundOptionByLabel.id!;
|
||||
}
|
||||
}
|
||||
}
|
||||
return idOrLabel;
|
||||
}
|
||||
|
||||
export function getFieldResponseForJsonLogic({
|
||||
field,
|
||||
value,
|
||||
}: {
|
||||
field: Field;
|
||||
value: Response[string]["value"] | undefined;
|
||||
field: Pick<Field, "options" | "type">;
|
||||
value: FormResponse[string]["value"] | undefined;
|
||||
}) {
|
||||
if (!value) {
|
||||
return "";
|
||||
@@ -18,13 +66,24 @@ export default function transformResponse({
|
||||
return value;
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
if (value instanceof Array) {
|
||||
return value;
|
||||
}
|
||||
return value
|
||||
.toString()
|
||||
.split(",")
|
||||
.map((v) => v.trim());
|
||||
// Could be option id(i.e. a UUIDv4) or option label for ease of prefilling
|
||||
let valueOrLabelArray = value instanceof Array ? value : value.toString().split(",");
|
||||
|
||||
valueOrLabelArray = valueOrLabelArray.map((idOrLabel) => {
|
||||
return transformSelectValue({ field, idOrLabel });
|
||||
});
|
||||
|
||||
return valueOrLabelArray;
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
const valueAsStringOrStringArray = typeof value === "number" ? String(value) : value;
|
||||
const valueAsString =
|
||||
valueAsStringOrStringArray instanceof Array
|
||||
? valueAsStringOrStringArray[0]
|
||||
: valueAsStringOrStringArray;
|
||||
|
||||
return transformSelectValue({ field, idOrLabel: valueAsString });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ClipboardEvent } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Controller, useFieldArray, useWatch } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -29,8 +29,26 @@ import SingleForm, {
|
||||
} from "../../components/SingleForm";
|
||||
|
||||
export { getServerSideProps };
|
||||
type SelectOption = { label: string; id: string | null };
|
||||
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
|
||||
type SelectOption = { placeholder: string; value: string; id: string };
|
||||
|
||||
const appendArray = <T,>({
|
||||
target,
|
||||
arrayToAppend,
|
||||
appendAt,
|
||||
}: {
|
||||
arrayToAppend: T[];
|
||||
target: T[];
|
||||
appendAt: number;
|
||||
}) => {
|
||||
// Avoid mutating the original array
|
||||
const copyOfTarget = [...target];
|
||||
const numItemsToRemove = arrayToAppend.length;
|
||||
copyOfTarget.splice(appendAt, numItemsToRemove, ...arrayToAppend);
|
||||
return copyOfTarget;
|
||||
};
|
||||
|
||||
const PASTE_OPTIONS_SEPARATOR_REGEX = /\n+/;
|
||||
|
||||
export const FieldTypes = [
|
||||
{
|
||||
@@ -90,65 +108,51 @@ function Field({
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||
const watchedOptions =
|
||||
useWatch({
|
||||
control: hookForm.control,
|
||||
name: `${hookFieldNamespace}.options`,
|
||||
defaultValue: [
|
||||
{ label: "", id: uuidv4() },
|
||||
{ label: "", id: uuidv4() },
|
||||
{ label: "", id: uuidv4() },
|
||||
{ label: "", id: uuidv4() },
|
||||
],
|
||||
}) || [];
|
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>([
|
||||
{ placeholder: "< 10", value: "", id: uuidv4() },
|
||||
{ placeholder: "10-100", value: "", id: uuidv4() },
|
||||
{ placeholder: "100-500", value: "", id: uuidv4() },
|
||||
{ placeholder: "> 500", value: "", id: uuidv4() },
|
||||
]);
|
||||
|
||||
const handleRemoveOptions = (index: number) => {
|
||||
const updatedOptions = options.filter((_, i) => i !== index);
|
||||
setOptions(updatedOptions);
|
||||
updateSelectText(updatedOptions);
|
||||
const setOptions = (updatedOptions: SelectOption[]) => {
|
||||
hookForm.setValue(`${hookFieldNamespace}.options`, updatedOptions);
|
||||
};
|
||||
|
||||
const handleAddOptions = () => {
|
||||
setOptions((prevState) => [
|
||||
...prevState,
|
||||
const handleRemoveOptions = (index: number) => {
|
||||
const updatedOptions = watchedOptions.filter((_, i) => i !== index);
|
||||
// We can't let the user remove the last option
|
||||
if (updatedOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
setOptions(updatedOptions);
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setOptions([
|
||||
...watchedOptions,
|
||||
{
|
||||
placeholder: "New Option",
|
||||
value: "",
|
||||
label: "",
|
||||
id: uuidv4(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const originalValues = hookForm.getValues(`${hookFieldNamespace}.selectText`);
|
||||
if (originalValues) {
|
||||
const values: SelectOption[] = originalValues.split("\n").map((fieldValue) => ({
|
||||
value: fieldValue,
|
||||
placeholder: "",
|
||||
id: uuidv4(),
|
||||
}));
|
||||
setOptions(values);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
|
||||
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, optionIndex: number) => {
|
||||
const updatedOptions = options.map((opt, index) => ({
|
||||
const updateLabelAtIndex = ({ label, optionIndex }: { label: string; optionIndex: number }) => {
|
||||
const updatedOptions = watchedOptions.map((opt, index) => ({
|
||||
...opt,
|
||||
...(index === optionIndex ? { value: e.target.value } : {}),
|
||||
...(index === optionIndex ? { label } : {}),
|
||||
}));
|
||||
|
||||
setOptions(updatedOptions);
|
||||
updateSelectText(updatedOptions);
|
||||
};
|
||||
|
||||
const updateSelectText = (updatedOptions: SelectOption[]) => {
|
||||
hookForm.setValue(
|
||||
`${hookFieldNamespace}.selectText`,
|
||||
updatedOptions
|
||||
.filter((opt) => opt.value)
|
||||
.map((opt) => opt.value)
|
||||
.join("\n")
|
||||
);
|
||||
};
|
||||
|
||||
const label = useWatch({
|
||||
@@ -162,18 +166,49 @@ function Field({
|
||||
});
|
||||
|
||||
function move(index: number, increment: 1 | -1) {
|
||||
const newList = [...options];
|
||||
const newList = [...watchedOptions];
|
||||
|
||||
const type = options[index];
|
||||
const tmp = options[index + increment];
|
||||
const type = watchedOptions[index];
|
||||
const tmp = watchedOptions[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = tmp;
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
setOptions(newList);
|
||||
updateSelectText(newList);
|
||||
}
|
||||
|
||||
const handlePasteInOptionAtIndex = ({
|
||||
event,
|
||||
optionIndex,
|
||||
}: {
|
||||
event: ClipboardEvent;
|
||||
optionIndex: number;
|
||||
}) => {
|
||||
const paste = event.clipboardData.getData("text");
|
||||
// The value being pasted could be a list of options
|
||||
const optionsBeingPasted = paste
|
||||
.split(PASTE_OPTIONS_SEPARATOR_REGEX)
|
||||
.map((optionLabel) => optionLabel.trim())
|
||||
.filter((optionLabel) => optionLabel)
|
||||
.map((optionLabel) => ({ label: optionLabel.trim(), id: uuidv4() }));
|
||||
if (optionsBeingPasted.length === 1) {
|
||||
// If there is only one option, we would just let that option be pasted
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow pasting that value, as we would update the options through state update
|
||||
event.preventDefault();
|
||||
|
||||
const updatedOptions = appendArray({
|
||||
target: watchedOptions,
|
||||
arrayToAppend: optionsBeingPasted,
|
||||
appendAt: optionIndex,
|
||||
});
|
||||
setOptions(updatedOptions);
|
||||
};
|
||||
|
||||
const optionsPlaceholders = ["< 10", "10 - 100", "100 - 500", "> 500"];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="field"
|
||||
@@ -261,10 +296,15 @@ function Field({
|
||||
{t("options")}
|
||||
</Skeleton>
|
||||
<ul ref={animationRef}>
|
||||
{options.map((field, index) => (
|
||||
<li key={`select-option-${field.id}`} className="group mt-2 flex items-center gap-2">
|
||||
{watchedOptions.map((option, index) => (
|
||||
<li
|
||||
key={`select-option-${option.id}`}
|
||||
className="group mt-2 flex items-center gap-2"
|
||||
onPaste={(event: ClipboardEvent) =>
|
||||
handlePasteInOptionAtIndex({ event, optionIndex: index })
|
||||
}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.length && index !== 0 ? (
|
||||
{watchedOptions.length && index !== 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
@@ -272,7 +312,7 @@ function Field({
|
||||
<Icon name="arrow-up" />
|
||||
</button>
|
||||
) : null}
|
||||
{options.length && index !== options.length - 1 ? (
|
||||
{watchedOptions.length && index !== watchedOptions.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
@@ -287,18 +327,22 @@ function Field({
|
||||
containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400"
|
||||
className="border-0 focus:ring-0 focus:ring-offset-0"
|
||||
labelSrOnly
|
||||
placeholder={field.placeholder.toString()}
|
||||
value={field.value}
|
||||
placeholder={optionsPlaceholders[index] ?? "New Option"}
|
||||
value={option.label}
|
||||
type="text"
|
||||
required
|
||||
addOnClassname="bg-transparent border-0"
|
||||
onChange={(e) => handleChange(e, index)}
|
||||
onChange={(e) => updateLabelAtIndex({ label: e.target.value, optionIndex: index })}
|
||||
dataTestid={`${hookFieldNamespace}.options.${index}`}
|
||||
addOnSuffix={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveOptions(index)}
|
||||
aria-label={t("remove")}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</button>
|
||||
watchedOptions.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveOptions(index)}
|
||||
aria-label={t("remove")}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -312,7 +356,7 @@ function Field({
|
||||
type="button"
|
||||
StartIcon="plus"
|
||||
color="secondary"
|
||||
onClick={handleAddOptions}>
|
||||
onClick={addOption}>
|
||||
Add an option
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import { processRoute } from "../../lib/processRoute";
|
||||
import { substituteVariables } from "../../lib/substituteVariables";
|
||||
import transformResponse from "../../lib/transformResponse";
|
||||
import type { Response } from "../../types/types";
|
||||
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
|
||||
import type { FormResponse } from "../../types/types";
|
||||
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
|
||||
@@ -96,7 +96,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
form: enrichFormWithMigrationData(formWithUserProfile),
|
||||
});
|
||||
|
||||
const response: Response = {};
|
||||
const response: FormResponse = {};
|
||||
if (!serializableForm.fields) {
|
||||
throw new Error("Form has no fields");
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
|
||||
response[field.id] = {
|
||||
label: field.label,
|
||||
value: transformResponse({ field, value: fieldResponse }),
|
||||
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
|
||||
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import { processRoute } from "../../lib/processRoute";
|
||||
import { substituteVariables } from "../../lib/substituteVariables";
|
||||
import transformResponse from "../../lib/transformResponse";
|
||||
import type { NonRouterRoute, Response } from "../../types/types";
|
||||
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
|
||||
import type { NonRouterRoute, FormResponse } from "../../types/types";
|
||||
import { getServerSideProps } from "./getServerSideProps";
|
||||
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
const useBrandColors = ({
|
||||
@@ -59,10 +60,13 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
// - like a network error
|
||||
// - or he abandoned booking flow in between
|
||||
const formFillerId = formFillerIdRef.current;
|
||||
const decidedActionWithFormResponseRef = useRef<{ action: NonRouterRoute["action"]; response: Response }>();
|
||||
const decidedActionWithFormResponseRef = useRef<{
|
||||
action: NonRouterRoute["action"];
|
||||
response: FormResponse;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = (response: Response) => {
|
||||
const onSubmit = (response: FormResponse) => {
|
||||
const decidedAction = processRoute({ form, response });
|
||||
|
||||
if (!decidedAction) {
|
||||
@@ -97,7 +101,11 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
if (!fields) {
|
||||
throw new Error("Routing Form fields must exist here");
|
||||
}
|
||||
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
|
||||
const allURLSearchParams = getUrlSearchParamsToForward({
|
||||
formResponse: decidedActionWithFormResponse.response,
|
||||
fields,
|
||||
searchParams: new URLSearchParams(window.location.search),
|
||||
});
|
||||
const decidedAction = decidedActionWithFormResponse.action;
|
||||
sdkActionManager?.fire("routed", {
|
||||
actionType: decidedAction.type,
|
||||
@@ -191,55 +199,6 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
|
||||
type Params = Record<string, string | string[]>;
|
||||
const paramsFromResponse: Params = {};
|
||||
const paramsFromCurrentUrl: Params = {};
|
||||
|
||||
// Build query params from response
|
||||
Object.entries(response).forEach(([key, fieldResponse]) => {
|
||||
const foundField = fields.find((f) => f.id === key);
|
||||
if (!foundField) {
|
||||
// If for some reason, the field isn't there, let's just
|
||||
return;
|
||||
}
|
||||
const valueAsStringOrStringArray =
|
||||
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
|
||||
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
|
||||
valueAsStringOrStringArray;
|
||||
});
|
||||
|
||||
// Build query params from current URL. It excludes route params
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
|
||||
const target = paramsFromCurrentUrl[name];
|
||||
if (target instanceof Array) {
|
||||
target.push(value);
|
||||
} else {
|
||||
paramsFromCurrentUrl[name] = [value];
|
||||
}
|
||||
}
|
||||
|
||||
const allQueryParams: Params = {
|
||||
...paramsFromCurrentUrl,
|
||||
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
|
||||
...paramsFromResponse,
|
||||
};
|
||||
|
||||
const allQueryURLSearchParams = new URLSearchParams();
|
||||
|
||||
// Make serializable URLSearchParams instance
|
||||
Object.entries(allQueryParams).forEach(([param, value]) => {
|
||||
const valueArray = value instanceof Array ? value : [value];
|
||||
valueArray.forEach((v) => {
|
||||
allQueryURLSearchParams.append(param, v);
|
||||
});
|
||||
});
|
||||
|
||||
return allQueryURLSearchParams;
|
||||
}
|
||||
|
||||
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
return <RoutingForm {...props} />;
|
||||
}
|
||||
@@ -250,7 +209,7 @@ export { getServerSideProps };
|
||||
|
||||
const usePrefilledResponse = (form: Props["form"]) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const prefillResponse: Response = {};
|
||||
const prefillResponse: FormResponse = {};
|
||||
|
||||
// Prefill the form from query params
|
||||
form.fields?.forEach((field) => {
|
||||
@@ -259,10 +218,10 @@ const usePrefilledResponse = (form: Props["form"]) => {
|
||||
const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0];
|
||||
|
||||
prefillResponse[field.id] = {
|
||||
value: transformResponse({ field, value }),
|
||||
value: getFieldResponseForJsonLogic({ field, value }),
|
||||
label: field.label,
|
||||
};
|
||||
});
|
||||
const [response, setResponse] = useState<Response>(prefillResponse);
|
||||
const [response, setResponse] = useState<FormResponse>(prefillResponse);
|
||||
return [response, setResponse] as const;
|
||||
};
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
||||
|
||||
function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
|
||||
if (entries === null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Consider setting atleast ES2015 as target
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
for (const [key, value] of entries) {
|
||||
if (result.hasOwnProperty(key)) {
|
||||
let currentValue = result[key];
|
||||
if (!Array.isArray(currentValue)) {
|
||||
currentValue = [currentValue];
|
||||
}
|
||||
currentValue.push(value);
|
||||
result[key] = currentValue;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("getUrlSearchParamsToForward", () => {
|
||||
it("should build query params from response correctly when identifier is present in fields", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "value1", label: "Field 1" },
|
||||
[field2Id]: { value: ["option1", "option2"], label: "Field 2" },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, identifier: "f1", type: "text", label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
identifier: "f2",
|
||||
label: "Field 2",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
const expectedParams = {
|
||||
f1: "value1",
|
||||
f2: ["Option 1", "Option 2"],
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should build query params from response correctly when identifier is not present in fields. Should fallback to label", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: "value2" },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1", type: "text" },
|
||||
{ id: field2Id, label: "Field 2", type: "text" },
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
|
||||
const expectedParams = {
|
||||
"Field 1": "value1",
|
||||
"Field 2": "value2",
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should handle select fields correctly when options have id set", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "Option 1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field1Id,
|
||||
label: "Field 1",
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
{ id: "option3", label: "Option 3" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
|
||||
const expectedParams = {
|
||||
"Field 1": "Option 1",
|
||||
"Field 2": ["Option 1", "Option 2"],
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should handle select fields correctly when options have no id set(Legacy options)", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "Option 1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field1Id,
|
||||
label: "Field 1",
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
{ id: "option3", label: "Option 3" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
|
||||
const expectedParams = {
|
||||
"Field 1": "Option 1",
|
||||
"Field 2": ["Option 1", "Option 2"],
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should handle number values correctly", () => {
|
||||
const field1Id = uuidv4();
|
||||
const response = {
|
||||
[field1Id]: { value: 123 },
|
||||
};
|
||||
|
||||
const fields = [{ id: field1Id, label: "Field 1", type: "number" }];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
const expectedParams = {
|
||||
"Field 1": "123",
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse: response,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import type { getServerSideProps } from "./getServerSideProps";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
export function getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
}: {
|
||||
formResponse: Record<
|
||||
string,
|
||||
{
|
||||
value: number | string | string[];
|
||||
}
|
||||
>;
|
||||
fields: Pick<
|
||||
NonNullable<Props["form"]["fields"]>[number],
|
||||
"id" | "type" | "options" | "identifier" | "label"
|
||||
>[];
|
||||
searchParams: URLSearchParams;
|
||||
}) {
|
||||
type Params = Record<string, string | string[]>;
|
||||
const paramsFromResponse: Params = {};
|
||||
const paramsFromCurrentUrl: Params = {};
|
||||
|
||||
// Build query params from response
|
||||
Object.entries(formResponse).forEach(([key, fieldResponse]) => {
|
||||
const foundField = fields.find((f) => f.id === key);
|
||||
if (!foundField) {
|
||||
// If for some reason, the field isn't there, let's just
|
||||
return;
|
||||
}
|
||||
let valueAsStringOrStringArray =
|
||||
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
|
||||
if (foundField.type === "select" || foundField.type === "multiselect") {
|
||||
const options = foundField.options || [];
|
||||
let arr =
|
||||
valueAsStringOrStringArray instanceof Array
|
||||
? valueAsStringOrStringArray
|
||||
: [valueAsStringOrStringArray];
|
||||
arr = arr.map((idOrLabel) => {
|
||||
const foundOptionById = options.find((option) => {
|
||||
return option.id === idOrLabel;
|
||||
});
|
||||
if (foundOptionById) {
|
||||
return foundOptionById.label;
|
||||
}
|
||||
return idOrLabel;
|
||||
});
|
||||
valueAsStringOrStringArray = foundField.type === "select" ? arr[0] : arr;
|
||||
}
|
||||
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
|
||||
valueAsStringOrStringArray;
|
||||
});
|
||||
|
||||
// Build query params from current URL. It excludes route params
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
for (const [name, value] of searchParams.entries()) {
|
||||
const target = paramsFromCurrentUrl[name];
|
||||
if (target instanceof Array) {
|
||||
target.push(value);
|
||||
} else {
|
||||
paramsFromCurrentUrl[name] = [value];
|
||||
}
|
||||
}
|
||||
|
||||
const allQueryParams: Params = {
|
||||
...paramsFromCurrentUrl,
|
||||
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
|
||||
...paramsFromResponse,
|
||||
};
|
||||
|
||||
const allQueryURLSearchParams = new URLSearchParams();
|
||||
|
||||
// Make serializable URLSearchParams instance
|
||||
Object.entries(allQueryParams).forEach(([param, value]) => {
|
||||
const valueArray = value instanceof Array ? value : [value];
|
||||
valueArray.forEach((v) => {
|
||||
allQueryURLSearchParams.append(param, v);
|
||||
});
|
||||
});
|
||||
|
||||
return allQueryURLSearchParams;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ function todo(title: string) {
|
||||
test.skip(title, () => {});
|
||||
}
|
||||
|
||||
const Identifiers = {
|
||||
multi: "multi",
|
||||
multiNewFormat: "multi-new-format",
|
||||
};
|
||||
|
||||
test.describe("Routing Forms", () => {
|
||||
test.describe("Zero State Routing Forms", () => {
|
||||
test("should be able to add a new form and view it", async ({ page }) => {
|
||||
@@ -254,11 +259,11 @@ test.describe("Routing Forms", () => {
|
||||
responses.push(rowResponses);
|
||||
}
|
||||
|
||||
expect(headers).toEqual(["Test field", "Multi Select"]);
|
||||
expect(headers).toEqual(["Test field", "Multi Select(with Legacy `selectText`)", "Multi Select"]);
|
||||
expect(responses).toEqual([
|
||||
["event-routing", ""],
|
||||
["external-redirect", ""],
|
||||
["custom-page", ""],
|
||||
["event-routing", "", ""],
|
||||
["external-redirect", "", ""],
|
||||
["custom-page", "", ""],
|
||||
]);
|
||||
|
||||
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
|
||||
@@ -281,19 +286,21 @@ test.describe("Routing Forms", () => {
|
||||
});
|
||||
const csvRows = csv.trim().split("\n");
|
||||
const csvHeaderRow = csvRows[0];
|
||||
expect(csvHeaderRow).toEqual("Test field,Multi Select,Submission Time");
|
||||
expect(csvHeaderRow).toEqual(
|
||||
"Test field,Multi Select(with Legacy `selectText`),Multi Select,Submission Time"
|
||||
);
|
||||
|
||||
const firstResponseCells = csvRows[1].split(",");
|
||||
const secondResponseCells = csvRows[2].split(",");
|
||||
const thirdResponseCells = csvRows[3].split(",");
|
||||
|
||||
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,");
|
||||
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,,");
|
||||
expect(new Date(firstResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
|
||||
|
||||
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,");
|
||||
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,,");
|
||||
expect(new Date(secondResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
|
||||
|
||||
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,");
|
||||
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,,");
|
||||
expect(new Date(thirdResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
|
||||
});
|
||||
|
||||
@@ -318,8 +325,13 @@ test.describe("Routing Forms", () => {
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
||||
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
||||
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
|
||||
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multi}=Option-2`);
|
||||
await expect(page.locator("text=Multiselect(Legacy) chosen")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.goto(
|
||||
`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multiNewFormat}=d1302635-9f12-17b1-9153-c3a854649182`
|
||||
);
|
||||
await expect(page.locator("text=Multiselect chosen")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("Routing Link should validate fields", async ({ page, users }) => {
|
||||
@@ -364,8 +376,34 @@ test.describe("Routing Forms", () => {
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("External Redirect");
|
||||
expect(route).toBe("https://google.com");
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
|
||||
// Multiselect(Legacy)
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
|
||||
await page.click(`[data-testid="form-field-${Identifiers.multi}"]`); // Open dropdown
|
||||
await page.click("text=Option-2"); // Select option
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("Custom Page");
|
||||
expect(route).toBe("Multiselect(Legacy) chosen");
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
|
||||
// Multiselect
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
|
||||
await page.click(`[data-testid="form-field-${Identifiers.multiNewFormat}"]`); // Open dropdown
|
||||
await page.click("text=Option-2"); // Select option
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("Custom Page");
|
||||
expect(route).toBe("Multiselect chosen");
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
|
||||
//fallback route
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
await page.fill('[data-testid="form-field-Test field"]', "fallback");
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
@@ -452,8 +490,11 @@ async function addAllTypesOfFieldsAndSaveForm(
|
||||
identifier = "firstField";
|
||||
}
|
||||
|
||||
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
|
||||
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
|
||||
if (fieldTypeLabel === "Multiple Selection" || fieldTypeLabel === "Single Selection") {
|
||||
await page.fill(`[data-testid="fields.${nth}.options.0-input"]`, "123");
|
||||
await page.fill(`[data-testid="fields.${nth}.options.1-input"]`, "456");
|
||||
await page.fill(`[data-testid="fields.${nth}.options.2-input"]`, "789");
|
||||
await page.fill(`[data-testid="fields.${nth}.options.3-input"]`, "10-11-12");
|
||||
}
|
||||
|
||||
await page.fill(`[name="fields.${nth}.label"]`, label);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildResponsesForReporting } from "./report.handler";
|
||||
|
||||
describe("buildResponsesForReporting", () => {
|
||||
it("for fields with options, it should return the labels of the options", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["option1", "option2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with no options but having array value, it should return the value as is", () => {
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 2"]);
|
||||
});
|
||||
|
||||
it("should correctly handle numeric responses converting them to strings", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: 1 },
|
||||
[field2Id]: { value: [1, 2] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const expectedResponses = [["1", "1, 2"]];
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("should handle empty responses", () => {
|
||||
const field1Id = uuidv4();
|
||||
const responsesFromDb = [{}];
|
||||
|
||||
const fields = [{ id: field1Id, label: "Field 1" }];
|
||||
|
||||
const expectedResponses = [[""]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1"]);
|
||||
});
|
||||
|
||||
it("should show correct header for deleted fields", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
deleted: true,
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(expectedHeaders);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { Response } from "../types/types";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { zodNonRouterField, zodFieldView } from "../zod";
|
||||
import type { TReportInputSchema } from "./report.schema";
|
||||
|
||||
interface ReportHandlerOptions {
|
||||
@@ -13,6 +17,45 @@ interface ReportHandlerOptions {
|
||||
};
|
||||
input: TReportInputSchema;
|
||||
}
|
||||
|
||||
type Field = z.infer<typeof zodNonRouterField>;
|
||||
|
||||
function ensureStringOrStringArray(value: string | number | (string | number)[]): string | string[] {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
} else if (value instanceof Array) {
|
||||
return value.map((v) => v.toString());
|
||||
}
|
||||
return [value.toString()];
|
||||
}
|
||||
|
||||
function getLabelsFromOptionIds({
|
||||
options,
|
||||
optionIds,
|
||||
}: {
|
||||
options: NonNullable<Field["options"]>;
|
||||
optionIds: string | string[];
|
||||
}) {
|
||||
if (optionIds instanceof Array) {
|
||||
const labels = optionIds.map((optionId) => {
|
||||
const foundOption = options.find((option) => option.id === optionId);
|
||||
// It would mean that the optionId is actually a label which is why it isn't matching any option id.
|
||||
// Fallback to optionId(i.e. label) which was the case with legacy options
|
||||
if (!foundOption) {
|
||||
return optionId;
|
||||
}
|
||||
return foundOption.label;
|
||||
});
|
||||
return labels;
|
||||
} else {
|
||||
const foundOption = options.find((option) => option.id === optionIds);
|
||||
if (!foundOption) {
|
||||
return [optionIds];
|
||||
}
|
||||
return [foundOption.label];
|
||||
}
|
||||
}
|
||||
|
||||
export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
|
||||
// Can be any prisma `where` clause
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -49,27 +92,13 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
|
||||
const fields = serializedForm?.fields || [];
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: (string | number)[][] = [];
|
||||
rows.forEach((r) => {
|
||||
const rowResponses: (string | number)[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r.response) {
|
||||
return;
|
||||
}
|
||||
const response = r.response as Response;
|
||||
const value = response[field.id]?.value || "";
|
||||
let transformedValue;
|
||||
if (value instanceof Array) {
|
||||
transformedValue = value.join(", ");
|
||||
} else {
|
||||
transformedValue = value;
|
||||
}
|
||||
rowResponses.push(transformedValue);
|
||||
});
|
||||
const { responses, headers } = buildResponsesForReporting({
|
||||
responsesFromDb: rows.map((r) => r.response),
|
||||
fields,
|
||||
});
|
||||
|
||||
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
|
||||
return {
|
||||
headers,
|
||||
@@ -79,3 +108,36 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
|
||||
};
|
||||
|
||||
export default reportHandler;
|
||||
|
||||
export function buildResponsesForReporting({
|
||||
responsesFromDb,
|
||||
fields,
|
||||
}: {
|
||||
responsesFromDb: App_RoutingForms_FormResponse["response"][];
|
||||
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
|
||||
}) {
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: string[][] = [];
|
||||
responsesFromDb.forEach((r) => {
|
||||
const rowResponses: string[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
const response = r as FormResponse;
|
||||
const value = response[field.id]?.value || "";
|
||||
if (field.options) {
|
||||
const optionIds = ensureStringOrStringArray(value);
|
||||
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
|
||||
rowResponses.push(labels.join(", "));
|
||||
} else {
|
||||
const arrayOfValues = value instanceof Array ? value : [value];
|
||||
const transformedValue = arrayOfValues.join(", ");
|
||||
rowResponses.push(transformedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { responses, headers };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { Response } from "../types/types";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { TResponseInputSchema } from "./response.schema";
|
||||
import { onFormSubmission } from "./utils";
|
||||
|
||||
@@ -117,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
|
||||
await onFormSubmission(
|
||||
{ ...serializableFormWithFields, userWithEmails },
|
||||
dbFormResponse.response as Response
|
||||
dbFormResponse.response as FormResponse
|
||||
);
|
||||
return dbFormResponse;
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,30 +8,97 @@ import { WebhookTriggerEvents } from "@calcom/prisma/client";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import type { OrderedResponses } from "../types/types";
|
||||
import type { Response, SerializableForm } from "../types/types";
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
|
||||
type Field = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
|
||||
|
||||
function isOptionsField(field: Pick<Field, "type" | "options">) {
|
||||
return (field.type === "select" || field.type === "multiselect") && field.options;
|
||||
}
|
||||
|
||||
function getFieldResponse({
|
||||
field,
|
||||
fieldResponseValue,
|
||||
}: {
|
||||
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
|
||||
field: Pick<Field, "type" | "options">;
|
||||
}) {
|
||||
if (!isOptionsField(field)) {
|
||||
return {
|
||||
value: fieldResponseValue,
|
||||
response: fieldResponseValue,
|
||||
};
|
||||
}
|
||||
|
||||
if (!field.options) {
|
||||
return {
|
||||
value: fieldResponseValue,
|
||||
response: fieldResponseValue,
|
||||
};
|
||||
}
|
||||
|
||||
const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue];
|
||||
const chosenOptions = valueArray.map((idOrLabel) => {
|
||||
const foundOptionById = field.options?.find((option) => {
|
||||
return option.id === idOrLabel;
|
||||
});
|
||||
if (foundOptionById) {
|
||||
return {
|
||||
label: foundOptionById.label,
|
||||
id: foundOptionById.id,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: idOrLabel.toString(),
|
||||
id: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
return {
|
||||
// value is a legacy prop that is just sending the labels which can change
|
||||
value: chosenOptions.map((option) => option.label),
|
||||
// response is new prop that is sending the label along with id(which doesn't change)
|
||||
response: chosenOptions,
|
||||
};
|
||||
}
|
||||
|
||||
type SelectFieldWebhookResponse = string | number | string[] | { label: string; id: string | null };
|
||||
type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record<
|
||||
string,
|
||||
{
|
||||
/**
|
||||
* Deprecates `value` prop as it now has both the id(that doesn't change) and the label(that can change but is human friendly)
|
||||
*/
|
||||
response: number | string | string[] | SelectFieldWebhookResponse | SelectFieldWebhookResponse[];
|
||||
/**
|
||||
* @deprecated Use `response` instead
|
||||
*/
|
||||
value: FormResponse[keyof FormResponse]["value"];
|
||||
}
|
||||
>;
|
||||
|
||||
export async function onFormSubmission(
|
||||
form: Ensure<
|
||||
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
|
||||
"fields"
|
||||
>,
|
||||
response: Response
|
||||
response: FormResponse
|
||||
) {
|
||||
const fieldResponsesByName: Record<
|
||||
string,
|
||||
{
|
||||
value: Response[keyof Response]["value"];
|
||||
}
|
||||
> = {};
|
||||
const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
const field = form.fields.find((f) => f.id === fieldId);
|
||||
if (!field) {
|
||||
throw new Error(`Field with id ${fieldId} not found`);
|
||||
}
|
||||
// Use the label lowercased as the key to identify a field.
|
||||
const key =
|
||||
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||
fieldResponsesByName[key] = {
|
||||
value: fieldResponse.value,
|
||||
};
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByIdentifier);
|
||||
fieldResponsesByIdentifier[key] = getFieldResponse({
|
||||
fieldResponseValue: fieldResponse.value,
|
||||
field,
|
||||
});
|
||||
}
|
||||
|
||||
const { userId, teamId } = getWebhookTargetEntity(form);
|
||||
@@ -57,14 +124,14 @@ export async function onFormSubmission(
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId: form.teamId,
|
||||
responses: fieldResponsesByName,
|
||||
responses: fieldResponsesByIdentifier,
|
||||
},
|
||||
rootData: {
|
||||
// Send responses unwrapped at root level for backwards compatibility
|
||||
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
|
||||
...Object.entries(fieldResponsesByIdentifier).reduce((acc, [key, value]) => {
|
||||
acc[key] = value.value;
|
||||
return acc;
|
||||
}, {} as Record<string, Response[keyof Response]["value"]>),
|
||||
}, {} as Record<string, FormResponse[keyof FormResponse]["value"]>),
|
||||
},
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ export type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
export type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
|
||||
|
||||
export type Response = Record<
|
||||
export type FormResponse = Record<
|
||||
// Field ID
|
||||
string,
|
||||
{
|
||||
@@ -20,7 +20,7 @@ export type Response = Record<
|
||||
>;
|
||||
|
||||
export type Fields = z.infer<typeof zodFieldsView>;
|
||||
export type Field = Fields[number];
|
||||
export type Field = NonNullable<Fields>[number];
|
||||
export type Routes = z.infer<typeof zodRoutesView>;
|
||||
export type Route = Routes[0];
|
||||
export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>;
|
||||
@@ -55,4 +55,4 @@ export type SerializableRoute =
|
||||
})
|
||||
| GlobalRoute;
|
||||
|
||||
export type OrderedResponses = Response[string][];
|
||||
export type OrderedResponses = FormResponse[string][];
|
||||
|
||||
@@ -6,9 +6,23 @@ export const zodNonRouterField = z.object({
|
||||
identifier: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
type: z.string(),
|
||||
/**
|
||||
* @deprecated in favour of `options`
|
||||
*/
|
||||
selectText: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
// To keep backwards compatibility with the options generated from legacy selectText, we allow saving null as id
|
||||
// It helps in differentiating whether the routing logic should consider the option.label as value or option.id as value.
|
||||
// This is important for legacy routes which has option.label saved in conditions and it must keep matching with the value of the option
|
||||
id: z.string().or(z.null()),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const zodRouterField = zodNonRouterField.extend({
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { appDataSchema } from "../zod";
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const siteId = getAppData("SITE_ID");
|
||||
const scriptURL = getAppData("SCRIPT_URL");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
return (
|
||||
@@ -19,15 +20,27 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
}}
|
||||
switchChecked={enabled}
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="Site ID"
|
||||
value={siteId}
|
||||
placeholder="Enter your Site ID"
|
||||
onChange={(e) => {
|
||||
setAppData("SITE_ID", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<fieldset className="space-y-2" disabled={disabled}>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="Script URL"
|
||||
value={scriptURL}
|
||||
defaultValue="https://us.umami.is/script.js"
|
||||
placeholder="Enter the script source URL"
|
||||
onChange={(e) => {
|
||||
setAppData("SCRIPT_URL", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="Site ID"
|
||||
value={siteId}
|
||||
placeholder="Enter your Site ID"
|
||||
onChange={(e) => {
|
||||
setAppData("SITE_ID", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"tag": {
|
||||
"scripts": [
|
||||
{
|
||||
"src": "https://us.umami.is/script.js",
|
||||
"src": "{SCRIPT_URL}",
|
||||
"attrs": {
|
||||
"data-website-id": "{SITE_ID}"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
SITE_ID: z.string().optional(),
|
||||
SCRIPT_URL: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { EventStatus } from "ics";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import generateIcsString from "./generateIcsString";
|
||||
|
||||
export enum GenerateIcsRole {
|
||||
ATTENDEE = "attendee",
|
||||
ORGANIZER = "organizer",
|
||||
}
|
||||
|
||||
export default function generateIcsFile({
|
||||
calEvent,
|
||||
title,
|
||||
subtitle,
|
||||
role,
|
||||
status,
|
||||
t,
|
||||
isRequestReschedule,
|
||||
}: {
|
||||
calEvent: CalendarEvent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
role: GenerateIcsRole;
|
||||
status: EventStatus;
|
||||
t?: TFunction;
|
||||
isRequestReschedule?: boolean;
|
||||
}) {
|
||||
// O365 deletes emails if the calendar event is selected. Currently no option to disable this on the web
|
||||
if (
|
||||
role !== GenerateIcsRole.ATTENDEE &&
|
||||
calEvent.destinationCalendar &&
|
||||
calEvent.destinationCalendar[0].integration === "office365_calendar"
|
||||
)
|
||||
return null;
|
||||
|
||||
return {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: calEvent,
|
||||
title,
|
||||
subtitle,
|
||||
role,
|
||||
status,
|
||||
t,
|
||||
isRequestReschedule,
|
||||
}),
|
||||
method: "REQUEST",
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { getWhen } from "@calcom/lib/CalEventParser";
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { GenerateIcsRole } from "./generateIcsFile";
|
||||
|
||||
export enum BookingAction {
|
||||
Create = "create",
|
||||
Cancel = "cancel",
|
||||
@@ -30,7 +32,7 @@ const generateIcsString = ({
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: EventStatus;
|
||||
role: "attendee" | "organizer";
|
||||
role: GenerateIcsRole;
|
||||
isRequestReschedule?: boolean;
|
||||
t?: TFunction;
|
||||
}) => {
|
||||
@@ -47,7 +49,7 @@ const generateIcsString = ({
|
||||
|
||||
const getTextBody = (title: string, subtitle: string): string => {
|
||||
let body: string;
|
||||
if (isRequestReschedule && role === "attendee" && t) {
|
||||
if (isRequestReschedule && role === GenerateIcsRole.ATTENDEE && t) {
|
||||
body = `
|
||||
${title}
|
||||
${getWhen(event, t)}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("new_guests_added"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "attendee",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("new_guests_added"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ATTENDEE,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_request_cancelled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CANCELLED",
|
||||
role: "attendee",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("event_request_cancelled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ATTENDEE,
|
||||
status: "CANCELLED",
|
||||
}),
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_location_changed"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "attendee",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("event_location_changed"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ATTENDEE,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_type_has_been_rescheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "attendee",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("event_type_has_been_rescheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ATTENDEE,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export default class AttendeeScheduledEmail extends BaseEmail {
|
||||
@@ -33,19 +33,15 @@ export default class AttendeeScheduledEmail extends BaseEmail {
|
||||
const clonedCalEvent = cloneDeep(this.calEvent);
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.calEvent.recurringEvent?.count
|
||||
? this.t("your_event_has_been_scheduled_recurring")
|
||||
: this.t("your_event_has_been_scheduled"),
|
||||
role: "attendee",
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.calEvent.recurringEvent?.count
|
||||
? this.t("your_event_has_been_scheduled_recurring")
|
||||
: this.t("your_event_has_been_scheduled"),
|
||||
role: GenerateIcsRole.ATTENDEE,
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "..";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail {
|
||||
@@ -17,19 +17,15 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche
|
||||
const toAddresses = [this.calEvent.attendees[0].email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("request_reschedule_booking"),
|
||||
subtitle: this.t("request_reschedule_subtitle", {
|
||||
organizer: this.calEvent.organizer.name,
|
||||
}),
|
||||
role: "attendee",
|
||||
status: "CANCELLED",
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("request_reschedule_booking"),
|
||||
subtitle: this.t("request_reschedule_subtitle", {
|
||||
organizer: this.calEvent.organizer.name,
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
role: GenerateIcsRole.ATTENDEE,
|
||||
status: "CANCELLED",
|
||||
}),
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: `${this.t("requested_to_reschedule_subject_attendee", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
|
||||
@@ -9,17 +9,13 @@ export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("new_guests_added"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("new_guests_added"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ORGANIZER,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
|
||||
@@ -9,17 +9,13 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_request_cancelled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CANCELLED",
|
||||
role: "organizer",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("event_request_cancelled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CANCELLED",
|
||||
role: GenerateIcsRole.ORGANIZER,
|
||||
}),
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: `${this.t("event_cancelled_subject", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmail {
|
||||
@@ -9,17 +9,13 @@ export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmai
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_location_changed"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("event_location_changed"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ORGANIZER,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "..";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerRequestedToRescheduleEmail extends OrganizerScheduledEmail {
|
||||
@@ -20,21 +20,17 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu
|
||||
const toAddresses = [this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("request_reschedule_title_organizer", {
|
||||
attendee: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
subtitle: this.t("request_reschedule_subtitle_organizer", {
|
||||
attendee: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
role: "organizer",
|
||||
status: "CANCELLED",
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("request_reschedule_title_organizer", {
|
||||
attendee: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
subtitle: this.t("request_reschedule_subtitle_organizer", {
|
||||
attendee: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
role: GenerateIcsRole.ORGANIZER,
|
||||
status: "CANCELLED",
|
||||
}),
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: `${this.t("rescheduled_event_type_subject", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
|
||||
@@ -9,17 +9,13 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_type_has_been_rescheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.t("event_type_has_been_rescheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ORGANIZER,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
|
||||
|
||||
@@ -8,7 +8,8 @@ import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import generateIcsFile from "../lib/generateIcsFile";
|
||||
import { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
@@ -31,19 +32,15 @@ export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.calEvent.recurringEvent?.count
|
||||
? this.t("new_event_scheduled_recurring")
|
||||
: this.t("new_event_scheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
icalEvent: generateIcsFile({
|
||||
calEvent: this.calEvent,
|
||||
title: this.calEvent.recurringEvent?.count
|
||||
? this.t("new_event_scheduled_recurring")
|
||||
: this.t("new_event_scheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: GenerateIcsRole.ORGANIZER,
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
|
||||
|
||||
@@ -107,6 +107,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
|
||||
bookingFields: true,
|
||||
seatsShowAttendees: true,
|
||||
metadata: true,
|
||||
schedulingType: true,
|
||||
hosts: {
|
||||
select: {
|
||||
user: true,
|
||||
@@ -357,7 +358,13 @@ async function handler(req: CustomRequest) {
|
||||
smsReminderNumber: bookingToDelete.smsReminderNumber,
|
||||
evt: {
|
||||
...evt,
|
||||
...{ eventType: { slug: bookingToDelete.eventType?.slug } },
|
||||
...{
|
||||
eventType: {
|
||||
slug: bookingToDelete.eventType?.slug,
|
||||
schedulingType: bookingToDelete.eventType?.schedulingType,
|
||||
hosts: bookingToDelete.eventType?.hosts,
|
||||
},
|
||||
},
|
||||
},
|
||||
hideBranding: !!bookingToDelete.eventType?.owner?.hideBranding,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
|
||||
@@ -135,6 +136,15 @@ export async function handleConfirmation(args: {
|
||||
eventType: {
|
||||
bookingFields: Prisma.JsonValue | null;
|
||||
slug: string;
|
||||
schedulingType: SchedulingType | null;
|
||||
hosts: {
|
||||
user: {
|
||||
email: string;
|
||||
destinationCalendar?: {
|
||||
primaryEmail: string | null;
|
||||
} | null;
|
||||
};
|
||||
}[];
|
||||
owner: {
|
||||
hideBranding?: boolean | null;
|
||||
} | null;
|
||||
@@ -175,6 +185,21 @@ export async function handleConfirmation(args: {
|
||||
select: {
|
||||
slug: true,
|
||||
bookingFields: true,
|
||||
schedulingType: true,
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
destinationCalendar: {
|
||||
select: {
|
||||
primaryEmail: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
@@ -220,11 +245,26 @@ export async function handleConfirmation(args: {
|
||||
select: {
|
||||
slug: true,
|
||||
bookingFields: true,
|
||||
schedulingType: true,
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
destinationCalendar: {
|
||||
select: {
|
||||
primaryEmail: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
@@ -249,7 +289,11 @@ export async function handleConfirmation(args: {
|
||||
const evtOfBooking = {
|
||||
...evt,
|
||||
metadata: { videoCallUrl: meetingUrl },
|
||||
eventType: { slug: eventTypeSlug },
|
||||
eventType: {
|
||||
slug: eventTypeSlug,
|
||||
schedulingType: updatedBookings[index].eventType?.schedulingType,
|
||||
hosts: updatedBookings[index].eventType?.hosts,
|
||||
},
|
||||
};
|
||||
evtOfBooking.startTime = updatedBookings[index].startTime.toISOString();
|
||||
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
|
||||
|
||||
@@ -1741,7 +1741,11 @@ async function handler(
|
||||
loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
const evtWithMetadata = { ...evt, metadata, eventType: { slug: eventType.slug } };
|
||||
const evtWithMetadata = {
|
||||
...evt,
|
||||
metadata,
|
||||
eventType: { slug: eventType.slug, schedulingType: eventType.schedulingType, hosts: eventType.hosts },
|
||||
};
|
||||
|
||||
if (!eventType.metadata?.disableStandardEmails?.all?.attendee) {
|
||||
await scheduleMandatoryReminder(
|
||||
|
||||
@@ -2654,7 +2654,12 @@ describe("handleNewBooking", () => {
|
||||
|
||||
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
|
||||
expectAwaitingPaymentEmails({ organizer, booker, emails });
|
||||
expectAwaitingPaymentEmails({
|
||||
organizer,
|
||||
booker,
|
||||
emails,
|
||||
subject: "complete_your_booking_subject",
|
||||
});
|
||||
expectBookingPaymentIntiatedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
|
||||
@@ -392,8 +392,7 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectWorkflowToBeTriggered({
|
||||
// emailsToReceive: [organizer.email].concat(otherTeamMembers.map(member => member.email)),
|
||||
emailsToReceive: [organizer.email],
|
||||
emailsToReceive: [organizer.email].concat(otherTeamMembers.map((member) => member.email)),
|
||||
emails,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -104,7 +104,17 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
await scheduleWorkflowReminders({
|
||||
workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
calendarEvent: {
|
||||
...evt,
|
||||
...{
|
||||
metadata,
|
||||
eventType: {
|
||||
slug: eventType.slug,
|
||||
schedulingType: eventType.schedulingType,
|
||||
hosts: eventType.hosts,
|
||||
},
|
||||
},
|
||||
},
|
||||
isNotConfirmed: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
|
||||
@@ -9,7 +9,7 @@ import logger from "@calcom/lib/logger";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
import { SchedulingType, WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
|
||||
@@ -119,6 +119,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
switch (reminder.workflowStep.action) {
|
||||
case WorkflowActions.EMAIL_HOST:
|
||||
sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email;
|
||||
const hosts = reminder?.booking?.eventType?.hosts
|
||||
?.filter((host) =>
|
||||
reminder.booking?.attendees.some((attendee) => attendee.email === host.user.email)
|
||||
)
|
||||
.map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email);
|
||||
const schedulingType = reminder.booking.eventType?.schedulingType;
|
||||
|
||||
if (
|
||||
hosts &&
|
||||
(schedulingType === SchedulingType.COLLECTIVE || schedulingType === SchedulingType.ROUND_ROBIN)
|
||||
) {
|
||||
sendTo = sendTo ? [sendTo, ...hosts] : hosts;
|
||||
}
|
||||
break;
|
||||
case WorkflowActions.EMAIL_ATTENDEE:
|
||||
sendTo = reminder.booking.attendees[0].email;
|
||||
|
||||
@@ -27,7 +27,14 @@ type PartialBooking =
|
||||
| "attendees"
|
||||
| "userPrimaryEmail"
|
||||
| "smsReminderNumber"
|
||||
> & { eventType: (Partial<EventType> & { team: { parentId?: number } }) | null } & {
|
||||
> & {
|
||||
eventType:
|
||||
| (Partial<EventType> & {
|
||||
team: { parentId?: number };
|
||||
hosts: { user: { email: string; destinationCalendar?: { primaryEmail: string } } }[] | undefined;
|
||||
})
|
||||
| null;
|
||||
} & {
|
||||
user: Partial<User> | null;
|
||||
})
|
||||
| null;
|
||||
@@ -159,6 +166,20 @@ export const select: Prisma.WorkflowReminderSelect = {
|
||||
bookingFields: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
destinationCalendar: {
|
||||
select: {
|
||||
primaryEmail: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recurringEvent: true,
|
||||
team: {
|
||||
select: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { scheduleEmailReminder } from "./emailReminderManager";
|
||||
@@ -16,7 +16,11 @@ import { scheduleWhatsappReminder } from "./whatsappReminderManager";
|
||||
|
||||
export type ExtendedCalendarEvent = CalendarEvent & {
|
||||
metadata?: { videoCallUrl: string | undefined };
|
||||
eventType: { slug?: string };
|
||||
eventType: {
|
||||
slug?: string;
|
||||
schedulingType?: SchedulingType | null;
|
||||
hosts?: { user: { email: string; destinationCalendar?: { primaryEmail: string | null } | null } }[];
|
||||
};
|
||||
};
|
||||
|
||||
type ProcessWorkflowStepParams = {
|
||||
@@ -85,6 +89,13 @@ const processWorkflowStep = async (
|
||||
break;
|
||||
case WorkflowActions.EMAIL_HOST:
|
||||
sendTo = [evt.organizer?.email || ""];
|
||||
|
||||
const schedulingType = evt.eventType.schedulingType;
|
||||
const isTeamEvent =
|
||||
schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE;
|
||||
if (isTeamEvent && evt.team?.members) {
|
||||
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
|
||||
}
|
||||
break;
|
||||
case WorkflowActions.EMAIL_ATTENDEE:
|
||||
const attendees = !!emailAttendeeSendToOverride
|
||||
|
||||
@@ -121,10 +121,9 @@ export default function CreateEventTypeDialog({
|
||||
await router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`);
|
||||
|
||||
if (isInfiniteScrollEnabled) {
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({
|
||||
await utils.viewer.eventTypes.getEventTypesFromGroup.fetchInfinite({
|
||||
group: { teamId: eventType.teamId, parentId: eventType.parentId },
|
||||
limit: 10,
|
||||
group: { teamId: eventType?.teamId, parentId: eventType?.parentId },
|
||||
});
|
||||
} else {
|
||||
await utils.viewer.eventTypes.getByViewer.invalidate();
|
||||
|
||||
@@ -214,7 +214,8 @@ const useBanners = () => {
|
||||
|
||||
const Layout = (props: LayoutProps) => {
|
||||
const banners = useBanners();
|
||||
|
||||
const pathname = usePathname();
|
||||
const isFullPageWithoutSidebar = pathname.startsWith("/apps/routing-forms/reporting/");
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const { boot } = useIntercom();
|
||||
const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title;
|
||||
@@ -253,7 +254,7 @@ const Layout = (props: LayoutProps) => {
|
||||
<TimezoneChangeDialog />
|
||||
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{banners && !props.isPlatformUser && (
|
||||
{banners && !props.isPlatformUser && !isFullPageWithoutSidebar && (
|
||||
<div className="sticky top-0 z-10 w-full divide-y divide-black">
|
||||
{Object.keys(banners).map((key) => {
|
||||
if (key === "teamUpgradeBanner") {
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
BookingLimitsKeyOutputType_2024_06_14,
|
||||
TransformBookingLimitsSchema_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookingWindowPeriodOutputTypeEnum_2024_06_14,
|
||||
BookingLimitsEnum_2024_06_14,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
import {
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type Integration_2024_06_14,
|
||||
type BusinessDaysWindow_2024_06_14,
|
||||
type RangeWindow_2024_06_14,
|
||||
type TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
type BookingLimitsKeyOutputType_2024_06_14,
|
||||
type TransformBookingLimitsSchema_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookingWindowPeriodOutputTypeEnum_2024_06_14,
|
||||
} from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
|
||||
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/interval-limits.enum";
|
||||
|
||||
const integrationsMapping: Record<Integration_2024_06_14, string> = {
|
||||
"cal-video": "integrations:daily",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookingWindowPeriodOutputTypeEnum_2024_06_14,
|
||||
BookingLimitsEnum_2024_06_14,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
import type {
|
||||
AddressLocation_2024_06_14,
|
||||
IntegrationLocation_2024_06_14,
|
||||
@@ -13,11 +18,6 @@ import type {
|
||||
BusinessDaysWindow_2024_06_14,
|
||||
BookingField_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookingWindowPeriodOutputTypeEnum_2024_06_14,
|
||||
} from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
|
||||
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/interval-limits.enum";
|
||||
|
||||
import type { transformApiEventTypeBookingFields, transformApiEventTypeLocations } from "./api-request";
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const processResult = (
|
||||
case "username_exists":
|
||||
return {
|
||||
statusCode: 418,
|
||||
message: "A user exists with that username",
|
||||
message: "A user exists with that username or email",
|
||||
};
|
||||
case "is_premium":
|
||||
return { statusCode: 402, message: "This is a premium username." };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { SetStateAction, Dispatch } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
@@ -14,7 +15,6 @@ import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { sortAvailabilityStrings } from "@calcom/lib/weekstart";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
import {
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
TimezoneSelect as WebTimezoneSelect,
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
@@ -97,6 +96,12 @@ type AvailabilitySettingsProps = {
|
||||
customClassNames?: CustomClassNames;
|
||||
disableEditableHeading?: boolean;
|
||||
enableOverrides?: boolean;
|
||||
bulkUpdateModalProps?: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
save: ({ eventTypeIds }: { eventTypeIds: number[] }) => void;
|
||||
isSaving: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const DeleteDialogButton = ({
|
||||
@@ -246,21 +251,11 @@ export function AvailabilitySettings({
|
||||
customClassNames,
|
||||
disableEditableHeading = false,
|
||||
enableOverrides = false,
|
||||
bulkUpdateModalProps,
|
||||
}: AvailabilitySettingsProps) {
|
||||
const [openSidebar, setOpenSidebar] = useState(false);
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const bulkUpdateDefaultAvailabilityMutation =
|
||||
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.availability.list.invalidate();
|
||||
setBulkUpdateModal(false);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<AvailabilityFormValues>({
|
||||
defaultValues: {
|
||||
...schedule,
|
||||
@@ -353,7 +348,7 @@ export function AvailabilitySettings({
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
setBulkUpdateModal(checked);
|
||||
bulkUpdateModalProps?.setIsOpen(checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -362,12 +357,12 @@ export function AvailabilitySettings({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bulkUpdateModal && (
|
||||
{bulkUpdateModalProps && bulkUpdateModalProps?.isOpen && (
|
||||
<BulkEditDefaultForEventsModal
|
||||
isPending={bulkUpdateDefaultAvailabilityMutation.isPending}
|
||||
open={bulkUpdateModal}
|
||||
setOpen={setBulkUpdateModal}
|
||||
bulkUpdateFunction={bulkUpdateDefaultAvailabilityMutation.mutate}
|
||||
isPending={bulkUpdateModalProps?.isSaving}
|
||||
open={bulkUpdateModalProps?.isOpen}
|
||||
setOpen={bulkUpdateModalProps.setIsOpen}
|
||||
bulkUpdateFunction={bulkUpdateModalProps?.save}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
@@ -29,6 +30,16 @@ export const AvailabilitySettingsWebWrapper = () => {
|
||||
const { data: travelSchedules, isPending: isPendingTravelSchedules } =
|
||||
trpc.viewer.getTravelSchedules.useQuery();
|
||||
|
||||
const [isBulkUpdateModalOpen, setIsBulkUpdateModalOpen] = useState(false);
|
||||
const bulkUpdateDefaultAvailabilityMutation =
|
||||
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.availability.list.invalidate();
|
||||
setIsBulkUpdateModalOpen(false);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const isDefaultSchedule = me.data?.defaultScheduleId === scheduleId;
|
||||
|
||||
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
||||
@@ -101,6 +112,12 @@ export const AvailabilitySettingsWebWrapper = () => {
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
bulkUpdateModalProps={{
|
||||
isOpen: isBulkUpdateModalOpen,
|
||||
setIsOpen: setIsBulkUpdateModalOpen,
|
||||
save: bulkUpdateDefaultAvailabilityMutation.mutate,
|
||||
isSaving: bulkUpdateDefaultAvailabilityMutation.isPending,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
|
||||
<Dialog open={isDialogOpen}>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
StartIcon="calendar-days"
|
||||
StartIcon="calendar"
|
||||
color="primary"
|
||||
disabled={isDisabled}
|
||||
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^5.0.10",
|
||||
"vite-plugin-dts": "^3.7.3",
|
||||
"vite-plugin-inspect": "^0.8.4"
|
||||
"vite-plugin-inspect": "^0.8.4",
|
||||
"vite-plugin-node-polyfills": "^0.22.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,59 +1,66 @@
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import path from "path";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import Inspect from "vite-plugin-inspect";
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
"@calcom/lib",
|
||||
"@calcom/features",
|
||||
"@calcom/prisma",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/platform-constants",
|
||||
"@calcom/platform-types",
|
||||
"@calcom/platform-utils",
|
||||
],
|
||||
},
|
||||
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
|
||||
const webAppUrl = env.NEXT_PUBLIC_WEBAPP_URL ?? "https://app.cal.com";
|
||||
return {
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
"@calcom/lib",
|
||||
"@calcom/features",
|
||||
"@calcom/prisma",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/platform-constants",
|
||||
"@calcom/platform-types",
|
||||
"@calcom/platform-utils",
|
||||
],
|
||||
},
|
||||
lib: {
|
||||
entry: [resolve(__dirname, "index.ts")],
|
||||
name: "CalAtoms",
|
||||
fileName: "cal-atoms",
|
||||
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
|
||||
define: {
|
||||
"process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["react", "fs", "path", "os", "react-dom"],
|
||||
output: {
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
|
||||
},
|
||||
lib: {
|
||||
entry: [resolve(__dirname, "index.ts")],
|
||||
name: "CalAtoms",
|
||||
fileName: "cal-atoms",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["react", "fs", "path", "os", "react-dom"],
|
||||
output: {
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
|
||||
path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
|
||||
os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
|
||||
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
|
||||
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
|
||||
"@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"),
|
||||
"@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"),
|
||||
"@calcom/web/public/static/locales/en/common.json": path.resolve(
|
||||
__dirname,
|
||||
"../../../apps/web/public/static/locales/en/common.json"
|
||||
),
|
||||
resolve: {
|
||||
alias: {
|
||||
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
|
||||
path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
|
||||
os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
|
||||
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
|
||||
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
|
||||
"@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"),
|
||||
"@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"),
|
||||
"@calcom/web/public/static/locales/en/common.json": path.resolve(
|
||||
__dirname,
|
||||
"../../../apps/web/public/static/locales/en/common.json"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Contains enums shared by platform api and packages
|
||||
|
||||
from api-v2 please import from @calcom/platform-enums
|
||||
|
||||
from any other package please import from @calcom/platform-enums/monorepo
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user