Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0466b7ebd6 | ||
|
|
09329707c1 | ||
|
|
1773aba378 | ||
|
|
f8be2b3833 | ||
|
|
79d488faca | ||
|
|
da2c57c302 | ||
|
|
abaf03adde |
@@ -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
|
||||
|
||||
Vendored
+8
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,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 |
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 })}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+18
-3
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+19
-2
@@ -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);
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user