Compare commits

...
12 Commits
Author SHA1 Message Date
Alex van Andel fc1d553b74 chore: Release v4.7.2 (#17692)
release.yml / release (push) Canceled after 0s
2024-11-16 18:36:43 +00:00
Calcom BotandReplexica b8c2fdac44 feat: update translations via @replexica (#17691)
Co-authored-by: Replexica <support@replexica.com>
2024-11-16 18:32:52 +00:00
Abul Hassan SathuliandPrashant Varma e24c40f518 fix: how-to-setup-api-in-a-local-instance.mdx in docs with correction after api-v2 (#17667)
* Updating how-to-setup-api-in-a-local-instance.mdx with correction after api v2

After the implementation of api v2, some of the documents are not yet updated and could you make developers spend too much time on finding these things.

* fix: typo

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-11-16 18:27:02 +00:00
Alex van Andel c5cefc3e61 chore: Found a critical index missing on profileId (#17690) 2024-11-16 18:26:50 +00:00
Omar López 2c57ad0288 perf: optimize organization admin check (#17683) 2024-11-16 18:26:23 +00:00
sean-brydon af9bf14d9e fix: routing column filters (#17688)
* remove column filters

* remove column filters

* use a loading state while loading columns

* fix
2024-11-16 18:05:36 +00:00
sean-brydon b1a0cdc8e2 fix: large org routing insights UI issues (#17687)
* fix overflow colour in darkmode

* truncate/show overflow scroll indicator when overflowing on response values

* remove total created count

* overflow Y on failed response container

* fix darkmode styles on barlist

* radix hovercards to properly allow overflow in container

* column visible state

* remove constrained width on dropdowns to w-fit

* export type
2024-11-16 11:01:26 -03:00
Alex van AndelandPeer Richelsen 7fef3e7b85 chore: Release v4.7.1 (#17686)
release.yml / release (push) Canceled after 0s
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-16 10:00:16 +00:00
Hariom Balhara 384deab0ce test: findTeamMemberEmailFromCrm (#17685)
* We need to choose contact owner when crm app slug is not present

* Add unit tests for getTeamMemberEmailFromCrm module
2024-11-16 09:32:33 +00:00
Hariom Balhara 22da681f8c We need to choose contact owner when crm app slug is not present (#17684) 2024-11-16 09:02:24 +00:00
Omar López f4f4afad39 perf: optimize team admin check queries (#17681)
## What does this PR do?

Optimizes team and organization admin checks by:
- Removing unnecessary member data fetching in profile repository
- Simplifying team admin verification query to directly check membership
- Refactoring organization admin check to use the optimized team verification method

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs
- [ ] I confirm automated tests are in place

## How should this be tested?

1. Log in as a team admin/owner
2. Verify team admin privileges still work correctly
3. Log in as an organization admin
4. Confirm organization admin permissions are maintained
5. Check that admin-only features remain accessible

Expected behavior:
- Team admins/owners should retain all their existing permissions
- Organization admins should maintain their administrative capabilities
- No degradation in admin functionality while improving query performance

## Checklist

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-15 19:00:18 -07:00
Keith Williams 1c05952077 chore: Remove playwright install for API v2 E2E (#17677) 2024-11-15 23:47:26 +00:00
53 changed files with 858 additions and 112 deletions
-1
View File
@@ -65,7 +65,6 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- name: Run Tests
working-directory: apps/api/v2
@@ -0,0 +1,410 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { v4 } from "uuid";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { SchedulingType } from "@calcom/prisma/enums";
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "../getTeamMemberEmailFromCrm";
vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({
default: {
salesforce: vi.fn(),
},
}));
vi.mock("@calcom/app-store/_utils/CRMRoundRobinSkip", () => ({
getCRMContactOwnerForRRLeadSkip: vi.fn(),
}));
function mockGetCRMContactOwnerForRRLeadSkip({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve(teamMemberEmail);
}
return Promise.resolve(undefined);
});
}
function mockBookingFormHandler({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(bookingFormHandlers.salesforce).mockImplementation(
(_bookerEmail, _attributeRoutingConfig, _eventTypeId) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve({ email: teamMemberEmail });
}
return Promise.resolve({ email: null });
}
);
}
async function createRoutingFormWithResponse({
formData,
}: {
formData: {
routes: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue?: any;
action: {
eventTypeId: number;
type: RouteActionType;
value: string;
};
attributeRoutingConfig: {
skipContactOwner: boolean;
};
}[];
};
}) {
const user = await prismock.user.create({
data: {
name: "Routing Form User",
username: "routing-form-user",
email: "routing-form-user@example.com",
},
});
const form = await prismock.app_RoutingForms_Form.create({
data: {
...formData,
name: "Test Form",
routes: formData.routes.map((route) => ({
queryValue: {
id: v4(),
type: "group",
children1: [],
properties: {},
},
id: v4(),
...route,
})),
user: {
connect: {
id: user.id,
},
},
},
});
const responseRecord = await prismock.app_RoutingForms_FormResponse.create({
data: {
response: {},
form: {
connect: {
id: form.id,
},
},
},
});
return { form, responseRecord };
}
async function createHostForEvent({
eventTypeData,
userData,
}: {
eventTypeData: { id: number } & Partial<{
title: string;
slug: string;
length: number;
}>;
userData: {
email: string;
} & Partial<{
name: string;
username: string;
}>;
}) {
const user = await prismock.user.create({
data: {
name: "Test User",
username: "testuser",
...userData,
},
});
await prismock.eventType.create({
data: {
title: "Title",
slug: "slug",
length: 30,
...eventTypeData,
hosts: {
create: {
userId: user.id,
isFixed: true,
},
},
},
});
}
describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
beforeEach(() => {
vi.resetAllMocks();
});
const mockEventData = {
id: 1,
isInstantEvent: false,
schedulingType: SchedulingType.ROUND_ROBIN,
metadata: null,
length: 30,
};
it("should return null when email is not provided in query", async () => {
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {},
eventData: mockEventData,
});
expect(result).toBeNull();
});
it("should return null when scheduling type is not ROUND_ROBIN", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "teamMember@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: { ...mockEventData, schedulingType: SchedulingType.COLLECTIVE },
});
expect(result).toBeNull();
});
it("should return CRM owner email when valid", async () => {
const ownerEmail = "owner@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: ownerEmail,
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: "booker@example.com" },
eventData: mockEventData,
});
expect(result).toBe(ownerEmail);
});
it("should return null when CRM owner is not found", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "teamMember@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: mockEventData,
});
expect(result).toBeNull();
});
it("should return null when CRM owner is not part of event type", async () => {
const ownerEmail = "owner@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: mockEventData,
});
expect(result).toBeNull();
});
describe("Booking form handler", () => {
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(teamMemberEmail);
});
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: true },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
// Because skipContactOwner is true, the booking form handler should return null
expect(result).toBe(null);
});
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
const SOME_OTHER_EVENT_ID = 200;
await createHostForEvent({
eventTypeData: {
id: SOME_OTHER_EVENT_ID,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(null);
});
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.somethingelse.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(null);
});
});
});
+22 -27
View File
@@ -21,6 +21,21 @@ interface EventData {
length: number;
}
async function findUserByEmailWhoIsAHostOfEventType({
email,
eventTypeId,
}: {
email: string;
eventTypeId: number;
}) {
return prisma.user.findFirst({
where: {
email,
hosts: { some: { eventTypeId } },
},
});
}
function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) {
const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]);
const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber;
@@ -99,16 +114,11 @@ function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise<string | null> {
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
if (!crmContactOwnerEmail) return null;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await prisma.user.findFirst({
where: {
email: crmContactOwnerEmail,
hosts: {
some: {
eventTypeId: eventData.id,
},
},
},
const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({
email: crmContactOwnerEmail,
eventTypeId: eventData.id,
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
@@ -138,7 +148,6 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[crmAppSlug];
if (!appHandler) return nullReturnValue;
const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
@@ -146,16 +155,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await prisma.user.findFirst({
where: {
email: userEmail,
hosts: {
some: {
eventTypeId: eventTypeId,
},
},
},
});
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
if (!userQuery) return nullReturnValue;
@@ -164,22 +164,21 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
async function getTeamMemberEmailForResponseOrContact({
bookerEmail,
eventTypeId,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
}: {
bookerEmail: string;
eventTypeId: number;
eventData: EventData;
routingFormResponseId?: number | null;
/**
* If provided, we won't go look for the route from DB.
*/
chosenRoute?: LocalRoute;
crmAppSlug: string;
crmAppSlug?: string;
}) {
const eventTypeId = eventData.id;
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const attributeRoutingConfigGetterData = routingFormResponseId
@@ -214,12 +213,10 @@ async function getTeamMemberEmailForResponseOrContact({
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventTypeId,
eventData,
chosenRoute,
}: {
query: ParsedUrlQuery;
eventTypeId: number;
eventData: EventData;
chosenRoute?: LocalRoute;
}) {
@@ -231,13 +228,11 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
if (!crmAppSlug) return null;
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
return await getTeamMemberEmailForResponseOrContact({
bookerEmail: query.email,
eventTypeId,
eventData,
routingFormResponseId,
chosenRoute,
@@ -114,7 +114,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventTypeId,
eventData,
}),
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.7.0",
"version": "4.7.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "تم الحجز بواسطة",
"routing_form_insights_created_at": "تم الإنشاء في",
"routing_form_insights_booking_status": "حالة الحجز",
"routing_form_insights_booking_at": "موعد الحجز",
"routing_form_insights_submitted_at": "تاريخ التقديم",
"access_denied": "تم رفض الوصول",
"salesforce_route_to_owner": "سيكون مالك جهة الاتصال هو مضيف Round Robin إذا كان متاحاً",
"salesforce_do_not_route_to_owner": "لن يتم فرض مالك جهة الاتصال (يمكن أن يظل مضيفاً إذا تطابق مع السمات ومعايير Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Rezervasiya edən",
"routing_form_insights_created_at": "Yaradılma tarixi",
"routing_form_insights_booking_status": "Rezervasiya statusu",
"routing_form_insights_booking_at": "Rezervasiya tarixi",
"routing_form_insights_submitted_at": "Göndərilmə tarixi",
"access_denied": "Giriş qadağandır",
"salesforce_route_to_owner": "Əlaqə sahibi mövcud olduqda Round Robin ev sahibi olacaq",
"salesforce_do_not_route_to_owner": "Əlaqə sahibi məcbur edilməyəcək (əgər atributlara və Round Robin meyarlarına uyğun gəlirsə, yenə də ev sahibi ola bilər)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Rezervoval/a",
"routing_form_insights_created_at": "Vytvořeno",
"routing_form_insights_booking_status": "Stav rezervace",
"routing_form_insights_booking_at": "Čas rezervace",
"routing_form_insights_submitted_at": "Čas odeslání",
"access_denied": "Přístup odepřen",
"salesforce_route_to_owner": "Vlastník kontaktu bude hostem Round Robin, pokud je k dispozici",
"salesforce_do_not_route_to_owner": "Vlastník kontaktu nebude nucen (stále může být hostem, pokud odpovídá atributům a kritériím Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Booket af",
"routing_form_insights_created_at": "Oprettet den",
"routing_form_insights_booking_status": "Bookingstatus",
"routing_form_insights_booking_at": "Booket den",
"routing_form_insights_submitted_at": "Indsendt den",
"access_denied": "Adgang nægtet",
"salesforce_route_to_owner": "Kontaktens ejer vil være Round Robin-vært, hvis tilgængelig",
"salesforce_do_not_route_to_owner": "Kontaktens ejer vil ikke blive tvunget (kan stadig være vært, hvis det matcher attributterne og Round Robin-kriterierne)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Gebucht von",
"routing_form_insights_created_at": "Erstellt am",
"routing_form_insights_booking_status": "Buchungsstatus",
"routing_form_insights_booking_at": "Gebucht am",
"routing_form_insights_submitted_at": "Eingereicht am",
"access_denied": "Zugriff verweigert",
"salesforce_route_to_owner": "Der Kontaktinhaber wird der Round Robin Host sein, falls verfügbar",
"salesforce_do_not_route_to_owner": "Der Kontaktinhaber wird nicht erzwungen (kann trotzdem Host sein, wenn die Attribute und Round Robin Kriterien übereinstimmen)",
@@ -2783,6 +2783,8 @@
"routing_form_insights_booked_by": "Booked by",
"routing_form_insights_created_at": "Created at",
"routing_form_insights_booking_status": "Booking Status",
"routing_form_insights_booking_at": "Booking At",
"routing_form_insights_submitted_at": "Submitted At",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Reservado por",
"routing_form_insights_created_at": "Creado el",
"routing_form_insights_booking_status": "Estado de la reserva",
"routing_form_insights_booking_at": "Reservado el",
"routing_form_insights_submitted_at": "Enviado el",
"access_denied": "Acceso denegado",
"salesforce_route_to_owner": "El propietario del contacto será el anfitrión de Round Robin si está disponible",
"salesforce_do_not_route_to_owner": "El propietario del contacto no será forzado (aún puede ser anfitrión si coincide con los atributos y criterios de Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Reservado por",
"routing_form_insights_created_at": "Creado el",
"routing_form_insights_booking_status": "Estado de la reserva",
"routing_form_insights_booking_at": "Fecha de reserva",
"routing_form_insights_submitted_at": "Fecha de envío",
"access_denied": "Acceso denegado",
"salesforce_route_to_owner": "El propietario del contacto será el anfitrión de Round Robin si está disponible",
"salesforce_do_not_route_to_owner": "No se forzará al propietario del contacto (aún puede ser anfitrión si coincide con los atributos y criterios de Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Broneeris",
"routing_form_insights_created_at": "Loodud",
"routing_form_insights_booking_status": "Broneeringu staatus",
"routing_form_insights_booking_at": "Broneeritud",
"routing_form_insights_submitted_at": "Esitatud",
"access_denied": "Juurdepääs keelatud",
"salesforce_route_to_owner": "Kontakti omanik on round robin host, kui saadaval",
"salesforce_do_not_route_to_owner": "Kontakti omanikku ei sunnita (võib siiski olla host, kui vastab atribuutidele ja round robin kriteeriumitele)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Réservé par",
"routing_form_insights_created_at": "Créé le",
"routing_form_insights_booking_status": "Statut de la réservation",
"routing_form_insights_booking_at": "Réservé le",
"routing_form_insights_submitted_at": "Soumis le",
"access_denied": "Accès refusé",
"salesforce_route_to_owner": "Le propriétaire du contact sera l'hôte Round Robin si disponible",
"salesforce_do_not_route_to_owner": "Le propriétaire du contact ne sera pas forcé (peut toujours être l'hôte si cela correspond aux attributs et aux critères Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "הוזמן על ידי",
"routing_form_insights_created_at": "נוצר ב-",
"routing_form_insights_booking_status": "סטטוס הזמנה",
"routing_form_insights_booking_at": "מועד ההזמנה",
"routing_form_insights_submitted_at": "מועד ההגשה",
"access_denied": "הגישה נדחתה",
"salesforce_route_to_owner": "בעל איש הקשר יהיה מארח ה-Round Robin אם זמין",
"salesforce_do_not_route_to_owner": "בעל איש הקשר לא יהיה מאולץ (עדיין יכול להיות מארח אם תואם את המאפיינים וקריטריוני ה-Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Foglalva általa",
"routing_form_insights_created_at": "Létrehozva",
"routing_form_insights_booking_status": "Foglalás állapota",
"routing_form_insights_booking_at": "Foglalás időpontja",
"routing_form_insights_submitted_at": "Beküldés időpontja",
"access_denied": "Hozzáférés megtagadva",
"salesforce_route_to_owner": "A kapcsolattartó tulajdonosa lesz a round robin házigazda, ha elérhető",
"salesforce_do_not_route_to_owner": "A kapcsolattartó tulajdonosa nem lesz kötelező (továbbra is lehet házigazda, ha megfelel a tulajdonságoknak és a round robin kritériumoknak)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Prenotato da",
"routing_form_insights_created_at": "Creato il",
"routing_form_insights_booking_status": "Stato della prenotazione",
"routing_form_insights_booking_at": "Data prenotazione",
"routing_form_insights_submitted_at": "Data invio",
"access_denied": "Accesso negato",
"salesforce_route_to_owner": "Il proprietario del contatto sarà l'host del Round Robin se disponibile",
"salesforce_do_not_route_to_owner": "Il proprietario del contatto non sarà obbligato (può comunque essere l'host se corrisponde agli attributi e ai criteri del Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "予約者",
"routing_form_insights_created_at": "作成日",
"routing_form_insights_booking_status": "予約状況",
"routing_form_insights_booking_at": "予約日時",
"routing_form_insights_submitted_at": "送信日時",
"access_denied": "アクセスが拒否されました",
"salesforce_route_to_owner": "可能な場合、取引先責任者の所有者がラウンドロビンのホストになります",
"salesforce_do_not_route_to_owner": "取引先責任者の所有者は強制されません(属性とラウンドロビンの基準に一致する場合はホストになる可能性があります)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "កក់ដោយ",
"routing_form_insights_created_at": "បង្កើតនៅ",
"routing_form_insights_booking_status": "ស្ថានភាពនៃការកក់",
"routing_form_insights_booking_at": "កាលបរិច្ឆេទកក់",
"routing_form_insights_submitted_at": "កាលបរិច្ឆេទដាក់ស្នើ",
"access_denied": "ការចូលប្រើត្រូវបានបដិសេធ",
"salesforce_route_to_owner": "ម្ចាស់ទំនាក់ទំនងនឹងជាម្ចាស់ Round Robin ប្រសិនបើមានស្រាប់។",
"salesforce_do_not_route_to_owner": "ម្ចាស់ទំនាក់ទំនងនឹងមិនត្រូវបានបង្ខំ (អាចនៅតែជាម្ចាស់ប្រសិនបើវាដូចគ្នាជាមួយលក្ខណៈ និងលក្ខខណ្ឌ Round Robin)។",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "예약자",
"routing_form_insights_created_at": "생성 시간",
"routing_form_insights_booking_status": "예약 상태",
"routing_form_insights_booking_at": "예약 시간",
"routing_form_insights_submitted_at": "제출 시간",
"access_denied": "접근 거부됨",
"salesforce_route_to_owner": "연락처 소유자가 가능하면 Round Robin 호스트가 됩니다.",
"salesforce_do_not_route_to_owner": "연락처 소유자가 강제되지 않습니다 (속성과 Round Robin 기준이 일치하면 여전히 호스트가 될 수 있습니다).",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Geboekt door",
"routing_form_insights_created_at": "Aangemaakt op",
"routing_form_insights_booking_status": "Boekingsstatus",
"routing_form_insights_booking_at": "Geboekt op",
"routing_form_insights_submitted_at": "Ingediend op",
"access_denied": "Toegang geweigerd",
"salesforce_route_to_owner": "Contacteigenaar zal de Round Robin host zijn indien beschikbaar",
"salesforce_do_not_route_to_owner": "Contacteigenaar wordt niet geforceerd (kan nog steeds host zijn als het overeenkomt met de attributen en Round Robin criteria)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Bestilt av",
"routing_form_insights_created_at": "Opprettet den",
"routing_form_insights_booking_status": "Bestillingsstatus",
"routing_form_insights_booking_at": "Booket",
"routing_form_insights_submitted_at": "Sendt inn",
"access_denied": "Tilgang nektet",
"salesforce_route_to_owner": "Kontaktens eier vil være Round Robin-vert hvis tilgjengelig",
"salesforce_do_not_route_to_owner": "Kontaktens eier vil ikke bli tvunget (kan fortsatt være vert hvis det samsvarer med attributtene og Round Robin-kriteriene)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Zarezerwowane przez",
"routing_form_insights_created_at": "Utworzono",
"routing_form_insights_booking_status": "Status rezerwacji",
"routing_form_insights_booking_at": "Data rezerwacji",
"routing_form_insights_submitted_at": "Data przesłania",
"access_denied": "Odmowa dostępu",
"salesforce_route_to_owner": "Właściciel kontaktu będzie hostem Round Robin, jeśli jest dostępny",
"salesforce_do_not_route_to_owner": "Właściciel kontaktu nie będzie wymuszony (może nadal być hostem, jeśli spełnia atrybuty i kryteria Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Agendado por",
"routing_form_insights_created_at": "Criado em",
"routing_form_insights_booking_status": "Status do Agendamento",
"routing_form_insights_booking_at": "Agendado em",
"routing_form_insights_submitted_at": "Enviado em",
"access_denied": "Acesso negado",
"salesforce_route_to_owner": "O proprietário do contato será o anfitrião do Round Robin se disponível",
"salesforce_do_not_route_to_owner": "O proprietário do contato não será forçado (ainda pode ser anfitrião se corresponder aos atributos e critérios do Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Agendado por",
"routing_form_insights_created_at": "Criado em",
"routing_form_insights_booking_status": "Status do agendamento",
"routing_form_insights_booking_at": "Agendado em",
"routing_form_insights_submitted_at": "Enviado em",
"access_denied": "Acesso negado",
"salesforce_route_to_owner": "O proprietário do contato será o anfitrião do Round Robin se disponível",
"salesforce_do_not_route_to_owner": "O proprietário do contato não será forçado (ainda pode ser anfitrião se corresponder aos atributos e critérios do Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Programat de",
"routing_form_insights_created_at": "Creat la",
"routing_form_insights_booking_status": "Status programare",
"routing_form_insights_booking_at": "Programat la",
"routing_form_insights_submitted_at": "Trimis la",
"access_denied": "Acces refuzat",
"salesforce_route_to_owner": "Proprietarul contactului va fi gazda Round Robin dacă este disponibil",
"salesforce_do_not_route_to_owner": "Proprietarul contactului nu va fi forțat (poate fi totuși gazdă dacă se potrivesc atributele și criteriile Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Rezervoval",
"routing_form_insights_created_at": "Vytvorené",
"routing_form_insights_booking_status": "Stav rezervácie",
"routing_form_insights_booking_at": "Čas rezervácie",
"routing_form_insights_submitted_at": "Čas odoslania",
"access_denied": "Prístup odmietnutý",
"salesforce_route_to_owner": "Vlastník kontaktu bude hostiteľom metódou Round Robin, ak je k dispozícii",
"salesforce_do_not_route_to_owner": "Vlastník kontaktu nebude nútený (môže byť hostiteľom, ak zodpovedá atribútom a kritériám Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Rezervisao/la",
"routing_form_insights_created_at": "Kreirano",
"routing_form_insights_booking_status": "Status rezervacije",
"routing_form_insights_booking_at": "Vreme rezervacije",
"routing_form_insights_submitted_at": "Vreme podnošenja",
"access_denied": "Pristup odbijen",
"salesforce_route_to_owner": "Vlasnik kontakta će biti Round Robin domaćin ako je dostupan",
"salesforce_do_not_route_to_owner": "Vlasnik kontakta neće biti primoran (može i dalje biti domaćin ako odgovara atributima i Round Robin kriterijumima)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Bokad av",
"routing_form_insights_created_at": "Skapad",
"routing_form_insights_booking_status": "Bokningsstatus",
"routing_form_insights_booking_at": "Bokad den",
"routing_form_insights_submitted_at": "Skickad den",
"access_denied": "Åtkomst nekad",
"salesforce_route_to_owner": "Kontaktägaren blir Round Robin-värd om tillgänglig",
"salesforce_do_not_route_to_owner": "Kontaktägaren kommer inte att tvingas (kan fortfarande vara värd om det matchar attributen och Round Robin-kriterierna)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Rezervasyon yapan",
"routing_form_insights_created_at": "Oluşturulma tarihi",
"routing_form_insights_booking_status": "Rezervasyon durumu",
"routing_form_insights_booking_at": "Rezervasyon tarihi",
"routing_form_insights_submitted_at": "Gönderilme tarihi",
"access_denied": "Erişim Reddedildi",
"salesforce_route_to_owner": "Mümkünse kişi sahibi round robin ev sahibi olacak",
"salesforce_do_not_route_to_owner": "Kişi sahibi zorlanmayacak (özellikler ve round robin kriterlerine uyuyorsa yine de ev sahibi olabilir)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Заброньовано",
"routing_form_insights_created_at": "Створено",
"routing_form_insights_booking_status": "Статус бронювання",
"routing_form_insights_booking_at": "Час бронювання",
"routing_form_insights_submitted_at": "Час подання",
"access_denied": "Доступ заборонено",
"salesforce_route_to_owner": "Власник контакту буде ведучим Round Robin, якщо доступний",
"salesforce_do_not_route_to_owner": "Власник контакту не буде примусово призначений (може бути ведучим, якщо відповідає атрибутам і критеріям Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "Được đặt bởi",
"routing_form_insights_created_at": "Được tạo vào",
"routing_form_insights_booking_status": "Trạng thái đặt chỗ",
"routing_form_insights_booking_at": "Thời gian đặt lịch",
"routing_form_insights_submitted_at": "Thời gian gửi",
"access_denied": "Từ chối truy cập",
"salesforce_route_to_owner": "Người sở hữu liên hệ sẽ là người chủ trì Round Robin nếu khả dụng",
"salesforce_do_not_route_to_owner": "Người sở hữu liên hệ sẽ không bị bắt buộc (vẫn có thể là người chủ trì nếu phù hợp với các thuộc tính và tiêu chí Round Robin)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "预订者",
"routing_form_insights_created_at": "创建于",
"routing_form_insights_booking_status": "预订状态",
"routing_form_insights_booking_at": "预约时间",
"routing_form_insights_submitted_at": "提交时间",
"access_denied": "访问被拒绝",
"salesforce_route_to_owner": "如果联系人所有者可用,将作为轮询主持人",
"salesforce_do_not_route_to_owner": "不会强制指定联系人所有者(如果符合属性和轮询标准,仍可成为主持人)",
@@ -2779,6 +2779,8 @@
"routing_form_insights_booked_by": "預約者",
"routing_form_insights_created_at": "建立時間",
"routing_form_insights_booking_status": "預約狀態",
"routing_form_insights_booking_at": "預約時間",
"routing_form_insights_submitted_at": "提交時間",
"access_denied": "拒絕存取",
"salesforce_route_to_owner": "如果聯絡人負責人可用,將成為Round Robin主持人",
"salesforce_do_not_route_to_owner": "不會強制指定聯絡人負責人(如果符合屬性和Round Robin條件,仍可成為主持人)",
@@ -6,7 +6,7 @@ To test API in your local instance, you have the following pre-requisites:
<Steps>
<Step title="Clone the cal.com repository">
Please close the cal.com repository. You can do it by following the instructions provided in [Installation](/developing/local-development)
Please clone the cal.com repository. You can do it by following the instructions provided in [Installation](/developing/local-development)
</Step>
<Step title="Add license key in .env">
Add a staging license key that goes in as value for `CALCOM_LICENSE_KEY` in your root `.env` file.
+2
View File
@@ -2781,6 +2781,8 @@ checksums:
routing_form_insights_booked_by: 7574cbcc0dd68a6469b6715452e035cc
routing_form_insights_created_at: f98a3c3bc73494df9bbc14e4e47a018c
routing_form_insights_booking_status: 08b945804d902b55184884401a840d57
routing_form_insights_booking_at: ca8a804eb7068845bede9adbb6bb7d3b
routing_form_insights_submitted_at: b95063c78796a32900c35743237512c4
access_denied: 589456a5c4103e29c44e9df16a4eaf82
salesforce_route_to_owner: 39a88a7afb1c1e8fb9215e27ae7a7033
salesforce_do_not_route_to_owner: bcae6e5615c17b809c2334a541f75e78
@@ -1,4 +1,3 @@
import { BarList } from "@tremor/react";
import { useState } from "react";
import { classNames } from "@calcom/lib";
@@ -6,6 +5,7 @@ import { trpc } from "@calcom/trpc";
import { ToggleGroup } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { BarList } from "./tremor/BarList";
interface FormCardProps {
formName: string;
@@ -27,7 +27,7 @@ function FormCard({ formName, fields }: FormCardProps) {
}));
return (
<div className="border-subtle h-[400px] w-full rounded-md border">
<div className="border-subtle w-full rounded-md border">
<div className="flex flex-col">
<div className="bg-subtle border-subtle flex h-12 items-center border-b">
<h3 className="text-default px-2 text-left align-middle font-medium">{formName}</h3>
@@ -40,7 +40,7 @@ function FormCard({ formName, fields }: FormCardProps) {
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-full overflow-y-auto">
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
@@ -17,7 +17,22 @@ import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import { DataTable, useFetchMoreOnBottomReached, Badge, Avatar, Icon } from "@calcom/ui";
import {
DataTable,
useFetchMoreOnBottomReached,
Badge,
Avatar,
Icon,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@calcom/ui";
import type { BadgeProps } from "@calcom/ui/components/badge/Badge";
import { useFilterContext } from "../context/provider";
@@ -36,7 +51,7 @@ function CellWithOverflowX({ children, className }: { children: React.ReactNode;
return (
<div className={classNames("group relative max-w-[200px]", className)}>
<div
className="no-scrollbar overflow-x-auto whitespace-nowrap"
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
ref={(el) => {
// Only add shadow if the scroll width is greater than 200px
if (!el) return;
@@ -51,7 +66,7 @@ function CellWithOverflowX({ children, className }: { children: React.ReactNode;
}}>
{children}
</div>
<div className="absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l from-white to-transparent group-hover:from-gray-100" />
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
</div>
);
}
@@ -92,7 +107,7 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
if (value.length === 0) return <div className="h-6 w-[200px]" />;
return (
<div className="flex w-[200px] flex-wrap gap-1">
<CellWithOverflowX className="flex w-[200px] gap-1">
{value.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
@@ -100,18 +115,20 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
{v}
</Badge>
))}
<div className="group/badge relative">
<Badge variant="gray">+{value.length - 2}</Badge>
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/badge:visible group-hover/badge:translate-y-0 group-hover/badge:opacity-100">
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{value.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{value.slice(2).map((v: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-sm text-gray-600">
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{v}
</span>
))}
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</>
) : (
value.map((v: string, i: number) => (
@@ -120,7 +137,7 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
</Badge>
))
)}
</div>
</CellWithOverflowX>
);
}
@@ -158,12 +175,16 @@ function BookingStatusCell({
}
return (
<div className="group/booking_status relative flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<Link href={`/booking/${booking.uid}`}>
<Badge variant={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/booking_status:visible group-hover/booking_status:translate-y-0 group-hover/booking_status:opacity-100 ">
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<Link href={`/booking/${booking.uid}`}>
<Badge variant={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
@@ -188,11 +209,13 @@ function BookingStatusCell({
<Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>
</div>
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable() {
const { t } = useLocale();
const { filter } = useFilterContext();
@@ -212,16 +235,17 @@ export function RoutingFormResponsesTable() {
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const { data: headers } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const { data: headers, isLoading: isHeadersLoading } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const { data, fetchNextPage, isFetching, hasNextPage } =
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
@@ -251,6 +275,7 @@ export function RoutingFormResponsesTable() {
const totalFetched = flatData.length;
const processedData = useMemo(() => {
if (isHeadersLoading) return [];
return flatData.map((response) => {
const row: RoutingFormTableRow = {
id: response.id,
@@ -283,7 +308,7 @@ export function RoutingFormResponsesTable() {
return row;
});
}, [flatData, headers]);
}, [flatData, headers, isHeadersLoading]);
const columnHelper = createColumnHelper<RoutingFormTableRow>();
@@ -298,6 +323,7 @@ export function RoutingFormResponsesTable() {
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
},
}),
...(headers?.map((header) => {
return columnHelper.accessor(header.id, {
id: header.id,
@@ -314,10 +340,9 @@ export function RoutingFormResponsesTable() {
},
});
}) ?? []),
columnHelper.accessor("routedToBooking", {
id: "bookingStatus",
header: t("routing_form_insights_booking_status"),
header: t("routing_form_insights_booking_at"),
size: 250,
cell: (info) => (
<div className="max-w-[250px]">
@@ -330,6 +355,14 @@ export function RoutingFormResponsesTable() {
</div>
),
}),
columnHelper.accessor("createdAt", {
id: "submittedAt",
header: t("routing_form_insights_submitted_at"),
size: 250,
cell: (info) => (
<div className="max-w-[250px]">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</div>
),
}),
],
[headers, t, copyToClipboard]
);
@@ -353,6 +386,52 @@ export function RoutingFormResponsesTable() {
totalDBRowCount
);
if (isHeadersLoading || (isFetching && !data)) {
return (
<div
className="grid h-[75dvh]"
style={{ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'" }}>
<div
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
style={{ gridArea: "body" }}>
<Table>
<TableHeader className="bg-subtle sticky top-0 z-10">
<TableRow>
{[...Array(4)].map((_, index) => (
<TableHead key={`skeleton-header-${index}`}>
<div className="bg-subtle h-4 w-[200px] animate-pulse rounded-md" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{[...Array(10)].map((_, rowIndex) => (
<TableRow key={`skeleton-row-${rowIndex}`}>
{[...Array(4)].map((_, colIndex) => (
<TableCell key={`skeleton-cell-${rowIndex}-${colIndex}`}>
<div
className={classNames(
"bg-subtle h-6 animate-pulse rounded-md",
colIndex === 0
? "w-[200px]"
: colIndex === 2
? "w-[250px]"
: colIndex === 3
? "w-[250px]"
: "w-[200px]"
)}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
return (
<div className="flex-1">
<DataTable
@@ -51,17 +51,8 @@ export const RoutingKPICards = () => {
const categories: {
title: string;
index:
| "created"
| "active"
| "total_responses"
| "total_responses_without_booking"
| "total_responses_with_booking";
index: "active" | "total_responses" | "total_responses_without_booking" | "total_responses_with_booking";
}[] = [
{
title: t("routing_forms_created"),
index: "created",
},
{
title: t("routing_forms_total_responses"),
index: "total_responses",
@@ -84,7 +75,7 @@ export const RoutingKPICards = () => {
return (
<>
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<Text className="text-default">{item.title}</Text>
@@ -9,5 +9,5 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
export { RoutingFormResponsesTable } from "./RoutingFormResponsesTable";
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
export { FailedBookingsByField } from "./FailedBookingsByField";
@@ -0,0 +1,165 @@
// Tremor BarList [v0.1.1]
import React from "react";
import { classNames } from "@calcom/lib";
type Bar<T> = T & {
key?: string;
href?: string;
value: number;
name: string;
};
const focusRing = [
// base
"outline outline-offset-2 outline-0 focus-visible:outline-2",
// outline color
"outline-brand",
];
interface BarListProps<T = unknown> extends React.HTMLAttributes<HTMLDivElement> {
data: Bar<T>[];
valueFormatter?: (value: number) => string;
showAnimation?: boolean;
onValueChange?: (payload: Bar<T>) => void;
sortOrder?: "ascending" | "descending" | "none";
}
function BarListInner<T>(
{
data = [],
valueFormatter = (value) => value.toString(),
showAnimation = false,
onValueChange,
sortOrder = "descending",
className,
...props
}: BarListProps<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>
) {
const Component = onValueChange ? "button" : "div";
const sortedData = React.useMemo(() => {
if (sortOrder === "none") {
return data;
}
return [...data].sort((a, b) => {
return sortOrder === "ascending" ? a.value - b.value : b.value - a.value;
});
}, [data, sortOrder]);
const widths = React.useMemo(() => {
const maxValue = Math.max(...sortedData.map((item) => item.value), 0);
return sortedData.map((item) => (item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2)));
}, [sortedData]);
const rowHeight = "h-8";
return (
<div
ref={forwardedRef}
className={classNames("flex justify-between space-x-6", className)}
aria-sort={sortOrder}
tremor-id="tremor-raw"
{...props}>
<div className="relative w-full space-y-1.5">
{sortedData.map((item, index) => (
<Component
key={item.key ?? item.name}
onClick={() => {
onValueChange?.(item);
}}
className={classNames(
// base
"group w-full rounded",
// focus
focusRing,
onValueChange
? [
"!-m-0 cursor-pointer",
// hover
"hover:bg-gray-50 hover:dark:bg-gray-900",
]
: ""
)}>
<div
className={classNames(
// base
"flex items-center rounded transition-all",
rowHeight,
// background color
"bg-blue-200 dark:bg-blue-900",
onValueChange ? "group-hover:bg-blue-300 group-hover:dark:bg-blue-800" : "",
// margin and duration
{
"mb-0": index === sortedData.length - 1,
"duration-800": showAnimation,
}
)}
style={{ width: `${widths[index]}%` }}>
<div className={classNames("absolute left-2 flex max-w-full pr-2")}>
{item.href ? (
<a
href={item.href}
className={classNames(
// base
"truncate whitespace-nowrap rounded text-sm",
// text color
"text-default",
// hover
"hover:underline hover:underline-offset-2",
// focus
focusRing
)}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}>
{item.name}
</a>
) : (
<p
className={classNames(
// base
"truncate whitespace-nowrap text-sm",
// text color
"text-default"
)}>
{item.name}
</p>
)}
</div>
</div>
</Component>
))}
</div>
<div>
{sortedData.map((item, index) => (
<div
key={item.key ?? item.name}
className={classNames(
"flex items-center justify-end",
rowHeight,
index === sortedData.length - 1 ? "mb-0" : "mb-1.5"
)}>
<p
className={classNames(
// base
"truncate whitespace-nowrap text-sm leading-none",
// text color
"text-default"
)}>
{valueFormatter(item.value)}
</p>
</div>
))}
</div>
</div>
);
}
BarListInner.displayName = "BarList";
const BarList = React.forwardRef(BarListInner) as <T>(
p: BarListProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => ReturnType<typeof BarListInner>;
export { BarList, type BarListProps };
@@ -108,9 +108,9 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-50 w-44">
<DropdownMenuContent className="z-50">
{filterOptions?.map((option) => (
<DropdownMenuItem key={option.label} className="w-44">
<DropdownMenuItem key={option.label} className="w-full">
<DropdownItem
type="button"
StartIcon={option.StartIcon}
@@ -124,9 +124,11 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
});
}}
childrenClassName="w-full">
<div className="flex w-full items-center justify-between">
{t(option.label)}
{selectedFilter?.includes(option.value) && <Icon name="check" className="h-4 w-4" />}
<div className="flex w-full items-center justify-between whitespace-normal">
<span className="mr-2">{t(option.label)}</span>
{selectedFilter?.includes(option.value) && (
<Icon name="check" className="h-4 w-4 flex-shrink-0" />
)}
</div>
</DropdownItem>
</DropdownMenuItem>
@@ -376,7 +376,13 @@ export class ProfileRepository {
},
},
members: {
distinct: ["role"],
select: membershipSelect,
where: {
accepted: true,
// Filter out memberships that are not owned by the user
user: { profiles: { some: { id } } },
},
},
},
},
+6 -13
View File
@@ -664,25 +664,18 @@ export class UserRepository {
return !!teams.length;
}
static async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) {
const team = await prisma.team.findUnique({
const isAdminOrOwnerOfTeam = await prisma.membership.findFirst({
where: {
id: teamId,
AND: [
{
members: {
some: {
userId,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
},
},
],
userId,
teamId,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
accepted: true,
},
select: {
id: true,
},
});
return !!team;
return !!isAdminOrOwnerOfTeam;
}
static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await prisma.user.findUnique({
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "EventType_profileId_idx" ON "EventType"("profileId");
+1
View File
@@ -163,6 +163,7 @@ model EventType {
@@unique([userId, parentId])
@@index([userId])
@@index([teamId])
@@index([profileId])
@@index([scheduleId])
@@index([secondaryEmailId])
@@index([parentId])
@@ -102,23 +102,16 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
const locale = user?.locale ?? ctx.locale;
const isOrgAdmin = !!user.profile?.organization?.members.filter(
(member) => (member.role === "ADMIN" || member.role === "OWNER") && member.userId === user.id
).length;
const { members = [], ..._organization } = user.profile?.organization || {};
const isOrgAdmin = members.some((member) => ["OWNER", "ADMIN"].includes(member.role));
if (isOrgAdmin) {
logger.debug("User is an org admin", safeStringify({ userId: user.id }));
} else {
logger.debug("User is not an org admin", safeStringify({ userId: user.id }));
}
// Want to reduce the amount of data being sent
if (isOrgAdmin && user.profile?.organization?.members) {
user.profile.organization.members = [];
}
const organization = {
...user.profile?.organization,
..._organization,
id: user.profile?.organization?.id ?? null,
isOrgAdmin,
metadata: orgMetadata,
@@ -214,7 +214,6 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
const timeBeforeCrm = performance.now();
const contactOwnerEmail = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: fromEntriesWithDuplicateKeys(urlSearchParamsToForward.entries()),
eventTypeId: eventType.id,
eventData: eventType,
chosenRoute: route,
});
@@ -41,6 +41,9 @@ function ColumnVisibilityButtonComponent<TData>(
const { t } = useLocale();
const allColumns = table.getAllLeafColumns();
const [open, setOpen] = useState(false);
const [visibleColumns, setVisibleColumns] = useState(
() => new Set(allColumns.filter((col) => col.getIsVisible()).map((col) => col.id))
);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -59,15 +62,28 @@ function ColumnVisibilityButtonComponent<TData>(
const canHide = column.getCanHide();
if (!column.columnDef.header || typeof column.columnDef.header !== "string" || !canHide)
return null;
const isVisible = column.getIsVisible();
const isVisible = visibleColumns.has(column.id);
return (
<CommandItem key={column.id} onSelect={() => column.toggleVisibility(!isVisible)}>
<CommandItem
key={column.id}
onSelect={() => {
column.toggleVisibility(!isVisible);
setVisibleColumns((prev) => {
const next = new Set(prev);
if (isVisible) {
next.delete(column.id);
} else {
next.add(column.id);
}
return next;
});
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isVisible ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
<Icon name="check" className={classNames("h-4 w-4", !isVisible && "invisible")} />
</div>
{column.columnDef.header}
</CommandItem>
@@ -78,7 +94,10 @@ function ColumnVisibilityButtonComponent<TData>(
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => allColumns.forEach((column) => column.toggleVisibility(true))}
onSelect={() => {
allColumns.forEach((column) => column.toggleVisibility(true));
setVisibleColumns(new Set(allColumns.map((col) => col.id)));
}}
className={classNames(
"w-full justify-center text-center",
buttonClasses({ color: "secondary" })
+1 -1
View File
@@ -39,7 +39,7 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
{...props}
sideOffset={sideOffset}
className={classNames(
"shadow-dropdown w-50 bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
"shadow-dropdown bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
"[&>*:first-child]:mt-1 [&>*:last-child]:mb-1",
props.className
)}
@@ -0,0 +1,29 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import * as React from "react";
import { classNames } from "@calcom/lib";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={classNames(
"bg-default text-default data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-subtle z-50 w-64 rounded-md border p-2 shadow-sm outline-none",
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+2
View File
@@ -170,6 +170,8 @@ export {
CommandShortcut,
} from "./components/command";
export { HoverCard, HoverCardTrigger, HoverCardContent } from "./components/hover-card";
export { OrgBanner } from "./components/organization-banner";
export type { OrgBannerProps } from "./components/organization-banner";