Compare commits

...
16 Commits
Author SHA1 Message Date
Keith Williams 685eadc30b chore: v4.4.3 (#16321)
release.yml / release (push) Canceled after 0s
2024-08-22 14:28:17 +00:00
Udit TakkarandHariom Balhara c821f15a14 fix: error message when email exists (#16022)
* fix: error message when email exists

* chore: change postion of error

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-08-22 14:20:41 +00:00
Morgan 8a972a74c6 refactor: bulk update modal logic shifted to web wrapper (#16315) 2024-08-22 13:42:43 +00:00
cf76cf7aae feat: Multiple Options Paste and Routing Form improvements related to Select fields (#15706)
* Add handlePaste function and integrate it with options fields

* Prevents function running if user does not paste a list, allows list items to be pasted without deleting following items

* Subs useState for rhf useWatch to fix issues with multiple form fields

* Sets options array as optional property in zod schema

* Assigns default value to options array to fix possibly undefined type errors

* Sets placedholder property as optional

* maps selectText to options on mount

* Removes comma & semicolon delimiting for pasted lists (new line only) & defines regex as a constant

* Sets default value of placeholder to empty string, provides default fallback for component text field if placeholder is omitted.

* Fix type error on placeholder

* Extracts handlePaste to service function & tests thoroughly

* Some improvements and tests arent needed

* More fixes

* Fix reporting as well

* TS fixes

* Do transforms to and fro from label

* Add unit tests

* Unit test reporting

* Test Improvements

* Self review feedback addressed

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-22 13:38:46 +00:00
Keith Williams 4dd762681c chore: Update how E2E test suite checks for Stripe (#16319) 2024-08-22 13:19:50 +00:00
Joe Au-YeungandCarina Wollendorfer 399a23a52f fix: Disable ICS file generation when destination calendar is Outlook (#16247)
* Set the organizer in the organizer field

* Create generateIcsFile

* Add role enum and only disable for organizers

* Move emails to generateIcsFile

* Add new ics func to new emails

* Fix tests

* Type fix

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-08-22 09:49:53 +00:00
Udit Takkar 342f935aba fix: create event type refresh bug (#16301)
* fix: create event type refresh bug

* chore

* fix: type err
2024-08-22 09:43:09 +00:00
27e4210693 fix: atoms icons and build issues (#16248)
* fix: atoms icons and build issues

* fixup! fix: atoms icons and build issues

* fixup! fixup! fix: atoms icons and build issues

* fix import paths

* rename SchedulingType to something more specific

* fix: Invalid define value (must be an entity name or valid JSON syntax)

* chore: atoms build CI job

* chore: enable atoms build in CI

* fix build

* fixup! fix build

* fixup! fixup! fix build

* chore: replace calendar-days icon with calendar

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2024-08-22 08:32:39 +00:00
Amit SharmaandUdit Takkar 66cb2bcc13 fix: Workflow action 'email to host' only sends to one host of team e… (#14767)
* fix: Workflow action 'email to host' only sends to one host of team event

* add in missing files

* fix host logic

* uncomment working test

* code refactor

* use connected calendar email

* add changes to util file

* fix: extend eventType info in sendCancelledReminders

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-08-22 08:04:31 +00:00
Akash RajpurohitandAnik Dhabal Babu 30c31d3585 feat: ✨ support for adding script url added for umami (#16300)
* feat: ✨ support for adding script url added for umami

This will help self hosters in setting up umami.

* feat: ✨ set default value for script URL in the form field

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-22 09:39:03 +02:00
Omar López e17303ea88 fix: permissions issue in pr labeler (#16245) 2024-08-22 01:22:07 +01:00
Anik Dhabal BabuandAmit Sharma bebf1d80a7 fix: round robin event rejection problem (#16279)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-21 17:07:30 +00:00
Benny Joo 5bcefb0b3e settings/teams/[id]/sso is not needed (#16288) 2024-08-21 16:47:34 +00:00
Harshit SharmaandAnik Dhabal Babu f52de7331f fix: UI Breaks When Entering a Long Username in Profile Settings (#16304)
* feat: Improve UI for username availability in UsernameTextfield component

* fix: Fixed UI for username availability in UsernameTextfield component

* Update UsernameTextfield.tsx

* fix: FixedUI for username availability in PremiumTextfield and UsernameTextfield components

* fix: Fixed the PremiumTextField.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-21 16:17:14 +00:00
MorganandLauris Skraucis 1690770d6a fix: check profiles and orgId from user.profiles apiv2 (#16249)
* fix: check profiles and orgId from user.profiles apiv2

* fixup! fix: check profiles and orgId from user.profiles apiv2

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-08-21 15:15:48 +00:00
Rajiv Sahal 7759e6a029 chore: update platform billing plans (#16233)
* update links for no platform plan

* sync platform pricing with one we have in website
2024-08-21 20:14:15 +05:30
120 changed files with 2062 additions and 594 deletions

No files matched your search

+5
View File
@@ -34,6 +34,11 @@ jobs:
uses: ./.github/workflows/api-v2-production-build.yml uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit secrets: inherit
build-atoms:
name: Production builds
uses: ./.github/workflows/atoms-production-build.yml
secrets: inherit
build: build:
name: Production builds name: Production builds
uses: ./.github/workflows/production-build-without-database.yml 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
+9 -1
View File
@@ -14,7 +14,15 @@ jobs:
steps: steps:
- name: Label PR as ready for E2E - name: Label PR as ready for E2E
if: github.event.review.state == 'approved' if: github.event.review.state == 'approved'
uses: actions-ecosystem/action-add-labels@v1 uses: actions/github-script@v4
with: 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 }} github_token: ${{ secrets.READY_FOR_E2E_PAT }}
labels: 'ready-for-e2e' labels: 'ready-for-e2e'
+7
View File
@@ -132,6 +132,13 @@ jobs:
uses: ./.github/workflows/api-v2-production-build.yml uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit 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: build:
name: Production builds name: Production builds
needs: [changes, check-label, deps] needs: [changes, check-label, deps]
+2 -1
View File
@@ -10,7 +10,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "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: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", "dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js", "start:prod": "node ./dist/apps/api/v2/src/main.js",
@@ -25,6 +25,7 @@
}, },
"dependencies": { "dependencies": {
"@calcom/platform-constants": "*", "@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28", "@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-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*", "@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 { 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 { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.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 { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
@@ -9,7 +10,11 @@ import type { PrismaClient } from "@calcom/prisma";
@Injectable() @Injectable()
export class EventTypesRepository_2024_04_15 { 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( async createUserEventType(
userId: number, userId: number,
@@ -46,7 +51,7 @@ export class EventTypesRepository_2024_04_15 {
eventTypeId: number eventTypeId: number
) { ) {
return await getEventTypeById({ return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId, currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId, eventTypeId,
userId: user.id, userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient, prisma: this.dbRead.prisma as unknown as PrismaClient,
@@ -6,6 +6,7 @@ import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs
import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; 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 { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
@@ -24,7 +25,8 @@ export class EventTypesService_2024_04_15 {
private readonly membershipsRepository: MembershipsRepository, private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository, private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {} ) {}
async createUserEventType( async createUserEventType(
@@ -53,11 +55,11 @@ export class EventTypesService_2024_04_15 {
} }
async getUserToCreateEvent(user: UserWithProfile) { async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId; const organizationId = this.usersService.getUserMainOrgId(user);
const isOrgAdmin = organizationId const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false; : false;
const profileId = user.movedToProfile?.id || null; const profileId = this.usersService.getUserMainProfile(user)?.id || null;
return { return {
id: user.id, id: user.id,
role: user.role, role: user.role,
@@ -80,7 +82,7 @@ export class EventTypesService_2024_04_15 {
} }
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) { async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId; const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
@@ -153,7 +155,7 @@ export class EventTypesService_2024_04_15 {
} }
async getUserToUpdateEvent(user: UserWithProfile) { 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); const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars }; return { ...user, profile: { id: profileId }, selectedCalendars };
} }
@@ -20,13 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth"; import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants"; 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 { import {
ApiSuccessResponse, ApiSuccessResponse,
CreateEventTypeInput_2024_06_14, CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14, EventTypeOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types"; } 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"; import { SchedulingType } from "@calcom/prisma/enums";
describe("Event types Endpoints", () => { describe("Event types Endpoints", () => {
@@ -1,5 +1,6 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.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 { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
@@ -34,7 +35,11 @@ type InputEventTransformed = Omit<
@Injectable() @Injectable()
export class EventTypesRepository_2024_06_14 { 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) { async createUserEventType(userId: number, body: InputEventTransformed) {
return this.dbWrite.prisma.eventType.create({ return this.dbWrite.prisma.eventType.create({
@@ -80,7 +85,7 @@ export class EventTypesRepository_2024_06_14 {
eventTypeId: number eventTypeId: number
) { ) {
return await getEventTypeById({ return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId, currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId, eventTypeId,
userId: user.id, userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient, prisma: this.dbRead.prisma as unknown as PrismaClient,
@@ -119,11 +119,11 @@ export class EventTypesService_2024_06_14 {
} }
async getUserToCreateEvent(user: UserWithProfile) { async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId; const organizationId = this.usersService.getUserMainOrgId(user);
const isOrgAdmin = organizationId const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false; : false;
const profileId = user.movedToProfile?.id || null; const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id); const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { return {
id: user.id, id: user.id,
@@ -159,7 +159,7 @@ export class EventTypesService_2024_06_14 {
} }
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) { async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId; const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
@@ -270,7 +270,7 @@ export class EventTypesService_2024_06_14 {
} }
async getUserToUpdateEvent(user: UserWithProfile) { 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); const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars }; return { ...user, profile: { id: profileId }, selectedCalendars };
} }
@@ -1,6 +1,7 @@
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator"; import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository"; import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
@@ -12,13 +13,14 @@ export class OrganizationRolesGuard implements CanActivate {
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
private organizationsService: OrganizationsService, private organizationsService: OrganizationsService,
private membershipRepository: MembershipsRepository private membershipRepository: MembershipsRepository,
private usersService: UsersService
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const user: UserWithProfile = request.user; const user: UserWithProfile = request.user;
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId; const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
if (!user || !organizationId) { if (!user || !organizationId) {
throw new ForbiddenException("No organization associated with the user."); 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 { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module"; import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module"; import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { BullModule } from "@nestjs/bull"; import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
duration: 1000, duration: 1000,
}, },
}), }),
UsersModule,
], ],
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor], providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
exports: [BillingService, BillingRepository], exports: [BillingService, BillingRepository],
@@ -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 { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository"; import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository"; import { UsersRepository } from "@/modules/users/users.repository";
import { import {
@@ -59,7 +60,8 @@ export class OAuthClientsController {
constructor( constructor(
private readonly oauthClientRepository: OAuthClientRepository, private readonly oauthClientRepository: OAuthClientRepository,
private readonly userRepository: UsersRepository, private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository private readonly teamsRepository: OrganizationsRepository,
private usersService: UsersService
) {} ) {}
@Post("/") @Post("/")
@@ -74,7 +76,7 @@ export class OAuthClientsController {
@GetUser() user: UserWithProfile, @GetUser() user: UserWithProfile,
@Body() body: CreateOAuthClientInput @Body() body: CreateOAuthClientInput
): Promise<CreateOAuthClientResponseDto> { ): Promise<CreateOAuthClientResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number; const organizationId = this.usersService.getUserMainOrgId(user) as number;
this.logger.log( this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}` `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]) @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION }) @DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> { 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); const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
return { status: SUCCESS_STATUS, data: clients }; return { status: SUCCESS_STATUS, data: clients };
@@ -1,5 +1,6 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { import {
Injectable, Injectable,
CanActivate, CanActivate,
@@ -11,12 +12,12 @@ import { Request } from "express";
@Injectable() @Injectable()
export class OAuthClientGuard implements CanActivate { export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository) {} constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>(); const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
const user: GetUserReturnType = request.user; 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; const oAuthClientId = request.params.clientId;
if (!oAuthClientId) { if (!oAuthClientId) {
@@ -17,6 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth"; import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
import { import {
ApiSuccessResponse, ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14, CreateTeamEventTypeInput_2024_06_14,
@@ -24,7 +25,6 @@ import {
TeamEventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types"; } 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"; import { Team } from "@calcom/prisma/client";
describe("Organizations Event Types Endpoints", () => { describe("Organizations Event Types Endpoints", () => {
@@ -8,8 +8,8 @@ import {
CreateTeamEventTypeInput_2024_06_14, CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14,
HostPriority, HostPriority,
SchedulingType,
} from "@calcom/platform-types"; } from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/client";
@Injectable() @Injectable()
export class InputOrganizationsEventTypesService { export class InputOrganizationsEventTypesService {
@@ -96,7 +96,8 @@ export class OrganizationsEventTypesService {
async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) { async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId); const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId);
const profileId = const profileId =
this.usersService.getUserProfileByOrgId(user, organizationId)?.id || user.movedToProfileId; this.usersService.getUserProfileByOrgId(user, organizationId)?.id ||
this.usersService.getUserMainProfile(user)?.id;
return { return {
id: user.id, id: user.id,
role: user.role, 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) { getUserProfileByOrgId(user: UserWithProfile, organizationId: number) {
return user.profiles?.find((p) => p.organizationId === organizationId); return user.profiles?.find((p) => p.organizationId === organizationId);
} }
@@ -1,5 +1,6 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; 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 { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { import {
CanActivate, CanActivate,
@@ -17,7 +18,8 @@ import { PlatformOAuthClient, Webhook } from "@calcom/prisma/client";
export class IsOAuthClientWebhookGuard implements CanActivate { export class IsOAuthClientWebhookGuard implements CanActivate {
constructor( constructor(
private readonly webhooksService: WebhooksService, private readonly webhooksService: WebhooksService,
private readonly oAuthClientRepository: OAuthClientRepository private readonly oAuthClientRepository: OAuthClientRepository,
private usersService: UsersService
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,7 +29,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
const user = request.user as GetUserReturnType; const user = request.user as GetUserReturnType;
const webhookId = request.params.webhookId; const webhookId = request.params.webhookId;
const oAuthClientId = request.params.clientId; const oAuthClientId = request.params.clientId;
const organizationId = user.movedToProfile?.organizationId || user.organizationId; const organizationId = this.usersService.getUserMainOrgId(user);
if (!user) { if (!user) {
throw new ForbiddenException("User not authenticated"); throw new ForbiddenException("User not authenticated");
+6
View File
@@ -4955,6 +4955,9 @@
"items": { "items": {
"$ref": "#/components/schemas/Source" "$ref": "#/components/schemas/Source"
} }
},
"disableOnPrefill": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -5529,6 +5532,9 @@
"items": { "items": {
"$ref": "#/components/schemas/Source" "$ref": "#/components/schemas/Source"
} }
},
"disableOnPrefill": {
"type": "boolean"
} }
}, },
"required": [ "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." description="You are not subscribed to a Platform plan."
buttonRaw={ buttonRaw={
<div className="flex gap-2"> <div className="flex gap-2">
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button> <Button
<Button color="secondary" href="https://cal.com/pricing"> 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 Contact Sales
</Button> </Button>
</div> </div>
@@ -14,10 +14,10 @@ export const platformPlans: IndividualPlatformPlan[] = [
pricing: 99, pricing: 99,
includes: [ includes: [
"Up to 100 bookings a month", "Up to 100 bookings a month",
"$0.99 overage beyond",
"Community Support", "Community Support",
"Cal Atoms (React Library)", "Cal Atoms (React Library)",
"Platform APIs", "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.", "Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 299, pricing: 299,
includes: [ includes: [
"Up to 500 bookings a month. $0,60 overage beyond",
"Everything in Starter", "Everything in Starter",
"Cal Atoms (React Library)", "Up to 500 bookings a month",
"$0.60 overage beyond",
"User Management and Analytics", "User Management and Analytics",
"Technical Account Manager and Onboarding Support", "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.", "The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
pricing: 2499, pricing: 2499,
includes: [ includes: [
"Up to 5000 bookings a month. $0.50 overage beyond",
"Everything in Essentials", "Everything in Essentials",
"Up to 5000 bookings a month",
"$0.50 overage beyond",
"Credential import from other platforms", "Credential import from other platforms",
"Compliance Check SOC2, HIPAA", "Compliance Check SOC2, HIPAA",
"One-on-one developer calls", "One-on-one developer calls",
@@ -51,7 +52,18 @@ export const platformPlans: IndividualPlatformPlan[] = [
}, },
{ {
plan: "Enterprise", plan: "Enterprise",
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.", description:
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"], "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="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
<div className="flex-1 px-2"> <div className="flex-1 px-2">
<p className="text-subtle">{t("current_username")}</p> <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} {currentUsername}
</p> </p>
</div> </div>
@@ -288,7 +288,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
<p className="text-subtle" data-testid="new-username"> <p className="text-subtle" data-testid="new-username">
{t("new_username")} {t("new_username")}
</p> </p>
<p className="text-emphasis">{inputUsernameValue}</p> <p className="text-emphasis break-all">{inputUsernameValue}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -164,7 +164,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
<p className="text-subtle">{t("current_username")}</p> <p className="text-subtle">{t("current_username")}</p>
<Tooltip content={currentUsername}> <Tooltip content={currentUsername}>
<p <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"> data-testid="current-username">
{currentUsername} {currentUsername}
</p> </p>
@@ -175,7 +175,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
{t("new_username")} {t("new_username")}
</p> </p>
<Tooltip content={inputUsernameValue}> <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} {inputUsernameValue}
</p> </p>
</Tooltip> </Tooltip>
@@ -741,7 +741,7 @@ export default function Success(props: PageProps) {
/> />
</> </>
))} ))}
{userIsOwner && !isCancelled && isRejectionMode && ( {!isCancelled && isRejectionMode && (
<> <>
<hr className="border-subtle" /> <hr className="border-subtle" />
<RejectBooking <RejectBooking
@@ -170,7 +170,8 @@ const InfiniteMobileTeamsTab: FC<InfiniteMobileTeamsTabProps> = (props) => {
}, },
{ {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
staleTime: 1 * 60 * 60 * 1000, refetchOnMount: true,
staleTime: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor, getNextPageParam: (lastPage) => lastPage.nextCursor,
} }
); );
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "4.4.2", "version": "4.4.3",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -36,6 +36,7 @@
"@calcom/embed-snippet": "workspace:*", "@calcom/embed-snippet": "workspace:*",
"@calcom/features": "*", "@calcom/features": "*",
"@calcom/lib": "*", "@calcom/lib": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-types": "*", "@calcom/platform-types": "*",
"@calcom/prisma": "*", "@calcom/prisma": "*",
"@calcom/trpc": "*", "@calcom/trpc": "*",
+8 -9
View File
@@ -321,15 +321,6 @@ export default function Signup({
<HeadSeo title={t("sign_up")} description={t("sign_up")} /> <HeadSeo title={t("sign_up")} description={t("sign_up")} />
{/* Left side */} {/* 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"> <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"> <div className="flex flex-col gap-2">
<h1 className="font-cal text-[28px] leading-none "> <h1 className="font-cal text-[28px] leading-none ">
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")} {IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
@@ -408,6 +399,14 @@ export default function Signup({
onChange={() => handleConsentChange(COOKIE_CONSENT)} onChange={() => handleConsentChange(COOKIE_CONSENT)}
description={t("cookie_consent_checkbox")} description={t("cookie_consent_checkbox")}
/> />
{errors.apiError && (
<Alert
className="mb-3"
severity="error"
message={errors.apiError?.message}
data-testid="signup-error-message"
/>
)}
<Button <Button
type="submit" type="submit"
className="my-2 w-full justify-center" className="my-2 w-full justify-center"
+1 -6
View File
@@ -7,15 +7,10 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { moveUserToOrg } from "@lib/orgMigration"; import { moveUserToOrg } from "@lib/orgMigration";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { IS_STRIPE_ENABLED } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" }); 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 = !( const IS_SELF_HOSTED = !(
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com") 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; label: string;
identifier?: string; identifier?: string;
required: boolean; required: boolean;
options?: {
label: string;
id: string | null;
}[];
}[]; }[];
}) { }) {
return await prisma.app_RoutingForms_Form.create({ return await prisma.app_RoutingForms_Form.create({
+45 -4
View File
@@ -301,6 +301,10 @@ export const createUsersFixture = (
} }
if (scenario.seedRoutingForms) { 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({ await prisma.app_RoutingForms_Form.create({
data: { data: {
routes: [ routes: [
@@ -366,6 +370,26 @@ export const createUsersFixture = (
}, },
{ {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8", 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" }, action: { type: "customPageMessage", value: "Multiselect chosen" },
queryValue: { queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8", id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
@@ -374,8 +398,8 @@ export const createUsersFixture = (
"b98a8abb-cdef-4012-b456-718262343d27": { "b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule", type: "rule",
properties: { properties: {
field: "d4292635-9f12-17b1-9153-c3a854649182", field: multiSelectFieldUuid,
value: [["Option-2"]], value: [[option2Uuid]],
operator: "multiselect_equals", operator: "multiselect_equals",
valueSrc: ["value"], valueSrc: ["value"],
valueType: ["multiselect"], valueType: ["multiselect"],
@@ -399,13 +423,30 @@ export const createUsersFixture = (
required: true, required: true,
}, },
{ {
id: "d4292635-9f12-17b1-9153-c3a854649182", id: multiSelectLegacyFieldUuid,
type: "multiselect", type: "multiselect",
label: "Multi Select", label: "Multi Select(with Legacy `selectText`)",
identifier: "multi", identifier: "multi",
selectText: "Option-1\nOption-2", selectText: "Option-1\nOption-2",
required: false, 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: { user: {
connect: { connect: {
@@ -7,19 +7,11 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { test, todo } from "./lib/fixtures"; import { test, todo } from "./lib/fixtures";
import type { Fixtures } 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.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll()); 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", () => { test.describe("Stripe integration skip true", () => {
// eslint-disable-next-line playwright/no-skipped-test // eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed"); test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
+8
View File
@@ -26,6 +26,14 @@ export const testName = "Test Testson";
export const teamEventTitle = "Team Event - 30min"; export const teamEventTitle = "Team Event - 30min";
export const teamEventSlug = "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 } = {}) { export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) {
const { const {
requestHandler = ({ res }) => { requestHandler = ({ res }) => {
+4 -6
View File
@@ -12,14 +12,9 @@ import {
bookTimeSlot, bookTimeSlot,
doOnOrgDomain, doOnOrgDomain,
goToUrlWithErrorHandling, goToUrlWithErrorHandling,
IS_STRIPE_ENABLED,
} from "./lib/testUtils"; } 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.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll()); 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 }) => { 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(); const user = await users.create();
await user.apiLogin(); await user.apiLogin();
await user.installStripePersonal({ skip: true }); await user.installStripePersonal({ skip: true });
+35 -2
View File
@@ -647,6 +647,7 @@ test.describe("FORM_SUBMITTED", async () => {
responses: { responses: {
name: { name: {
value: "John Doe", value: "John Doe",
response: "John Doe",
}, },
}, },
}, },
@@ -673,14 +674,33 @@ test.describe("FORM_SUBMITTED", async () => {
identifier: "name", identifier: "name",
required: true, 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 page.waitForLoadState("networkidle");
await gotoRoutingLink({ page, formId: form.id }); await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name"; const textFieldIdentifier = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe"); 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"]'); page.click('button[type="submit"]');
await webhookReceiver.waitForRequestCount(1); await webhookReceiver.waitForRequestCount(1);
@@ -699,10 +719,23 @@ test.describe("FORM_SUBMITTED", async () => {
responses: { responses: {
name: { name: {
value: "John Doe", value: "John Doe",
response: "John Doe",
},
multi: {
value: ["Option-2"],
response: [
{
id: "2",
label: "Option-2",
},
],
}, },
}, },
}, },
/* Legacy fields Start */
name: "John Doe", name: "John Doe",
multi: ["Option-2"],
/* Legacy fields End */
}); });
webhookReceiver.close(); webhookReceiver.close();
+12
View File
@@ -92,6 +92,18 @@
<path d="M3 10h18" ></path> <path d="M3 10h18" ></path>
<path d="m16 20 2 2 4-4" ></path> <path d="m16 20 2 2 4-4" ></path>
</symbol> </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"> <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="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> <path d="M8 2v4" ></path>

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+16 -4
View File
@@ -81,15 +81,24 @@ declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace // eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest { namespace jest {
interface Matchers<R> { interface Matchers<R> {
toHaveEmail(expectedEmail: ExpectedEmail, to: string): R; toHaveEmail(expectedEmail: ExpectedEmail, to: string, subject?: string): R;
} }
} }
} }
expect.extend({ expect.extend({
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string) { toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string, subject?: string) {
const { isNot } = this; 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 const emailsToLog = emails
.get() .get()
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent })); .map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
@@ -742,10 +751,12 @@ export function expectSuccessfulBookingRescheduledEmails({
export function expectAwaitingPaymentEmails({ export function expectAwaitingPaymentEmails({
emails, emails,
booker, booker,
subject,
}: { }: {
emails: Fixtures["emails"]; emails: Fixtures["emails"];
organizer: { email: string; name: string }; organizer: { email: string; name: string };
booker: { email: string; name: string }; booker: { email: string; name: string };
subject?: string;
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
@@ -753,7 +764,8 @@ export function expectAwaitingPaymentEmails({
to: `${booker.name} <${booker.email}>`, to: `${booker.name} <${booker.email}>`,
noIcs: true, 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"), dateTime: dayjs(event.endTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: [ organizer: {
// Add the calEvent organizer emailAddress: {
{ address: event.destinationCalendar
emailAddress: { ? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ??
address: event.destinationCalendar event.organizer.email
? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ?? : event.organizer.email,
event.organizer.email name: event.organizer.name,
: event.organizer.email,
name: event.organizer.name,
},
}, },
},
attendees: [
...event.attendees.map((attendee) => ({ ...event.attendees.map((attendee) => ({
emailAddress: { emailAddress: {
address: attendee.email, address: attendee.email,
@@ -6,7 +6,7 @@ import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermis
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { getSerializableForm } from "../../lib/getSerializableForm"; import { getSerializableForm } from "../../lib/getSerializableForm";
import type { Response, SerializableForm } from "../../types/types"; import type { FormResponse, SerializableForm } from "../../types/types";
function escapeCsvText(str: string) { function escapeCsvText(str: string) {
return str.replace(/,/, "%2C"); return str.replace(/,/, "%2C");
@@ -32,7 +32,7 @@ async function* getResponses(
) { ) {
const csv: string[] = []; const csv: string[] = [];
responses.forEach((response) => { responses.forEach((response) => {
const fieldResponses = response.response as Response; const fieldResponses = response.response as FormResponse;
const csvCells: string[] = []; const csvCells: string[] = [];
headerFields.forEach((headerField) => { headerFields.forEach((headerField) => {
const fieldResponse = fieldResponses[headerField.id]; const fieldResponse = fieldResponses[headerField.id];
@@ -4,13 +4,14 @@ import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier"; import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig"; import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField"; import isRouterLinkedField from "../lib/isRouterLinkedField";
import transformResponse from "../lib/transformResponse"; import { getUIOptionsForSelect } from "../lib/selectOptions";
import type { SerializableForm, Response } from "../types/types"; import { getFieldResponseForJsonLogic } from "../lib/transformResponse";
import type { SerializableForm, FormResponse } from "../types/types";
type Props = { type Props = {
form: SerializableForm<App_RoutingForms_Form>; form: SerializableForm<App_RoutingForms_Form>;
response: Response; response: FormResponse;
setResponse: Dispatch<SetStateAction<Response>>; setResponse: Dispatch<SetStateAction<FormResponse>>;
}; };
export default function FormInputFields(props: Props) { export default function FormInputFields(props: Props) {
@@ -31,14 +32,7 @@ export default function FormInputFields(props: Props) {
} }
const Component = widget.factory; const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n"); const options = getUIOptionsForSelect(field);
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return ( return (
<div key={field.id} className="mb-4 block flex-col sm:flex "> <div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow"> <div className="min-w-48 mb-2 flex-grow">
@@ -62,7 +56,7 @@ export default function FormInputFields(props: Props) {
...response, ...response,
[field.id]: { [field.id]: {
label: field.label, 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 { RoutingPages } from "../lib/RoutingPages";
import { isFallbackRoute } from "../lib/isFallbackRoute"; import { isFallbackRoute } from "../lib/isFallbackRoute";
import { processRoute } from "../lib/processRoute"; 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 { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields"; import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar"; import RoutingNavBar from "./RoutingNavBar";
@@ -238,7 +238,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
const { t } = useLocale(); const { t } = useLocale();
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false); const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<Response>({}); const [response, setResponse] = useState<FormResponse>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null); const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true); const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [eventTypeUrl, setEventTypeUrl] = useState(""); const [eventTypeUrl, setEventTypeUrl] = useState("");
@@ -1,6 +1,7 @@
import { FieldTypes } from "../pages/form-edit/[...appPages]"; import { FieldTypes } from "../pages/form-edit/[...appPages]";
import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types"; import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types";
import { InitialConfig } from "./InitialConfig"; import { InitialConfig } from "./InitialConfig";
import { getUIOptionsForSelect } from "./selectOptions";
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) { export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
const fields: Record< const fields: Record<
@@ -22,14 +23,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
field = field.routerField; field = field.routerField;
} }
if (FieldTypes.map((f) => f.value).includes(field.type)) { if (FieldTypes.map((f) => f.value).includes(field.type)) {
const optionValues = field.selectText?.trim().split("\n"); const options = getUIOptionsForSelect(field);
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
const widget = InitialConfig.widgets[field.type]; const widget = InitialConfig.widgets[field.type];
const widgetType = widget.type; const widgetType = widget.type;
@@ -10,6 +10,7 @@ import { zodFields, zodRoutes } from "../zod";
import getConnectedForms from "./getConnectedForms"; import getConnectedForms from "./getConnectedForms";
import isRouter from "./isRouter"; import isRouter from "./isRouter";
import isRouterLinkedField from "./isRouterLinkedField"; import isRouterLinkedField from "./isRouterLinkedField";
import { getFieldWithOptions } from "./selectOptions";
/** /**
* Doesn't have deleted fields by default * Doesn't have deleted fields by default
@@ -57,7 +58,9 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
name: f.name, name: f.name,
description: f.description, description: f.description,
})); }));
const finalFields = fields;
const finalFields = fields.map((field) => getFieldWithOptions(field));
let teamMembers: SerializableFormTeamMembers[] = []; let teamMembers: SerializableFormTeamMembers[] = [];
if (form.teamId) { if (form.teamId) {
teamMembers = await prisma.user.findMany({ 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 { Utils as QbUtils } from "react-awesome-query-builder";
import type { z } from "zod"; 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 type { zodNonRouterRoute } from "../zod";
import { getQueryBuilderConfig } from "./getQueryBuilderConfig"; import { getQueryBuilderConfig } from "./getQueryBuilderConfig";
import { isFallbackRoute } from "./isFallbackRoute"; import { isFallbackRoute } from "./isFallbackRoute";
@@ -16,7 +16,7 @@ export function processRoute({
response, response,
}: { }: {
form: SerializableForm<App_RoutingForms_Form>; form: SerializableForm<App_RoutingForms_Form>;
response: Record<string, Pick<Response[string], "value">>; response: Record<string, Pick<FormResponse[string], "value">>;
}) { }) {
const queryBuilderConfig = getQueryBuilderConfig(form); const queryBuilderConfig = getQueryBuilderConfig(form);
@@ -52,7 +52,7 @@ export function processRoute({
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config); const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic; const logic = jsonLogicQuery.logic;
let result = false; 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)) { for (const [uuid, { value }] of Object.entries(response)) {
responseValues[uuid] = value; 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 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"; import getFieldIdentifier from "./getFieldIdentifier";
export const substituteVariables = ( export const substituteVariables = (
routeValue: Route["action"]["value"], routeValue: Route["action"]["value"],
response: Response, response: FormResponse,
fields: Field[] fields: Field[]
) => { ) => {
const regex = /\{([^\}]+)\}/g; const regex = /\{([^\}]+)\}/g;
@@ -15,7 +15,11 @@ export const substituteVariables = (
variables.forEach((variable) => { variables.forEach((variable) => {
for (const key in response) { 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()) { if (identifier.toLowerCase() === variable.toLowerCase()) {
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, slugify(response[key].value.toString() || "")); 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, field,
value, value,
}: { }: {
field: Field; field: Pick<Field, "options" | "type">;
value: Response[string]["value"] | undefined; value: FormResponse[string]["value"] | undefined;
}) { }) {
if (!value) { if (!value) {
return ""; return "";
@@ -18,13 +66,24 @@ export default function transformResponse({
return value; return value;
} }
if (field.type === "multiselect") { if (field.type === "multiselect") {
if (value instanceof Array) { // Could be option id(i.e. a UUIDv4) or option label for ease of prefilling
return value; let valueOrLabelArray = value instanceof Array ? value : value.toString().split(",");
}
return value valueOrLabelArray = valueOrLabelArray.map((idOrLabel) => {
.toString() return transformSelectValue({ field, idOrLabel });
.split(",") });
.map((v) => v.trim());
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; return value;
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useAutoAnimate } from "@formkit/auto-animate/react"; 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 type { UseFormReturn } from "react-hook-form";
import { Controller, useFieldArray, useWatch } from "react-hook-form"; import { Controller, useFieldArray, useWatch } from "react-hook-form";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@@ -29,8 +29,26 @@ import SingleForm, {
} from "../../components/SingleForm"; } from "../../components/SingleForm";
export { getServerSideProps }; export { getServerSideProps };
type SelectOption = { label: string; id: string | null };
type HookForm = UseFormReturn<RoutingFormWithResponseCount>; 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 = [ export const FieldTypes = [
{ {
@@ -90,65 +108,51 @@ function Field({
}) { }) {
const { t } = useLocale(); const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>(); 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[]>([ const setOptions = (updatedOptions: SelectOption[]) => {
{ placeholder: "< 10", value: "", id: uuidv4() }, hookForm.setValue(`${hookFieldNamespace}.options`, updatedOptions);
{ 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 handleAddOptions = () => { const handleRemoveOptions = (index: number) => {
setOptions((prevState) => [ const updatedOptions = watchedOptions.filter((_, i) => i !== index);
...prevState, // We can't let the user remove the last option
if (updatedOptions.length === 0) {
return;
}
setOptions(updatedOptions);
};
const addOption = () => {
setOptions([
...watchedOptions,
{ {
placeholder: "New Option", label: "",
value: "",
id: uuidv4(), 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 router = hookForm.getValues(`${hookFieldNamespace}.router`);
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`); const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, optionIndex: number) => { const updateLabelAtIndex = ({ label, optionIndex }: { label: string; optionIndex: number }) => {
const updatedOptions = options.map((opt, index) => ({ const updatedOptions = watchedOptions.map((opt, index) => ({
...opt, ...opt,
...(index === optionIndex ? { value: e.target.value } : {}), ...(index === optionIndex ? { label } : {}),
})); }));
setOptions(updatedOptions); 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({ const label = useWatch({
@@ -162,18 +166,49 @@ function Field({
}); });
function move(index: number, increment: 1 | -1) { function move(index: number, increment: 1 | -1) {
const newList = [...options]; const newList = [...watchedOptions];
const type = options[index]; const type = watchedOptions[index];
const tmp = options[index + increment]; const tmp = watchedOptions[index + increment];
if (tmp) { if (tmp) {
newList[index] = tmp; newList[index] = tmp;
newList[index + increment] = type; newList[index + increment] = type;
} }
setOptions(newList); 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 ( return (
<div <div
data-testid="field" data-testid="field"
@@ -261,10 +296,15 @@ function Field({
{t("options")} {t("options")}
</Skeleton> </Skeleton>
<ul ref={animationRef}> <ul ref={animationRef}>
{options.map((field, index) => ( {watchedOptions.map((option, index) => (
<li key={`select-option-${field.id}`} className="group mt-2 flex items-center gap-2"> <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"> <div className="flex flex-col gap-2">
{options.length && index !== 0 ? ( {watchedOptions.length && index !== 0 ? (
<button <button
type="button" type="button"
onClick={() => move(index, -1)} onClick={() => move(index, -1)}
@@ -272,7 +312,7 @@ function Field({
<Icon name="arrow-up" /> <Icon name="arrow-up" />
</button> </button>
) : null} ) : null}
{options.length && index !== options.length - 1 ? ( {watchedOptions.length && index !== watchedOptions.length - 1 ? (
<button <button
type="button" type="button"
onClick={() => move(index, 1)} onClick={() => move(index, 1)}
@@ -287,18 +327,22 @@ function Field({
containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400" containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400"
className="border-0 focus:ring-0 focus:ring-offset-0" className="border-0 focus:ring-0 focus:ring-offset-0"
labelSrOnly labelSrOnly
placeholder={field.placeholder.toString()} placeholder={optionsPlaceholders[index] ?? "New Option"}
value={field.value} value={option.label}
type="text" type="text"
required
addOnClassname="bg-transparent border-0" addOnClassname="bg-transparent border-0"
onChange={(e) => handleChange(e, index)} onChange={(e) => updateLabelAtIndex({ label: e.target.value, optionIndex: index })}
dataTestid={`${hookFieldNamespace}.options.${index}`}
addOnSuffix={ addOnSuffix={
<button watchedOptions.length > 1 ? (
type="button" <button
onClick={() => handleRemoveOptions(index)} type="button"
aria-label={t("remove")}> onClick={() => handleRemoveOptions(index)}
<Icon name="x" className="h-4 w-4" /> aria-label={t("remove")}>
</button> <Icon name="x" className="h-4 w-4" />
</button>
) : null
} }
/> />
</div> </div>
@@ -312,7 +356,7 @@ function Field({
type="button" type="button"
StartIcon="plus" StartIcon="plus"
color="secondary" color="secondary"
onClick={handleAddOptions}> onClick={addOption}>
Add an option Add an option
</Button> </Button>
</div> </div>
@@ -12,8 +12,8 @@ import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm"; import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute"; import { processRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables"; import { substituteVariables } from "../../lib/substituteVariables";
import transformResponse from "../../lib/transformResponse"; import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { Response } from "../../types/types"; import type { FormResponse } from "../../types/types";
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps"; import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] }); const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
@@ -96,7 +96,7 @@ export const getServerSideProps = async function getServerSideProps(
form: enrichFormWithMigrationData(formWithUserProfile), form: enrichFormWithMigrationData(formWithUserProfile),
}); });
const response: Response = {}; const response: FormResponse = {};
if (!serializableForm.fields) { if (!serializableForm.fields) {
throw new Error("Form has no fields"); throw new Error("Form has no fields");
} }
@@ -105,7 +105,7 @@ export const getServerSideProps = async function getServerSideProps(
response[field.id] = { response[field.id] = {
label: field.label, 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 getFieldIdentifier from "../../lib/getFieldIdentifier";
import { processRoute } from "../../lib/processRoute"; import { processRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables"; import { substituteVariables } from "../../lib/substituteVariables";
import transformResponse from "../../lib/transformResponse"; import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { NonRouterRoute, Response } from "../../types/types"; import type { NonRouterRoute, FormResponse } from "../../types/types";
import { getServerSideProps } from "./getServerSideProps"; import { getServerSideProps } from "./getServerSideProps";
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
type Props = inferSSRProps<typeof getServerSideProps>; type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({ const useBrandColors = ({
@@ -59,10 +60,13 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
// - like a network error // - like a network error
// - or he abandoned booking flow in between // - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current; const formFillerId = formFillerIdRef.current;
const decidedActionWithFormResponseRef = useRef<{ action: NonRouterRoute["action"]; response: Response }>(); const decidedActionWithFormResponseRef = useRef<{
action: NonRouterRoute["action"];
response: FormResponse;
}>();
const router = useRouter(); const router = useRouter();
const onSubmit = (response: Response) => { const onSubmit = (response: FormResponse) => {
const decidedAction = processRoute({ form, response }); const decidedAction = processRoute({ form, response });
if (!decidedAction) { if (!decidedAction) {
@@ -97,7 +101,11 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
if (!fields) { if (!fields) {
throw new Error("Routing Form fields must exist here"); 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; const decidedAction = decidedActionWithFormResponse.action;
sdkActionManager?.fire("routed", { sdkActionManager?.fire("routed", {
actionType: decidedAction.type, 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>) { export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm {...props} />; return <RoutingForm {...props} />;
} }
@@ -250,7 +209,7 @@ export { getServerSideProps };
const usePrefilledResponse = (form: Props["form"]) => { const usePrefilledResponse = (form: Props["form"]) => {
const searchParams = useCompatSearchParams(); const searchParams = useCompatSearchParams();
const prefillResponse: Response = {}; const prefillResponse: FormResponse = {};
// Prefill the form from query params // Prefill the form from query params
form.fields?.forEach((field) => { form.fields?.forEach((field) => {
@@ -259,10 +218,10 @@ const usePrefilledResponse = (form: Props["form"]) => {
const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0]; const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0];
prefillResponse[field.id] = { prefillResponse[field.id] = {
value: transformResponse({ field, value }), value: getFieldResponseForJsonLogic({ field, value }),
label: field.label, label: field.label,
}; };
}); });
const [response, setResponse] = useState<Response>(prefillResponse); const [response, setResponse] = useState<FormResponse>(prefillResponse);
return [response, setResponse] as const; return [response, setResponse] as const;
}; };
@@ -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, () => {}); test.skip(title, () => {});
} }
const Identifiers = {
multi: "multi",
multiNewFormat: "multi-new-format",
};
test.describe("Routing Forms", () => { test.describe("Routing Forms", () => {
test.describe("Zero State Routing Forms", () => { test.describe("Zero State Routing Forms", () => {
test("should be able to add a new form and view it", async ({ page }) => { 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); 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([ expect(responses).toEqual([
["event-routing", ""], ["event-routing", "", ""],
["external-redirect", ""], ["external-redirect", "", ""],
["custom-page", ""], ["custom-page", "", ""],
]); ]);
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`); 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 csvRows = csv.trim().split("\n");
const csvHeaderRow = csvRows[0]; 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 firstResponseCells = csvRows[1].split(",");
const secondResponseCells = csvRows[2].split(","); const secondResponseCells = csvRows[2].split(",");
const thirdResponseCells = csvRows[3].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(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(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()); 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 page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await expect(page.locator("text=Custom Page Result")).toBeVisible(); await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`); await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multi}=Option-2`);
await expect(page.locator("text=Multiselect chosen")).toBeVisible(); 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 }) => { 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(); route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("External Redirect"); expect(routingType).toBe("External Redirect");
expect(route).toBe("https://google.com"); 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 //fallback route
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-Test field"]', "fallback"); await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="test-routing"]'); await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText(); routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
@@ -452,8 +490,11 @@ async function addAllTypesOfFieldsAndSaveForm(
identifier = "firstField"; identifier = "firstField";
} }
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") { if (fieldTypeLabel === "Multiple Selection" || fieldTypeLabel === "Single Selection") {
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789"); 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); 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 logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma";
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
import { TRPCError } from "@calcom/trpc/server"; import { TRPCError } from "@calcom/trpc/server";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma"; import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { getSerializableForm } from "../lib/getSerializableForm"; 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"; import type { TReportInputSchema } from "./report.schema";
interface ReportHandlerOptions { interface ReportHandlerOptions {
@@ -13,6 +17,45 @@ interface ReportHandlerOptions {
}; };
input: TReportInputSchema; 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) => { export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
// Can be any prisma `where` clause // Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -49,27 +92,13 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
take, take,
skip, skip,
}); });
const fields = serializedForm?.fields || []; const fields = serializedForm?.fields || [];
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); const { responses, headers } = buildResponsesForReporting({
const responses: (string | number)[][] = []; responsesFromDb: rows.map((r) => r.response),
rows.forEach((r) => { fields,
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 areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return { return {
headers, headers,
@@ -79,3 +108,36 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
}; };
export default reportHandler; 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 { TRPCError } from "@calcom/trpc/server";
import { getSerializableForm } from "../lib/getSerializableForm"; import { getSerializableForm } from "../lib/getSerializableForm";
import type { Response } from "../types/types"; import type { FormResponse } from "../types/types";
import type { TResponseInputSchema } from "./response.schema"; import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission } from "./utils"; import { onFormSubmission } from "./utils";
@@ -117,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
await onFormSubmission( await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails }, { ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as Response dbFormResponse.response as FormResponse
); );
return dbFormResponse; return dbFormResponse;
} catch (e) { } catch (e) {
+82 -15
View File
@@ -8,30 +8,97 @@ import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils"; import type { Ensure } from "@calcom/types/utils";
import type { OrderedResponses } from "../types/types"; 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( export async function onFormSubmission(
form: Ensure< form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] }, SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
"fields" "fields"
>, >,
response: Response response: FormResponse
) { ) {
const fieldResponsesByName: Record< const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {};
string,
{
value: Response[keyof Response]["value"];
}
> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) { 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. // Use the label lowercased as the key to identify a field.
const key = const key =
form.fields.find((f) => f.id === fieldId)?.identifier || form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName); (fieldResponse.label as keyof typeof fieldResponsesByIdentifier);
fieldResponsesByName[key] = { fieldResponsesByIdentifier[key] = getFieldResponse({
value: fieldResponse.value, fieldResponseValue: fieldResponse.value,
}; field,
});
} }
const { userId, teamId } = getWebhookTargetEntity(form); const { userId, teamId } = getWebhookTargetEntity(form);
@@ -57,14 +124,14 @@ export async function onFormSubmission(
formId: form.id, formId: form.id,
formName: form.name, formName: form.name,
teamId: form.teamId, teamId: form.teamId,
responses: fieldResponsesByName, responses: fieldResponsesByIdentifier,
}, },
rootData: { rootData: {
// Send responses unwrapped at root level for backwards compatibility // 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; acc[key] = value.value;
return acc; return acc;
}, {} as Record<string, Response[keyof Response]["value"]>), }, {} as Record<string, FormResponse[keyof FormResponse]["value"]>),
}, },
}).catch((e) => { }).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e); console.error(`Error executing routing form webhook`, webhook, e);
+3 -3
View File
@@ -10,7 +10,7 @@ export type RoutingForm = SerializableForm<App_RoutingForms_Form>;
export type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] }; export type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export type Response = Record< export type FormResponse = Record<
// Field ID // Field ID
string, string,
{ {
@@ -20,7 +20,7 @@ export type Response = Record<
>; >;
export type Fields = z.infer<typeof zodFieldsView>; 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 Routes = z.infer<typeof zodRoutesView>;
export type Route = Routes[0]; export type Route = Routes[0];
export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>; export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>;
@@ -55,4 +55,4 @@ export type SerializableRoute =
}) })
| GlobalRoute; | GlobalRoute;
export type OrderedResponses = Response[string][]; export type OrderedResponses = FormResponse[string][];
+14
View File
@@ -6,9 +6,23 @@ export const zodNonRouterField = z.object({
identifier: z.string().optional(), identifier: z.string().optional(),
placeholder: z.string().optional(), placeholder: z.string().optional(),
type: z.string(), type: z.string(),
/**
* @deprecated in favour of `options`
*/
selectText: z.string().optional(), selectText: z.string().optional(),
required: z.boolean().optional(), required: z.boolean().optional(),
deleted: 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({ export const zodRouterField = zodNonRouterField.extend({
@@ -9,6 +9,7 @@ import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>(); const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const siteId = getAppData("SITE_ID"); const siteId = getAppData("SITE_ID");
const scriptURL = getAppData("SCRIPT_URL");
const { enabled, updateEnabled } = useIsAppEnabled(app); const { enabled, updateEnabled } = useIsAppEnabled(app);
return ( return (
@@ -19,15 +20,27 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}} }}
switchChecked={enabled} switchChecked={enabled}
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<TextField <fieldset className="space-y-2" disabled={disabled}>
disabled={disabled} <TextField
name="Site ID" disabled={disabled}
value={siteId} name="Script URL"
placeholder="Enter your Site ID" value={scriptURL}
onChange={(e) => { defaultValue="https://us.umami.is/script.js"
setAppData("SITE_ID", e.target.value); 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> </AppCard>
); );
}; };
+1 -1
View File
@@ -14,7 +14,7 @@
"tag": { "tag": {
"scripts": [ "scripts": [
{ {
"src": "https://us.umami.is/script.js", "src": "{SCRIPT_URL}",
"attrs": { "attrs": {
"data-website-id": "{SITE_ID}" "data-website-id": "{SITE_ID}"
} }
+1
View File
@@ -5,6 +5,7 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge( export const appDataSchema = eventTypeAppCardZod.merge(
z.object({ z.object({
SITE_ID: z.string().optional(), SITE_ID: z.string().optional(),
SCRIPT_URL: z.string().optional(),
}) })
); );
+51
View File
@@ -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",
};
}
+4 -2
View File
@@ -9,6 +9,8 @@ import { getWhen } from "@calcom/lib/CalEventParser";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { GenerateIcsRole } from "./generateIcsFile";
export enum BookingAction { export enum BookingAction {
Create = "create", Create = "create",
Cancel = "cancel", Cancel = "cancel",
@@ -30,7 +32,7 @@ const generateIcsString = ({
title: string; title: string;
subtitle: string; subtitle: string;
status: EventStatus; status: EventStatus;
role: "attendee" | "organizer"; role: GenerateIcsRole;
isRequestReschedule?: boolean; isRequestReschedule?: boolean;
t?: TFunction; t?: TFunction;
}) => { }) => {
@@ -47,7 +49,7 @@ const generateIcsString = ({
const getTextBody = (title: string, subtitle: string): string => { const getTextBody = (title: string, subtitle: string): string => {
let body: string; let body: string;
if (isRequestReschedule && role === "attendee" && t) { if (isRequestReschedule && role === GenerateIcsRole.ATTENDEE && t) {
body = ` body = `
${title} ${title}
${getWhen(event, t)} ${getWhen(event, t)}
@@ -1,21 +1,17 @@
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email"; import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail { export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> { protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("new_guests_added"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("new_guests_added"), role: GenerateIcsRole.ATTENDEE,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "attendee", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`, to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
@@ -1,21 +1,17 @@
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email"; import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeCancelledEmail extends AttendeeScheduledEmail { export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> { protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("event_request_cancelled"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("event_request_cancelled"), role: GenerateIcsRole.ATTENDEE,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CANCELLED",
status: "CANCELLED", }),
role: "attendee",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`, to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
@@ -1,21 +1,17 @@
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email"; import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail { export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> { protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("event_location_changed"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("event_location_changed"), role: GenerateIcsRole.ATTENDEE,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "attendee", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`, to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
@@ -1,21 +1,17 @@
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email"; import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail { export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> { protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("event_type_has_been_rescheduled"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("event_type_has_been_rescheduled"), role: GenerateIcsRole.ATTENDEE,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "attendee", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`, to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email], 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 type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import BaseEmail from "./_base-email"; import BaseEmail from "./_base-email";
export default class AttendeeScheduledEmail extends BaseEmail { export default class AttendeeScheduledEmail extends BaseEmail {
@@ -33,19 +33,15 @@ export default class AttendeeScheduledEmail extends BaseEmail {
const clonedCalEvent = cloneDeep(this.calEvent); const clonedCalEvent = cloneDeep(this.calEvent);
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.calEvent.recurringEvent?.count
event: this.calEvent, ? this.t("your_event_has_been_scheduled_recurring")
title: this.calEvent.recurringEvent?.count : this.t("your_event_has_been_scheduled"),
? this.t("your_event_has_been_scheduled_recurring") role: GenerateIcsRole.ATTENDEE,
: this.t("your_event_has_been_scheduled"), subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee", status: "CONFIRMED",
subtitle: this.t("emailed_you_and_any_other_attendees"), }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`, to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email], 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 type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from ".."; import { renderEmail } from "..";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email"; import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail { export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail {
@@ -17,19 +17,15 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche
const toAddresses = [this.calEvent.attendees[0].email]; const toAddresses = [this.calEvent.attendees[0].email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("request_reschedule_booking"),
event: this.calEvent, subtitle: this.t("request_reschedule_subtitle", {
title: this.t("request_reschedule_booking"), organizer: this.calEvent.organizer.name,
subtitle: this.t("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
}),
role: "attendee",
status: "CANCELLED",
}), }),
method: "REQUEST", role: GenerateIcsRole.ATTENDEE,
}, status: "CANCELLED",
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
subject: `${this.t("requested_to_reschedule_subject_attendee", { subject: `${this.t("requested_to_reschedule_subject_attendee", {
@@ -1,7 +1,7 @@
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email"; import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail { 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]; const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("new_guests_added"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("new_guests_added"), role: GenerateIcsRole.ORGANIZER,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "organizer", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
from: `${APP_NAME} <${this.getMailerOptions().from}>`, from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)], replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
@@ -1,7 +1,7 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email"; import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { 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]; const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("event_request_cancelled"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("event_request_cancelled"), status: "CANCELLED",
subtitle: this.t("emailed_you_and_any_other_attendees"), role: GenerateIcsRole.ORGANIZER,
status: "CANCELLED", }),
role: "organizer",
}),
method: "REQUEST",
},
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
subject: `${this.t("event_cancelled_subject", { subject: `${this.t("event_cancelled_subject", {
@@ -1,7 +1,7 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email"; import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmail { 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]; const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("event_location_changed"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("event_location_changed"), role: GenerateIcsRole.ORGANIZER,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "organizer", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)], 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 type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from ".."; import { renderEmail } from "..";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email"; import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerRequestedToRescheduleEmail extends OrganizerScheduledEmail { export default class OrganizerRequestedToRescheduleEmail extends OrganizerScheduledEmail {
@@ -20,21 +20,17 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu
const toAddresses = [this.calEvent.organizer.email]; const toAddresses = [this.calEvent.organizer.email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("request_reschedule_title_organizer", {
event: this.calEvent, attendee: this.calEvent.attendees[0].name,
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",
}), }),
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}>`, from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
subject: `${this.t("rescheduled_event_type_subject", { subject: `${this.t("rescheduled_event_type_subject", {
@@ -1,7 +1,7 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email"; import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail { 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]; const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.t("event_type_has_been_rescheduled"),
event: this.calEvent, subtitle: this.t("emailed_you_and_any_other_attendees"),
title: this.t("event_type_has_been_rescheduled"), role: GenerateIcsRole.ORGANIZER,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "organizer", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)], 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 type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../"; import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString"; import generateIcsFile from "../lib/generateIcsFile";
import { GenerateIcsRole } from "../lib/generateIcsFile";
import BaseEmail from "./_base-email"; import BaseEmail from "./_base-email";
export default class OrganizerScheduledEmail extends BaseEmail { 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]; const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return { return {
icalEvent: { icalEvent: generateIcsFile({
filename: "event.ics", calEvent: this.calEvent,
content: generateIcsString({ title: this.calEvent.recurringEvent?.count
event: this.calEvent, ? this.t("new_event_scheduled_recurring")
title: this.calEvent.recurringEvent?.count : this.t("new_event_scheduled"),
? this.t("new_event_scheduled_recurring") subtitle: this.t("emailed_you_and_any_other_attendees"),
: this.t("new_event_scheduled"), role: GenerateIcsRole.ORGANIZER,
subtitle: this.t("emailed_you_and_any_other_attendees"), status: "CONFIRMED",
role: "organizer", }),
status: "CONFIRMED",
}),
method: "REQUEST",
},
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","), to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)], 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, bookingFields: true,
seatsShowAttendees: true, seatsShowAttendees: true,
metadata: true, metadata: true,
schedulingType: true,
hosts: { hosts: {
select: { select: {
user: true, user: true,
@@ -357,7 +358,13 @@ async function handler(req: CustomRequest) {
smsReminderNumber: bookingToDelete.smsReminderNumber, smsReminderNumber: bookingToDelete.smsReminderNumber,
evt: { evt: {
...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, hideBranding: !!bookingToDelete.eventType?.owner?.hideBranding,
}); });
@@ -20,6 +20,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
@@ -135,6 +136,15 @@ export async function handleConfirmation(args: {
eventType: { eventType: {
bookingFields: Prisma.JsonValue | null; bookingFields: Prisma.JsonValue | null;
slug: string; slug: string;
schedulingType: SchedulingType | null;
hosts: {
user: {
email: string;
destinationCalendar?: {
primaryEmail: string | null;
} | null;
};
}[];
owner: { owner: {
hideBranding?: boolean | null; hideBranding?: boolean | null;
} | null; } | null;
@@ -175,6 +185,21 @@ export async function handleConfirmation(args: {
select: { select: {
slug: true, slug: true,
bookingFields: true, bookingFields: true,
schedulingType: true,
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
owner: { owner: {
select: { select: {
hideBranding: true, hideBranding: true,
@@ -220,11 +245,26 @@ export async function handleConfirmation(args: {
select: { select: {
slug: true, slug: true,
bookingFields: true, bookingFields: true,
schedulingType: true,
owner: { owner: {
select: { select: {
hideBranding: true, hideBranding: true,
}, },
}, },
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
}, },
}, },
uid: true, uid: true,
@@ -249,7 +289,11 @@ export async function handleConfirmation(args: {
const evtOfBooking = { const evtOfBooking = {
...evt, ...evt,
metadata: { videoCallUrl: meetingUrl }, 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.startTime = updatedBookings[index].startTime.toISOString();
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString(); evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
@@ -1741,7 +1741,11 @@ async function handler(
loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error })); 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) { if (!eventType.metadata?.disableStandardEmails?.all?.attendee) {
await scheduleMandatoryReminder( await scheduleMandatoryReminder(
@@ -2654,7 +2654,12 @@ describe("handleNewBooking", () => {
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
expectAwaitingPaymentEmails({ organizer, booker, emails }); expectAwaitingPaymentEmails({
organizer,
booker,
emails,
subject: "complete_your_booking_subject",
});
expectBookingPaymentIntiatedWebhookToHaveBeenFired({ expectBookingPaymentIntiatedWebhookToHaveBeenFired({
booker, booker,
organizer, organizer,
@@ -392,8 +392,7 @@ describe("handleNewBooking", () => {
}); });
expectWorkflowToBeTriggered({ expectWorkflowToBeTriggered({
// emailsToReceive: [organizer.email].concat(otherTeamMembers.map(member => member.email)), emailsToReceive: [organizer.email].concat(otherTeamMembers.map((member) => member.email)),
emailsToReceive: [organizer.email],
emails, emails,
}); });
}, },
@@ -104,7 +104,17 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
await scheduleWorkflowReminders({ await scheduleWorkflowReminders({
workflows, workflows,
smsReminderNumber: smsReminderNumber || null, 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, isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid, isRescheduleEvent: !!rescheduleUid,
emailAttendeeSendToOverride: bookerEmail, emailAttendeeSendToOverride: bookerEmail,
@@ -9,7 +9,7 @@ import logger from "@calcom/lib/logger";
import { defaultHandler } from "@calcom/lib/server"; import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma"; 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 { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders"; import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
@@ -119,6 +119,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
switch (reminder.workflowStep.action) { switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST: case WorkflowActions.EMAIL_HOST:
sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email; 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; break;
case WorkflowActions.EMAIL_ATTENDEE: case WorkflowActions.EMAIL_ATTENDEE:
sendTo = reminder.booking.attendees[0].email; sendTo = reminder.booking.attendees[0].email;
@@ -27,7 +27,14 @@ type PartialBooking =
| "attendees" | "attendees"
| "userPrimaryEmail" | "userPrimaryEmail"
| "smsReminderNumber" | "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; user: Partial<User> | null;
}) })
| null; | null;
@@ -159,6 +166,20 @@ export const select: Prisma.WorkflowReminderSelect = {
bookingFields: true, bookingFields: true,
title: true, title: true,
slug: true, slug: true,
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
recurringEvent: true, recurringEvent: true,
team: { team: {
select: { select: {
@@ -6,7 +6,7 @@ import {
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types"; import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants"; 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 type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleEmailReminder } from "./emailReminderManager"; import { scheduleEmailReminder } from "./emailReminderManager";
@@ -16,7 +16,11 @@ import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type ExtendedCalendarEvent = CalendarEvent & { export type ExtendedCalendarEvent = CalendarEvent & {
metadata?: { videoCallUrl: string | undefined }; metadata?: { videoCallUrl: string | undefined };
eventType: { slug?: string }; eventType: {
slug?: string;
schedulingType?: SchedulingType | null;
hosts?: { user: { email: string; destinationCalendar?: { primaryEmail: string | null } | null } }[];
};
}; };
type ProcessWorkflowStepParams = { type ProcessWorkflowStepParams = {
@@ -85,6 +89,13 @@ const processWorkflowStep = async (
break; break;
case WorkflowActions.EMAIL_HOST: case WorkflowActions.EMAIL_HOST:
sendTo = [evt.organizer?.email || ""]; 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; break;
case WorkflowActions.EMAIL_ATTENDEE: case WorkflowActions.EMAIL_ATTENDEE:
const attendees = !!emailAttendeeSendToOverride const attendees = !!emailAttendeeSendToOverride
@@ -121,10 +121,9 @@ export default function CreateEventTypeDialog({
await router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`); await router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`);
if (isInfiniteScrollEnabled) { if (isInfiniteScrollEnabled) {
await utils.viewer.eventTypes.getUserEventGroups.invalidate(); await utils.viewer.eventTypes.getEventTypesFromGroup.fetchInfinite({
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({ group: { teamId: eventType.teamId, parentId: eventType.parentId },
limit: 10, limit: 10,
group: { teamId: eventType?.teamId, parentId: eventType?.parentId },
}); });
} else { } else {
await utils.viewer.eventTypes.getByViewer.invalidate(); await utils.viewer.eventTypes.getByViewer.invalidate();
+3 -2
View File
@@ -214,7 +214,8 @@ const useBanners = () => {
const Layout = (props: LayoutProps) => { const Layout = (props: LayoutProps) => {
const banners = useBanners(); const banners = useBanners();
const pathname = usePathname();
const isFullPageWithoutSidebar = pathname.startsWith("/apps/routing-forms/reporting/");
const { data: user } = trpc.viewer.me.useQuery(); const { data: user } = trpc.viewer.me.useQuery();
const { boot } = useIntercom(); const { boot } = useIntercom();
const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title; const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title;
@@ -253,7 +254,7 @@ const Layout = (props: LayoutProps) => {
<TimezoneChangeDialog /> <TimezoneChangeDialog />
<div className="flex min-h-screen flex-col"> <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"> <div className="sticky top-0 z-10 w-full divide-y divide-black">
{Object.keys(banners).map((key) => { {Object.keys(banners).map((key) => {
if (key === "teamUpgradeBanner") { if (key === "teamUpgradeBanner") {
@@ -1,21 +1,19 @@
import { z } from "zod"; import { z } from "zod";
import type { import {
BookingLimitsKeyOutputType_2024_06_14, BookingWindowPeriodInputTypeEnum_2024_06_14,
TransformBookingLimitsSchema_2024_06_14, BookingWindowPeriodOutputTypeEnum_2024_06_14,
} from "@calcom/platform-types"; BookingLimitsEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
import { import {
type CreateEventTypeInput_2024_06_14, type CreateEventTypeInput_2024_06_14,
type Integration_2024_06_14, type Integration_2024_06_14,
type BusinessDaysWindow_2024_06_14, type BusinessDaysWindow_2024_06_14,
type RangeWindow_2024_06_14, type RangeWindow_2024_06_14,
type TransformFutureBookingsLimitSchema_2024_06_14, type TransformFutureBookingsLimitSchema_2024_06_14,
type BookingLimitsKeyOutputType_2024_06_14,
type TransformBookingLimitsSchema_2024_06_14,
} from "@calcom/platform-types"; } 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> = { const integrationsMapping: Record<Integration_2024_06_14, string> = {
"cal-video": "integrations:daily", "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 { import type {
AddressLocation_2024_06_14, AddressLocation_2024_06_14,
IntegrationLocation_2024_06_14, IntegrationLocation_2024_06_14,
@@ -13,11 +18,6 @@ import type {
BusinessDaysWindow_2024_06_14, BusinessDaysWindow_2024_06_14,
BookingField_2024_06_14, BookingField_2024_06_14,
} from "@calcom/platform-types"; } 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"; import type { transformApiEventTypeBookingFields, transformApiEventTypeLocations } from "./api-request";
+1 -1
View File
@@ -78,7 +78,7 @@ const processResult = (
case "username_exists": case "username_exists":
return { return {
statusCode: 418, statusCode: 418,
message: "A user exists with that username", message: "A user exists with that username or email",
}; };
case "is_premium": case "is_premium":
return { statusCode: 402, message: "This is a premium username." }; return { statusCode: 402, message: "This is a premium username." };
@@ -1,5 +1,6 @@
"use client"; "use client";
import type { SetStateAction, Dispatch } from "react";
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"; 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 classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { sortAvailabilityStrings } from "@calcom/lib/weekstart"; import { sortAvailabilityStrings } from "@calcom/lib/weekstart";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import type { TimeRange, WorkingHours } from "@calcom/types/schedule"; import type { TimeRange, WorkingHours } from "@calcom/types/schedule";
import { import {
@@ -32,7 +32,6 @@ import {
TimezoneSelect as WebTimezoneSelect, TimezoneSelect as WebTimezoneSelect,
Tooltip, Tooltip,
VerticalDivider, VerticalDivider,
showToast,
} from "@calcom/ui"; } from "@calcom/ui";
import { Icon } from "@calcom/ui"; import { Icon } from "@calcom/ui";
@@ -97,6 +96,12 @@ type AvailabilitySettingsProps = {
customClassNames?: CustomClassNames; customClassNames?: CustomClassNames;
disableEditableHeading?: boolean; disableEditableHeading?: boolean;
enableOverrides?: boolean; enableOverrides?: boolean;
bulkUpdateModalProps?: {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
save: ({ eventTypeIds }: { eventTypeIds: number[] }) => void;
isSaving: boolean;
};
}; };
const DeleteDialogButton = ({ const DeleteDialogButton = ({
@@ -246,21 +251,11 @@ export function AvailabilitySettings({
customClassNames, customClassNames,
disableEditableHeading = false, disableEditableHeading = false,
enableOverrides = false, enableOverrides = false,
bulkUpdateModalProps,
}: AvailabilitySettingsProps) { }: AvailabilitySettingsProps) {
const [openSidebar, setOpenSidebar] = useState(false); const [openSidebar, setOpenSidebar] = useState(false);
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const { t, i18n } = useLocale(); 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>({ const form = useForm<AvailabilityFormValues>({
defaultValues: { defaultValues: {
...schedule, ...schedule,
@@ -353,7 +348,7 @@ export function AvailabilitySettings({
checked={value} checked={value}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
onChange(checked); onChange(checked);
setBulkUpdateModal(checked); bulkUpdateModalProps?.setIsOpen(checked);
}} }}
/> />
)} )}
@@ -362,12 +357,12 @@ export function AvailabilitySettings({
) : null} ) : null}
</div> </div>
{bulkUpdateModal && ( {bulkUpdateModalProps && bulkUpdateModalProps?.isOpen && (
<BulkEditDefaultForEventsModal <BulkEditDefaultForEventsModal
isPending={bulkUpdateDefaultAvailabilityMutation.isPending} isPending={bulkUpdateModalProps?.isSaving}
open={bulkUpdateModal} open={bulkUpdateModalProps?.isOpen}
setOpen={setBulkUpdateModal} setOpen={bulkUpdateModalProps.setIsOpen}
bulkUpdateFunction={bulkUpdateDefaultAvailabilityMutation.mutate} bulkUpdateFunction={bulkUpdateModalProps?.save}
/> />
)} )}
@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react";
import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown"; import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
@@ -29,6 +30,16 @@ export const AvailabilitySettingsWebWrapper = () => {
const { data: travelSchedules, isPending: isPendingTravelSchedules } = const { data: travelSchedules, isPending: isPendingTravelSchedules } =
trpc.viewer.getTravelSchedules.useQuery(); 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 isDefaultSchedule = me.data?.defaultScheduleId === scheduleId;
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({ const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
@@ -101,6 +112,12 @@ export const AvailabilitySettingsWebWrapper = () => {
...values, ...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}> <Dialog open={isDialogOpen}>
<DialogTrigger> <DialogTrigger>
<Button <Button
StartIcon="calendar-days" StartIcon="calendar"
color="primary" color="primary"
disabled={isDisabled} disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")} className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
+2 -1
View File
@@ -31,7 +31,8 @@
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vite": "^5.0.10", "vite": "^5.0.10",
"vite-plugin-dts": "^3.7.3", "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": [ "files": [
"dist", "dist",
+53 -46
View File
@@ -1,59 +1,66 @@
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import path from "path"; import path from "path";
import { resolve } from "path"; import { resolve } from "path";
import { defineConfig } from "vite"; import { defineConfig, loadEnv } from "vite";
import dts from "vite-plugin-dts"; import dts from "vite-plugin-dts";
import Inspect from "vite-plugin-inspect"; import Inspect from "vite-plugin-inspect";
export default defineConfig({ export default defineConfig(({ mode }) => {
optimizeDeps: { const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
include: [ const webAppUrl = env.NEXT_PUBLIC_WEBAPP_URL ?? "https://app.cal.com";
"@calcom/lib", return {
"@calcom/features", optimizeDeps: {
"@calcom/prisma", include: [
"@calcom/dayjs", "@calcom/lib",
"@calcom/platform-constants", "@calcom/features",
"@calcom/platform-types", "@calcom/prisma",
"@calcom/platform-utils", "@calcom/dayjs",
], "@calcom/platform-constants",
}, "@calcom/platform-types",
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })], "@calcom/platform-utils",
build: { ],
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
}, },
lib: { plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
entry: [resolve(__dirname, "index.ts")], define: {
name: "CalAtoms", "process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
fileName: "cal-atoms",
}, },
rollupOptions: { build: {
external: ["react", "fs", "path", "os", "react-dom"], commonjsOptions: {
output: { include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
globals: { },
react: "React", lib: {
"react-dom": "ReactDOM", 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: {
resolve: { alias: {
alias: { fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"), path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../../node_modules/rollup-plugin-node-builtins"), os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../../node_modules/rollup-plugin-node-builtins"), "@": path.resolve(__dirname, "./src"),
"@": path.resolve(__dirname, "./src"), ".prisma/client": path.resolve(__dirname, "../../prisma-client"),
".prisma/client": path.resolve(__dirname, "../../prisma-client"), "@prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@prisma/client": path.resolve(__dirname, "../../prisma-client"), "@calcom/prisma": path.resolve(__dirname, "../../prisma"),
"@calcom/prisma": path.resolve(__dirname, "../../prisma"), "@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"), "@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"), "@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"),
"@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"), "@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"), "@calcom/web/public/static/locales/en/common.json": path.resolve(
"@calcom/web/public/static/locales/en/common.json": path.resolve( __dirname,
__dirname, "../../../apps/web/public/static/locales/en/common.json"
"../../../apps/web/public/static/locales/en/common.json" ),
), },
}, },
}, };
}); });
+5
View File
@@ -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
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more