Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea6e105e6 | ||
|
|
a082cf73a0 | ||
|
|
61268f36b9 | ||
|
|
99cd0c5c41 | ||
|
|
2266301469 | ||
|
|
30b0012e6a |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
This PR fixes `onBookerStateChange` prop not working
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: add send & verify code flow to booker atom when email verification is turned on
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: added hideEventMetadata prop to the booker atom that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information.
|
||||
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
@@ -33,6 +34,7 @@ describe("AppController", () => {
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let apiKeyString: string;
|
||||
|
||||
@@ -108,6 +110,14 @@ describe("AppController", () => {
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
|
||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import { createRequest } from "node-mocks-http";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
|
||||
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
|
||||
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
|
||||
}),
|
||||
ProfilesModule,
|
||||
TokensModule,
|
||||
MembershipsModule,
|
||||
],
|
||||
providers: [
|
||||
MockedRedisService,
|
||||
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
|
||||
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
|
||||
organizationTwo = await teamRepositoryFixture.create({
|
||||
name: `api-auth-organization-2-${randomString()}`,
|
||||
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
|
||||
organization: { connect: { id: organizationTwo.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: validOAuthUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: validOAuthUser.id } },
|
||||
team: { connect: { id: organizationTwo.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
|
||||
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
private readonly profilesRepository: ProfilesRepository,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersService: UsersService,
|
||||
private readonly membershipsRepository: MembershipsRepository
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||
}
|
||||
|
||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||
const platformCreatorId =
|
||||
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
|
||||
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
|
||||
|
||||
if (!platformCreatorId) {
|
||||
throw new UnauthorizedException(
|
||||
|
||||
@@ -20,6 +20,36 @@ export class MembershipsRepository {
|
||||
return membership;
|
||||
}
|
||||
|
||||
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
|
||||
const owner = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return owner?.userId ?? undefined;
|
||||
}
|
||||
|
||||
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
|
||||
const admin = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return admin?.userId ?? undefined;
|
||||
}
|
||||
|
||||
async findMembershipByTeamId(teamId: number, userId: number) {
|
||||
const membership = await this.dbRead.prisma.membership.findUnique({
|
||||
where: {
|
||||
|
||||
+10
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
let organization: Team;
|
||||
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await usersRepositoryFixtures.create({
|
||||
email: userEmail,
|
||||
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
|
||||
movedFromUser: { connect: { id: user.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
});
|
||||
|
||||
|
||||
+7
@@ -39,6 +39,7 @@ import {
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Query,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsEventTypesController {
|
||||
private readonly logger = new Logger("OrganizationsEventTypesController");
|
||||
|
||||
constructor(
|
||||
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
|
||||
private readonly inputService: InputOrganizationsEventTypesService,
|
||||
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
|
||||
teamId,
|
||||
bodyEventType
|
||||
);
|
||||
this.logger.debug(
|
||||
"nl debug - create org team event type - transformedBody",
|
||||
JSON.stringify(transformedBody, null, 2)
|
||||
);
|
||||
|
||||
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
|
||||
user,
|
||||
|
||||
+4
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
this.logger.debug(
|
||||
"nl debug - create org team event type - eventTypeCreated",
|
||||
JSON.stringify(eventTypeCreated, null, 2)
|
||||
);
|
||||
|
||||
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
|
||||
});
|
||||
|
||||
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
|
||||
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import BookingsList from "~/bookings/views/bookings-listing-view";
|
||||
|
||||
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList status={parsed.data.status} />
|
||||
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -910,7 +910,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow preventCloseOnOutsideClick>
|
||||
<DialogContent preventCloseOnOutsideClick>
|
||||
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
|
||||
<RerouteDialogContentAndFooter
|
||||
booking={teamEventTypeBooking}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ZMultiSelectFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ZTextFilterValue,
|
||||
type SystemFilterSegment,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -55,11 +56,45 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
|
||||
type BookingsProps = {
|
||||
status: (typeof validStatuses)[number];
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const systemSegments: SystemFilterSegment[] = useMemo(() => {
|
||||
if (!userId) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: "my_bookings",
|
||||
name: t("my_bookings"),
|
||||
type: "system",
|
||||
activeFilters: [
|
||||
{
|
||||
f: "userId",
|
||||
v: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [userId],
|
||||
},
|
||||
},
|
||||
],
|
||||
perPage: 10,
|
||||
},
|
||||
];
|
||||
}, [userId, t]);
|
||||
|
||||
return systemSegments;
|
||||
}
|
||||
|
||||
export default function Bookings(props: BookingsProps) {
|
||||
const pathname = usePathname();
|
||||
const systemSegments = useSystemSegments(props.userId);
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments}>
|
||||
<DataTableProvider
|
||||
useSegments={useSegments}
|
||||
systemSegments={systemSegments}
|
||||
tableIdentifier={pathname || undefined}>
|
||||
<BookingsContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ColumnFilterType,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
@@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function InsightsPage({ timeZone }: { timeZone: string }) {
|
||||
return (
|
||||
<DataTableProvider timeZone={timeZone}>
|
||||
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<InsightsPageContent />
|
||||
</InsightsOrgTeamsProvider>
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function applySelectFilter(page: Page, columnId: string, value: str
|
||||
}
|
||||
|
||||
export async function selectOptionValue(page: Page, columnId: string, value: string) {
|
||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor();
|
||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
|
||||
}
|
||||
|
||||
@@ -176,3 +177,71 @@ export async function listSegments(page: Page): Promise<string[]> {
|
||||
export function locateSelectedSegmentName(page: Page, expectedName: string) {
|
||||
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a system segment is visible in the dropdown
|
||||
*/
|
||||
export async function expectSystemSegmentVisible(page: Page, segmentName: string) {
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await expect(
|
||||
page.locator('[data-testid="filter-segment-select-content"]').getByText("Default")
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
|
||||
.filter({ hasText: segmentName })
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that no segment is currently selected
|
||||
*/
|
||||
export async function expectSegmentCleared(page: Page) {
|
||||
// Check that no segment is selected (button shows default text)
|
||||
const segmentSelect = page.getByTestId("filter-segment-select");
|
||||
const buttonText = await segmentSelect.textContent();
|
||||
expect(buttonText?.trim()).toEqual("Segment");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a specific segment is currently selected
|
||||
*/
|
||||
export async function expectSegmentSelected(page: Page, segmentName: string) {
|
||||
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing segment
|
||||
*/
|
||||
export async function duplicateSegment(page: Page, originalName: string, newName: string) {
|
||||
await openSegmentSubmenu(page, originalName);
|
||||
await page.getByTestId("filter-segment-select-submenu-content").getByText("Duplicate").click();
|
||||
await page.getByTestId("duplicate-segment-name").fill(newName);
|
||||
await page.getByRole("button", { name: "Duplicate" }).click();
|
||||
await expect(page.getByText("Filter segment duplicated")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an existing segment
|
||||
*/
|
||||
export async function renameSegment(page: Page, originalName: string, newName: string) {
|
||||
await openSegmentSubmenu(page, originalName);
|
||||
await page.getByTestId("filter-segment-select-submenu-content").getByText("Rename").click();
|
||||
await page.getByTestId("rename-segment-name").fill(newName);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Filter segment updated")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a segment group (like "Default" or "Personal") is visible
|
||||
*/
|
||||
export async function expectSegmentGroupVisible(page: Page, groupName: string) {
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await expect(
|
||||
page.locator('[data-testid="filter-segment-select-content"]').getByText(groupName)
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
|
||||
import { clearFilters, applySelectFilter } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
@@ -202,25 +202,11 @@ test.describe("Insights", async () => {
|
||||
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Choose User filter item from dropdown
|
||||
await addFilter(page, "userId");
|
||||
|
||||
// Wait for the URL to include userId
|
||||
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await openFilter(page, "userId");
|
||||
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
await applySelectFilter(page, "userId", member.username || "");
|
||||
|
||||
await clearFilters(page);
|
||||
|
||||
await expect(page.url()).not.toContain("userId");
|
||||
await expect(page).not.toHaveURL(/[?&]userId=/);
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { addFilter, selectSegment, expectSegmentCleared, expectSegmentSelected } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigate to bookings page with specific status
|
||||
*/
|
||||
async function navigateToBookings(page: Page, status = "upcoming") {
|
||||
// Wait for the bookings API response like existing tests do
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/${status}`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
}
|
||||
|
||||
test.describe("System Segments", () => {
|
||||
test.describe("Core Functionality", () => {
|
||||
test("My Bookings system segment filters to current user's bookings only", async ({ page, users }) => {
|
||||
const user1 = await users.create({ username: "user1" });
|
||||
|
||||
// Create some bookings for user (this would need booking setup)
|
||||
// For now, we'll test the filter application
|
||||
await user1.apiLogin();
|
||||
await navigateToBookings(page);
|
||||
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Verify the userId filter is applied (check URL or filter indicators)
|
||||
const url = page.url();
|
||||
expect(url).toContain("activeFilters");
|
||||
});
|
||||
|
||||
test("System segments show only Duplicate option in submenu", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
|
||||
await navigateToBookings(page);
|
||||
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await page
|
||||
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
|
||||
.filter({ hasText: "My Bookings" })
|
||||
.locator('[data-testid="filter-segment-select-submenu-button"]')
|
||||
.click();
|
||||
|
||||
const submenu = page.getByTestId("filter-segment-select-submenu-content");
|
||||
await expect(submenu.getByText("Duplicate")).toBeVisible();
|
||||
await expect(submenu.getByText("Rename")).toBeHidden();
|
||||
await expect(submenu.getByText("Delete")).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("State Persistence", () => {
|
||||
test("System segment selection persists across page visits", async ({ page, users }) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true });
|
||||
await owner.apiLogin();
|
||||
|
||||
// Visit bookings page and select system segment
|
||||
await navigateToBookings(page);
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Revisit the page - should preserve selection
|
||||
await navigateToBookings(page);
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
});
|
||||
|
||||
test("Cleared system segment state persists (no default selection after manual clear)", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true });
|
||||
await owner.apiLogin();
|
||||
|
||||
await navigateToBookings(page);
|
||||
|
||||
// Select system segment
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Unselect system segment
|
||||
await selectSegment(page, "My Bookings");
|
||||
|
||||
// Revisit the page - should NOT have any segment selected
|
||||
await navigateToBookings(page);
|
||||
await expectSegmentCleared(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Auto-clear Behavior", () => {
|
||||
test("Manual filter clears system segment selection", async ({ page, users }) => {
|
||||
const user = await users.create(undefined, { hasTeam: true });
|
||||
await user.apiLogin();
|
||||
|
||||
await navigateToBookings(page);
|
||||
|
||||
// Select system segment
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Apply manual filter - this should clear system segment
|
||||
await addFilter(page, "eventTypeId");
|
||||
await page.keyboard.press("Escape");
|
||||
await expectSegmentCleared(page);
|
||||
|
||||
// Verify filter is still applied
|
||||
await expect(page.getByTestId("filter-popover-trigger-eventTypeId")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3472,6 +3472,7 @@
|
||||
"webhook_metadata": "Metadata",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"my_bookings": "My Bookings",
|
||||
"phone": "Phone",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* TODO: Consolidate this file with BookingLocationService and add tests
|
||||
*/
|
||||
import type { TFunction } from "i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
|
||||
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
|
||||
|
||||
export const DailyLocationType = "integrations:daily";
|
||||
|
||||
export const CalVideoLocationType = DailyLocationType;
|
||||
export const MeetLocationType = "integrations:google:meet";
|
||||
|
||||
export const MSTeamsLocationType = "integrations:office365_video";
|
||||
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
|
||||
eventLocations: LocationObject[]
|
||||
) => {
|
||||
let bookingLocation = bookingLocationTypeOrValue;
|
||||
let conferenceCredentialId = undefined;
|
||||
let conferenceCredentialId: number | undefined = undefined;
|
||||
|
||||
eventLocations.forEach((location) => {
|
||||
if (location.type === bookingLocationTypeOrValue) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`,
|
||||
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
|
||||
state: "",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
|
||||
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`);
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`);
|
||||
const params = new URLSearchParams([
|
||||
["grant_type", "authorization_code"],
|
||||
["client_id", client_id],
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import debounce from "lodash/debounce";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName";
|
||||
|
||||
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
|
||||
import {
|
||||
@@ -22,8 +26,12 @@ import {
|
||||
import {
|
||||
type FilterValue,
|
||||
type FilterSegmentOutput,
|
||||
type SystemFilterSegment,
|
||||
type CombinedFilterSegment,
|
||||
type SegmentIdentifier,
|
||||
type ActiveFilters,
|
||||
type UseSegments,
|
||||
SYSTEM_SEGMENT_PREFIX,
|
||||
} from "./lib/types";
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
|
||||
|
||||
@@ -55,10 +63,10 @@ export type DataTableContextType = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
|
||||
segments: FilterSegmentOutput[];
|
||||
selectedSegment: FilterSegmentOutput | undefined;
|
||||
segmentId: number | undefined;
|
||||
setSegmentId: (id: number | null) => void;
|
||||
segments: CombinedFilterSegment[];
|
||||
selectedSegment: CombinedFilterSegment | undefined;
|
||||
segmentId: SegmentIdentifier | null;
|
||||
setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
|
||||
canSaveSegment: boolean;
|
||||
isSegmentEnabled: boolean;
|
||||
|
||||
@@ -78,7 +86,8 @@ interface DataTableProviderProps {
|
||||
defaultPageSize?: number;
|
||||
segments?: FilterSegmentOutput[];
|
||||
timeZone?: string;
|
||||
preferredSegmentId?: number | null;
|
||||
preferredSegmentId?: SegmentIdentifier | null;
|
||||
systemSegments?: SystemFilterSegment[];
|
||||
}
|
||||
|
||||
export function DataTableProvider({
|
||||
@@ -90,8 +99,18 @@ export function DataTableProvider({
|
||||
segments: providedSegments,
|
||||
timeZone,
|
||||
preferredSegmentId,
|
||||
systemSegments,
|
||||
}: DataTableProviderProps) {
|
||||
const pathname = usePathname() as string | null;
|
||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||
if (!tableIdentifier) {
|
||||
throw new Error("tableIdentifier is required");
|
||||
}
|
||||
|
||||
const filterToOpen = useRef<string | undefined>(undefined);
|
||||
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
|
||||
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
|
||||
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
|
||||
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
|
||||
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
|
||||
@@ -99,34 +118,172 @@ export function DataTableProvider({
|
||||
columnVisibilityParser
|
||||
);
|
||||
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
|
||||
const [segmentId, setSegmentId] = useQueryState(
|
||||
"segment",
|
||||
segmentIdParser.withDefault(preferredSegmentId ?? -1)
|
||||
const initialSegmentId = useMemo(
|
||||
() => (preferredSegmentId ? String(preferredSegmentId.id) : null),
|
||||
[preferredSegmentId]
|
||||
);
|
||||
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
|
||||
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
|
||||
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
|
||||
const [segmentId, _setSegmentId] = useQueryState(
|
||||
"segment",
|
||||
initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser
|
||||
);
|
||||
const {
|
||||
segments,
|
||||
preferredSegmentId: fetchedPreferredSegmentId,
|
||||
setPreference: setSegmentPreference,
|
||||
isSegmentEnabled,
|
||||
isSuccess: isSegmentFetchedSuccessfully,
|
||||
} = useSegments({
|
||||
tableIdentifier,
|
||||
providedSegments,
|
||||
systemSegments,
|
||||
});
|
||||
|
||||
const findSelectedSegment = useCallback(
|
||||
(segmentId: string) => {
|
||||
return segments.find((segment) => {
|
||||
if (
|
||||
segment.type === "system" &&
|
||||
segmentId &&
|
||||
segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) &&
|
||||
segment.id === segmentId
|
||||
) {
|
||||
return true;
|
||||
} else if (segment.type === "user") {
|
||||
const segmentIdNumber = parseInt(segmentId, 10);
|
||||
return segment.id === segmentIdNumber;
|
||||
}
|
||||
});
|
||||
},
|
||||
[segments]
|
||||
);
|
||||
|
||||
const segmentIdObject = useMemo(() => {
|
||||
if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) {
|
||||
return {
|
||||
id: segmentId,
|
||||
type: "system" as const,
|
||||
};
|
||||
} else {
|
||||
const segmentIdNumber = parseInt(segmentId, 10);
|
||||
if (!Number.isNaN(segmentIdNumber)) {
|
||||
return {
|
||||
id: segmentIdNumber,
|
||||
type: "user" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [segmentId]);
|
||||
|
||||
const [selectedSegment, setSelectedSegment] = useState<CombinedFilterSegment | undefined>(
|
||||
findSelectedSegment(segmentId)
|
||||
);
|
||||
|
||||
const setSegmentId = useCallback(
|
||||
(segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => {
|
||||
if (!segmentId) {
|
||||
_setSegmentId(null);
|
||||
setSelectedSegment(undefined);
|
||||
setSegmentPreference({
|
||||
tableIdentifier,
|
||||
segmentId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const segment = providedSegment || findSelectedSegment(String(segmentId.id));
|
||||
if (!segment) {
|
||||
// If segmentId is invalid (or not found), clear the segmentId from the query params,
|
||||
// but we still keep all the other states like activeFilters, etc.
|
||||
// This is useful when someone shares a URL that is inaccessible to someone else.
|
||||
_setSegmentId(null);
|
||||
setSelectedSegment(undefined);
|
||||
setSegmentPreference({
|
||||
tableIdentifier,
|
||||
segmentId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_setSegmentId(String(segmentId.id));
|
||||
setSelectedSegment(segment);
|
||||
setSegmentPreference({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
});
|
||||
|
||||
// apply the segment
|
||||
setActiveFilters(segment.activeFilters);
|
||||
if (segment.sorting) {
|
||||
setSorting(segment.sorting);
|
||||
}
|
||||
if (segment.columnVisibility) {
|
||||
setColumnVisibility(segment.columnVisibility);
|
||||
}
|
||||
if (segment.columnSizing) {
|
||||
setColumnSizing(segment.columnSizing);
|
||||
}
|
||||
if (segment.perPage !== undefined) {
|
||||
setPageSize(segment.perPage);
|
||||
}
|
||||
if (segment.searchTerm !== undefined) {
|
||||
setSearchTerm(segment.searchTerm);
|
||||
}
|
||||
setPageIndex(0);
|
||||
},
|
||||
[
|
||||
_setSegmentId,
|
||||
setSelectedSegment,
|
||||
setSegmentPreference,
|
||||
tableIdentifier,
|
||||
findSelectedSegment,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setSearchTerm,
|
||||
setPageIndex,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSegmentFetchedSuccessfully) {
|
||||
return;
|
||||
}
|
||||
// If the preferred segment id has been fetched
|
||||
// and no segment id has been selected yet,
|
||||
// then we set it.
|
||||
if (fetchedPreferredSegmentId && !segmentId) {
|
||||
setSegmentId(fetchedPreferredSegmentId);
|
||||
} else if (segmentId) {
|
||||
setSelectedSegment(findSelectedSegment(segmentId));
|
||||
}
|
||||
// We intentionally have only `isSegmentFetchedSuccessfully`
|
||||
// in the dependency array.
|
||||
}, [isSegmentFetchedSuccessfully]);
|
||||
|
||||
const clearSystemSegmentSelectionIfExists = useCallback(() => {
|
||||
if (selectedSegment?.type === "system") {
|
||||
setSegmentId(null);
|
||||
}
|
||||
}, [selectedSegment, setSegmentId]);
|
||||
|
||||
const setDebouncedSearchTerm = useMemo(
|
||||
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
|
||||
[setSearchTerm]
|
||||
);
|
||||
|
||||
const pathname = usePathname() as string | null;
|
||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||
if (!tableIdentifier) {
|
||||
throw new Error("tableIdentifier is required");
|
||||
}
|
||||
|
||||
const addFilter = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||
// do not reset the page to 0 here,
|
||||
// because we don't have the filter value yet (`v: undefined`)
|
||||
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
}
|
||||
},
|
||||
[activeFilters, setActiveFilters]
|
||||
[activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const setPageIndexWrapper = useCallback(
|
||||
@@ -151,8 +308,9 @@ export function DataTableProvider({
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
},
|
||||
[setActiveFilters, setPageIndex]
|
||||
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const removeFilter = useCallback(
|
||||
@@ -162,61 +320,72 @@ export function DataTableProvider({
|
||||
const remainingFilters = prev.filter((filter) => filter.f !== columnId);
|
||||
return remainingFilters.length === 0 ? null : remainingFilters;
|
||||
});
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
},
|
||||
[setActiveFilters, setPageIndex]
|
||||
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const setPageSizeAndGoToFirstPage = useCallback(
|
||||
(newPageSize: number | null) => {
|
||||
setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
|
||||
setPageIndex(null);
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
},
|
||||
[setPageSize, setPageIndex, defaultPageSize]
|
||||
);
|
||||
|
||||
const { segments, selectedSegment, canSaveSegment, setAndPersistSegmentId, isSegmentEnabled } = useSegments(
|
||||
{
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
segmentId,
|
||||
setSegmentId,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
segments: providedSegments,
|
||||
preferredSegmentId,
|
||||
}
|
||||
[setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const clearAll = useCallback(
|
||||
(exclude?: string[]) => {
|
||||
setAndPersistSegmentId(null);
|
||||
setSegmentId(null);
|
||||
setPageIndex(null);
|
||||
setActiveFilters((prev) => {
|
||||
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
|
||||
return remainingFilters.length === 0 ? null : remainingFilters;
|
||||
});
|
||||
},
|
||||
[setActiveFilters, setPageIndex, setAndPersistSegmentId]
|
||||
[setActiveFilters, setPageIndex, setSegmentId]
|
||||
);
|
||||
|
||||
const ctaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ctaContainerClassName) {
|
||||
const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement;
|
||||
ctaContainerRef.current = element;
|
||||
// Check if current state differs from selected segment
|
||||
const hasStateChanged = useMemo(() => {
|
||||
if (!selectedSegment) return false;
|
||||
|
||||
return (
|
||||
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
||||
!isEqual(sorting, selectedSegment.sorting) ||
|
||||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
||||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
||||
!isEqual(pageSize, selectedSegment.perPage) ||
|
||||
!isEqual(searchTerm || "", selectedSegment.searchTerm || "")
|
||||
);
|
||||
}, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]);
|
||||
|
||||
const canSaveSegment = useMemo(() => {
|
||||
if (selectedSegment) {
|
||||
return hasStateChanged;
|
||||
}
|
||||
}, [ctaContainerClassName]);
|
||||
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
|
||||
return (
|
||||
activeFilters.length > 0 ||
|
||||
sorting.length > 0 ||
|
||||
Object.keys(columnVisibility).length > 0 ||
|
||||
Object.keys(columnSizing).length > 0 ||
|
||||
pageSize !== defaultPageSize ||
|
||||
searchTerm?.length > 0
|
||||
);
|
||||
}, [
|
||||
selectedSegment,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
hasStateChanged,
|
||||
]);
|
||||
|
||||
const ctaContainerRef = useElementByClassName<HTMLDivElement>(ctaContainerClassName);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
@@ -243,8 +412,8 @@ export function DataTableProvider({
|
||||
offset: pageIndex * pageSize,
|
||||
segments,
|
||||
selectedSegment,
|
||||
segmentId: segmentId || undefined,
|
||||
setSegmentId: setAndPersistSegmentId,
|
||||
segmentId: segmentIdObject,
|
||||
setSegmentId,
|
||||
canSaveSegment,
|
||||
isSegmentEnabled,
|
||||
searchTerm,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_deleted"), "success");
|
||||
if (segmentId === id) {
|
||||
if (segmentId && segmentId.type === "user" && segmentId.id === id) {
|
||||
setSegmentId(null);
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
import type { CombinedFilterSegment } from "../../lib/types";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({
|
||||
segment,
|
||||
onClose,
|
||||
}: {
|
||||
segment: FilterSegmentOutput;
|
||||
segment: CombinedFilterSegment;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
@@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_duplicated"), "success");
|
||||
setSegmentId(id);
|
||||
setSegmentId({ id, type: "user" });
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
||||
|
||||
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
||||
createSegment({
|
||||
...rest,
|
||||
teamId: teamId ?? 0,
|
||||
scope: "TEAM",
|
||||
name: data.name,
|
||||
});
|
||||
} else {
|
||||
if (segment.type === "user") {
|
||||
const { type: _type, id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
||||
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
||||
createSegment({
|
||||
...rest,
|
||||
teamId: teamId ?? 0,
|
||||
scope: "TEAM",
|
||||
name: data.name,
|
||||
});
|
||||
} else {
|
||||
createSegment({
|
||||
...rest,
|
||||
scope: "USER",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
} else if (segment.type === "system") {
|
||||
const { type: _type, ...rest } = segment;
|
||||
createSegment({
|
||||
...rest,
|
||||
scope: "USER",
|
||||
@@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({
|
||||
<DialogHeader title={t("duplicate_segment")} />
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
||||
<TextField
|
||||
required
|
||||
data-testid="duplicate-segment-name"
|
||||
type="text"
|
||||
label={t("name")}
|
||||
{...form.register("name")}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button color="minimal" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
import type {
|
||||
FilterSegmentOutput,
|
||||
CombinedFilterSegment,
|
||||
SystemFilterSegmentInternal,
|
||||
UserFilterSegment,
|
||||
} from "../../lib/types";
|
||||
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
|
||||
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
|
||||
import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
||||
@@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
||||
type SubmenuItem = {
|
||||
iconName: IconName;
|
||||
labelKey: string;
|
||||
onClick: (segment: FilterSegmentOutput) => void;
|
||||
onClick: (segment: CombinedFilterSegment) => void;
|
||||
isDestructive?: boolean;
|
||||
adminOnly: boolean;
|
||||
enabledForSystemSegment?: boolean;
|
||||
};
|
||||
|
||||
export function FilterSegmentSelect() {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
|
||||
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
|
||||
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>();
|
||||
const [segmentToDuplicate, setSegmentToDuplicate] = useState<CombinedFilterSegment | undefined>();
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
|
||||
|
||||
const submenuItems: SubmenuItem[] = [
|
||||
{
|
||||
iconName: "square-pen",
|
||||
labelKey: "rename",
|
||||
onClick: (segment) => setSegmentToRename(segment),
|
||||
onClick: (segment) => {
|
||||
if (segment.type === "system") {
|
||||
setSegmentToRename(undefined);
|
||||
} else {
|
||||
const { type: _type, ...rest } = segment;
|
||||
setSegmentToRename(rest);
|
||||
}
|
||||
},
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
@@ -48,55 +63,79 @@ export function FilterSegmentSelect() {
|
||||
labelKey: "duplicate",
|
||||
onClick: (segment) => setSegmentToDuplicate(segment),
|
||||
adminOnly: false,
|
||||
enabledForSystemSegment: true,
|
||||
},
|
||||
{
|
||||
iconName: "trash-2",
|
||||
labelKey: "delete",
|
||||
onClick: (segment) => setSegmentToDelete(segment),
|
||||
onClick: (segment) => {
|
||||
if (segment.type === "system") {
|
||||
setSegmentToDelete(undefined);
|
||||
} else {
|
||||
const { type: _type, ...rest } = segment;
|
||||
setSegmentToDelete(rest);
|
||||
}
|
||||
},
|
||||
isDestructive: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const segmentGroups = useMemo(() => {
|
||||
const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name);
|
||||
const sortFn = (a: CombinedFilterSegment, b: CombinedFilterSegment) => a.name.localeCompare(b.name);
|
||||
|
||||
const personalSegments = segments?.filter((segment) => !segment.team) || [];
|
||||
const systemSegments =
|
||||
segments?.filter((s): s is SystemFilterSegmentInternal => s.type === "system") || [];
|
||||
const personalSegments =
|
||||
segments?.filter((s): s is UserFilterSegment => s.type === "user" && !s.team) || [];
|
||||
const teamSegments =
|
||||
segments?.filter(
|
||||
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
||||
segment.team !== null
|
||||
(s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
||||
s.type === "user" && s.team !== null
|
||||
) || [];
|
||||
|
||||
// Group team segments by team name
|
||||
const teamSegmentsByTeam = teamSegments.reduce<{ [teamName: string]: FilterSegmentOutput[] }>(
|
||||
(acc, segment) => {
|
||||
const teamName = segment.team.name;
|
||||
if (!acc[teamName]) {
|
||||
acc[teamName] = [];
|
||||
}
|
||||
acc[teamName].push(segment);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const groups = [];
|
||||
|
||||
return [
|
||||
...(personalSegments.length > 0
|
||||
? [
|
||||
{
|
||||
label: t("personal"),
|
||||
segments: personalSegments.sort(sortFn),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
if (systemSegments.length > 0) {
|
||||
groups.push({
|
||||
label: t("default"),
|
||||
segments: systemSegments.sort(sortFn),
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Personal segments
|
||||
if (personalSegments.length > 0) {
|
||||
groups.push({
|
||||
label: t("personal"),
|
||||
segments: personalSegments.sort(sortFn),
|
||||
isSystem: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Team segments (existing grouping logic)
|
||||
const teamSegmentsByTeam = teamSegments.reduce<{
|
||||
[teamName: string]: UserFilterSegment[];
|
||||
}>((acc, segment) => {
|
||||
const teamName = segment.team!.name;
|
||||
if (!acc[teamName]) {
|
||||
acc[teamName] = [];
|
||||
}
|
||||
acc[teamName].push(segment);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
groups.push(
|
||||
...Object.entries(teamSegmentsByTeam)
|
||||
.map(([teamName, segments]) => ({
|
||||
label: teamName,
|
||||
segments: segments.sort(sortFn),
|
||||
isSystem: false,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
);
|
||||
|
||||
return groups;
|
||||
}, [segments, t]);
|
||||
|
||||
if (!isSegmentEnabled) {
|
||||
@@ -128,22 +167,43 @@ export function FilterSegmentSelect() {
|
||||
{group.label && (
|
||||
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
|
||||
)}
|
||||
{group.segments.map((segment) => (
|
||||
<DropdownItemWithSubmenu
|
||||
key={segment.id}
|
||||
submenuItems={submenuItems}
|
||||
segment={segment}
|
||||
onSelect={() => {
|
||||
if (segmentId === segment.id) {
|
||||
setSegmentId(null);
|
||||
} else {
|
||||
setSegmentId(segment.id);
|
||||
}
|
||||
}}>
|
||||
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
|
||||
<span className="ml-3">{segment.name}</span>
|
||||
</DropdownItemWithSubmenu>
|
||||
))}
|
||||
{group.segments.map((segment) => {
|
||||
let items: SubmenuItem[] = [];
|
||||
if (segment.type === "system") {
|
||||
items = submenuItems.filter((item) => item.enabledForSystemSegment);
|
||||
} else if (segment.type === "user" && segment.scope === "USER") {
|
||||
items = submenuItems;
|
||||
} else if (segment.type === "user" && segment.scope === "TEAM") {
|
||||
items = submenuItems.filter(
|
||||
(item) =>
|
||||
// Team segments: show if not admin-only or if user is admin/owner
|
||||
isAdminOrOwner || !item.adminOnly
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownItemWithSubmenu
|
||||
key={segment.id}
|
||||
submenuItems={items}
|
||||
segment={segment}
|
||||
onSelect={() => {
|
||||
if (segmentId && segmentId.type === segment.type && segmentId.id === segment.id) {
|
||||
setSegmentId(null);
|
||||
} else {
|
||||
if (segment.type === "system") {
|
||||
setSegmentId({ id: segment.id, type: "system" });
|
||||
} else {
|
||||
setSegmentId({ id: segment.id, type: "user" });
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{segmentId && segmentId.type === segment.type && segmentId.id === segment.id && (
|
||||
<Icon name="check" className="ml-3 h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-3">{segment.name}</span>
|
||||
</DropdownItemWithSubmenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
segment: FilterSegmentOutput;
|
||||
segment: CombinedFilterSegment;
|
||||
submenuItems: SubmenuItem[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
|
||||
// Filter submenu items based on segment type and user role
|
||||
const filteredSubmenuItems = submenuItems.filter((item) => {
|
||||
if (!segment.team) {
|
||||
// Personal segments: show all actions
|
||||
return true;
|
||||
}
|
||||
|
||||
// Team segments: show if not admin-only or if user is admin/owner
|
||||
return !item.adminOnly || isAdminOrOwner;
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
|
||||
@@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
data-testid="filter-segment-select-submenu-content">
|
||||
{filteredSubmenuItems.map((item, index) => (
|
||||
{submenuItems.map((item, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<DropdownItem
|
||||
color={item.isDestructive ? "destructive" : undefined}
|
||||
|
||||
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
|
||||
<DialogHeader title={t("rename_segment")} />
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
||||
<TextField
|
||||
required
|
||||
data-testid="rename-segment-name"
|
||||
type="text"
|
||||
label={t("name")}
|
||||
{...form.register("name")}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("save")}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() {
|
||||
} = useDataTable();
|
||||
|
||||
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
|
||||
selectedSegment ? "update" : "create"
|
||||
selectedSegment && selectedSegment.type === "user" ? "update" : "create"
|
||||
);
|
||||
|
||||
// When the dialog is not open,
|
||||
@@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() {
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
setSaveMode(selectedSegment ? "update" : "create");
|
||||
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
|
||||
}, [selectedSegment, isOpen]);
|
||||
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
|
||||
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
|
||||
onSuccess: ({ id }) => {
|
||||
onSuccess: (segment) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_saved"), "success");
|
||||
setSegmentId(id);
|
||||
setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment });
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() {
|
||||
searchTerm,
|
||||
};
|
||||
|
||||
if (saveMode === "update") {
|
||||
if (!selectedSegment) {
|
||||
// theoretically this should never happen
|
||||
showToast(t("segment_not_found"), "error");
|
||||
return;
|
||||
}
|
||||
if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") {
|
||||
const scope = selectedSegment.scope;
|
||||
if (scope === "TEAM") {
|
||||
updateSegment({
|
||||
@@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() {
|
||||
// Reset form state when dialog closes
|
||||
setIsTeamSegment(false);
|
||||
setSelectedTeamId(undefined);
|
||||
setSaveMode(selectedSegment ? "update" : "create");
|
||||
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
|
||||
form.reset();
|
||||
}
|
||||
setIsOpen(open);
|
||||
@@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() {
|
||||
<DialogContent data-testid="save-filter-segment-dialog">
|
||||
<DialogHeader title={t("save_segment")} />
|
||||
<Form form={form} handleSubmit={onSubmit}>
|
||||
{selectedSegment ? (
|
||||
{selectedSegment && selectedSegment.type === "user" ? (
|
||||
<div className="mb-4">
|
||||
<RadioGroup
|
||||
defaultValue="update"
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { isEqual } from "lodash";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { recalculateDateRange } from "../lib/dateRange";
|
||||
import { type UseSegments } from "../lib/types";
|
||||
import { type UseSegments, SYSTEM_SEGMENT_PREFIX } from "../lib/types";
|
||||
import { isDateRangeFilterValue } from "../lib/utils";
|
||||
|
||||
export const useSegments: UseSegments = ({
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
segmentId,
|
||||
setSegmentId,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
segments: providedSegments,
|
||||
preferredSegmentId,
|
||||
}) => {
|
||||
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
|
||||
export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => {
|
||||
const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery(
|
||||
{
|
||||
tableIdentifier,
|
||||
},
|
||||
@@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({
|
||||
enabled: !providedSegments, // Only fetch if segments are not provided
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
|
||||
|
||||
// Recalculate date ranges based on the current timestamp
|
||||
const segments = useMemo(() => {
|
||||
const segmentsSource = providedSegments || rawSegments?.segments;
|
||||
if (!segmentsSource) return [];
|
||||
const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]);
|
||||
|
||||
return segmentsSource.map((segment) => ({
|
||||
const segments = useMemo(() => {
|
||||
const userSegments = providedSegments || rawSegments?.segments || [];
|
||||
|
||||
const processedSystemSegments = (systemSegments || []).map((segment) => ({
|
||||
perPage: 10,
|
||||
tableIdentifier,
|
||||
...segment,
|
||||
id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`,
|
||||
type: "system" as const,
|
||||
activeFilters: segment.activeFilters.map((filter) => {
|
||||
if (isDateRangeFilterValue(filter.v)) {
|
||||
return {
|
||||
@@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({
|
||||
return filter;
|
||||
}),
|
||||
}));
|
||||
}, [rawSegments, providedSegments]);
|
||||
|
||||
const selectedSegment = useMemo(() => {
|
||||
return segments?.find((segment) => segment.id === segmentId);
|
||||
}, [segments, segmentId]);
|
||||
const processedUserSegments = userSegments.map((segment) => ({
|
||||
...segment,
|
||||
type: "user" as const,
|
||||
activeFilters: segment.activeFilters.map((filter) => {
|
||||
if (isDateRangeFilterValue(filter.v)) {
|
||||
return {
|
||||
...filter,
|
||||
v: recalculateDateRange(filter.v),
|
||||
};
|
||||
}
|
||||
return filter;
|
||||
}),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (segments && segmentId > 0 && !isFetchingSegments) {
|
||||
const segment = segments.find((segment) => segment.id === segmentId);
|
||||
if (!segment) {
|
||||
// If segmentId is invalid (or not found), clear the segmentId from the query params,
|
||||
// but we still keep all the other states like activeFilters, etc.
|
||||
// This is useful when someone shares a URL that is inaccessible to someone else.
|
||||
setSegmentId(null);
|
||||
}
|
||||
}
|
||||
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
|
||||
|
||||
const memoizedPreferredSegmentId = useMemo(
|
||||
() => preferredSegmentId ?? rawSegments?.preferredSegmentId,
|
||||
[preferredSegmentId, rawSegments]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoizedPreferredSegmentId) {
|
||||
setSegmentId(memoizedPreferredSegmentId);
|
||||
}
|
||||
}, [memoizedPreferredSegmentId, setSegmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSegment) {
|
||||
// segment is selected, so we apply the filters, sorting, etc. from the segment
|
||||
setActiveFilters(selectedSegment.activeFilters);
|
||||
setSorting(selectedSegment.sorting);
|
||||
setColumnVisibility(selectedSegment.columnVisibility);
|
||||
setColumnSizing(selectedSegment.columnSizing);
|
||||
setPageSize(selectedSegment.perPage);
|
||||
setSearchTerm(selectedSegment.searchTerm);
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [
|
||||
selectedSegment,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
]);
|
||||
|
||||
const canSaveSegment = useMemo(() => {
|
||||
if (!selectedSegment) {
|
||||
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
|
||||
return (
|
||||
activeFilters.length > 0 ||
|
||||
sorting.length > 0 ||
|
||||
Object.keys(columnVisibility).length > 0 ||
|
||||
Object.keys(columnSizing).length > 0 ||
|
||||
pageSize !== defaultPageSize ||
|
||||
searchTerm?.length > 0
|
||||
);
|
||||
} else {
|
||||
// if a segment is selected, we can save the segment if the active filters, sorting, etc. are different from the segment
|
||||
return (
|
||||
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
||||
!isEqual(sorting, selectedSegment.sorting) ||
|
||||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
||||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
||||
!isEqual(pageSize, selectedSegment.perPage) ||
|
||||
!isEqual(searchTerm, selectedSegment.searchTerm)
|
||||
);
|
||||
}
|
||||
}, [
|
||||
selectedSegment,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
]);
|
||||
|
||||
const setAndPersistSegmentId = useCallback(
|
||||
(segmentId: number | null) => {
|
||||
setSegmentId(segmentId);
|
||||
setPreference({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
});
|
||||
},
|
||||
[tableIdentifier, setSegmentId, setPreference]
|
||||
);
|
||||
return [...processedSystemSegments, ...processedUserSegments];
|
||||
}, [rawSegments, providedSegments, systemSegments, tableIdentifier]);
|
||||
|
||||
return {
|
||||
segments: segments ?? [],
|
||||
selectedSegment,
|
||||
canSaveSegment,
|
||||
setAndPersistSegmentId,
|
||||
segments,
|
||||
preferredSegmentId,
|
||||
isSuccess: Boolean(providedSegments) || isSuccess,
|
||||
setPreference,
|
||||
isSegmentEnabled: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types";
|
||||
export const useSegmentsNoop: UseSegments = ({}) => {
|
||||
return {
|
||||
segments: [],
|
||||
selectedSegment: undefined,
|
||||
canSaveSegment: false,
|
||||
setAndPersistSegmentId: noop,
|
||||
preferredSegmentId: null,
|
||||
isSuccess: false,
|
||||
setPreference: noop,
|
||||
isSegmentEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD
|
||||
DEFAULT_COLUMN_VISIBILITY
|
||||
);
|
||||
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
|
||||
export const segmentIdParser = parseAsInteger.withDefault(-1);
|
||||
export const segmentIdParser = parseAsString.withDefault("");
|
||||
export const pageIndexParser = parseAsInteger.withDefault(0);
|
||||
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
|
||||
export const searchTermParser = parseAsString.withDefault("");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { SortingState, ColumnSort, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
import type { SortingState, ColumnSort } from "@tanstack/react-table";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
export type { SortingState } from "@tanstack/react-table";
|
||||
|
||||
export const SYSTEM_SEGMENT_PREFIX = "system_";
|
||||
|
||||
export enum ColumnFilterType {
|
||||
SINGLE_SELECT = "ss",
|
||||
MULTI_SELECT = "ms",
|
||||
@@ -255,11 +257,31 @@ export type FilterSegmentOutput = {
|
||||
team: { id: number; name: string } | null;
|
||||
};
|
||||
|
||||
export type FilterSegmentsListResponse = {
|
||||
segments: FilterSegmentOutput[];
|
||||
preferredSegmentId: number | null;
|
||||
export type SystemFilterSegment = {
|
||||
id: string;
|
||||
name: string;
|
||||
activeFilters: ActiveFilters;
|
||||
sorting?: SortingState;
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
columnSizing?: Record<string, number>;
|
||||
perPage?: number;
|
||||
searchTerm?: string | null;
|
||||
type: "system";
|
||||
};
|
||||
|
||||
export type SystemFilterSegmentInternal = Omit<SystemFilterSegment, "perPage"> & {
|
||||
tableIdentifier: string;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export type UserFilterSegment = FilterSegmentOutput & {
|
||||
type: "user";
|
||||
};
|
||||
|
||||
export type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
|
||||
|
||||
export type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
|
||||
|
||||
export type SegmentStorage = {
|
||||
[tableIdentifier: string]: {
|
||||
segmentId: number;
|
||||
@@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
|
||||
|
||||
export type UseSegmentsProps = {
|
||||
tableIdentifier: string;
|
||||
activeFilters: ActiveFilters;
|
||||
sorting: SortingState;
|
||||
columnVisibility: VisibilityState;
|
||||
columnSizing: ColumnSizingState;
|
||||
pageSize: number;
|
||||
searchTerm: string;
|
||||
defaultPageSize: number;
|
||||
segmentId: number;
|
||||
setSegmentId: (segmentId: number | null) => void;
|
||||
setActiveFilters: (activeFilters: ActiveFilters) => void;
|
||||
setSorting: (sorting: SortingState) => void;
|
||||
setColumnVisibility: (columnVisibility: VisibilityState) => void;
|
||||
setColumnSizing: (columnSizing: ColumnSizingState) => void;
|
||||
setPageSize: (pageSize: number) => void;
|
||||
setPageIndex: (pageIndex: number) => void;
|
||||
setSearchTerm: (searchTerm: string | null) => void;
|
||||
segments?: FilterSegmentOutput[];
|
||||
preferredSegmentId?: number | null;
|
||||
providedSegments?: FilterSegmentOutput[];
|
||||
systemSegments?: SystemFilterSegment[];
|
||||
};
|
||||
|
||||
export type UseSegmentsReturn = {
|
||||
segments: FilterSegmentOutput[];
|
||||
selectedSegment: FilterSegmentOutput | undefined;
|
||||
canSaveSegment: boolean;
|
||||
setAndPersistSegmentId: (segmentId: number | null) => void;
|
||||
segments: CombinedFilterSegment[];
|
||||
preferredSegmentId: SegmentIdentifier | null;
|
||||
isSuccess: boolean;
|
||||
setPreference: ({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
}: {
|
||||
tableIdentifier: string;
|
||||
segmentId: SegmentIdentifier | null;
|
||||
}) => void;
|
||||
isSegmentEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({
|
||||
|
||||
const results = updateManager.results ?? [];
|
||||
|
||||
const calVideoResult = results.find((result) => result.type === "daily_video");
|
||||
// Check if Cal Video Creation Failed - That is the fallback for Cal.com and is expected to always work
|
||||
if (calVideoResult && !calVideoResult.success) {
|
||||
handleRescheduleEventManager.error("Cal Video creation failed", {
|
||||
error: calVideoResult.error,
|
||||
bookingLocation,
|
||||
});
|
||||
// This happens only when Cal Video is down
|
||||
throw new Error("Failed to set video conferencing link, but the meeting has been rescheduled");
|
||||
}
|
||||
|
||||
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
|
||||
results: results,
|
||||
});
|
||||
@@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({
|
||||
let videoCallUrl = _videoCallUrl;
|
||||
let metadata: AdditionalInformation = {};
|
||||
metadata = videoMetadata;
|
||||
|
||||
if (results.length) {
|
||||
// Handle Google Meet results
|
||||
if (bookingLocation === MeetLocationType) {
|
||||
@@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
}
|
||||
|
||||
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
|
||||
|
||||
await BookingReferenceRepository.replaceBookingReferences({
|
||||
|
||||
@@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn
|
||||
|
||||
import { describe, vi, expect } from "vitest";
|
||||
|
||||
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
vi.mock("@calcom/lib/EventManager");
|
||||
vi.mock("@calcom/app-store/utils", () => ({
|
||||
getAppFromSlug: vi.fn(),
|
||||
}));
|
||||
|
||||
const testDestinationCalendar = {
|
||||
integration: "test-calendar",
|
||||
externalId: "test-calendar",
|
||||
// Type definitions
|
||||
type TestUserMetadata = {
|
||||
defaultConferencingApp?: {
|
||||
appSlug: string;
|
||||
appLink?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const testUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: "user-1",
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: "host-1",
|
||||
email: "host1@test.com",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
destinationCalendar: testDestinationCalendar,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "user-2",
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: "host-2",
|
||||
email: "host2@test.com",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "user-3",
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: "host-3",
|
||||
email: "host3@test.com",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
},
|
||||
];
|
||||
type TestUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
timeZone: string;
|
||||
username: string;
|
||||
email: string;
|
||||
schedules: (typeof TestData.schedules.IstWorkHours)[];
|
||||
destinationCalendar?: { integration: string; externalId: string };
|
||||
metadata?: TestUserMetadata;
|
||||
};
|
||||
|
||||
type MockBookingAttendee = ReturnType<typeof getMockBookingAttendee>;
|
||||
|
||||
// Test Data Builders
|
||||
const createTestUser = (overrides?: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
destinationCalendar?: { integration: string; externalId: string };
|
||||
metadata?: TestUserMetadata;
|
||||
}) => ({
|
||||
id: overrides?.id ?? 1,
|
||||
name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: overrides?.username ?? `host-${overrides?.id ?? 1}`,
|
||||
email: overrides?.email ?? `host${overrides?.id ?? 1}@test.com`,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
destinationCalendar: overrides?.destinationCalendar,
|
||||
metadata: overrides?.metadata,
|
||||
defaultScheduleId: 1,
|
||||
credentials: [],
|
||||
selectedCalendars: [],
|
||||
weekStart: "Monday" as const,
|
||||
teams: [],
|
||||
availability: [],
|
||||
profiles: [],
|
||||
outOfOffice: undefined,
|
||||
smsLockState: undefined,
|
||||
completedOnboarding: true,
|
||||
locked: false,
|
||||
organizationId: null,
|
||||
});
|
||||
|
||||
const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({
|
||||
integration: overrides?.integration ?? "test-calendar",
|
||||
externalId: overrides?.externalId ?? "test-calendar",
|
||||
});
|
||||
|
||||
const createTestBooking = async (params: {
|
||||
eventTypeId: number;
|
||||
userId: number;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
attendees?: MockBookingAttendee[];
|
||||
location?: string;
|
||||
}) => {
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
|
||||
return {
|
||||
id: params.bookingId,
|
||||
eventTypeId: params.eventTypeId,
|
||||
userId: params.userId,
|
||||
uid: params.bookingUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
location: params.location ?? null,
|
||||
attendees: params.attendees ?? [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
name: "attendee",
|
||||
email: "attendee@test.com",
|
||||
locale: "en",
|
||||
timeZone: "Asia/Kolkata",
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const createRoundRobinEventType = (params: {
|
||||
id: number;
|
||||
slug: string;
|
||||
users: TestUser[];
|
||||
hosts?: { userId: number; isFixed: boolean }[];
|
||||
locations?: { type: string; credentialId?: number }[];
|
||||
teamId?: number;
|
||||
schedulingType?: SchedulingType;
|
||||
parentId?: number;
|
||||
}) => ({
|
||||
id: params.id,
|
||||
slug: params.slug,
|
||||
schedulingType: params.schedulingType ?? SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: params.users.map((user) => ({ id: user.id })),
|
||||
hosts: params.hosts ?? params.users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||
locations: params.locations,
|
||||
teamId: params.teamId,
|
||||
parentId: params.parentId,
|
||||
});
|
||||
|
||||
// Assertion Helpers
|
||||
const expectEventManagerCalledWith = (
|
||||
spy: ReturnType<typeof vi.spyOn>,
|
||||
expectedParams: {
|
||||
location?: string;
|
||||
uid: string;
|
||||
changedOrganizer: boolean;
|
||||
destinationCalendars?: Array<{ integration: string; externalId: string }>;
|
||||
}
|
||||
) => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expectedParams.location
|
||||
? expect.objectContaining({ location: expectedParams.location })
|
||||
: expect.any(Object),
|
||||
expectedParams.uid,
|
||||
undefined,
|
||||
expectedParams.changedOrganizer,
|
||||
expectedParams.destinationCalendars ?? expect.any(Array)
|
||||
);
|
||||
};
|
||||
|
||||
// Mock Helpers
|
||||
type MockEventManagerConfig = {
|
||||
failConferencing?: boolean;
|
||||
};
|
||||
|
||||
type ConferenceResult = {
|
||||
type: string;
|
||||
success: boolean;
|
||||
uid: string;
|
||||
originalEvent: Record<string, unknown>;
|
||||
error?: string;
|
||||
updatedEvent?: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => {
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const spy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
spy.mockImplementation(async (event: any) => {
|
||||
const location = event.location;
|
||||
const results: ConferenceResult[] = [];
|
||||
|
||||
// Check if location is a static link (starts with http:// or https://)
|
||||
const isStaticLink = location?.startsWith("http://") || location?.startsWith("https://");
|
||||
|
||||
if (isStaticLink) {
|
||||
// For static links, no conferencing result is generated
|
||||
return { referencesToCreate: [] };
|
||||
}
|
||||
|
||||
// Handle conferencing based on location type
|
||||
if (location?.includes("integrations:")) {
|
||||
const conferenceType = location.split(":")[1];
|
||||
|
||||
if (config?.failConferencing) {
|
||||
results.push({
|
||||
type: `${conferenceType}_video`,
|
||||
success: false,
|
||||
error: `Failed to create ${conferenceType} room`,
|
||||
uid: event.uid || "test-uid",
|
||||
originalEvent: {},
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
type: `${conferenceType}_video`,
|
||||
success: true,
|
||||
uid: event.uid || "test-uid",
|
||||
originalEvent: {},
|
||||
updatedEvent: {
|
||||
url:
|
||||
conferenceType === "zoom"
|
||||
? "https://zoom.us/j/123456789"
|
||||
: conferenceType === "google"
|
||||
? "https://meet.google.com/test-meeting"
|
||||
: `https://${conferenceType}.co/test-room`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { results, referencesToCreate: [] };
|
||||
});
|
||||
|
||||
return spy;
|
||||
};
|
||||
|
||||
const mockGetAppFromSlug = async () => {
|
||||
const { getAppFromSlug } = await import("@calcom/app-store/utils");
|
||||
return vi.mocked(getAppFromSlug);
|
||||
};
|
||||
|
||||
describe("roundRobinManualReassignment test", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
test("manually reassign round robin organizer", async ({ emails }) => {
|
||||
test("should reassign round robin booking to new host and update workflows", async ({ emails }) => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const newHost = createTestUser({ id: 2 });
|
||||
const users = [originalHost, newHost, createTestUser({ id: 3 })];
|
||||
|
||||
const users = testUsers;
|
||||
const originalHost = users[0];
|
||||
const newHost = users[1];
|
||||
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
|
||||
|
||||
const bookingToReassignUid = "booking-to-reassign";
|
||||
|
||||
const bookingData = await createBookingScenario(
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
workflows: [
|
||||
{
|
||||
@@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => {
|
||||
trigger: "NEW_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeEventTypeId: 1,
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: users.map((user) => ({ id: user.id })),
|
||||
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||
},
|
||||
users,
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 123,
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
name: "attendee",
|
||||
email: "attendee@test.com",
|
||||
locale: "en",
|
||||
timeZone: "Asia/Kolkata",
|
||||
}),
|
||||
],
|
||||
},
|
||||
bookingId: 123,
|
||||
bookingUid: bookingToReassignUid,
|
||||
}),
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await addWorkflowReminders([
|
||||
{
|
||||
bookingUid: bookingToReassignUid,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduledDate: dateStringPlusTwo,
|
||||
scheduledDate: new Date(dateStringPlusTwo),
|
||||
scheduled: true,
|
||||
workflowStepId: 1,
|
||||
workflowId: 1,
|
||||
@@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => {
|
||||
bookingId: 123,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
expect(eventManagerSpy).toBeCalledTimes(1);
|
||||
expect(eventManagerSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
bookingToReassignUid,
|
||||
undefined,
|
||||
true,
|
||||
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
|
||||
);
|
||||
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||
uid: bookingToReassignUid,
|
||||
changedOrganizer: true,
|
||||
destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]),
|
||||
});
|
||||
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
@@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => {
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
|
||||
});
|
||||
|
||||
test("Manually reassign round robin host with fixed host as organizer", async () => {
|
||||
test("should include conferenceCredentialId when reassigning booking with with fixed host as organizer", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const newHost = createTestUser({ id: 2 });
|
||||
const users = [originalHost, newHost];
|
||||
|
||||
const users = testUsers;
|
||||
const bookingToReassignUid = "booking-with-conference-credential";
|
||||
const conferenceCredentialId = 999;
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
users,
|
||||
locations: [
|
||||
{
|
||||
type: "integrations:zoom",
|
||||
credentialId: conferenceCredentialId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
bookingId: 123,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:zoom",
|
||||
}),
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 123,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
// Verify that EventManager.reschedule was called with an event containing conferenceCredentialId
|
||||
expect(eventManagerRescheduleSpy).toHaveBeenCalledTimes(1);
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).conferenceCredentialId).toBe(conferenceCredentialId);
|
||||
});
|
||||
|
||||
test("should reassign round robin attendee while keeping fixed host as organizer", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const fixedHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const currentRRHost = createTestUser({ id: 2 });
|
||||
const newHost = createTestUser({ id: 3 });
|
||||
const users = [fixedHost, currentRRHost, newHost];
|
||||
|
||||
const bookingToReassignUid = "booking-to-reassign";
|
||||
|
||||
const fixedHost = users[0];
|
||||
const currentRRHost = users[1];
|
||||
const newHost = users[2];
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
workflows: [
|
||||
@@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => {
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: users.map((user) => ({ id: user.id })),
|
||||
users,
|
||||
hosts: users.map((user) => ({
|
||||
userId: user.id,
|
||||
isFixed: user.id === fixedHost.id,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 123,
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: fixedHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
bookingId: 123,
|
||||
bookingUid: bookingToReassignUid,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 1,
|
||||
@@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => {
|
||||
timeZone: currentRRHost.timeZone,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
organizer: fixedHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
@@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => {
|
||||
bookingId: 123,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
expect(eventManagerSpy).toBeCalledTimes(1);
|
||||
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||
uid: bookingToReassignUid,
|
||||
changedOrganizer: false,
|
||||
});
|
||||
|
||||
// Ensure organizer stays the same
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: fixedHost.id,
|
||||
});
|
||||
|
||||
const bookingRepo = new BookingRepository(prismaMock);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bookingRepo = new BookingRepository(prismaMock as any);
|
||||
const attendees = await bookingRepo.getBookingAttendees(123);
|
||||
|
||||
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
|
||||
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
|
||||
});
|
||||
|
||||
test("sends cancellation email to previous RR host when reassigning", async ({ emails }) => {
|
||||
test("should send cancellation email to previous round robin attendee when reassigning", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
|
||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
|
||||
await mockEventManagerReschedule();
|
||||
|
||||
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
|
||||
await import("@calcom/emails"),
|
||||
"sendRoundRobinCancelledEmailsAndSMS"
|
||||
);
|
||||
|
||||
const users = testUsers;
|
||||
const originalHost = users[0];
|
||||
const previousRRHost = users[1];
|
||||
const newHost = users[2];
|
||||
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const previousRRHost = createTestUser({ id: 2 });
|
||||
const newHost = createTestUser({ id: 3 });
|
||||
const users = [originalHost, previousRRHost, newHost];
|
||||
|
||||
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: users.map((user) => ({ id: user.id })),
|
||||
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||
},
|
||||
users,
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 124,
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
bookingId: 124,
|
||||
bookingUid: bookingToReassignUid,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
@@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => {
|
||||
timeZone: previousRRHost.timeZone,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
@@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => {
|
||||
bookingId: 124,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundRobinManualReassignment - Location Changes", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
const createConferencingUsers = () => {
|
||||
return {
|
||||
googleMeetHost: createTestUser({
|
||||
id: 1,
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "test-calendar",
|
||||
},
|
||||
metadata: {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "google-meet",
|
||||
},
|
||||
},
|
||||
}),
|
||||
zoomHost: createTestUser({
|
||||
id: 2,
|
||||
metadata: {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "zoom",
|
||||
},
|
||||
},
|
||||
}),
|
||||
noDefaultConferencingAppHost: createTestUser({
|
||||
id: 3,
|
||||
metadata: {},
|
||||
}),
|
||||
staticVideoLinkHost: createTestUser({
|
||||
id: 4,
|
||||
metadata: {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "custom-video",
|
||||
appLink: "https://custom-video.com/room123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const mockAppStore = async (config?: { googleMeet?: boolean; zoom?: boolean; customVideo?: boolean }) => {
|
||||
const getAppFromSlug = await mockGetAppFromSlug();
|
||||
getAppFromSlug.mockImplementation((slug?: string) => {
|
||||
if (slug === "google-meet" && config?.googleMeet !== false) {
|
||||
return {
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:google:meet",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
if (slug === "zoom" && config?.zoom !== false) {
|
||||
return {
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:zoom",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
if (slug === "custom-video" && config?.customVideo !== false) {
|
||||
return {
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:custom-video",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return getAppFromSlug;
|
||||
};
|
||||
|
||||
describe("when event has Organizer's Default Conferencing App location", () => {
|
||||
test("should update location from Google Meet to Zoom when reassigning between hosts with different default conferencing apps", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
await mockAppStore({ googleMeet: true, zoom: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const zoomHost = users.zoomHost;
|
||||
|
||||
const bookingToReassignUid = "booking-location-change-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
teamId: 1,
|
||||
users: Object.values(users),
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }, { type: "integrations:daily" }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 125,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 125,
|
||||
newUserId: zoomHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||
location: "integrations:zoom",
|
||||
uid: bookingToReassignUid,
|
||||
changedOrganizer: true,
|
||||
destinationCalendars: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integration: "google_calendar",
|
||||
externalId: "test-calendar",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: zoomHost.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should fallback to first available event location in Event Type when new host has no default conferencing app", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
// (await mockGetAppFromSlug()).mockReturnValue(undefined);
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
|
||||
|
||||
const bookingToReassignUid = "booking-fallback-location-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 2,
|
||||
slug: "round-robin-event-fallback",
|
||||
users: Object.values(users),
|
||||
locations: [
|
||||
{ type: OrganizerDefaultConferencingAppType },
|
||||
{ type: "integrations:first-available" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 2,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 126,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 126,
|
||||
newUserId: noDefaultConferencingAppHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).location).toBe("integrations:first-available");
|
||||
});
|
||||
|
||||
test("should update location from Google Meet to static video link when new host has static video link app", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
await mockAppStore({ customVideo: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const staticVideoLinkHost = users.staticVideoLinkHost;
|
||||
|
||||
const bookingToReassignUid = "booking-static-link-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 4,
|
||||
slug: "managed-event",
|
||||
// This is a managed event type child which allows Organizer's Default Conferencing App
|
||||
parentId: 1,
|
||||
users: Object.values(users),
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 4,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 128,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 128,
|
||||
newUserId: staticVideoLinkHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).location).toBe("https://custom-video.com/room123");
|
||||
});
|
||||
|
||||
test("should update location from Static Video Link to Google Meet when new host has Google Meet app", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
await mockAppStore({ googleMeet: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const staticVideoLinkHost = users.staticVideoLinkHost;
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
|
||||
const bookingToReassignUid = "booking-static-link-to-google-meet-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 3,
|
||||
slug: "round-robin-event-error",
|
||||
users: Object.values(users),
|
||||
teamId: 1,
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 3,
|
||||
userId: staticVideoLinkHost.id,
|
||||
bookingId: 127,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "https://custom-video.com/room123",
|
||||
}),
|
||||
],
|
||||
organizer: staticVideoLinkHost,
|
||||
usersApartFromOrganizer: Object.values(users).filter((user) => user.id !== staticVideoLinkHost.id),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 127,
|
||||
newUserId: googleMeetHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).location).toBe("integrations:google:meet");
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: googleMeetHost.id,
|
||||
location: "integrations:google:meet",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error when Cal Video fallback fails", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
await mockEventManagerReschedule({ failConferencing: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
|
||||
|
||||
const bookingToReassignUid = "booking-calvideo-error-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 3,
|
||||
slug: "round-robin-event-error",
|
||||
users: Object.values(users),
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 3,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 127,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
roundRobinManualReassignment({
|
||||
bookingId: 127,
|
||||
newUserId: noDefaultConferencingAppHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
})
|
||||
).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
sendRoundRobinCancelledEmailsAndSMS,
|
||||
@@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
@@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({
|
||||
|
||||
const originalOrganizer = booking.user;
|
||||
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
|
||||
|
||||
const newUser = newUserHost.user;
|
||||
const newUserT = await getTranslation(newUser.locale || "en", "common");
|
||||
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
|
||||
@@ -149,7 +147,7 @@ export const roundRobinManualReassignment = async ({
|
||||
|
||||
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
|
||||
let bookingLocation = booking.location;
|
||||
|
||||
let conferenceCredentialId: number | null = null;
|
||||
if (hasOrganizerChanged) {
|
||||
const bookingResponses = booking.responses;
|
||||
const responseSchema = getBookingResponsesSchema({
|
||||
@@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({
|
||||
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
|
||||
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
|
||||
|
||||
if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) {
|
||||
const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata);
|
||||
const defaultLocationUrl = newUserMetadataSafeParse.success
|
||||
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
|
||||
: undefined;
|
||||
const currentBookingLocation = booking.location || "integrations:daily";
|
||||
bookingLocation =
|
||||
defaultLocationUrl ||
|
||||
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
|
||||
// Determine the location for the new host
|
||||
const isManagedEventType = !!eventType.parentId;
|
||||
const isTeamEventType = !!eventType.teamId;
|
||||
|
||||
const locationResult = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: newUser.metadata,
|
||||
eventTypeLocations: eventType.locations,
|
||||
isManagedEventType,
|
||||
isTeamEventType,
|
||||
});
|
||||
|
||||
bookingLocation = locationResult.bookingLocation;
|
||||
if (locationResult.requiresActualLink) {
|
||||
conferenceCredentialId = locationResult.conferenceCredentialId;
|
||||
}
|
||||
|
||||
const newBookingTitle = getEventName({
|
||||
@@ -219,6 +222,18 @@ export const roundRobinManualReassignment = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
|
||||
if (!hasOrganizerChanged && bookingLocation) {
|
||||
const { conferenceCredentialId: extractedCredentialId } =
|
||||
BookingLocationService.getLocationDetailsFromType({
|
||||
locationType: bookingLocation,
|
||||
eventTypeLocations: eventType.locations || [],
|
||||
});
|
||||
if (extractedCredentialId) {
|
||||
conferenceCredentialId = extractedCredentialId;
|
||||
}
|
||||
}
|
||||
|
||||
const destinationCalendar = await getDestinationCalendar({
|
||||
eventType,
|
||||
booking,
|
||||
@@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({
|
||||
customReplyToEmail: eventType?.customReplyToEmail,
|
||||
location: bookingLocation,
|
||||
...(platformClientParams ? platformClientParams : {}),
|
||||
conferenceCredentialId: conferenceCredentialId ?? undefined,
|
||||
};
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useElementByClassName<T extends HTMLElement = HTMLDivElement>(
|
||||
className?: string
|
||||
): React.RefObject<T> {
|
||||
const elementRef = useRef<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (className) {
|
||||
const element = document.getElementsByClassName(className)[0] as T;
|
||||
elementRef.current = element || null;
|
||||
} else {
|
||||
elementRef.current = null;
|
||||
}
|
||||
}, [className]);
|
||||
|
||||
return elementRef;
|
||||
}
|
||||
@@ -296,7 +296,7 @@ describe("FilterSegmentRepository.get()", () => {
|
||||
});
|
||||
|
||||
expect(result.segments).toHaveLength(1);
|
||||
expect(result.preferredSegmentId).toBe(segment.id);
|
||||
expect(result.preferredSegmentId).toEqual({ id: segment.id, type: "user" });
|
||||
expect(result.segments[0].name).toBe("Important Contacts");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
|
||||
import type { FilterSegmentOutput, SegmentIdentifier } from "@calcom/features/data-table/lib/types";
|
||||
import {
|
||||
ZActiveFilters,
|
||||
ZSortingState,
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
ZColumnVisibility,
|
||||
} from "@calcom/features/data-table/lib/types";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { FilterSegment, UserFilterSegmentPreference } from "@calcom/prisma/client";
|
||||
import type { UserFilterSegmentPreference } from "@calcom/prisma/client";
|
||||
|
||||
import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type";
|
||||
|
||||
export interface IFilterSegmentRepository {
|
||||
get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{
|
||||
segments: FilterSegmentOutput[];
|
||||
preferredSegmentId: number | null;
|
||||
preferredSegmentId: SegmentIdentifier | null;
|
||||
}>;
|
||||
|
||||
create({
|
||||
@@ -22,7 +22,7 @@ export interface IFilterSegmentRepository {
|
||||
}: {
|
||||
userId: number;
|
||||
input: TCreateFilterSegmentInputSchema;
|
||||
}): Promise<FilterSegment>;
|
||||
}): Promise<FilterSegmentOutput>;
|
||||
|
||||
update({
|
||||
userId,
|
||||
@@ -30,7 +30,7 @@ export interface IFilterSegmentRepository {
|
||||
}: {
|
||||
userId: number;
|
||||
input: TUpdateFilterSegmentInputSchema;
|
||||
}): Promise<FilterSegment>;
|
||||
}): Promise<FilterSegmentOutput>;
|
||||
|
||||
delete({ userId, id }: { userId: number; id: number }): Promise<void>;
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface IFilterSegmentRepository {
|
||||
}: {
|
||||
userId: number;
|
||||
tableIdentifier: string;
|
||||
segmentId: number | null;
|
||||
segmentId: SegmentIdentifier | null;
|
||||
}): Promise<UserFilterSegmentPreference | null>;
|
||||
}
|
||||
|
||||
@@ -124,12 +124,19 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
||||
},
|
||||
select: {
|
||||
segmentId: true,
|
||||
systemSegmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const preferredSegmentId = preference?.segmentId
|
||||
? { id: preference.segmentId, type: "user" as const }
|
||||
: preference?.systemSegmentId
|
||||
? { id: preference.systemSegmentId, type: "system" as const }
|
||||
: null;
|
||||
|
||||
return {
|
||||
segments: parsedSegments,
|
||||
preferredSegmentId: preference?.segmentId || null,
|
||||
preferredSegmentId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,9 +181,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
||||
...(scope === "TEAM" ? { teamId } : {}),
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
tableIdentifier: true,
|
||||
scope: true,
|
||||
activeFilters: true,
|
||||
sorting: true,
|
||||
columnVisibility: true,
|
||||
columnSizing: true,
|
||||
perPage: true,
|
||||
searchTerm: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return filterSegment;
|
||||
// Parse the segment with Zod schemas
|
||||
const parsedSegment: FilterSegmentOutput = {
|
||||
...filterSegment,
|
||||
activeFilters: ZActiveFilters.catch([]).parse(filterSegment.activeFilters),
|
||||
sorting: ZSortingState.catch([]).parse(filterSegment.sorting),
|
||||
columnVisibility: ZColumnVisibility.catch({}).parse(filterSegment.columnVisibility),
|
||||
columnSizing: ZColumnSizing.catch({}).parse(filterSegment.columnSizing),
|
||||
};
|
||||
|
||||
return parsedSegment;
|
||||
}
|
||||
|
||||
async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) {
|
||||
@@ -228,9 +266,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
||||
perPage,
|
||||
searchTerm,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
tableIdentifier: true,
|
||||
scope: true,
|
||||
activeFilters: true,
|
||||
sorting: true,
|
||||
columnVisibility: true,
|
||||
columnSizing: true,
|
||||
perPage: true,
|
||||
searchTerm: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return updatedSegment;
|
||||
// Parse the segment with Zod schemas
|
||||
const parsedSegment: FilterSegmentOutput = {
|
||||
...updatedSegment,
|
||||
activeFilters: ZActiveFilters.catch([]).parse(updatedSegment.activeFilters),
|
||||
sorting: ZSortingState.catch([]).parse(updatedSegment.sorting),
|
||||
columnVisibility: ZColumnVisibility.catch({}).parse(updatedSegment.columnVisibility),
|
||||
columnSizing: ZColumnSizing.catch({}).parse(updatedSegment.columnSizing),
|
||||
};
|
||||
|
||||
return parsedSegment;
|
||||
}
|
||||
|
||||
async delete({ userId, id }: { userId: number; id: number }) {
|
||||
@@ -282,7 +351,7 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
||||
}: {
|
||||
userId: number;
|
||||
tableIdentifier: string;
|
||||
segmentId: number | null;
|
||||
segmentId: SegmentIdentifier | null;
|
||||
}) {
|
||||
if (segmentId === null) {
|
||||
await prisma.userFilterSegmentPreference.deleteMany({
|
||||
@@ -294,6 +363,18 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSystemSegment = segmentId.type === "system";
|
||||
|
||||
const updateData = isSystemSegment
|
||||
? {
|
||||
segmentId: null,
|
||||
systemSegmentId: segmentId.id,
|
||||
}
|
||||
: {
|
||||
segmentId: segmentId.id,
|
||||
systemSegmentId: null,
|
||||
};
|
||||
|
||||
const preference = await prisma.userFilterSegmentPreference.upsert({
|
||||
where: {
|
||||
userId_tableIdentifier: {
|
||||
@@ -301,13 +382,11 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
||||
tableIdentifier,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
segmentId,
|
||||
},
|
||||
update: updateData,
|
||||
create: {
|
||||
userId,
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
...updateData,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
|
||||
import {
|
||||
ZActiveFilters,
|
||||
ZSortingState,
|
||||
@@ -8,11 +7,6 @@ import {
|
||||
ZColumnVisibility,
|
||||
} from "@calcom/features/data-table/lib/types";
|
||||
|
||||
export type FilterSegmentsListResponse = {
|
||||
segments: FilterSegmentOutput[];
|
||||
preferredSegmentId: number | null;
|
||||
};
|
||||
|
||||
export const ZListFilterSegmentsInputSchema = z.object({
|
||||
tableIdentifier: z.string(),
|
||||
});
|
||||
@@ -58,9 +52,14 @@ export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
|
||||
|
||||
export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>;
|
||||
|
||||
const ZSegmentIdentifier = z.discriminatedUnion("type", [
|
||||
z.object({ id: z.string(), type: z.literal("system") }),
|
||||
z.object({ id: z.number(), type: z.literal("user") }),
|
||||
]);
|
||||
|
||||
export const ZSetFilterSegmentPreferenceInputSchema = z.object({
|
||||
tableIdentifier: z.string(),
|
||||
segmentId: z.number().nullable(),
|
||||
segmentId: ZSegmentIdentifier.nullable(),
|
||||
});
|
||||
|
||||
export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>;
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { BookingLocationService } from "./bookingLocationService";
|
||||
|
||||
vi.mock("@calcom/app-store/utils", () => ({
|
||||
getAppFromSlug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/locations", async () => {
|
||||
const actual = await vi.importActual("@calcom/app-store/locations");
|
||||
return {
|
||||
...actual,
|
||||
OrganizerDefaultConferencingAppType: "conferencing",
|
||||
CalVideoLocationType: "integrations:daily-video",
|
||||
};
|
||||
});
|
||||
|
||||
const { getAppFromSlug } = await import("@calcom/app-store/utils");
|
||||
|
||||
describe("BookingLocationService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getOrganizerDefaultConferencingAppLocation", () => {
|
||||
describe("Dynamic conferencing apps", () => {
|
||||
it("should return the app location type for a dynamic app like Google Meet", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "google-meet",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:google:meet",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
conferencingAppLocationType: "integrations:google:meet",
|
||||
isStaticLinkApp: false,
|
||||
});
|
||||
expect(getAppFromSlug).toHaveBeenCalledWith("google-meet");
|
||||
});
|
||||
|
||||
it("should return the app location type for Zoom", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "zoom",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:zoom",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
conferencingAppLocationType: "integrations:zoom",
|
||||
isStaticLinkApp: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when app is not found", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "unknown-app",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue(null);
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Static link apps", () => {
|
||||
it("should return static link for apps with appLink", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "custom-video",
|
||||
appLink: "https://custom-video.com/room123",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:custom-video",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
conferencingAppLocationType: "integrations:custom-video",
|
||||
isStaticLinkApp: true,
|
||||
staticLink: "https://custom-video.com/room123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback scenarios", () => {
|
||||
it("should return null when no conferencing app is set", () => {
|
||||
const mockMetadata = {};
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getAppFromSlug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle null metadata gracefully", () => {
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: null,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle undefined metadata gracefully", () => {
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: undefined,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invalid metadata parsing", () => {
|
||||
it("should handle invalid metadata structure", () => {
|
||||
const invalidMetadata = {
|
||||
defaultConferencingApp: "not-an-object",
|
||||
};
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: invalidMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle metadata with missing appSlug", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appLink: "https://some-link.com",
|
||||
},
|
||||
};
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle app with no appData", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "app-without-data",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({} as any);
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle app with no location type", () => {
|
||||
const mockMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "app-without-location-type",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: mockMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocationForHost", () => {
|
||||
it("should use organizer default when allowed and available", () => {
|
||||
const mockHostMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "zoom",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:zoom",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: mockHostMetadata,
|
||||
eventTypeLocations: [{ type: "conferencing" }],
|
||||
isTeamEventType: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:zoom",
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use static link when organizer default is a static link app", () => {
|
||||
const mockHostMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "custom-video",
|
||||
appLink: "https://custom-video.com/room123",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:custom-video",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: mockHostMetadata,
|
||||
eventTypeLocations: [{ type: "conferencing" }],
|
||||
isTeamEventType: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "https://custom-video.com/room123",
|
||||
requiresActualLink: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to first real location when organizer default not available", () => {
|
||||
const mockHostMetadata = {};
|
||||
|
||||
const result = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: mockHostMetadata,
|
||||
eventTypeLocations: [{ type: "conferencing" }, { type: "integrations:google:meet" }],
|
||||
isTeamEventType: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:google:meet",
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default to Cal Video when no locations configured", () => {
|
||||
const mockHostMetadata = {};
|
||||
|
||||
const result = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: mockHostMetadata,
|
||||
eventTypeLocations: [{ type: "conferencing" }],
|
||||
isTeamEventType: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:daily-video",
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not use organizer default for non-team and non-managed events", () => {
|
||||
const mockHostMetadata = {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "zoom",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getAppFromSlug).mockReturnValue({
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:zoom",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: mockHostMetadata,
|
||||
eventTypeLocations: [{ type: "conferencing" }, { type: "integrations:google:meet" }],
|
||||
isTeamEventType: false,
|
||||
isManagedEventType: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:google:meet",
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocationDetailsFromType", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Dynamic location types", () => {
|
||||
it("should return location type with credential ID when present", () => {
|
||||
const locationType = "integrations:zoom";
|
||||
const eventTypeLocations = [
|
||||
{ type: "integrations:zoom", credentialId: 123 },
|
||||
{ type: "integrations:google:meet" },
|
||||
];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:zoom",
|
||||
conferenceCredentialId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return location type without credential ID when not present", () => {
|
||||
const locationType = "integrations:google:meet";
|
||||
const eventTypeLocations = [{ type: "integrations:google:meet" }, { type: "integrations:zoom" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:google:meet",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Cal Video location type", () => {
|
||||
const locationType = "integrations:daily-video";
|
||||
const eventTypeLocations = [{ type: "integrations:daily-video" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:daily-video",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Static link values", () => {
|
||||
it("should return static URL as-is without credential ID", () => {
|
||||
const locationType = "https://zoom.us/j/123456789";
|
||||
const eventTypeLocations = [{ type: "integrations:zoom", link: "https://zoom.us/j/123456789" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "https://zoom.us/j/123456789",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle custom static links", () => {
|
||||
const locationType = "https://custom-video.com/room123";
|
||||
const eventTypeLocations = [
|
||||
{ type: "integrations:custom", link: "https://custom-video.com/room123" },
|
||||
];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "https://custom-video.com/room123",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty event type locations", () => {
|
||||
const locationType = "integrations:zoom";
|
||||
const eventTypeLocations: any[] = [];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:zoom",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle location type not in event type locations", () => {
|
||||
const locationType = "integrations:teams";
|
||||
const eventTypeLocations = [{ type: "integrations:zoom" }, { type: "integrations:google:meet" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:teams",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple locations with same type but different credential IDs", () => {
|
||||
const locationType = "integrations:zoom";
|
||||
const eventTypeLocations = [
|
||||
{ type: "integrations:zoom", credentialId: 123 },
|
||||
{ type: "integrations:zoom", credentialId: 456 },
|
||||
];
|
||||
|
||||
// getLocationValueForDB would pick the last matching one due to forEach
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:zoom",
|
||||
conferenceCredentialId: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle location with 0 as credential ID", () => {
|
||||
const locationType = "integrations:zoom";
|
||||
const eventTypeLocations = [{ type: "integrations:zoom", credentialId: 0 }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "integrations:zoom",
|
||||
conferenceCredentialId: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Special location types", () => {
|
||||
it("should handle phone location type", () => {
|
||||
const locationType = "phone";
|
||||
const eventTypeLocations = [{ type: "phone" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "phone",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle in-person location type", () => {
|
||||
const locationType = "inPerson";
|
||||
const eventTypeLocations = [{ type: "inPerson", address: "123 Main St" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "123 Main St", // getLocationValueForDB returns the address for inPerson
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle attendee in-person location", () => {
|
||||
const locationType = "attendeeInPerson";
|
||||
const eventTypeLocations = [{ type: "attendeeInPerson" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "attendeeInPerson",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conferencing app location", () => {
|
||||
it("should handle conferencing location type (organizer default)", () => {
|
||||
const locationType = "conferencing";
|
||||
const eventTypeLocations = [{ type: "conferencing" }];
|
||||
|
||||
const result = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bookingLocation: "conferencing",
|
||||
conferenceCredentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { getLocationValueForDB, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
|
||||
import { CalVideoLocationType, type LocationObject } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
type GetOrganizerDefaultConferencingAppLocationParams = {
|
||||
organizerMetadata: unknown;
|
||||
};
|
||||
|
||||
type GetOrganizerDefaultConferencingAppLocationResult = {
|
||||
/**
|
||||
* The conferencing app location type.
|
||||
* It starts with 'integrations:' e.g. 'integrations:zoom' and it is defined in the app's config/metadata.ts file
|
||||
*/
|
||||
conferencingAppLocationType: string;
|
||||
} & (
|
||||
| {
|
||||
isStaticLinkApp: true;
|
||||
staticLink: string;
|
||||
}
|
||||
| {
|
||||
isStaticLinkApp: false;
|
||||
}
|
||||
);
|
||||
|
||||
type GetLocationForHostParams = {
|
||||
/**
|
||||
* The new host's metadata containing their default conferencing app preferences
|
||||
*/
|
||||
hostMetadata: unknown;
|
||||
/**
|
||||
* The event type's configured locations
|
||||
*/
|
||||
eventTypeLocations: LocationObject[];
|
||||
/**
|
||||
* Whether this is a managed event type
|
||||
*/
|
||||
isManagedEventType?: boolean;
|
||||
/**
|
||||
* Whether this is a team event type
|
||||
*/
|
||||
isTeamEventType?: boolean;
|
||||
};
|
||||
|
||||
type GetLocationForHostResult = {
|
||||
/**
|
||||
* The location string to be stored in the booking
|
||||
*/
|
||||
bookingLocation: string;
|
||||
} & (
|
||||
| {
|
||||
requiresActualLink: false;
|
||||
}
|
||||
| {
|
||||
requiresActualLink: true;
|
||||
// Credential ID for generating the conferencing link. Set when:
|
||||
// 1. The app supports team installation (concurrentMeetings: true)
|
||||
// 2. The app is installed at team level (credential has teamId)
|
||||
// 3. The event type is a team event that can use team credentials
|
||||
conferenceCredentialId: number | null;
|
||||
}
|
||||
);
|
||||
|
||||
type GetLocationDetailsFromTypeParams = {
|
||||
/**
|
||||
* The location type or value to resolve
|
||||
* @example "integrations:zoom" - A dynamic location type
|
||||
* @example "https://example.com/meeting" - A static link value
|
||||
* @example "integrations:daily" - Cal Video location type
|
||||
*/
|
||||
locationType: string;
|
||||
/**
|
||||
* The event type's configured locations that may contain credential IDs
|
||||
*/
|
||||
eventTypeLocations: LocationObject[];
|
||||
};
|
||||
|
||||
type GetLocationDetailsFromTypeResult = {
|
||||
/**
|
||||
* The resolved location value for the database
|
||||
*/
|
||||
bookingLocation: string;
|
||||
/**
|
||||
* The conference credential ID if present in the location configuration
|
||||
*/
|
||||
conferenceCredentialId: number | null;
|
||||
};
|
||||
|
||||
export class BookingLocationService {
|
||||
/**
|
||||
* Determines the booking location based on the Organizer's Default Conferencing App.
|
||||
*
|
||||
* Note that Organizer's Default Conferencing App is applicable only to Team Events. It gets also applicable to Children Managed Event Types(which are technically User Event Types) as well because the location is configured at Parent Managed Event Type(which is a Team Event Type)
|
||||
*
|
||||
* This function handles two types of conferencing apps:
|
||||
* 1. Dynamic link apps (e.g., Google Meet, Zoom) - Generate a new link for each booking
|
||||
* - These apps only have `appSlug` in organizerMetadata
|
||||
* - The location is determined by the app's metadata (e.g., "integrations:google:meet")
|
||||
*
|
||||
* 2. Static link apps (e.g., Campfire)
|
||||
* - These apps have `appLink`, as well as `appSlug` in organizerMetadata
|
||||
* - The `appLink` is only used for managed or team event types
|
||||
*
|
||||
* @returns The location configuration or null if no valid conferencing app is found.
|
||||
* Callers should handle the null case with their own fallback logic.
|
||||
*/
|
||||
static getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata,
|
||||
}: GetOrganizerDefaultConferencingAppLocationParams): GetOrganizerDefaultConferencingAppLocationResult | null {
|
||||
// Parse the user metadata to extract conferencing app settings
|
||||
const metadataParseResult = userMetadataSchema.safeParse(organizerMetadata);
|
||||
const parsedMetadata = metadataParseResult.success ? metadataParseResult.data : undefined;
|
||||
|
||||
if (!parsedMetadata?.defaultConferencingApp?.appSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Retrieve the app configuration from the app store
|
||||
const app = getAppFromSlug(parsedMetadata.defaultConferencingApp.appSlug);
|
||||
// Extract the location type from the app's metadata (e.g., "integrations:zoom")
|
||||
const conferencingAppLocationType = app?.appData?.location?.type || null;
|
||||
|
||||
// If the app doesn't have a valid location type, return null
|
||||
if (!conferencingAppLocationType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsedMetadata.defaultConferencingApp.appLink) {
|
||||
return {
|
||||
conferencingAppLocationType,
|
||||
isStaticLinkApp: true,
|
||||
staticLink: parsedMetadata.defaultConferencingApp.appLink,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
conferencingAppLocationType,
|
||||
isStaticLinkApp: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate booking location based on the chosen host
|
||||
*
|
||||
* Logic:
|
||||
* 1. If the event type includes OrganizerDefaultConferencingAppType in its locations,
|
||||
* and the new host has a default conferencing app configured, use that.
|
||||
* 2. Otherwise, use the first available location from the event type's configured locations.
|
||||
*
|
||||
*
|
||||
* @returns The location configuration for the new host
|
||||
*/
|
||||
static getLocationForHost({
|
||||
hostMetadata,
|
||||
eventTypeLocations,
|
||||
isManagedEventType = false,
|
||||
isTeamEventType = false,
|
||||
}: GetLocationForHostParams): GetLocationForHostResult {
|
||||
// Check if the event type allows using the organizer's default conferencing app
|
||||
const eventAllowsOrganizerDefault = eventTypeLocations.some(
|
||||
(location) => location.type === OrganizerDefaultConferencingAppType
|
||||
);
|
||||
|
||||
const isOrganizerDefaultAllowed = isTeamEventType || isManagedEventType;
|
||||
|
||||
if (eventAllowsOrganizerDefault && isOrganizerDefaultAllowed) {
|
||||
const organizerDefaultLocation = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
|
||||
organizerMetadata: hostMetadata,
|
||||
});
|
||||
|
||||
if (organizerDefaultLocation) {
|
||||
if (organizerDefaultLocation.isStaticLinkApp) {
|
||||
return {
|
||||
requiresActualLink: false,
|
||||
bookingLocation: organizerDefaultLocation.staticLink,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId: null,
|
||||
bookingLocation: organizerDefaultLocation.conferencingAppLocationType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Find the first real location (not OrganizerDefaultConferencingAppType)
|
||||
const firstRealLocation = eventTypeLocations.find(
|
||||
(location) => location.type !== OrganizerDefaultConferencingAppType
|
||||
);
|
||||
|
||||
if (!firstRealLocation) {
|
||||
// If no real locations are configured, default to Cal Video
|
||||
return {
|
||||
bookingLocation: CalVideoLocationType,
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Process the first real location through getLocationValueForDB to handle static links properly
|
||||
const { bookingLocation, conferenceCredentialId } = BookingLocationService.getLocationDetailsFromType({
|
||||
locationType: firstRealLocation.type,
|
||||
eventTypeLocations,
|
||||
});
|
||||
|
||||
return {
|
||||
bookingLocation,
|
||||
requiresActualLink: true,
|
||||
conferenceCredentialId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts location details including conferenceCredentialId from a location type or value
|
||||
*
|
||||
* @param params - The location type/value and event type locations
|
||||
* @returns The resolved booking location and conference credential ID if present
|
||||
*
|
||||
* @example
|
||||
* // Dynamic location type with credential ID
|
||||
* getLocationDetailsFromType({
|
||||
* locationType: "integrations:zoom",
|
||||
* eventTypeLocations: [{ type: "integrations:zoom", credentialId: 123 }]
|
||||
* })
|
||||
* // Returns: { bookingLocation: "integrations:zoom", conferenceCredentialId: 123 }
|
||||
*
|
||||
* @example
|
||||
* // Static link value (already resolved)
|
||||
* getLocationDetailsFromType({
|
||||
* locationType: "https://zoom.us/j/123456789",
|
||||
* eventTypeLocations: [{ type: "integrations:zoom", link: "https://zoom.us/j/123456789" }]
|
||||
* })
|
||||
* // Returns: { bookingLocation: "https://zoom.us/j/123456789", conferenceCredentialId: null }
|
||||
*/
|
||||
static getLocationDetailsFromType({
|
||||
locationType,
|
||||
eventTypeLocations,
|
||||
}: GetLocationDetailsFromTypeParams): GetLocationDetailsFromTypeResult {
|
||||
const { bookingLocation, conferenceCredentialId } = getLocationValueForDB(
|
||||
locationType,
|
||||
eventTypeLocations
|
||||
);
|
||||
|
||||
return { bookingLocation, conferenceCredentialId: conferenceCredentialId ?? null };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
## 1.1.2
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#23074](https://github.com/calcom/cal.com/pull/23074) [`09cf888`](https://github.com/calcom/cal.com/commit/09cf8885cf5e88fc0911edaf7e43f5c1c2d64923) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - feat: add send & verify code flow to booker atom when email verification is turned on
|
||||
|
||||
- [#23211](https://github.com/calcom/cal.com/pull/23211) [`89a3b77`](https://github.com/calcom/cal.com/commit/89a3b77a1dd0773e6b746b14e72f9a792ac1c43a) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - feat: added hideEventMetadata prop to the booker atom that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#23213](https://github.com/calcom/cal.com/pull/23213) [`40caa82`](https://github.com/calcom/cal.com/commit/40caa82e5592cc17d84dd5e73531d1b376ce187c) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This PR fixes `onBookerStateChange` prop not working
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserFilterSegmentPreference" ADD COLUMN "systemSegmentId" TEXT,
|
||||
ALTER COLUMN "segmentId" DROP NOT NULL;
|
||||
|
||||
-- Add an XOR CHECK constraint between segmentId and systemSegmentId
|
||||
ALTER TABLE "UserFilterSegmentPreference"
|
||||
ADD CONSTRAINT "UserFilterSegmentPreference_segment_xor_system_chk"
|
||||
CHECK (
|
||||
(("segmentId" IS NOT NULL AND "systemSegmentId" IS NULL)
|
||||
OR ("segmentId" IS NULL AND "systemSegmentId" IS NOT NULL))
|
||||
),
|
||||
ADD CONSTRAINT "UserFilterSegmentPreference_systemSegmentId_nonempty_chk"
|
||||
CHECK ("systemSegmentId" IS NULL OR length("systemSegmentId") > 0);
|
||||
@@ -2356,15 +2356,15 @@ model FilterSegment {
|
||||
}
|
||||
|
||||
model UserFilterSegmentPreference {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
tableIdentifier String
|
||||
segmentId Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
segment FilterSegment @relation(fields: [segmentId], references: [id], onDelete: Cascade)
|
||||
segmentId Int?
|
||||
systemSegmentId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
segment FilterSegment? @relation(fields: [segmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, tableIdentifier])
|
||||
@@index([userId])
|
||||
|
||||
Reference in New Issue
Block a user