Compare commits

...
7 Commits
Author SHA1 Message Date
GitHub Actions 0466b7ebd6 chore: release v4.8.1
Post release / release (push) Canceled after 0s
2024-12-16 14:35:27 +00:00
Lauris Skraucis 09329707c1 fix: bookings parsing errors (#18200) 2024-12-16 16:31:04 +02:00
Anik Dhabal Babu 1773aba378 fix: fix data expose issue in routing form insights (#18207) 2024-12-16 15:30:25 +01:00
Somay ChauhanandMorgan f8be2b3833 feat: added office 365 video to conferencing atoms (#18067)
* feat: added office 365 video to conferencing atoms

* added documentation for conferencing atoms

* added props table to the documentation

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-16 16:20:59 +02:00
Eunjae Leeandsean-brydon 79d488faca feat: insights filter refactoring (#18167)
* Reapply "feat: add text & numeric filters to insights routing responses (#18016)"

This reverts commit 49070b96ae.

* support SINGLE_SELECT

* fix type error

* put back ts-expect-error

* remove playground

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-12-16 13:47:28 +00:00
Venkata Rohan KambhampatiandUdit Takkar da2c57c302 fix: Booking questions order correction in confirmation email (#18192)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-16 09:33:04 +00:00
Keith Williams abaf03adde fix: Next.js bundle analysis build error (#18193) 2024-12-15 17:40:59 +05:30
75 changed files with 1955 additions and 598 deletions
+3 -2
View File
@@ -17,7 +17,6 @@ env:
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
@@ -51,7 +50,8 @@ env:
jobs:
analyze:
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
@@ -60,6 +60,7 @@ jobs:
- name: Analyze bundle
run: |
cd apps/web
export NODE_OPTIONS="--max_old_space_size=8192"
npx -p nextjs-bundle-analysis@0.5.0 report
- name: Upload bundle
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
@@ -1,7 +1,7 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import {
bookingResponsesSchema,
seatedBookingResponsesSchema,
seatedBookingDataSchema,
} from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
@@ -340,7 +340,7 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`);
}
const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data);
const { responses: bookingResponses } = seatedBookingDataSchema.parse(seat.data);
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
if (!attendee) {
@@ -1,4 +1,11 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import {
defaultBookingMetadata,
defaultBookingResponses,
defaultSeatedBookingData,
defaultSeatedBookingMetadata,
} from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { DateTime } from "luxon";
@@ -30,9 +37,10 @@ export const bookingResponsesSchema = z
guests: z.array(z.string()).optional(),
rescheduleReason: z.string().optional(),
})
.passthrough();
.passthrough()
.describe("BookingResponses");
export const seatedBookingResponsesSchema = z
export const seatedBookingDataSchema = z
.object({
responses: z
.object({
@@ -47,7 +55,10 @@ export const seatedBookingResponsesSchema = z
})
.passthrough(),
})
.passthrough();
.passthrough()
.describe("SeatedBookingData");
const seatedBookingMetadataSchema = z.object({}).catchall(z.string()).describe("SeatedBookingMetadata");
type DatabaseUser = { id: number; name: string | null; email: string; username: string | null };
@@ -72,8 +83,6 @@ type BookingWithUser = Booking & { user: DatabaseUser | null };
type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
@Injectable()
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
@@ -82,8 +91,12 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const bookingResponses = safeParse(
bookingResponsesSchema,
databaseBooking.responses,
defaultBookingResponses
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -167,8 +180,12 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const bookingResponses = safeParse(
bookingResponsesSchema,
databaseBooking.responses,
defaultBookingResponses
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -225,7 +242,7 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -254,7 +271,11 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData
);
const attendeeData = {
name: attendee.name,
@@ -267,7 +288,11 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
@@ -320,7 +345,7 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -353,7 +378,11 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData
);
const attendeeData = {
name: attendee.name,
@@ -366,7 +395,11 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
@@ -0,0 +1,12 @@
export const defaultBookingResponses = {
name: "unknown",
email: "unknown",
guests: [],
rescheduleReason: "unknown",
};
export const defaultBookingMetadata = { videoCallUrl: "unknown" };
export const defaultSeatedBookingData = { responses: { name: "unknown", email: "unknown" } };
export const defaultSeatedBookingMetadata = {};
@@ -0,0 +1,23 @@
import { Logger } from "@nestjs/common";
import { ZodSchema } from "zod";
const logger = new Logger("safeParse");
export function safeParse<T>(schema: ZodSchema<T>, value: unknown, defaultValue: T): T {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
} else {
const errorStack = new Error().stack;
logger.error(
`Zod parsing failed.\n` +
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
`3. Zod Error: ${result.error}\n` +
`4. Call Stack: ${errorStack}`
);
return defaultValue;
}
}
@@ -3,6 +3,7 @@ import { ConferencingController } from "@/modules/conferencing/controllers/confe
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -21,6 +22,7 @@ import { ConfigModule } from "@nestjs/config";
UsersRepository,
TokensRepository,
ZoomVideoService,
Office365VideoService,
AppsRepository,
],
exports: [],
@@ -15,6 +15,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -39,7 +40,7 @@ import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS } from "@calcom/platform-constants";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO } from "@calcom/platform-constants";
export type OAuthCallbackState = {
accessToken: string;
@@ -61,7 +62,8 @@ export class ConferencingController {
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
) {}
@Post("/:app/connect")
@@ -116,8 +118,18 @@ export class ConferencingController {
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
case OFFICE_365_VIDEO:
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
default:
throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", "));
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
}
@@ -147,8 +159,14 @@ export class ConferencingController {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
case OFFICE_365_VIDEO:
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
default:
throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", "));
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
} catch (error) {
return {
@@ -0,0 +1,121 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { OFFICE_365_VIDEO, OFFICE_365_VIDEO_TYPE } from "@calcom/platform-constants";
import stringify = require("qs-stringify");
const zoomAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
@Injectable()
export class Office365VideoService {
private logger = new Logger("Office365VideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
async getOffice365AppKeys() {
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
if (!client_id) {
throw new NotFoundException("Office365 app not found");
}
if (!client_secret) {
throw new NotFoundException("Office365 app not found");
}
return { client_id, client_secret };
}
async generateOffice365AuthUrl(state: string) {
const { client_id } = await this.getOffice365AppKeys();
const params = {
response_type: "code",
client_id,
scope: this.scopes.join(" "),
redirect_uri: this.redirectUri,
state: state,
};
const query = stringify(params);
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
return { url };
}
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
const { client_id, client_secret } = await this.getOffice365AppKeys();
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
.join("&");
const body = toUrlEncoded({
client_id,
grant_type: "authorization_code",
code,
scope: this.scopes.join(" "),
redirect_uri: this.redirectUri,
client_secret,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
body,
});
const responseBody = await response.json();
if (!response.ok) {
throw new BadRequestException(responseBody.error);
}
const whoami = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${responseBody.access_token}` },
});
const graphUser = await whoami.json();
// In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address.
responseBody.email = graphUser.mail ?? graphUser.userPrincipalName;
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
type: OFFICE_365_VIDEO_TYPE,
userId,
appId: OFFICE_365_VIDEO,
});
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
OFFICE_365_VIDEO_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
OFFICE_365_VIDEO
);
return { url: state.returnTo ?? "" };
}
}
@@ -18,7 +18,7 @@ const zoomAppKeysSchema = z.object({
@Injectable()
export class ZoomVideoService {
private logger = new Logger("ZoomVideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/zoom/oauth/callback`;
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
+1 -1
View File
@@ -15367,4 +15367,4 @@
}
}
}
}
}
@@ -649,13 +649,17 @@ export default function Success(props: PageProps) {
)}
</div>
<div className="text-bookingdark dark:border-darkgray-200 mt-8 text-left dark:text-gray-300">
{Object.entries(bookingInfo.responses).map(([name, response]) => {
const field = eventType.bookingFields.find((field) => field.name === name);
{eventType.bookingFields.map((field) => {
if (!field) return null;
if (!bookingInfo.responses[field.name]) return null;
const response = bookingInfo.responses[field.name];
// We show location in the "where" section
// We show Booker Name, Emails and guests in Who section
// We show notes in additional notes section
// We show rescheduleReason at the top
if (!field) return null;
const isSystemField = SystemField.safeParse(field.name);
// SMS_REMINDER_NUMBER_FIELD is a system field but doesn't have a dedicated place in the UI. So, it would be shown through the following responses list
// TITLE is also an identifier for booking question "What is this meeting about?"
@@ -1,35 +1,19 @@
"use client";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
RoutingKPICards,
RoutedToPerPeriod,
} from "@calcom/features/insights/components";
import { FailedBookingsByField, RoutingFormResponsesTable, RoutedToPerPeriod } from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsPage() {
export default function InsightsRoutingFormResponsesPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable>
{/* We now render the "filters and KPI as a children of the table but we still need to pass the table instance to it so we can access column status in the toolbar.*/}
{(table) => (
<div className="header mb-4">
<div className="flex items-center justify-between">
<RoutingInsightsFilters table={table} />
</div>
<RoutingKPICards />
</div>
)}
</RoutingFormResponsesTable>
<RoutingFormResponsesTable />
<RoutedToPerPeriod />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.8.0",
"version": "4.8.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 227 KiB

+12
View File
@@ -51,6 +51,14 @@
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" ></path>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" ></path>
</symbol>
<symbol class="lucide lucide-binary" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="binary">
<rect x="14" y="14" width="4" height="6" rx="2" ></rect>
<rect x="6" y="4" width="4" height="6" rx="2" ></rect>
<path d="M6 20h4" ></path>
<path d="M14 10h4" ></path>
<path d="M6 14h2v6" ></path>
<path d="M14 4h2v6" ></path>
</symbol>
<symbol class="lucide lucide-blocks" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="blocks">
<rect width="7" height="7" x="14" y="3" rx="1" ></rect>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" ></path>
@@ -252,6 +260,10 @@
<rect width="20" height="14" x="2" y="5" rx="2" ></rect>
<line x1="2" x2="22" y1="10" y2="10" ></line>
</symbol>
<symbol class="lucide lucide-disc" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="disc">
<circle cx="12" cy="12" r="10" ></circle>
<circle cx="12" cy="12" r="2" ></circle>
</symbol>
<symbol class="lucide lucide-dot" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="dot">
<circle cx="12.1" cy="12.1" r="1" ></circle>
</symbol>

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 49 KiB

@@ -2878,6 +2878,7 @@
"salesforce_ignore_guests": "Do not create new records for guests added to the booking",
"google_meet": "Google Meet",
"zoom": "Zoom",
"office_365_video": "MS Teams Video",
"lock_attribute_for_assignment": "Lock for assignment",
"lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync",
"attribute_edited_successfully": "Attribute edited successfully",
+2 -1
View File
@@ -197,7 +197,8 @@
"platform/atoms/booker",
"platform/atoms/event-type",
"platform/atoms/calendar-settings",
"platform/atoms/payment-form"
"platform/atoms/payment-form",
"platform/atoms/conferencing-apps"
]
},
{
+93
View File
@@ -0,0 +1,93 @@
---
title: "Conferencing Apps"
---
The Conferencing Apps Atom allows users to seamlessly install applications such as Zoom, Google Meet, and Microsoft Teams, enabling them to set these as default or optional locations for their events.
Below code snippet can be used to render Conferencing Apps Atom
```js
import { ConferencingAppsSettings } from "@calcom/atoms";
import { usePathname } from "next/navigation";
export default function ConferencingApps() {
const pathname = usePathname();
const callbackUri = `${window.location.origin}${pathname}`;
return (
<>
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} />
</>
)
}
```
Below is a list of props that can be passed to the Conferencing Apps Atom
<p></p>
| Name | Required | Description |
| :------------------ | :------- | :-------------------------------------------------------------------------- |
| returnTo | No | The URL of the page to redirect to after a successful installation. |
| onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. |
| disableToasts | No | boolean value to disable toast notifications in the atom. |
## Google Meet
For a demonstration of installing Google Meet, setting it as the default conferencing app for all event types, and removing the app, please watch the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/b12957f8588843669dbe2c30aef18575?sid=c42c5643-a319-41bb-bcb4-236c9df7ebd4"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
<Info>Google meet requires Google Calendar to be installed first</Info>
## Zoom
For a demonstration of installing Zoom, setting it as the default conferencing app for all event types, and removing the app, please watch the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/b423af66770b4645b0b79cfaebda8869?sid=c7578b79-717a-4092-a112-2804aa2001a8"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
## MS Teams Video
For a demonstration of installing MS Teams Video, setting it as the default conferencing app for all event types, and removing the app, please watch the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/2828f1d52c7d48889ac5c9a5055bd040?sid=a5ce1cb5-4000-4b4b-94bd-45d23c247a2b"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
<Info>
Connecting with MS Teams requires a work/school Microsoft account.
If you continue with a personal account you will receive an error
</Info>
@@ -1,14 +1,12 @@
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { DialogProps } from "@calcom/ui";
import { Button } from "@calcom/ui";
import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export function AccountDialog(props: DialogProps) {
const mutation = useAddAppMutation(null);
export function AccountDialog(
props: DialogProps & {
handleSubmit: () => void;
}
) {
return (
<Dialog name="Account check" open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
@@ -27,21 +25,7 @@ export function AccountDialog(props: DialogProps) {
Cancel
</DialogClose>
<Button
type="button"
onClick={() =>
mutation.mutate({
type: "office365_video",
variant: "conferencing",
slug: "msteams",
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: "msteams",
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
})
}>
<Button type="button" onClick={props.handleSubmit}>
Continue
</Button>
</>
@@ -1,10 +1,29 @@
import { useState } from "react";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import useAddAppMutation from "../../_utils/useAddAppMutation";
import type { InstallAppButtonProps } from "../../types";
import AddIntegration from "./AccountDialog";
import AccountDialog from "./AccountDialog";
export default function InstallAppButton(props: InstallAppButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const mutation = useAddAppMutation(null);
const handleSubmit = () => {
mutation.mutate({
type: "office365_video",
variant: "conferencing",
slug: "msteams",
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: "msteams",
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
});
};
return (
<>
@@ -14,7 +33,7 @@ export default function InstallAppButton(props: InstallAppButtonProps) {
},
disabled: isModalOpen,
})}
<AddIntegration open={isModalOpen} onOpenChange={setIsModalOpen} />
<AccountDialog open={isModalOpen} onOpenChange={setIsModalOpen} handleSubmit={handleSubmit} />
</>
);
}
+1 -1
View File
@@ -158,7 +158,7 @@ const AdminAppsList = ({
<form
{...rest}
className={
classNames?.form ?? "max-w-80 bg-default mb-4 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
classNames?.form ?? "bg-default mb-4 max-w-80 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
}
onSubmit={(e) => {
e.preventDefault();
@@ -6,6 +6,7 @@ import type { Table as ReactTableType } from "@tanstack/react-table";
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
// eslint-disable-next-line no-restricted-imports
import kebabCase from "lodash/kebabCase";
import { usePathname } from "next/navigation";
import { useMemo, useEffect, memo } from "react";
import classNames from "@calcom/lib/classNames";
@@ -23,8 +24,10 @@ export interface DataTableProps<TData, TValue> {
variant?: "default" | "compact";
"data-testid"?: string;
children?: React.ReactNode;
enableColumnResizing?: { name: string };
identifier?: string;
enableColumnResizing?: boolean;
}
export function DataTable<TData, TValue>({
table,
tableContainerRef,
@@ -33,9 +36,13 @@ export function DataTable<TData, TValue>({
onRowMouseclick,
onScroll,
children,
identifier: _identifier,
enableColumnResizing,
...rest
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
const pathname = usePathname();
const identifier = _identifier ?? pathname;
const { rows } = table.getRowModel();
// https://stackblitz.com/github/tanstack/table/tree/main/examples/react/virtualized-infinite-scrolling
@@ -82,7 +89,7 @@ export function DataTable<TData, TValue>({
usePersistentColumnResizing({
enabled: Boolean(enableColumnResizing),
table,
name: enableColumnResizing?.name,
identifier,
});
return (
@@ -10,6 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonProps } from "@calcom/ui";
import { Button, Input } from "@calcom/ui";
import { useColumnFilters } from "../lib/utils";
interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
children: React.ReactNode;
}
@@ -89,7 +91,8 @@ function ClearFiltersButtonComponent<TData>(
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
const isFiltered = table.getState().columnFilters.length > 0;
const columnFilters = useColumnFilters();
const isFiltered = columnFilters.length > 0;
if (!isFiltered) return null;
return (
<Button
@@ -1,58 +1,24 @@
import { type Table } from "@tanstack/react-table";
"use client";
import type { FilterableColumn, FilterValue, SelectFilterValue, TextFilterValue } from "../../lib/types";
import type { ActiveFilter, FiltersSearchState, SetFiltersSearchState } from "../../lib/utils";
import type { FilterableColumn } from "../../lib/types";
import { MultiSelectFilterOptions } from "./MultiSelectFilterOptions";
import { NumberFilterOptions } from "./NumberFilterOptions";
import { SingleSelectFilterOptions } from "./SingleSelectFilterOptions";
import { TextFilterOptions } from "./TextFilterOptions";
export type FilterOptionsProps<TData> = {
export type FilterOptionsProps = {
column: FilterableColumn;
filter: ActiveFilter;
state: FiltersSearchState;
setState: SetFiltersSearchState;
table: Table<TData>;
};
export function FilterOptions<TData>({ column, filter, state, setState, table }: FilterOptionsProps<TData>) {
const filterValue = table.getColumn(column.id)?.getFilterValue() as FilterValue | undefined;
const setMultiSelectFilterValue = (value: SelectFilterValue) => {
setState({
activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)),
});
table.getColumn(column.id)?.setFilterValue(value);
};
const setTextFilterValue = (value: TextFilterValue) => {
setState({
activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)),
});
table.getColumn(column.id)?.setFilterValue(value);
};
const removeFilter = (columnId: string) => {
setState({ activeFilters: (state.activeFilters || []).filter((filter) => filter.f !== columnId) });
table.getColumn(columnId)?.setFilterValue(undefined);
};
if (column.filterType === "text") {
return (
<TextFilterOptions
column={column}
filterValue={filterValue as TextFilterValue | undefined}
setFilterValue={setTextFilterValue}
removeFilter={removeFilter}
/>
);
} else if (column.filterType === "select") {
return (
<MultiSelectFilterOptions
column={column}
filterValue={filterValue as SelectFilterValue | undefined}
setFilterValue={setMultiSelectFilterValue}
removeFilter={removeFilter}
/>
);
export function FilterOptions({ column }: FilterOptionsProps) {
if (column.type === "text") {
return <TextFilterOptions column={column} />;
} else if (column.type === "multi_select") {
return <MultiSelectFilterOptions column={column} />;
} else if (column.type === "single_select") {
return <SingleSelectFilterOptions column={column} />;
} else if (column.type === "number") {
return <NumberFilterOptions column={column} />;
} else {
return null;
}
@@ -1,3 +1,5 @@
"use client";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
@@ -12,22 +14,18 @@ import {
Icon,
} from "@calcom/ui";
import type { FilterableColumn, SelectFilterValue } from "../../lib/types";
import type { FilterableColumn } from "../../lib/types";
import { ZMultiSelectFilterValue } from "../../lib/types";
import { useDataTable, useFilterValue } from "../../lib/utils";
export type MultiSelectFilterOptionsProps = {
column: FilterableColumn;
filterValue?: SelectFilterValue;
setFilterValue: (value: SelectFilterValue) => void;
removeFilter: (columnId: string) => void;
column: Extract<FilterableColumn, { type: "multi_select" }>;
};
export function MultiSelectFilterOptions({
column,
filterValue,
setFilterValue,
removeFilter,
}: MultiSelectFilterOptionsProps) {
export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZMultiSelectFilterValue);
const { updateFilter, removeFilter } = useDataTable();
return (
<Command>
@@ -36,27 +34,28 @@ export function MultiSelectFilterOptions({
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{Array.from(column.options.keys()).map((option) => {
if (!option) return null;
const { label: optionLabel, value: optionValue } =
typeof option === "string" ? { label: option, value: option } : option;
return (
<CommandItem
key={option}
key={optionValue}
onSelect={() => {
const newFilterValue = filterValue?.includes(option)
? filterValue?.filter((value) => value !== option)
: [...(filterValue || []), option];
setFilterValue(newFilterValue);
const newFilterValue = filterValue?.data.includes(optionValue)
? filterValue?.data.filter((value) => value !== optionValue)
: [...(filterValue?.data || []), optionValue];
updateFilter(column.id, { type: "multi_select", data: newFilterValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
Array.isArray(filterValue) && (filterValue as string[])?.includes(option)
? "bg-primary"
: "opacity-50"
filterValue?.data.includes(optionValue) ? "bg-primary" : "opacity-50"
)}>
{Array.isArray(filterValue) && (filterValue as string[])?.includes(option) && (
{filterValue?.data.includes(optionValue) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{option}
{optionLabel}
</CommandItem>
);
})}
@@ -0,0 +1,103 @@
"use client";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Form, Input, Select, Button } from "@calcom/ui";
import type { FilterableColumn, NumberFilterOperator } from "../../lib/types";
import { ZNumberFilterValue } from "../../lib/types";
import { useFilterValue, useDataTable } from "../../lib/utils";
export type NumberFilterOperatorOption = {
label: string;
value: NumberFilterOperator;
};
const numberFilterOperatorOptions: NumberFilterOperatorOption[] = [
{ value: "eq", label: "=" },
{ value: "neq", label: "≠" },
{ value: "gt", label: ">" },
{ value: "gte", label: "≥" },
{ value: "lt", label: "<" },
{ value: "lte", label: "≤" },
];
export type NumberFilterOptionsProps = {
column: Extract<FilterableColumn, { type: "number" }>;
};
export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZNumberFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const form = useForm({
defaultValues: {
operatorOption: filterValue
? numberFilterOperatorOptions.find((o) => o.value === filterValue.data.operator)
: numberFilterOperatorOptions[0],
operand: filterValue?.data.operand || "",
},
});
return (
<div className="mx-3 my-2">
<Form
form={form}
handleSubmit={({ operatorOption, operand }) => {
if (operatorOption) {
updateFilter(column.id, {
type: "number",
data: {
operator: operatorOption.value,
operand: Number(operand),
},
});
}
}}>
<div>
<Controller
name="operatorOption"
control={form.control}
render={({ field: { value } }) => (
<div className="-mt-2 flex items-center gap-2">
<Select
className="basis-1/3"
options={numberFilterOperatorOptions}
value={value}
isSearchable={false}
onChange={(event) => {
if (event) {
form.setValue("operatorOption", { ...event }, { shouldDirty: true });
}
}}
/>
<Input type="number" className="mt-2 basis-2/3" {...form.register("operand")} />
</div>
)}
/>
<div className="bg-subtle -mx-3 mb-2 h-px" role="separator" />
<div className="flex items-center justify-between">
<Button
type="button"
color="secondary"
disabled={form.formState.isSubmitting}
onClick={() => removeFilter(column.id)}>
{t("clear")}
</Button>
<Button
type="submit"
color="primary"
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}>
{t("apply")}
</Button>
</div>
</div>
</Form>
</div>
);
}
@@ -0,0 +1,72 @@
"use client";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandItem,
CommandSeparator,
CommandGroup,
buttonClasses,
Icon,
} from "@calcom/ui";
import type { FilterableColumn } from "../../lib/types";
import { ZSingleSelectFilterValue } from "../../lib/types";
import { useDataTable, useFilterValue } from "../../lib/utils";
export type SingleSelectFilterOptionsProps = {
column: Extract<FilterableColumn, { type: "single_select" }>;
};
export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZSingleSelectFilterValue);
const { updateFilter, removeFilter } = useDataTable();
return (
<Command>
<CommandInput placeholder={t("search_options")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{Array.from(column.options.keys()).map((option) => {
if (!option) return null;
const { label: optionLabel, value: optionValue } =
typeof option === "string" ? { label: option, value: option } : option;
return (
<CommandItem
key={optionValue}
onSelect={() => {
updateFilter(column.id, { type: "single_select", data: optionValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data === optionValue ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data === optionValue && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
removeFilter(column.id);
}}
className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
);
}
@@ -1,9 +1,13 @@
"use client";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Form, Input, Select, Button } from "@calcom/ui";
import type { FilterableColumn, TextFilterValue, TextFilterOperator } from "../../lib/types";
import type { FilterableColumn, TextFilterOperator } from "../../lib/types";
import { ZTextFilterValue } from "../../lib/types";
import { useFilterValue, useDataTable } from "../../lib/utils";
export type TextFilterOperatorOption = {
label: string;
@@ -26,20 +30,14 @@ const useTextFilterOperatorOptions = (): TextFilterOperatorOption[] => {
};
export type TextFilterOptionsProps = {
column: FilterableColumn;
filterValue?: TextFilterValue;
setFilterValue: (value: TextFilterValue) => void;
removeFilter: (columnId: string) => void;
column: Extract<FilterableColumn, { type: "text" }>;
};
export function TextFilterOptions({
column,
filterValue,
setFilterValue,
removeFilter,
}: TextFilterOptionsProps) {
export function TextFilterOptions({ column }: TextFilterOptionsProps) {
const { t } = useLocale();
const textFilterOperatorOptions = useTextFilterOperatorOptions();
const filterValue = useFilterValue(column.id, ZTextFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const form = useForm({
defaultValues: {
@@ -56,7 +54,7 @@ export function TextFilterOptions({
form={form}
handleSubmit={({ operatorOption, operand }) => {
if (operatorOption) {
setFilterValue({
updateFilter(column.id, {
type: "text",
data: {
operator: operatorOption.value,
@@ -1,7 +1,7 @@
"use client";
import { type Table } from "@tanstack/react-table";
import { forwardRef, useState, useMemo } from "react";
import { forwardRef, useState, useMemo, useCallback, Fragment } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -22,8 +22,8 @@ import {
Icon,
} from "@calcom/ui";
import type { FilterableColumn } from "../../lib/types";
import { useFiltersSearchState } from "../../lib/utils";
import type { FilterableColumn, ExternalFilter } from "../../lib/types";
import { convertToTitleCase, useDataTable } from "../../lib/utils";
import { FilterOptions } from "./FilterOptions";
interface ColumnVisiblityProps<TData> {
@@ -118,37 +118,30 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <T
) => ReturnType<typeof ColumnVisibilityButtonComponent>;
// Filters
interface FilterButtonProps<TData> {
interface AddFilterButtonProps<TData> {
table: Table<TData>;
omit?: string[];
externalFilters?: ExternalFilter[];
}
function FilterButtonComponent<TData>(
{ table, omit }: FilterButtonProps<TData>,
function AddFilterButtonComponent<TData>(
{ table, omit, externalFilters }: AddFilterButtonProps<TData>,
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
const [_state, _setState] = useFiltersSearchState();
const { activeFilters, setActiveFilters, displayedExternalFilters, setDisplayedExternalFilters } =
useDataTable();
const activeFilters = _state.activeFilters;
const columns = table
.getAllColumns()
.filter((column) => column.getCanFilter())
.filter((column) => !omit?.includes(column.id));
const filterableColumns = useFilterableColumns(table, omit);
const filterableColumns = useMemo(() => {
return columns.map((column) => ({
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
options: column.getFacetedUniqueValues(),
}));
}, [columns]);
const handleAddFilter = (columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) {
_setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
}
};
const handleAddFilter = useCallback(
(columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) {
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
}
},
[activeFilters, setActiveFilters]
);
return (
<div className="flex items-center space-x-2">
@@ -167,11 +160,24 @@ function FilterButtonComponent<TData>(
{filterableColumns.map((column) => {
if (activeFilters?.some((filter) => filter.f === column.id)) return null;
return (
<CommandItem key={column.id} onSelect={() => handleAddFilter(column.id)}>
{column.title}
<CommandItem
key={column.id}
onSelect={() => handleAddFilter(column.id)}
className="px-4 py-2">
{convertToTitleCase(column.title)}
</CommandItem>
);
})}
{(externalFilters || [])
.filter((filter) => !displayedExternalFilters.includes(filter.key))
.map((filter, index) => (
<CommandItem
key={index}
onSelect={() => setDisplayedExternalFilters((prev) => [...prev, filter.key])}
className="px-4 py-2">
{t(filter.titleKey)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
@@ -180,59 +186,95 @@ function FilterButtonComponent<TData>(
);
}
const FilterButton = forwardRef(FilterButtonComponent) as <TData>(
props: FilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
) => ReturnType<typeof FilterButtonComponent>;
function useFilterableColumns<TData>(table: Table<TData>, omit?: string[]) {
const columns = useMemo(
() =>
table
.getAllColumns()
.filter((column) => column.getCanFilter())
.filter((column) => !omit?.includes(column.id)),
[table.getAllColumns(), omit]
);
const filterableColumns = useMemo<FilterableColumn[]>(
() =>
columns
.map((column) => {
const type = column.columnDef.meta?.filter?.type || "multi_select";
const base = {
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
...(column.columnDef.meta?.filter || {}),
type,
};
if (type === "multi_select" || type === "single_select") {
return {
...base,
options: column.getFacetedUniqueValues(),
};
} else {
return {
...base,
};
}
})
.filter((column): column is FilterableColumn => Boolean(column)),
[columns]
);
return filterableColumns;
}
const AddFilterButton = forwardRef(AddFilterButtonComponent) as <TData>(
props: AddFilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
) => ReturnType<typeof AddFilterButtonComponent>;
// Add the new ActiveFilters component
interface ActiveFiltersProps<TData> {
table: Table<TData>;
externalFilters?: ExternalFilter[];
}
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
const [_state, _setState] = useFiltersSearchState();
const filterIcons = {
text: "file-text",
number: "binary",
multi_select: "layers",
single_select: "disc",
} as const;
const columns = table.getAllColumns().filter((column) => column.getCanFilter());
const filterableColumns = useMemo<FilterableColumn[]>(() => {
return columns.map((column) => {
return {
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
filterType: column.columnDef.meta?.filterType || "select",
options: column.getFacetedUniqueValues(),
};
});
}, [columns]);
function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TData>) {
const { activeFilters, displayedExternalFilters } = useDataTable();
const filterableColumns = useFilterableColumns(table);
return (
<>
{(_state.activeFilters || []).map((filter) => {
{activeFilters.map((filter) => {
const column = filterableColumns.find((col) => col.id === filter.f);
if (!column) return null;
const icon = column.icon || filterIcons[column.type];
return (
<Popover key={column.id}>
<PopoverTrigger asChild>
<Button color="secondary">
{column.title}
<Icon name={icon} className="mr-2 h-4 w-4" />
{convertToTitleCase(column.title)}
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<FilterOptions
column={column}
filter={filter}
state={_state}
setState={_setState}
table={table}
/>
<FilterOptions column={column} />
</PopoverContent>
</Popover>
);
})}
{(displayedExternalFilters || []).map((key) => {
const filter = externalFilters?.find((filter) => filter.key === key);
if (!filter) return null;
return <Fragment key={key}>{filter.component()}</Fragment>;
})}
</>
);
}
// Update the export to include ActiveFilters
export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters };
+1
View File
@@ -1,4 +1,5 @@
export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/context";
export * from "./lib/resizing";
@@ -0,0 +1,80 @@
"use client";
import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs";
import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { z } from "zod";
import { type FilterValue, ZFilterValue } from "./types";
const dataTableFiltersSchema = z.object({
f: z.string(),
v: ZFilterValue.optional(),
});
type ActiveFilter = z.infer<typeof dataTableFiltersSchema>;
export type DataTableContextType = {
activeFilters: ActiveFilter[];
setActiveFilters: (filters: ActiveFilter[]) => void;
clearAll: () => void;
updateFilter: (columnId: string, value: FilterValue) => void;
removeFilter: (columnId: string) => void;
displayedExternalFilters: string[];
setDisplayedExternalFilters: Dispatch<SetStateAction<string[]>>;
removeDisplayedExternalFilter: (key: string) => void;
};
export const DataTableContext = createContext<DataTableContextType | null>(null);
export function DataTableProvider({ children }: { children: React.ReactNode }) {
const [activeFilters, setActiveFilters] = useQueryState(
"activeFilters",
parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([])
);
const [displayedExternalFilters, setDisplayedExternalFilters] = useState<string[]>([]);
const removeDisplayedExternalFilter = useCallback(
(key: string) => {
setDisplayedExternalFilters((prev) => prev.filter((f) => f !== key));
},
[setDisplayedExternalFilters]
);
const clearAll = useCallback(() => {
setActiveFilters([]);
}, [setActiveFilters]);
const updateFilter = useCallback(
(columnId: string, value: FilterValue) => {
setActiveFilters((prev) => {
return prev.map((item) => (item.f === columnId ? { ...item, v: value } : item));
});
},
[setActiveFilters]
);
const removeFilter = useCallback(
(columnId: string) => {
setActiveFilters((prev) => prev.filter((filter) => filter.f !== columnId));
},
[setActiveFilters]
);
return (
<DataTableContext.Provider
value={{
activeFilters,
setActiveFilters,
clearAll,
updateFilter,
removeFilter,
displayedExternalFilters,
setDisplayedExternalFilters,
removeDisplayedExternalFilter,
}}>
{children}
</DataTableContext.Provider>
);
}
+15 -15
View File
@@ -6,24 +6,24 @@ import { useState, useCallback, useEffect } from "react";
type UsePersistentColumnResizingProps<TData> = {
enabled: boolean;
table: Table<TData>;
name?: string;
identifier?: string;
};
function getLocalStorageKey(name: string) {
return `data-table-column-sizing-${name}`;
function getLocalStorageKey(identifier: string) {
return `data-table-column-sizing-${identifier}`;
}
function loadColumnSizing(name: string) {
function loadColumnSizing(identifier: string) {
try {
return JSON.parse(localStorage.getItem(getLocalStorageKey(name)) || "{}");
return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}");
} catch (error) {
return {};
}
return {};
}
function saveColumnSizing(name: string, columnSizing: ColumnSizingState) {
localStorage.setItem(getLocalStorageKey(name), JSON.stringify(columnSizing));
function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) {
localStorage.setItem(getLocalStorageKey(identifier), JSON.stringify(columnSizing));
}
const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
@@ -31,19 +31,19 @@ const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
export function usePersistentColumnResizing<TData>({
enabled,
table,
name,
identifier,
}: UsePersistentColumnResizingProps<TData>) {
const [_, setColumnSizing] = useState<ColumnSizingState>({});
const onColumnSizingChange = useCallback(
(updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => {
// `!name` is checked already in the `useEffect` hook,
// `!identifier` is checked already in the `useEffect` hook,
// but TS doesn't know that, and this won't happen.
if (!name) return;
if (!identifier) return;
table.setState((oldTableState) => {
const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
debouncedSaveColumnSizing(name, newColumnSizing);
debouncedSaveColumnSizing(identifier, newColumnSizing);
setColumnSizing(newColumnSizing);
return {
@@ -52,13 +52,13 @@ export function usePersistentColumnResizing<TData>({
};
});
},
[name, table]
[identifier, table]
);
useEffect(() => {
if (!enabled || !name) return;
if (!enabled || !identifier) return;
const newColumnSizing = loadColumnSizing(name);
const newColumnSizing = loadColumnSizing(identifier);
setColumnSizing(newColumnSizing);
table.setState((old) => ({
...old,
@@ -69,5 +69,5 @@ export function usePersistentColumnResizing<TData>({
columnResizeMode: "onChange",
onColumnSizingChange,
}));
}, [enabled, name, table, onColumnSizingChange]);
}, [enabled, identifier, table, onColumnSizingChange]);
}
+91 -14
View File
@@ -1,62 +1,88 @@
import type { FilterValue } from "./types";
import { isSelectFilterValue, isTextFilterValue } from "./utils";
import {
isSingleSelectFilterValue,
isMultiSelectFilterValue,
isTextFilterValue,
isNumberFilterValue,
} from "./utils";
export function makeWhereClause(columnName: string, filterValue: FilterValue) {
if (isSelectFilterValue(filterValue)) {
type makeWhereClauseProps = {
columnName: string;
filterValue: FilterValue;
json?: true | { path: string[] };
};
export function makeWhereClause(props: makeWhereClauseProps) {
const { columnName, filterValue } = props;
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
const jsonPath = isJson && typeof props.json === "object" ? props.json.path : undefined;
const jsonPathObj = isJson && jsonPath ? { path: jsonPath } : {};
if (isMultiSelectFilterValue(filterValue)) {
return {
[columnName]: {
in: filterValue,
...jsonPathObj,
...(isJson ? { array_contains: filterValue.data } : { in: filterValue.data }),
},
};
} else if (isSingleSelectFilterValue(filterValue)) {
return {
[columnName]: {
...jsonPathObj,
equals: filterValue.data,
},
};
} else if (isTextFilterValue(filterValue)) {
const { operator, operand } = filterValue.data;
switch (operator) {
case "equals":
return {
[columnName]: {
...jsonPathObj,
equals: operand,
},
};
case "notEquals":
return {
[columnName]: {
...jsonPathObj,
not: operand,
},
};
case "contains":
return {
[columnName]: {
contains: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }),
},
};
case "notContains":
return {
NOT: {
[columnName]: {
contains: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }),
},
},
};
case "startsWith":
return {
[columnName]: {
startsWith: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_starts_with: operand } : { startsWith: operand, mode: "insensitive" }),
},
};
case "endsWith":
return {
[columnName]: {
endsWith: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_ends_with: operand } : { endsWith: operand, mode: "insensitive" }),
},
};
case "isEmpty":
return {
[columnName]: {
...jsonPathObj,
equals: "",
},
};
@@ -64,11 +90,62 @@ export function makeWhereClause(columnName: string, filterValue: FilterValue) {
return {
NOT: {
[columnName]: {
...jsonPathObj,
equals: "",
},
},
};
default:
throw new Error(`Invalid operator for text filter: ${operator}`);
}
} else if (isNumberFilterValue(filterValue)) {
const { operator, operand } = filterValue.data;
switch (operator) {
case "eq":
return {
[columnName]: {
...jsonPathObj,
equals: operand,
},
};
case "neq":
return {
[columnName]: {
...jsonPathObj,
not: operand,
},
};
case "gt":
return {
[columnName]: {
...jsonPathObj,
gt: operand,
},
};
case "gte":
return {
[columnName]: {
...jsonPathObj,
gte: operand,
},
};
case "lt":
return {
[columnName]: {
...jsonPathObj,
lt: operand,
},
};
case "lte":
return {
[columnName]: {
...jsonPathObj,
lte: operand,
},
};
default:
throw new Error(`Invalid operator for number filter: ${operator}`);
}
}
return {};
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
}
+93 -5
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import type { IconName } from "@calcom/ui";
export const ZTextFilterOperator = z.enum([
"equals",
"notEquals",
@@ -13,9 +15,19 @@ export const ZTextFilterOperator = z.enum([
export type TextFilterOperator = z.infer<typeof ZTextFilterOperator>;
export const ZSelectFilterValue = z.array(z.string());
export const ZSingleSelectFilterValue = z.object({
type: z.literal("single_select"),
data: z.string(),
});
export type SelectFilterValue = z.infer<typeof ZSelectFilterValue>;
export type SingleSelectFilterValue = z.infer<typeof ZSingleSelectFilterValue>;
export const ZMultiSelectFilterValue = z.object({
type: z.literal("multi_select"),
data: z.array(z.string()),
});
export type MultiSelectFilterValue = z.infer<typeof ZMultiSelectFilterValue>;
export const ZTextFilterValue = z.object({
type: z.literal("text"),
@@ -27,13 +39,89 @@ export const ZTextFilterValue = z.object({
export type TextFilterValue = z.infer<typeof ZTextFilterValue>;
export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]);
export const ZNumberFilterOperator = z.enum([
"eq", // =
"neq", // !=
"gt", // >
"gte", // >=
"lt", // <
"lte", // <=
]);
export type NumberFilterOperator = z.infer<typeof ZNumberFilterOperator>;
export const ZNumberFilterValue = z.object({
type: z.literal("number"),
data: z.object({
operator: ZNumberFilterOperator,
operand: z.number(),
}),
});
export type NumberFilterValue = z.infer<typeof ZNumberFilterValue>;
export const ZFilterValue = z.union([
ZSingleSelectFilterValue,
ZMultiSelectFilterValue,
ZTextFilterValue,
ZNumberFilterValue,
]);
export type FilterValue = z.infer<typeof ZFilterValue>;
export type ColumnFilterType = "single_select" | "multi_select" | "text" | "number";
export type ColumnFilterMeta = {
type?: ColumnFilterType;
icon?: IconName;
};
export type FilterableColumn = {
id: string;
title: string;
filterType: "text" | "select";
options: Map<string, number>;
} & (
| {
type: "single_select";
icon?: IconName;
options: Map<string | { label: string; value: string }, number>;
}
| {
type: "multi_select";
icon?: IconName;
options: Map<string | { label: string; value: string }, number>;
}
| {
type: "text";
icon?: IconName;
}
| {
type: "number";
icon?: IconName;
}
);
export const ZColumnFilter = z.object({
id: z.string(),
value: ZFilterValue,
});
export type ColumnFilter = z.infer<typeof ZColumnFilter>;
export type TypedColumnFilter<T extends ColumnFilterType> = {
id: string;
value: T extends "text"
? TextFilterValue
: T extends "number"
? NumberFilterValue
: T extends "single_select"
? SingleSelectFilterValue
: T extends "multi_select"
? MultiSelectFilterValue
: never;
};
export type ExternalFilter = {
key: string;
titleKey: string;
component: () => React.ReactNode;
};
+130 -40
View File
@@ -1,51 +1,85 @@
"use client";
import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs";
import { useMemo } from "react";
import { z } from "zod";
import { useMemo, useContext } from "react";
import type { z } from "zod";
import type { SelectFilterValue, TextFilterValue } from "./types";
import { ZSelectFilterValue, ZTextFilterValue } from "./types";
import { DataTableContext } from "./context";
import type {
SingleSelectFilterValue,
MultiSelectFilterValue,
TextFilterValue,
FilterValue,
NumberFilterValue,
ColumnFilter,
} from "./types";
import {
ZFilterValue,
ZNumberFilterValue,
ZSingleSelectFilterValue,
ZMultiSelectFilterValue,
ZTextFilterValue,
} from "./types";
const filterSchema = z.object({
f: z.string(),
v: z.union([ZSelectFilterValue, ZTextFilterValue]),
});
export const filtersSearchParams = {
activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]),
};
export function useFiltersSearchState() {
return useQueryStates(filtersSearchParams);
export function useDataTable() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error("useDataTable must be used within a DataTableProvider");
}
return context;
}
export function useColumnFilters() {
const [filtersSearchState] = useFiltersSearchState();
const columnFilters = useMemo(() => {
return (filtersSearchState.activeFilters || [])
.map((filter) => ({
id: filter.f,
value: filter.v,
}))
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
const { activeFilters } = useDataTable();
return useMemo(() => {
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
if (schema && value) {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
}
return undefined;
}, [activeFilters, columnId, schema]);
}
export function useColumnFilters(): ColumnFilter[] {
const { activeFilters } = useDataTable();
return useMemo(() => {
return (activeFilters || [])
.filter(
(filter) =>
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
)
.map((filter) => {
const parsedValue = ZFilterValue.safeParse(filter.v);
if (!parsedValue.success) return null;
return {
id: filter.f,
value: parsedValue.data,
};
})
.filter((filter): filter is ColumnFilter => filter !== null)
.filter((filter) => {
// The empty arrays in `filtersSearchState` keep the filter UI component,
// but we do not send them to the actual query.
// Otherwise, { value: [] } would result in nothing being returned.
if (Array.isArray(filter.value) && filter.value.length === 0) {
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) {
return false;
}
return true;
});
}, [filtersSearchState.activeFilters]);
return columnFilters;
}, [activeFilters]);
}
export type FiltersSearchState = ReturnType<typeof useFiltersSearchState>[0];
export type SetFiltersSearchState = ReturnType<typeof useFiltersSearchState>[1];
export type ActiveFilter = NonNullable<FiltersSearchState["activeFilters"]>[number];
export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => {
if (filterValue.data.operator === "isEmpty" && cellValue === undefined) {
return true;
}
if (typeof cellValue !== "string") {
return false;
}
export const textFilter = (cellValue: string, filterValue: TextFilterValue) => {
switch (filterValue.data.operator) {
case "equals":
return cellValue.toLowerCase() === (filterValue.data.operand || "").toLowerCase();
@@ -69,14 +103,70 @@ export const textFilter = (cellValue: string, filterValue: TextFilterValue) => {
};
export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilterValue => {
return (
typeof filterValue === "object" &&
filterValue !== null &&
"type" in filterValue &&
filterValue.type === "text"
);
return ZTextFilterValue.safeParse(filterValue).success;
};
export const isSelectFilterValue = (filterValue: unknown): filterValue is SelectFilterValue => {
return Array.isArray(filterValue) && filterValue.every((item) => typeof item === "string");
export const multiSelectFilter = (cellValue: unknown | undefined, filterValue: MultiSelectFilterValue) => {
const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue];
if (!cellValueArray.every((value) => typeof value === "string")) {
return false;
}
return filterValue.data.length === 0 ? true : cellValueArray.some((v) => filterValue.data.includes(v));
};
export const isMultiSelectFilterValue = (filterValue: unknown): filterValue is MultiSelectFilterValue => {
return ZMultiSelectFilterValue.safeParse(filterValue).success;
};
export const singleSelectFilter = (cellValue: unknown | undefined, filterValue: SingleSelectFilterValue) => {
return filterValue.data === cellValue;
};
export const isSingleSelectFilterValue = (filterValue: unknown): filterValue is SingleSelectFilterValue => {
return ZSingleSelectFilterValue.safeParse(filterValue).success;
};
export const numberFilter = (cellValue: unknown, filterValue: NumberFilterValue) => {
if (typeof cellValue !== "number") {
return false;
}
switch (filterValue.data.operator) {
case "eq":
return cellValue === filterValue.data.operand;
case "neq":
return cellValue !== filterValue.data.operand;
case "gt":
return cellValue > filterValue.data.operand;
case "gte":
return cellValue >= filterValue.data.operand;
case "lt":
return cellValue < filterValue.data.operand;
case "lte":
return cellValue <= filterValue.data.operand;
}
return false;
};
export const isNumberFilterValue = (filterValue: unknown): filterValue is NumberFilterValue => {
return ZNumberFilterValue.safeParse(filterValue).success;
};
export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => {
if (isSingleSelectFilterValue(filterValue)) {
return singleSelectFilter(cellValue, filterValue);
} else if (isMultiSelectFilterValue(filterValue)) {
return multiSelectFilter(cellValue, filterValue);
} else if (isTextFilterValue(filterValue)) {
return textFilter(cellValue, filterValue);
} else if (isNumberFilterValue(filterValue)) {
return numberFilter(cellValue, filterValue);
}
return false;
};
export const convertToTitleCase = (str: string) => {
return str.replace(/\b\w/g, (char) => char.toUpperCase());
};
@@ -1,7 +1,6 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import type { ColumnFiltersState } from "@tanstack/react-table";
import {
getCoreRowModel,
getFilteredRowModel,
@@ -19,10 +18,12 @@ import type { Dispatch, SetStateAction } from "react";
import {
DataTable,
DataTableProvider,
DataTableToolbar,
DataTableFilters,
DataTableSelectionBar,
useFetchMoreOnBottomReached,
useColumnFilters,
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
@@ -150,6 +151,14 @@ interface Props {
}
export default function MemberList(props: Props) {
return (
<DataTableProvider>
<MemberListContent {...props} />
</DataTableProvider>
);
}
function MemberListContent(props: Props) {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const { t, i18n } = useLocale();
const { data: session } = useSession();
@@ -168,6 +177,8 @@ export default function MemberList(props: Props) {
limit: 10,
searchTerm: debouncedSearchTerm,
teamId: props.team.id,
// TODO: send `columnFilters` to server for server side filtering
// filters: columnFilters,
},
{
enabled: !!props.team.id,
@@ -179,8 +190,7 @@ export default function MemberList(props: Props) {
}
);
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const columnFilters = useColumnFilters();
const [rowSelection, setRowSelection] = useState({});
const removeMemberFromCache = ({
@@ -625,7 +635,6 @@ export default function MemberList(props: Props) {
columnFilters,
rowSelection,
},
onColumnFiltersChange: setColumnFilters,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -654,7 +663,7 @@ export default function MemberList(props: Props) {
<DataTableToolbar.Root>
<div className="flex w-full gap-2">
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
<DataTableFilters.FilterButton table={table} />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
{isAdminOrOwner && (
<DataTableToolbar.CTA
@@ -56,11 +56,12 @@ function FormCard({ formName, fields }: FormCardProps) {
export function FailedBookingsByField() {
const { filter } = useFilterContext();
const { selectedTeamId, isAll, initialConfig, selectedRoutingFormId } = filter;
const { selectedTeamId, selectedUserId, isAll, initialConfig, selectedRoutingFormId } = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
@@ -33,6 +33,7 @@ import { useFilterContext } from "../context/provider";
interface DownloadButtonProps {
teamId?: number;
userId?: number;
isAll?: boolean;
routingFormId?: string;
dateRange: [Dayjs, Dayjs, string | null];
@@ -41,6 +42,7 @@ interface DownloadButtonProps {
}
function DownloadButton({
userId,
teamId,
isAll,
routingFormId,
@@ -57,6 +59,7 @@ function DownloadButton({
try {
const result = await utils.viewer.insights.routedToPerPeriodCsv.fetch({
userId: userId ?? undefined,
teamId: teamId ?? undefined,
startDate: dateRange[0]?.toISOString() ?? "",
endDate: dateRange[1]?.toISOString() ?? "",
@@ -97,6 +100,7 @@ interface FormCardProps {
onSearchChange: (value: string) => void;
children: ReactNode;
teamId?: number;
userId?: number;
isAll?: boolean;
routingFormId?: string;
dateRange: [Dayjs, Dayjs, string | null];
@@ -109,6 +113,7 @@ function FormCard({
onSearchChange,
children,
teamId,
userId,
isAll,
routingFormId,
dateRange,
@@ -141,6 +146,7 @@ function FormCard({
/>
</div>
<DownloadButton
userId={userId}
teamId={teamId}
isAll={isAll}
routingFormId={routingFormId}
@@ -214,7 +220,7 @@ const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TF
export function RoutedToPerPeriod() {
const { t } = useLocale();
const { filter } = useFilterContext();
const { selectedTeamId, isAll, selectedRoutingFormId, dateRange } = filter;
const { selectedTeamId, selectedUserId, isAll, selectedRoutingFormId, dateRange } = filter;
const [selectedPeriod, setSelectedPeriod] = useQueryState("selectedPeriod", {
defaultValue: "perWeek",
});
@@ -233,6 +239,7 @@ export function RoutedToPerPeriod() {
const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =
trpc.viewer.insights.routedToPerPeriod.useInfiniteQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
startDate: dateRange[0]?.toISOString() ?? "",
endDate: dateRange[1]?.toISOString() ?? "",
@@ -327,6 +334,7 @@ export function RoutedToPerPeriod() {
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
userId={selectedUserId ?? undefined}
teamId={selectedTeamId ?? undefined}
isAll={isAll}
routingFormId={selectedRoutingFormId ?? undefined}
@@ -369,6 +377,7 @@ export function RoutedToPerPeriod() {
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
userId={selectedUserId ?? undefined}
teamId={selectedTeamId ?? undefined}
isAll={isAll}
routingFormId={selectedRoutingFormId ?? undefined}
@@ -10,13 +10,28 @@ import {
} from "@tanstack/react-table";
import Link from "next/link";
import { useRef, useMemo, useId } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { DataTable, DataTableSkeleton, useFetchMoreOnBottomReached } from "@calcom/features/data-table";
import type { FilterValue, ExternalFilter } from "@calcom/features/data-table";
import {
DataTableProvider,
DataTable,
DataTableSkeleton,
DataTableFilters,
useFetchMoreOnBottomReached,
useColumnFilters,
multiSelectFilter,
textFilter,
dataTableFilter,
convertToTitleCase,
useDataTable,
} from "@calcom/features/data-table";
import classNames from "@calcom/lib/classNames";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import {
Badge,
@@ -30,6 +45,13 @@ import {
} from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { ClearFilters } from "../filters/ClearFilters";
import { DateSelect } from "../filters/DateSelect";
import { RoutingDownload } from "../filters/Download";
import { RoutingFormFilterList } from "../filters/RoutingFormFilterList";
import { TeamAndSelfList } from "../filters/TeamAndSelfList";
import { UserListInTeam } from "../filters/UserListInTeam";
import { RoutingKPICards } from "./RoutingKPICards";
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
@@ -41,6 +63,17 @@ type RoutingFormTableRow = {
[key: string]: any;
};
type FieldCellValue = { label: string; value: string };
const ZResponseValue = z.object({
label: z.string(),
value: z.string(),
});
const ZResponseValues = z.array(ZResponseValue);
type ResponseValues = FieldCellValue[];
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={classNames("group relative max-w-[200px]", className)}>
@@ -96,29 +129,29 @@ function BookedByCell({
);
}
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: number }) {
const cellId = useId();
if (value.length === 0) return <div className="h-6 w-[200px]" />;
if (values.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{value.length > 2 ? (
{values.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
{values.slice(0, 2).map((value, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
{value.label}
</Badge>
))}
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{value.length - 2}</Badge>
<Badge variant="gray">+{values.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{value.slice(2).map((v: string, i: number) => (
{values.slice(2).map((value, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{v}
{value.label}
</span>
))}
</div>
@@ -127,9 +160,9 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
</HoverCard>
</>
) : (
value.map((v: string, i: number) => (
values.map((value, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
{value.label}
</Badge>
))
)}
@@ -215,7 +248,15 @@ function BookingAtCell({
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable({
export function RoutingFormResponsesTable() {
return (
<DataTableProvider>
<RoutingFormResponsesTableContent />
</DataTableProvider>
);
}
export function RoutingFormResponsesTableContent({
children,
}: {
children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode);
@@ -223,7 +264,7 @@ export function RoutingFormResponsesTable({
const { t } = useLocale();
const { filter } = useFilterContext();
const tableContainerRef = useRef<HTMLDivElement>(null);
const { copyToClipboard, isCopied } = useCopy();
const { copyToClipboard } = useCopy();
const {
dateRange,
@@ -232,15 +273,19 @@ export function RoutingFormResponsesTable({
initialConfig,
selectedRoutingFormId,
selectedMemberUserId,
selectedUserId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const columnFilters = useColumnFilters();
const { data: headers, isLoading: isHeadersLoading } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
@@ -256,11 +301,11 @@ export function RoutingFormResponsesTable({
teamId: selectedTeamId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
userId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
columnFilters,
limit: 30,
},
{
@@ -289,20 +334,27 @@ export function RoutingFormResponsesTable({
};
Object.entries(response.response).forEach(([fieldId, field]) => {
const header = headers?.find((h) => h.id === fieldId);
const fieldHeader = (headers || []).find((h) => h.id === fieldId);
if (header?.options) {
if (fieldHeader?.options) {
if (Array.isArray(field.value)) {
// Map the IDs to their corresponding labels for array values
const labels = field.value.map((id) => {
const option = header.options?.find((opt) => opt.id === id);
return option?.label ?? id;
});
const labels = field.value
.map((id) => {
const option = fieldHeader.options?.find((opt) => opt.id === id);
if (!option) {
return undefined;
}
return { label: option.label, value: option.id };
})
.filter(Boolean);
row[fieldId] = labels;
} else {
// Handle single value case
const option = header.options?.find((opt) => opt.id === field.value);
row[fieldId] = option?.label ?? field.value;
const option = fieldHeader.options?.find((opt) => opt.id === field.value);
if (option) {
row[fieldId] = { label: option.label, value: option.id };
}
}
} else {
row[fieldId] = field.value;
@@ -325,29 +377,79 @@ export function RoutingFormResponsesTable({
const columns = useMemo(
() => [
columnHelper.accessor("bookedAttendees", {
columnHelper.accessor("routedToBooking", {
id: "bookedBy",
header: t("routing_form_insights_booked_by"),
size: 200,
enableColumnFilter: false,
cell: (info) => {
const row = info.row.original;
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
},
}),
...(headers?.map((header) => {
return columnHelper.accessor(header.id, {
id: header.id,
header: header.label,
...((headers || []).map((fieldHeader) => {
const isText = [
RoutingFormFieldType.TEXT,
RoutingFormFieldType.EMAIL,
RoutingFormFieldType.PHONE,
RoutingFormFieldType.TEXTAREA,
].includes(fieldHeader.type as RoutingFormFieldType);
const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER;
const isSingleSelect = fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT;
const isMultiSelect = fieldHeader.type === RoutingFormFieldType.MULTI_SELECT;
const filterType = isSingleSelect
? "single_select"
: isNumber
? "number"
: isText
? "text"
: "multi_select";
return columnHelper.accessor(fieldHeader.id, {
id: fieldHeader.id,
header: convertToTitleCase(fieldHeader.label),
size: 200,
cell: (info) => {
let value = info.getValue();
value = Array.isArray(value) ? value : [value];
return (
<div className="max-w-[200px]">
<ResponseValueCell value={value} rowId={info.row.original.id} />
</div>
);
const values = info.getValue();
if (isMultiSelect) {
const result = ZResponseValues.safeParse(values);
return (
result.success && <ResponseValueCell values={result.data} rowId={info.row.original.id} />
);
} else if (isSingleSelect) {
const result = ZResponseValue.safeParse(values);
return (
result.success && <ResponseValueCell values={[result.data]} rowId={info.row.original.id} />
);
} else if (typeof values !== "object") {
return (
<div className="truncate">
<span title={values}>{values}</span>
</div>
);
} else {
return null;
}
},
meta: {
filter: { type: filterType },
},
filterFn: (row, id, filterValue: FilterValue) => {
let cellValue: unknown;
if (Array.isArray(fieldHeader.options)) {
cellValue = Array.isArray(row.original[id])
? row.original[id].map((item: FieldCellValue) => item.value)
: row.original[id].value;
} else {
cellValue = row.original[id];
}
return dataTableFilter(cellValue, filterValue);
},
});
}) ?? []),
@@ -360,6 +462,12 @@ export function RoutingFormResponsesTable({
<BookingStatusBadge booking={info.getValue()} />
</div>
),
meta: {
filter: { type: "multi_select", icon: "circle" },
},
filterFn: (row, id, filterValue) => {
return multiSelectFilter(row.original.routedToBooking?.status, filterValue);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.routedToBooking?.status;
const statusB = rowB.original.routedToBooking?.status;
@@ -373,6 +481,7 @@ export function RoutingFormResponsesTable({
id: "bookingAt",
header: t("routing_form_insights_booking_at"),
size: 250,
enableColumnFilter: false,
cell: (info) => (
<div className="max-w-[250px]">
<BookingAtCell
@@ -388,6 +497,9 @@ export function RoutingFormResponsesTable({
id: "assignmentReason",
header: t("routing_form_insights_assignment_reason"),
size: 250,
meta: {
filter: { type: "text" },
},
cell: (info) => {
const assignmentReason = info.getValue()?.assignmentReason;
return (
@@ -396,11 +508,15 @@ export function RoutingFormResponsesTable({
</div>
);
},
filterFn: (row, id, filterValue) => {
return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue);
},
}),
columnHelper.accessor("createdAt", {
id: "submittedAt",
header: t("routing_form_insights_submitted_at"),
size: 250,
enableColumnFilter: false,
cell: (info) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
@@ -420,6 +536,27 @@ export function RoutingFormResponsesTable({
defaultColumn: {
size: 200,
},
state: {
columnFilters,
},
getFacetedUniqueValues: (_, columnId) => () => {
if (!headers) {
return new Map();
}
const fieldHeader = headers.find((h) => h.id === columnId);
if (fieldHeader?.options) {
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
} else if (columnId === "bookingStatus") {
return new Map(
Object.keys(BookingStatus).map((status) => [
{ value: status, label: bookingStatusToText(status as BookingStatus) },
1,
])
);
}
return new Map();
},
});
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({
@@ -429,6 +566,24 @@ export function RoutingFormResponsesTable({
isFetching,
});
const { removeDisplayedExternalFilter } = useDataTable();
const externalFilters = useMemo<ExternalFilter[]>(
() => [
{
key: "memberUserId",
titleKey: "people",
component: () => (
<UserListInTeam
showOnlyWhenSelectedInContext={false}
onClear={() => removeDisplayedExternalFilter("memberUserId")}
/>
),
},
],
[removeDisplayedExternalFilter]
);
if (isHeadersLoading || ((isFetching || isLoading) && !data)) {
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
}
@@ -443,8 +598,22 @@ export function RoutingFormResponsesTable({
fetchMoreOnBottomReached(e.target as HTMLDivElement);
}
}}
enableColumnResizing={true}
isPending={isFetching && !data}>
{typeof children === "function" ? children(table) : children}
<div className="header mb-4">
<div className="flex flex-wrap items-start gap-2">
<TeamAndSelfList omitOrg={true} className="mb-0" />
<DataTableFilters.AddFilterButton table={table} externalFilters={externalFilters} />
<RoutingFormFilterList showOnlyWhenSelectedInContext={false} />
<DataTableFilters.ActiveFilters table={table} externalFilters={externalFilters} />
<ClearFilters />
<div className="grow" />
<DateSelect />
<RoutingDownload />
<DataTableFilters.ColumnVisibilityButton table={table} />
</div>
<RoutingKPICards />
</div>
</DataTable>
</div>
);
@@ -36,7 +36,8 @@ export const RoutingKPICards = () => {
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
routingFormId: selectedRoutingFormId ?? undefined,
userId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
},
@@ -96,6 +96,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
// Use the custom hook
const { mostPopularForm } = useDefaultRoutingForm({
userId: userIdParsed,
teamId: teamIdParsed,
isAll: safe.success ? !!safe.data.teamId : false,
routingFormId: routingFormIdParsed,
@@ -0,0 +1,48 @@
import { useDataTable } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Button, Tooltip } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
// This component clears filters from both:
// - the filter from FilterContext
// - the data table filters state
export const ClearFilters = () => {
const { t } = useLocale();
const { clearFilters, filter } = useFilterContext();
const { activeFilters, clearAll } = useDataTable();
const clear = () => {
// clear filters from the filter context
clearFilters();
// clear filters from the data table state
clearAll();
};
const { initialConfig, selectedTeamId, selectedMemberUserId } = filter;
const isFilterSelected =
initialConfig?.teamId !== selectedTeamId ||
initialConfig?.userId !== selectedMemberUserId ||
activeFilters?.length > 0;
if (!isFilterSelected) {
return null;
}
return (
<Tooltip content={t("clear_filters")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
className="min-w-24 h-[38px] border-0"
onClick={clear}>
<Icon name="x" className="mr-1 h-4 w-4" />
{t("clear")}
</Button>
</Tooltip>
);
};
@@ -2,6 +2,7 @@ import { useState } from "react";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { DateRangePicker } from "@calcom/ui";
import { Select } from "@calcom/ui";
@@ -9,7 +10,7 @@ import { Select } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import "./DateSelect.css";
export const DateSelect = () => {
export const DateSelect = ({ className }: { className?: string }) => {
const { t } = useLocale();
const presetOptions = [
{ label: t("today"), value: "tdy" },
@@ -68,7 +69,7 @@ export const DateSelect = () => {
};
return (
<div className="ml me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<div className={classNames("ml inline-flex space-x-2 rtl:space-x-reverse", className)}>
<DateRangePicker
dates={{
startDate: startValue,
@@ -29,6 +29,7 @@ const RoutingDownload = () => {
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId ?? undefined,
userId: filter.selectedUserId ?? undefined,
memberUserId: filter.selectedMemberUserId ?? undefined,
routingFormId: filter.selectedRoutingFormId ?? undefined,
bookingStatus: filter.selectedBookingStatus ?? undefined,
fieldFilter: filter.selectedRoutingFormFilter ?? undefined,
@@ -34,6 +34,7 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
// Query routing form field options when showRoutingFilters is true
const { data: routingFormFieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
userId: selectedUserId ?? -1,
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
@@ -9,10 +9,11 @@ import { useFilterContext } from "../context/provider";
export const RoutingFormFieldFilter = ({ fieldId }: { fieldId: string }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, isAll, selectedRoutingFormId, selectedRoutingFormFilter } = filter;
const { selectedTeamId, selectedUserId, isAll, selectedRoutingFormId, selectedRoutingFormFilter } = filter;
const { data: fieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
@@ -16,35 +16,38 @@ function buildFilterOptions(forms: Form[]) {
}));
}
export const RoutingFormFilterList = memo(() => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
export const RoutingFormFilterList = memo(
({ showOnlyWhenSelectedInContext = true }: { showOnlyWhenSelectedInContext?: boolean }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
if (!selectedFilter?.includes("routing_forms")) return null;
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("routing_forms")) return null;
const filterOptions = buildFilterOptions(allForms || []);
const filterOptions = buildFilterOptions(allForms || []);
return (
<FilterSelect
title={t("routing_forms")}
options={filterOptions}
selectedValue={selectedRoutingFormId}
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
/>
);
});
return (
<FilterSelect
title={t("routing_forms")}
options={filterOptions}
selectedValue={selectedRoutingFormId}
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
/>
);
}
);
RoutingFormFilterList.displayName = "RoutingFormFilterList";
@@ -12,7 +12,13 @@ import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
export const TeamAndSelfList = ({
omitOrg = false,
className = "",
}: {
omitOrg?: boolean;
className?: string;
}) => {
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
@@ -82,7 +88,7 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
return (
<AnimatedPopover text={text}>
<AnimatedPopover text={text} popoverTriggerClassNames={className}>
<FilterCheckboxFieldsContainer>
{isOrgDataAvailable && (
<FilterCheckboxField
@@ -94,6 +100,7 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
selectedTeamId: data[0].id,
selectedUserId: null,
selectedTeamName: null,
selectedRoutingFormId: null,
isAll: true,
});
}}
@@ -121,12 +128,14 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
selectedRoutingFormId: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: isOrgDataAvailable ? data[0].id : null,
selectedTeamName: null,
isAll: true,
selectedRoutingFormId: null,
isAll: isOrgDataAvailable,
});
}
}}
@@ -149,7 +158,9 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedRoutingFormId: null,
selectedUserId: session.data?.user.id,
selectedMemberUserId: null,
selectedTeamId: null,
isAll: false,
});
@@ -158,6 +169,7 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
selectedTeamId: isOrgDataAvailable ? data[0].id : null,
selectedUserId: null,
selectedTeamName: null,
selectedRoutingFormId: null,
isAll: isOrgDataAvailable,
});
}
@@ -13,7 +13,13 @@ const mapUserToOption = (user: User) => ({
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
});
export const UserListInTeam = () => {
export const UserListInTeam = ({
showOnlyWhenSelectedInContext = true,
onClear,
}: {
showOnlyWhenSelectedInContext?: boolean;
onClear?: () => void;
}) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
@@ -23,7 +29,11 @@ export const UserListInTeam = () => {
isAll: !!isAll,
});
if (!selectedFilter?.includes("user") || !selectedTeamId || !isSuccess || data?.length === 0) {
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("user")) {
return null;
}
if (!selectedTeamId || !isSuccess || data?.length === 0) {
return null;
}
@@ -34,7 +44,12 @@ export const UserListInTeam = () => {
title={t("people")}
options={userListOptions}
selectedValue={selectedMemberUserId}
onChange={(value) => setConfigFilters({ selectedMemberUserId: Number(value) })}
onChange={(value) => {
setConfigFilters({ selectedMemberUserId: value === null ? null : Number(value) });
if (value === null) {
onClear?.();
}
}}
buttonIcon={<Icon name="users" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
testId="people-filter"
+2 -2
View File
@@ -10,7 +10,7 @@ import { FilterType } from "./FilterType";
import { RoutingFormFieldFilter } from "./RoutingFormFieldFilter";
import { RoutingFormFilterList } from "./RoutingFormFilterList";
import { TeamAndSelfList } from "./TeamAndSelfList";
import { UserListInTeam } from "./UsersListInTeam";
import { UserListInTeam } from "./UserListInTeam";
const ClearFilters = () => {
const { t } = useLocale();
@@ -97,7 +97,7 @@ export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: b
</ButtonGroup> */}
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
{showRoutingFilters ? <RoutingDownload /> : <Download />}
<DateSelect />
<DateSelect className="me-2 ms-2" />
</div>
</div>
);
@@ -1,77 +0,0 @@
import { DataTableFilters } from "@calcom/features/data-table";
import type { RoutingFormTableType } from "@calcom/features/insights/components/RoutingFormResponsesTable";
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Tooltip } from "@calcom/ui";
import { BookingStatusFilter } from "../BookingStatusFilter";
import { DateSelect } from "../DateSelect";
import { RoutingDownload } from "../Download";
import { FilterType } from "../FilterType";
import { RoutingFormFieldFilter } from "../RoutingFormFieldFilter";
import { RoutingFormFilterList } from "../RoutingFormFilterList";
import { TeamAndSelfList } from "../TeamAndSelfList";
import { UserListInTeam } from "../UsersListInTeam";
const ClearFilters = () => {
const { t } = useLocale();
const { filter, clearFilters } = useFilterContext();
const { selectedFilter } = filter;
if (!selectedFilter || selectedFilter?.length < 1) return null;
return (
<Tooltip content={t("clear_filters")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
className="min-w-24 h-[38px] border-0"
onClick={() => {
clearFilters();
}}>
<Icon name="x" className="mr-1 h-4 w-4" />
{t("clear")}
</Button>
</Tooltip>
);
};
export const RoutingInsightsFilters = ({ table }: { table: RoutingFormTableType }) => {
const { filter } = useFilterContext();
const { selectedFilter } = filter;
// Get all filters that relate to the routing form
const routingFormFieldIds = selectedFilter
? selectedFilter.map((filter) => {
if (filter.startsWith("rf_")) return filter.substring(3);
})
: [];
return (
<div className="mt-6 flex w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-wrap gap-2 sm:flex-row sm:justify-start">
<TeamAndSelfList omitOrg={true} />
<UserListInTeam />
<RoutingFormFilterList />
<BookingStatusFilter />
{routingFormFieldIds.map((fieldId) => {
if (fieldId) return <RoutingFormFieldFilter key={fieldId} fieldId={fieldId} />;
})}
<FilterType showRoutingFilters={true} />
<ClearFilters />
</div>
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
<RoutingDownload />
<DateSelect />
<DataTableFilters.ColumnVisibilityButton table={table} />
</div>
</div>
);
};
@@ -5,11 +5,13 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { trpc } from "@calcom/trpc";
export function useDefaultRoutingForm({
userId,
teamId,
isAll,
routingFormId,
onRoutingFormChange,
}: {
userId: number | null | undefined;
teamId: number | null | undefined;
isAll: boolean;
routingFormId: string | null | undefined;
@@ -24,6 +26,7 @@ export function useDefaultRoutingForm({
// Query to get routing forms list
const { data: routingForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
userId: userId ?? undefined,
teamId: teamId ?? undefined,
isAll,
},
@@ -5,10 +5,13 @@ import {
routingFormResponseInDbSchema,
} from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table";
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
type RoutingFormInsightsTeamFilter = {
userId?: number | null;
teamId?: number | null;
isAll: boolean;
organizationId?: number | null;
@@ -18,17 +21,19 @@ type RoutingFormInsightsTeamFilter = {
type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
startDate?: string;
endDate?: string;
userId?: number | null;
memberUserId?: number | null;
searchQuery?: string | null;
bookingStatus?: BookingStatus | "NO_BOOKING" | null;
fieldFilter?: {
fieldId: string;
optionId: string;
} | null;
columnFilters: ColumnFilter[];
};
class RoutingEventsInsights {
private static async getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -52,23 +57,21 @@ class RoutingEventsInsights {
// Base where condition for forms
const formsWhereCondition: Prisma.App_RoutingForms_FormWhereInput = {
...(teamIds.length > 0 && {
teamId: {
in: teamIds,
},
}),
...(teamIds.length > 0
? {
teamId: {
in: teamIds,
},
}
: {
userId: userId ?? -1,
teamId: null,
}),
...(routingFormId && {
id: routingFormId,
}),
};
if (teamIds.length === 0 && !routingFormId) {
if (!organizationId) {
throw new Error("Organization ID is required");
}
formsWhereCondition.teamId = organizationId;
}
return formsWhereCondition;
}
@@ -80,12 +83,14 @@ class RoutingEventsInsights {
organizationId,
routingFormId,
userId,
memberUserId,
searchQuery,
bookingStatus,
fieldFilter,
}: RoutingFormInsightsFilter) {
}: Omit<RoutingFormInsightsFilter, "columnFilters">) {
// Get team IDs based on organization if applicable
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -101,13 +106,13 @@ class RoutingEventsInsights {
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus || searchQuery
...(memberUserId || bookingStatus || searchQuery
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(memberUserId && { userId: memberUserId }),
...(searchQuery && {
user: {
OR: [
@@ -163,16 +168,23 @@ class RoutingEventsInsights {
}
static async getRoutingFormsForFilters({
userId,
teamId,
isAll,
organizationId,
}: {
userId?: number;
teamId?: number;
isAll: boolean;
organizationId?: number | undefined;
routingFormId?: string | undefined;
}) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, isAll, organizationId });
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
});
return await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
@@ -197,16 +209,54 @@ class RoutingEventsInsights {
cursor,
limit,
userId,
fieldFilter,
bookingStatus,
}: RoutingFormInsightsFilter & { cursor?: number; limit?: number }) {
memberUserId,
columnFilters,
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & { cursor?: number; limit?: number }) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus");
const assignmentReasonFilter = columnFilters.find((filter) => filter.id === "assignmentReason") as
| TypedColumnFilter<"text">
| undefined;
const fieldFilters = columnFilters.filter(
(filter) => filter.id !== "bookingStatus" && filter.id !== "assignmentReason"
);
let bookingWhereInput: Prisma.BookingWhereInput = {};
if (memberUserId) {
bookingWhereInput.userId = memberUserId;
}
if (bookingStatusFilter) {
bookingWhereInput = {
...bookingWhereInput,
...makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value }),
};
}
if (assignmentReasonFilter) {
const operator = assignmentReasonFilter.value.data.operator;
if (operator === "isEmpty") {
bookingWhereInput.assignmentReason = { none: {} };
} else if (operator === "isNotEmpty") {
bookingWhereInput.assignmentReason = {
some: {
reasonString: {
not: "",
},
},
};
} else {
bookingWhereInput.assignmentReason = {
some: makeWhereClause({ columnName: "reasonString", filterValue: assignmentReasonFilter.value }),
};
}
}
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
@@ -215,27 +265,22 @@ class RoutingEventsInsights {
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}),
form: formsTeamWhereCondition,
};
if (fieldFilters.length > 0) {
responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => {
// NOTE: We cannot perform case-insensitive search on `response` column,
// until we normalize this table, use raw sql, or filter at the application level.
return makeWhereClause({
columnName: "response",
filterValue: fieldFilter.value,
json: { path: [fieldFilter.id, "value"] },
});
});
}
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
@@ -295,12 +340,14 @@ class RoutingEventsInsights {
}
static async getRoutingFormFieldOptions({
userId,
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
routingFormId,
@@ -321,12 +368,14 @@ class RoutingEventsInsights {
}
static async getFailedBookingsByRoutingFormGroup({
userId,
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -340,6 +389,10 @@ class RoutingEventsInsights {
// @ts-expect-error it doest exist but TS isnt smart enough when its unmber or int filter
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
// @ts-expect-error it doest exist but TS isnt smart enough when its unmber or int filter
if (!formsWhereCondition.teamId?.in && userId) {
teamConditions.push(`f.userId = '${userId}'`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
@@ -452,12 +505,14 @@ class RoutingEventsInsights {
}
static async getRoutingFormHeaders({
userId,
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -478,6 +533,7 @@ class RoutingEventsInsights {
return {
id: f.id,
label: f.label,
type: f.type,
options: f.options,
};
});
@@ -493,12 +549,14 @@ class RoutingEventsInsights {
organizationId,
routingFormId,
userId,
memberUserId,
bookingStatus,
fieldFilter,
take,
skip,
}: RoutingFormInsightsFilter & { take?: number; skip?: number }) {
}: Omit<RoutingFormInsightsFilter, "columnFilters"> & { take?: number; skip?: number }) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -552,13 +610,13 @@ class RoutingEventsInsights {
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
...(memberUserId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(memberUserId && { userId: memberUserId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
@@ -663,6 +721,7 @@ class RoutingEventsInsights {
}
static async routedToPerPeriod({
userId,
teamId,
isAll,
organizationId,
@@ -695,6 +754,7 @@ class RoutingEventsInsights {
// Build the team conditions for the WHERE clause
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -708,6 +768,10 @@ class RoutingEventsInsights {
// @ts-expect-error same as above
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
// @ts-expect-error it does exist but TS isn't smart enough when it's number or int filter
if (!formsWhereCondition.teamId?.in && userId) {
teamConditions.push(`f.userId = '${userId}'`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
@@ -918,6 +982,7 @@ class RoutingEventsInsights {
}
static async routedToPerPeriodCsv({
userId,
teamId,
isAll,
organizationId,
@@ -944,6 +1009,7 @@ class RoutingEventsInsights {
// Build the team conditions for the WHERE clause
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
@@ -957,6 +1023,10 @@ class RoutingEventsInsights {
// @ts-expect-error same as above
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
// @ts-expect-error it does exist but TS isn't smart enough when it's number or int filter
if (!formsWhereCondition.teamId?.in && userId) {
teamConditions.push(`f.userId = '${userId}'`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
@@ -3,6 +3,7 @@ import md5 from "md5";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZColumnFilter } from "@calcom/features/data-table";
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
import { randomString } from "@calcom/lib/random";
import type { readonlyPrisma } from "@calcom/prisma";
@@ -1551,10 +1552,11 @@ export const insightsRouter = router({
}),
getRoutingFormsForFilters: userBelongsToTeamProcedure
.input(z.object({ teamId: z.number().optional(), isAll: z.boolean() }))
.input(z.object({ userId: z.number().optional(), teamId: z.number().optional(), isAll: z.boolean() }))
.query(async ({ ctx, input }) => {
const { teamId, isAll } = input;
const { userId, teamId, isAll } = input;
return await RoutingEventsInsights.getRoutingFormsForFilters({
userId,
teamId,
isAll,
organizationId: ctx.user.organizationId ?? undefined,
@@ -1584,6 +1586,7 @@ export const insightsRouter = router({
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
userId: input.userId ?? null,
memberUserId: input.memberUserId ?? null,
bookingStatus: input.bookingStatus ?? null,
fieldFilter: input.fieldFilter ?? null,
});
@@ -1596,13 +1599,7 @@ export const insightsRouter = router({
routingFormId: z.string().optional(),
cursor: z.number().optional(),
limit: z.number().optional(),
bookingStatus: bookingStatusSchema,
fieldFilter: z
.object({
fieldId: z.string(),
optionId: z.string(),
})
.optional(),
columnFilters: z.array(ZColumnFilter),
})
)
.query(async ({ ctx, input }) => {
@@ -1616,14 +1613,19 @@ export const insightsRouter = router({
routingFormId: input.routingFormId ?? null,
cursor: input.cursor,
userId: input.userId ?? null,
memberUserId: input.memberUserId ?? null,
limit: input.limit,
bookingStatus: input.bookingStatus ?? null,
fieldFilter: input.fieldFilter ?? null,
columnFilters: input.columnFilters,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
.input(
z.object({ teamId: z.number().optional(), isAll: z.boolean(), routingFormId: z.string().optional() })
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ input, ctx }) => {
const options = await RoutingEventsInsights.getRoutingFormFieldOptions({
@@ -1634,7 +1636,12 @@ export const insightsRouter = router({
}),
failedBookingsByField: userBelongsToTeamProcedure
.input(
z.object({ teamId: z.number().optional(), isAll: z.boolean(), routingFormId: z.string().optional() })
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
@@ -1645,18 +1652,22 @@ export const insightsRouter = router({
routingFormResponsesHeaders: userBelongsToTeamProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormHeaders({
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
userId: input.userId ?? null,
teamId: input.teamId ?? null,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
});
return headers || [];
}),
rawRoutingData: userBelongsToTeamProcedure
.input(
@@ -1673,8 +1684,18 @@ export const insightsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, userId, isAll, routingFormId, bookingStatus, fieldFilter, cursor } =
input;
const {
teamId,
startDate,
endDate,
userId,
memberUserId,
isAll,
routingFormId,
bookingStatus,
fieldFilter,
cursor,
} = input;
if (!teamId && !userId) {
return { data: [], hasMore: false, nextCursor: null };
@@ -1686,6 +1707,7 @@ export const insightsRouter = router({
startDate,
endDate,
userId,
memberUserId,
isAll: isAll ?? false,
organizationId: ctx.user.organizationId,
routingFormId,
@@ -1723,9 +1745,11 @@ export const insightsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, period, cursor, limit, isAll, routingFormId, searchQuery } = input;
const { teamId, userId, startDate, endDate, period, cursor, limit, isAll, routingFormId, searchQuery } =
input;
return await RoutingEventsInsights.routedToPerPeriod({
userId: userId ?? null,
teamId: teamId ?? null,
startDate,
endDate,
@@ -1751,6 +1775,7 @@ export const insightsRouter = router({
const { startDate, endDate } = input;
try {
const csvData = await RoutingEventsInsights.routedToPerPeriodCsv({
userId: input.userId ?? null,
teamId: input.teamId ?? null,
startDate,
endDate,
@@ -1,9 +1,8 @@
import type { Table } from "@tanstack/react-table";
import type { ColumnFiltersState } from "@tanstack/react-table";
import { createContext, useContext, useState, useMemo, type PropsWithChildren } from "react";
import type { Dispatch, SetStateAction } from "react";
import { DataTableSelectionBar } from "@calcom/features/data-table";
import { DataTableSelectionBar, type ColumnFilter } from "@calcom/features/data-table";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
@@ -30,7 +29,7 @@ import type { UserTableUser } from "../types";
interface Props {
table: Table<UserTableUser>;
filters: ColumnFiltersState;
filters: ColumnFilter[];
}
type Attribute = _Attribute & { options: AttributeOption[] };
@@ -250,10 +249,7 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) {
limit: 10,
searchTerm: "",
expand: ["attributes"],
filters: filters.map((filter) => ({
id: filter.id,
value: filter.value as string[],
})),
filters,
},
// @ts-expect-error i really dont know how to type this
(oldData) => {
@@ -7,6 +7,7 @@ import { useQueryState, parseAsBoolean } from "nuqs";
import { useMemo, useReducer, useRef, useState } from "react";
import {
DataTableProvider,
DataTable,
DataTableToolbar,
DataTableFilters,
@@ -16,6 +17,10 @@ import {
useFetchMoreOnBottomReached,
textFilter,
isTextFilterValue,
isSingleSelectFilterValue,
isMultiSelectFilterValue,
singleSelectFilter,
multiSelectFilter,
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import classNames from "@calcom/lib/classNames";
@@ -97,6 +102,14 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState
}
export function UserListTable() {
return (
<DataTableProvider>
<UserListTableContent />
</DataTableProvider>
);
}
function UserListTableContent() {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
@@ -164,62 +177,88 @@ export function UserListTable() {
return [];
}
return (
(attributes?.map((attribute) => ({
id: attribute.id,
header: attribute.name,
meta: {
filterType: attribute.type.toLowerCase() === "text" ? "text" : "select",
},
size: 120,
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
cell: ({ row }) => {
const attributeValues = row.original.attributes.filter(
(attr) => attr.attributeId === attribute.id
);
if (attributeValues.length === 0) return null;
return (
<div
className={classNames(
attribute.type === "NUMBER" ? "flex w-full justify-center" : "flex flex-wrap"
)}>
{attributeValues.map((attributeValue, index) => {
const isAGroupOption = attributeValue.contains?.length > 0;
const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined;
return (
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
<Badge
variant={isAGroupOption ? "orange" : "gray"}
className={classNames(suffix && "rounded-r-none")}>
{attributeValue.value}
</Badge>
(attributes?.map((attribute) => {
// TODO: We need to normalize AttributeOption table first
// so that we can have `number_value` column for numeric operations.
// Currently, `value` column is used for both text and number attributes.
//
// const isNumber = attribute.type === "NUMBER";
const isNumber = false;
const isText = attribute.type === "TEXT";
const isSingleSelect = attribute.type === "SINGLE_SELECT";
const isMultiSelect = attribute.type === "MULTI_SELECT";
const filterType = isNumber
? "number"
: isText
? "text"
: isSingleSelect
? "single_select"
: "multi_select";
{suffix ? (
return {
id: attribute.id,
header: attribute.name,
meta: {
filter: { type: filterType },
},
size: 120,
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
cell: ({ row }) => {
const attributeValues = row.original.attributes.filter(
(attr) => attr.attributeId === attribute.id
);
if (attributeValues.length === 0) return null;
return (
<div className={classNames(isNumber ? "flex w-full justify-center" : "flex flex-wrap")}>
{attributeValues.map((attributeValue) => {
const isAGroupOption = attributeValue.contains?.length > 0;
const suffix = attribute.isWeightsEnabled
? `${attributeValue.weight || 100}%`
: undefined;
return (
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
<Badge
variant={isAGroupOption ? "orange" : "gray"}
style={{
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
}}
className="rounded-l-none">
{suffix}
className={classNames(suffix && "rounded-r-none")}>
{attributeValue.value}
</Badge>
) : null}
</div>
);
})}
</div>
);
},
filterFn: (row, id, filterValue) => {
const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id);
if (isTextFilterValue(filterValue)) {
return attributeValues.some((attr) => textFilter(attr.value, filterValue));
}
{suffix ? (
<Badge
variant={isAGroupOption ? "orange" : "gray"}
style={{
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
}}
className="rounded-l-none">
{suffix}
</Badge>
) : null}
</div>
);
})}
</div>
);
},
filterFn: (row, id, filterValue) => {
const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id);
if (attributeValues.length === 0) return false;
return attributeValues.some((attr) => filterValue.includes(attr.value));
},
})) as ColumnDef<UserTableUser>[]) ?? []
if (isTextFilterValue(filterValue)) {
return attributeValues.some((attr) => textFilter(attr.value, filterValue));
} else if (isSingleSelectFilterValue(filterValue)) {
return singleSelectFilter(
attributeValues.map((attr) => attr.value),
filterValue
);
} else if (isMultiSelectFilterValue(filterValue)) {
return multiSelectFilter(
attributeValues.map((attr) => attr.value),
filterValue
);
}
return false;
},
};
}) as ColumnDef<UserTableUser>[]) ?? []
);
};
const cols: ColumnDef<UserTableUser>[] = [
@@ -512,9 +551,9 @@ export function UserListTable() {
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={{ name: "UserListTable" }}
enableColumnResizing={true}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
<DataTableToolbar.Root>
<div className="flex w-full flex-col gap-2 sm:flex-row">
<div className="w-full sm:w-auto sm:min-w-[200px] sm:flex-1">
<DataTableToolbar.SearchBar
@@ -534,7 +573,7 @@ export function UserListTable() {
{t("download")}
</DataTableToolbar.CTA>
{/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */}
<DataTableFilters.FilterButton table={table} omit={["member"]} />
<DataTableFilters.AddFilterButton table={table} omit={["member"]} />
<DataTableFilters.ColumnVisibilityButton table={table} />
{adminOrOwner && (
<DataTableToolbar.CTA
@@ -1,13 +1,14 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useReducer } from "react";
import { useReducer, useState } from "react";
import AccountDialog from "@calcom/app-store/office365video/components/AccountDialog";
import { AppList } from "@calcom/features/apps/components/AppList";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { GOOGLE_MEET, ZOOM } from "@calcom/platform-constants";
import { GOOGLE_MEET, OFFICE_365_VIDEO, ZOOM } from "@calcom/platform-constants";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import type { App } from "@calcom/types/App";
import {
@@ -91,6 +92,8 @@ export const ConferencingAppsViewPlatformWrapper = ({
}
);
const [isAccountModalOpen, setIsAccountModalOpen] = useState(false);
const handleModelClose = () => {
updateModal({ isOpen: false, credentialId: null, app: null });
};
@@ -199,6 +202,14 @@ export const ConferencingAppsViewPlatformWrapper = ({
</DropdownItem>
</DropdownMenuItem>
)}
{installedApps && !installedApps?.find((app) => app.slug == OFFICE_365_VIDEO) && (
<DropdownMenuItem>
<DropdownItem color="secondary" onClick={() => setIsAccountModalOpen(true)}>
{t("office_365_video")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
);
@@ -257,6 +268,12 @@ export const ConferencingAppsViewPlatformWrapper = ({
app={modal.app}
handleRemoveApp={handleRemoveApp}
/>
<AccountDialog
open={isAccountModalOpen}
onOpenChange={setIsAccountModalOpen}
handleSubmit={() => connect(OFFICE_365_VIDEO)}
/>
</>
</SettingsHeader>
</AtomsWrapper>
@@ -1,7 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS, ERROR_STATUS, ZOOM, GOOGLE_MEET } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
ERROR_STATUS,
ZOOM,
GOOGLE_MEET,
OFFICE_365_VIDEO,
} from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import type { App } from "@calcom/types/App";
@@ -12,7 +18,7 @@ export type UseGetOauthAuthUrlProps = {
onErrorReturnTo?: string;
};
export const useGetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
return useQuery({
queryKey: ["get-zoom-auth-url"],
staleTime: Infinity,
@@ -35,6 +41,29 @@ export const useGetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAut
});
};
export const useOffice365GetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
return useQuery({
queryKey: ["get-office365-auth-url"],
staleTime: Infinity,
enabled: false,
queryFn: () => {
return http
?.get<ApiResponse<{ url: string }>>(
`conferencing/${OFFICE_365_VIDEO}/oauth/auth-url${
returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ""
}${onErrorReturnTo ? `&onErrorReturnTo=${encodeURIComponent(onErrorReturnTo)}` : ""}`
)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
return responseBody.data.url;
}
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
return "";
});
},
});
};
export type UseConnectGoogleMeetProps = {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse) => void;
@@ -69,18 +98,25 @@ export const useConnectNonOauthApp = (
};
export const useConnect = ({ returnTo, onErrorReturnTo, ...props }: UseConnectGoogleMeetProps) => {
const { refetch } = useGetOauthAuthUrl({ returnTo, onErrorReturnTo });
const { refetch: refetchZoomAuthUrl } = useGetZoomOauthAuthUrl({ returnTo, onErrorReturnTo });
const { refetch: refetchOffice365AuthUrl } = useOffice365GetOauthAuthUrl({ returnTo, onErrorReturnTo });
const connectNonOauthApp = useConnectNonOauthApp(props);
const connect = async (app: App["slug"]) => {
switch (app) {
case ZOOM:
const redirectUri = await refetch();
if (redirectUri.data) {
window.location.href = redirectUri.data;
const zoomRedirectUri = await refetchZoomAuthUrl();
if (zoomRedirectUri.data) {
window.location.href = zoomRedirectUri.data;
}
break;
case OFFICE_365_VIDEO:
const office365RedirectUri = await refetchOffice365AuthUrl();
if (office365RedirectUri.data) {
window.location.href = office365RedirectUri.data;
}
break;
case GOOGLE_MEET:
connectNonOauthApp.mutate(app);
+6 -2
View File
@@ -20,9 +20,13 @@ export const GOOGLE_MEET_ID = "google-meet";
export const ZOOM = "zoom";
export const ZOOM_TYPE = "zoom_video";
export const CAL_VIDEO = "daily-video";
export const OFFICE_365_VIDEO = "msteams";
export const OFFICE_365_VIDEO_TYPE = "office365_video";
export const CONFERENCING_APPS = [GOOGLE_MEET, ZOOM];
export const CAL_VIDEO = "daily-video";
export const CAL_VIDEO_TYPE = "daily_video";
export const CONFERENCING_APPS = [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO];
export const APPS_TYPE_ID_MAPPING = {
[GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID,
[OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID,
Binary file not shown.
+1
View File
@@ -21,6 +21,7 @@
"seed-insights": "ts-node --transpile-only ./seed-insights.ts"
},
"devDependencies": {
"@faker-js/faker": "9.2.0",
"npm-run-all": "^4.1.5"
},
"dependencies": {
+20
View File
@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import type { Prisma } from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
@@ -85,6 +86,23 @@ const shuffle = (
return booking;
};
async function createAttendees(bookings: any[]) {
for (const booking of bookings) {
await prisma.attendee.createMany({
data: Array(Math.floor(Math.random() * 4))
.fill(null)
.map(() => {
return {
bookingId: booking.id,
timeZone: faker.location.timeZone(),
email: faker.internet.email(),
name: faker.person.fullName(),
};
}),
});
}
}
const prisma = new PrismaClient();
async function main() {
// First find the organization we want to add insights to
@@ -268,6 +286,8 @@ async function main() {
],
});
await createAttendees(await prisma.booking.findMany());
// Find owner of the organization
const owner = orgMembers.find((m) => m.role === "OWNER" || m.role === "ADMIN");
+95 -7
View File
@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import type { Prisma, UserPermissionRole } from "@prisma/client";
import { randomUUID } from "crypto";
import { uuid } from "short-uuid";
@@ -440,9 +441,21 @@ export async function seedRoutingForms(
value: "team/insights-team/team-sales",
},
],
formFieldFilled: {
formFieldLocation: {
id: "674c169a-e40a-492c-b4bb-6f5213873bd6",
},
formFieldSkills: {
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
},
formFieldEmail: {
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
},
formFieldManager: {
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
},
formFieldRating: {
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
},
};
const form = await prisma.app_RoutingForms_Form.findUnique({
@@ -538,7 +551,17 @@ export async function seedRoutingForms(
],
fields: [
{
id: seededForm.formFieldFilled.id,
id: seededForm.formFieldLocation.id,
type: "select",
label: "Location",
options: attributeRaw[1].options.map((opt) => ({
id: opt.id,
label: opt.value,
})),
required: true,
},
{
id: seededForm.formFieldSkills.id,
type: "multiselect",
label: "skills",
options: attributeRaw[2].options.map((opt) => ({
@@ -547,6 +570,24 @@ export async function seedRoutingForms(
})),
required: true,
},
{
id: seededForm.formFieldEmail.id,
type: "email",
label: "Email",
required: true,
},
{
id: seededForm.formFieldManager.id,
type: "text",
label: "Manager",
required: true,
},
{
id: seededForm.formFieldRating.id,
type: "number",
label: "Rating",
required: true,
},
],
team: {
connect: {
@@ -571,7 +612,19 @@ type SeededForm = {
id: string;
value: string;
}[];
formFieldFilled: {
formFieldLocation: {
id: string;
};
formFieldSkills: {
id: string;
};
formFieldEmail: {
id: string;
};
formFieldManager: {
id: string;
};
formFieldRating: {
id: string;
};
};
@@ -607,8 +660,11 @@ export async function seedRoutingFormResponses(
for (const booking of bookings) {
// Randomly select 1-3 skills from the form field options
const numSkills = Math.floor(Math.random() * 3) + 1;
const shuffledOptions = [...attributeRaw[2].options].sort(() => Math.random() - 0.5);
const selectedSkills = shuffledOptions.slice(0, numSkills);
const shuffledSkillOptions = [...attributeRaw[2].options].sort(() => Math.random() - 0.5);
const selectedSkills = shuffledSkillOptions.slice(0, numSkills);
const selectedLocation =
attributeRaw[1].options[Math.floor(Math.random() * attributeRaw[1].options.length)];
// Generate a random date within the last 30 days
const randomDate = dayjs()
@@ -623,10 +679,26 @@ export async function seedRoutingFormResponses(
formFillerId: randomUUID(),
createdAt: randomDate.toDate(),
response: {
[seededForm.formFieldFilled.id]: {
[seededForm.formFieldLocation.id]: {
label: "Location",
value: selectedLocation.id,
},
[seededForm.formFieldSkills.id]: {
label: "skills",
value: selectedSkills.map((opt) => opt.id),
},
[seededForm.formFieldEmail.id]: {
label: "Email",
value: faker.internet.email(),
},
[seededForm.formFieldManager.id]: {
label: "Manager",
value: faker.person.fullName(),
},
[seededForm.formFieldRating.id]: {
label: "Rating",
value: Math.floor(Math.random() * 5) + 1,
},
},
},
});
@@ -650,10 +722,26 @@ export async function seedRoutingFormResponses(
formFillerId: randomUUID(),
createdAt: randomDate.subtract(2, "hour").toDate(),
response: {
[seededForm.formFieldFilled.id]: {
[seededForm.formFieldLocation.id]: {
label: "Location",
value: selectedLocation.id,
},
[seededForm.formFieldSkills.id]: {
label: "skills",
value: selectedSkills.map((opt) => opt.id),
},
[seededForm.formFieldEmail.id]: {
label: "Email",
value: faker.internet.email(),
},
[seededForm.formFieldManager.id]: {
label: "Manager",
value: faker.person.fullName(),
},
[seededForm.formFieldRating.id]: {
label: "Rating",
value: Math.floor(Math.random() * 5) + 1,
},
},
},
});
+2 -1
View File
@@ -453,7 +453,8 @@ export const bookingMetadataSchema = z
videoCallUrl: z.string().optional(),
})
.and(z.record(z.string()))
.nullable();
.nullable()
.describe("BookingMetadata");
export const customInputOptionSchema = z.array(
z.object({
@@ -2,7 +2,6 @@ import { makeWhereClause } from "@calcom/features/data-table/lib/server";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -67,7 +66,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
});
const whereClause = {
let whereClause: Prisma.MembershipWhereInput = {
user: {
isPlatformManaged: false,
},
@@ -77,31 +76,36 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
OR: [{ email: { contains: searchTerm } }, { username: { contains: searchTerm } }],
},
}),
} as Prisma.MembershipWhereInput;
};
filters.forEach((filter) => {
switch (filter.id) {
case "role":
whereClause.role = { in: filter.value as MembershipRole[] };
whereClause = {
...whereClause,
...makeWhereClause({
columnName: "role",
filterValue: filter.value,
}),
};
break;
case "teams":
whereClause.user = {
teams: {
some: {
team: {
name: {
in: filter.value as string[],
},
},
team: makeWhereClause({
columnName: "name",
filterValue: filter.value,
}),
},
},
};
break;
// We assume that if the filter is not one of the above, it must be an attribute filter
default:
const attributeOptionValues: string[] = [];
if (filter.value instanceof Array) {
filter.value.forEach((filterValueItem) => {
if (filter.value.type === "multi_select") {
const attributeOptionValues: string[] = [];
filter.value.data.forEach((filterValueItem) => {
attributeOptionValues.push(filterValueItem);
groupOptionsWithContainsOptionValues.forEach((groupOption) => {
if (groupOption.contains.find(({ value: containValue }) => containValue === filterValueItem)) {
@@ -109,6 +113,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
}
});
});
filter.value.data = attributeOptionValues;
}
whereClause.AttributeToUser = {
@@ -117,10 +123,10 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
attribute: {
id: filter.id,
},
...makeWhereClause(
"value",
attributeOptionValues.length > 0 ? attributeOptionValues : filter.value
),
...makeWhereClause({
columnName: "value",
filterValue: filter.value,
}),
},
},
};
+3 -1
View File
@@ -1,12 +1,14 @@
import "@tanstack/react-table";
import type { ColumnFilterMeta } from "@calcom/features/data-table";
declare module "@tanstack/table-core" {
interface ColumnMeta<TData extends RowData, TValue> {
sticky?: {
position: "left" | "right";
gap?: number;
};
filterType?: "select" | "text";
filter?: ColumnFilterMeta;
// `autoWidth` can make the column size dynamic,
// allowing each row to have a different width based on its content.
@@ -13,6 +13,7 @@ export const lucideIconList = new Set([
"badge-check",
"ban",
"bell",
"binary",
"blocks",
"bold",
"book-open-check",
@@ -54,6 +55,7 @@ export const lucideIconList = new Set([
"corner-down-left",
"corner-down-right",
"credit-card",
"disc",
"dot",
"download",
"ellipsis-vertical",
@@ -13,6 +13,7 @@ export type IconName =
| "badge-check"
| "ban"
| "bell"
| "binary"
| "blocks"
| "bold"
| "book-open-check"
@@ -54,6 +55,7 @@ export type IconName =
| "corner-down-left"
| "corner-down-right"
| "credit-card"
| "disc"
| "dot"
| "download"
| "ellipsis-vertical"
+8
View File
@@ -4567,6 +4567,7 @@ __metadata:
resolution: "@calcom/prisma@workspace:packages/prisma"
dependencies:
"@calcom/lib": "*"
"@faker-js/faker": 9.2.0
"@prisma/client": ^5.4.2
"@prisma/extension-accelerate": ^0.6.2
"@prisma/generator-helper": ^5.4.2
@@ -6315,6 +6316,13 @@ __metadata:
languageName: node
linkType: hard
"@faker-js/faker@npm:9.2.0":
version: 9.2.0
resolution: "@faker-js/faker@npm:9.2.0"
checksum: 7cbff7363f198c24aa1a25499f200a25506f80df577a419ae878b6bdd4aa901ecfe071ae3bbecf8afafa5542c825623d55f5884d44db7b12420c898241332cd1
languageName: node
linkType: hard
"@faker-js/faker@npm:^7.3.0":
version: 7.4.0
resolution: "@faker-js/faker@npm:7.4.0"