Compare commits

...
13 Commits
Author SHA1 Message Date
Alex van Andel 99fe67b879 v2.7.1
release.yml / release (push) Canceled after 0s
2023-03-16 15:24:19 +00:00
Alex van AndelandKeith Williams 2685e8154f Hotfix/account provider should be lowercase (#7763)
* Fix passThrough console error (passHref is not valid)

* Fix 'use-identity-login' error when logging in with idP

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-03-16 15:12:45 +00:00
4308ea8d52 Fix Google Calendar sharing guest information logic (#7323)
* Fix logic

* Update packages/app-store/googlecalendar/lib/CalendarService.ts

Co-authored-by: alannnc <alannnc@gmail.com>

* Fixed linting

* Add seats logic to reschedule

---------

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-03-16 14:50:35 +00:00
Alex van AndelandPeer Richelsen 358aa8be2b Prevent refresh_token nulling if the returned refresh token is empty (#7764)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-16 14:04:30 +00:00
Alex van Andel b20c0df4e8 Remove 'userIsOwner' from isCancelled (#7773) 2023-03-16 13:29:16 +00:00
Nafees NazikandPeer Richelsen 09af14b3f4 fix: badge component line-height according to the DS (#7766)
* fix: badge line-height

* fix: revert padding top and bottom

* fix: remove large prop

* fix: remove large prop

* feat: make md default

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-16 11:51:40 +00:00
77df4117d0 refactor: event types kbar action (#7423)
* refactor: event types action

* chore : wrap actions inside useMemo

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-16 09:49:12 +00:00
Nafees NazikandHariom Balhara 84f7924215 Hotfix: guest are not added to the invite and emails (#7767)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-03-16 14:34:17 +05:30
c8554e6878 [CAL-203] App Store: Add <iframe> as an option in the slider (#7324)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-16 05:30:11 +00:00
Hariom BalharaandAlex van Andel 5993e0842c Fix: EventType crash when Moving up and down beyond boundaries (#7765)
* Make sure that moving up and down doesnt go beyond boundary

* Update packages/features/form-builder/FormBuilder.tsx

Co-authored-by: Alex van Andel <me@alexvanandel.com>

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-16 05:25:37 +00:00
Hariom Balhara 7179b09d7b Allow rescheduleReason to be marked required (#7729) 2023-03-16 10:40:20 +05:30
Nafees Nazik 325dc9805f fix: reschedule reason title in email (#7746)
* fix: reschedule reason title

* fix: remove aditional check
2023-03-16 03:21:59 +00:00
Alex van Andel 0f6a678b74 Disable PRISMA_GENERATE_DATAPROXY 2023-03-16 01:07:02 +00:00
28 changed files with 594 additions and 513 deletions

No files matched your search

+6 -3
View File
@@ -22,10 +22,13 @@ CALCOM_LICENSE_KEY=
# - DATABASE ************************************************************************************************ # - DATABASE ************************************************************************************************
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
# Opt-in to a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you will be able to scale your DB independently of your application. # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy # @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
PRISMA_GENERATE_DATAPROXY=false # PRISMA_GENERATE_DATAPROXY=true
PRISMA_GENERATE_DATAPROXY=
# *********************************************************************************************************** # ***********************************************************************************************************
# - SHARED ************************************************************************************************** # - SHARED **************************************************************************************************
+23 -14
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import type { IframeHTMLAttributes } from "react";
import React, { useState } from "react"; import React, { useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
@@ -45,12 +46,12 @@ const Component = ({
tos, tos,
privacy, privacy,
isProOnly, isProOnly,
images, descriptionItems,
isTemplate, isTemplate,
dependencies, dependencies,
}: Parameters<typeof App>[0]) => { }: Parameters<typeof App>[0]) => {
const { t } = useLocale(); const { t } = useLocale();
const hasImages = images && images.length > 0; const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
const router = useRouter(); const router = useRouter();
const mutation = useAddAppMutation(null, { const mutation = useAddAppMutation(null, {
@@ -94,17 +95,25 @@ const Component = ({
return ( return (
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0"> <div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
{hasImages && ( {hasDescriptionItems && (
<div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md"> <div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
{images ? ( {descriptionItems ? (
images.map((img) => ( descriptionItems.map((descriptionItem, index) =>
<img typeof descriptionItem === "object" ? (
key={img} <div
src={img} key={`iframe-${index}`}
alt={`Screenshot of app ${name}`} className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full" <iframe allowFullScreen {...descriptionItem.iframe} />
/> </div>
)) ) : (
<img
key={descriptionItem}
src={descriptionItem}
alt={`Screenshot of app ${name}`}
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
/>
)
)
) : ( ) : (
<SkeletonText /> <SkeletonText />
)} )}
@@ -113,7 +122,7 @@ const Component = ({
<div <div
className={classNames( className={classNames(
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0", "sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
hasImages && "lg:ml-8" hasDescriptionItems && "lg:ml-8"
)}> )}>
<div className="mb-8 flex pt-4"> <div className="mb-8 flex pt-4">
<header> <header>
@@ -354,7 +363,7 @@ export default function App(props: {
privacy?: string; privacy?: string;
licenseRequired: AppType["licenseRequired"]; licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"]; isProOnly: AppType["isProOnly"];
images?: string[]; descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
isTemplate?: boolean; isTemplate?: boolean;
disableInstall?: boolean; disableInstall?: boolean;
dependencies?: string[]; dependencies?: string[];
@@ -135,7 +135,6 @@ const BookingDescription: FC<Props> = (props) => {
<li key={idx}> <li key={idx}>
<Badge <Badge
variant="gray" variant="gray"
size="lg"
className={classNames( className={classNames(
duration === dur.toString() duration === dur.toString()
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast" ? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
@@ -78,6 +78,7 @@ const BookingFields = ({
const { t } = useLocale(); const { t } = useLocale();
const { watch, setValue } = useFormContext(); const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location"); const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
return ( return (
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view // TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
@@ -87,12 +88,16 @@ const BookingFields = ({
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful // Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly = let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid; (field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
let noLabel = false; let noLabel = false;
let hidden = !!field.hidden; let hidden = !!field.hidden;
const fieldViews = field.views;
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
return null;
}
if (field.name === SystemField.Enum.rescheduleReason) { if (field.name === SystemField.Enum.rescheduleReason) {
if (!rescheduleUid) {
return null;
}
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule // rescheduleReason is a reschedule specific field and thus should be editable during reschedule
readOnly = false; readOnly = false;
} }
@@ -292,7 +297,10 @@ const BookingPage = ({
useTheme(profile.theme); useTheme(profile.theme);
const date = asStringOrNull(router.query.date); const date = asStringOrNull(router.query.date);
const querySchema = getBookingResponsesPartialSchema({ const querySchema = getBookingResponsesPartialSchema({
bookingFields: getBookingFieldsWithSystemFields(eventType), eventType: {
bookingFields: getBookingFieldsWithSystemFields(eventType),
},
view: rescheduleUid ? "reschedule" : "booking",
}); });
const parsedQuery = querySchema.parse({ const parsedQuery = querySchema.parse({
@@ -372,7 +380,8 @@ const BookingPage = ({
const bookingFormSchema = z const bookingFormSchema = z
.object({ .object({
responses: getBookingResponsesSchema({ responses: getBookingResponsesSchema({
bookingFields: getBookingFieldsWithSystemFields(eventType), eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
view: rescheduleUid ? "reschedule" : "booking",
}), }),
}) })
.passthrough(); .passthrough();
+5 -1
View File
@@ -112,7 +112,11 @@ export const getBookingWithResponses = <
return { return {
...booking, ...booking,
responses: getBookingResponsesPartialSchema({ responses: getBookingResponsesPartialSchema({
bookingFields: eventType.bookingFields, eventType: {
bookingFields: eventType.bookingFields,
},
// An existing booking can have data from any number of views, so the schema should consider ALL_VIEWS
view: "ALL_VIEWS",
}).parse(booking.responses || getResponsesFromOldBooking(booking)), }).parse(booking.responses || getResponsesFromOldBooking(booking)),
}; };
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "2.7.0", "version": "2.7.1",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
+23 -4
View File
@@ -3,6 +3,7 @@ import matter from "gray-matter";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import type { GetStaticPaths, GetStaticPropsContext } from "next"; import type { GetStaticPaths, GetStaticPropsContext } from "next";
import path from "path"; import path from "path";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -13,6 +14,23 @@ import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true }); const md = new MarkdownIt("default", { html: true, breaks: true });
const sourceSchema = z.object({
content: z.string(),
data: z.object({
description: z.string().optional(),
items: z
.array(
z.union([
z.string(),
z.object({
iframe: z.object({ src: z.string() }),
}),
])
)
.optional(),
}),
});
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) { function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
return ( return (
<App <App
@@ -33,7 +51,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
email={data.email} email={data.email}
licenseRequired={data.licenseRequired} licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly} isProOnly={data.isProOnly}
images={source.data?.items as string[] | undefined} descriptionItems={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate} isTemplate={data.isTemplate}
dependencies={data.dependencies} dependencies={data.dependencies}
// tos="https://zoom.us/terms" // tos="https://zoom.us/terms"
@@ -85,10 +103,11 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
source = singleApp.description; source = singleApp.description;
} }
const { content, data } = matter(source); const result = matter(source);
const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
if (data.items) { if (data.items) {
data.items = data.items.map((item: string) => { data.items = data.items.map((item) => {
if (!item.includes("/api/app-store")) { if (typeof item === "string" && !item.includes("/api/app-store")) {
// Make relative paths absolute // Make relative paths absolute
return `/api/app-store/${appDirname}/${item}`; return `/api/app-store/${appDirname}/${item}`;
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export default function Logout(props: Props) {
</div> </div>
</div> </div>
</div> </div>
<Button href="/auth/login" passHref className="flex w-full justify-center"> <Button href="/auth/login" className="flex w-full justify-center">
{t("go_back_login")} {t("go_back_login")}
</Button> </Button>
</AuthContainer> </AuthContainer>
+3 -5
View File
@@ -272,10 +272,8 @@ export default function Success(props: SuccessProps) {
const isCancelled = const isCancelled =
status === "CANCELLED" || status === "CANCELLED" ||
status === "REJECTED" || status === "REJECTED" ||
(isCancellationMode && (!!seatReferenceUid &&
(!!seatReferenceUid !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid));
? !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid)
: !userIsOwner));
const telemetry = useTelemetry(); const telemetry = useTelemetry();
useEffect(() => { useEffect(() => {
@@ -606,7 +604,7 @@ export default function Success(props: SuccessProps) {
{!props.recurringBookings && ( {!props.recurringBookings && (
<span className="text-bookinglight inline text-gray-700"> <span className="text-bookinglight inline text-gray-700">
<span className="underline"> <span className="underline" data-testid="reschedule-link">
<Link <Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`} href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
legacyBehavior> legacyBehavior>
+1 -5
View File
@@ -351,11 +351,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
/> />
)} )}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse"> <div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{type.hidden && ( {type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Badge variant="gray" size="lg">
{t("hidden")}
</Badge>
)}
<Tooltip content={t("show_eventtype_on_profile")}> <Tooltip content={t("show_eventtype_on_profile")}>
<div className="self-center rounded-md p-2 hover:bg-gray-200"> <div className="self-center rounded-md p-2 hover:bg-gray-200">
<Switch <Switch
+11 -9
View File
@@ -21,7 +21,8 @@ const PasswordView = () => {
const { t } = useLocale(); const { t } = useLocale();
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery(); const { data: user } = trpc.viewer.me.useQuery();
const metadata = userMetadata.parse(user?.metadata); const metadata = userMetadata.safeParse(user?.metadata);
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const sessionMutation = trpc.viewer.updateProfile.useMutation({ const sessionMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -36,10 +37,10 @@ const PasswordView = () => {
const previousValue = utils.viewer.me.getData(); const previousValue = utils.viewer.me.getData();
const previousMetadata = userMetadata.parse(previousValue?.metadata); const previousMetadata = userMetadata.parse(previousValue?.metadata);
if (previousValue && metadata?.sessionTimeout) { if (previousValue && sessionTimeout) {
utils.viewer.me.setData(undefined, { utils.viewer.me.setData(undefined, {
...previousValue, ...previousValue,
metadata: { ...previousMetadata, sessionTimeout: metadata?.sessionTimeout }, metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
}); });
} }
return { previousValue }; return { previousValue };
@@ -81,19 +82,19 @@ const PasswordView = () => {
defaultValues: { defaultValues: {
oldPassword: "", oldPassword: "",
newPassword: "", newPassword: "",
sessionTimeout: metadata?.sessionTimeout, sessionTimeout,
}, },
}); });
const sessionTimeoutWatch = formMethods.watch("sessionTimeout"); const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
const handleSubmit = (values: ChangePasswordSessionFormValues) => { const handleSubmit = (values: ChangePasswordSessionFormValues) => {
const { oldPassword, newPassword, sessionTimeout } = values; const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
if (oldPassword && newPassword) { if (oldPassword && newPassword) {
passwordMutation.mutate({ oldPassword, newPassword }); passwordMutation.mutate({ oldPassword, newPassword });
} }
if (metadata?.sessionTimeout !== sessionTimeout) { if (sessionTimeout !== newSessionTimeout) {
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout } }); sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
} }
}; };
@@ -106,6 +107,7 @@ const PasswordView = () => {
const passwordMinLength = data?.user.role === "USER" ? 7 : 15; const passwordMinLength = data?.user.role === "USER" ? 7 : 15;
const isUser = data?.user.role === "USER"; const isUser = data?.user.role === "USER";
return ( return (
<> <>
<Meta title={t("password")} description={t("password_description")} /> <Meta title={t("password")} description={t("password_description")} />
@@ -176,8 +178,8 @@ const PasswordView = () => {
<Select <Select
options={timeoutOptions} options={timeoutOptions}
defaultValue={ defaultValue={
metadata?.sessionTimeout sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === metadata.sessionTimeout) ? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1] : timeoutOptions[1]
} }
isSearchable={false} isSearchable={false}
@@ -33,56 +33,108 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click(); await firstEventTypeElement.click();
}); });
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => { await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
await addQuestionAndSave({ });
page, });
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await doOnFreshPreview(page, context, async (page) => { test.describe("For Team EventType", () => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page); test("Do a booking with a user added question and verify a few thing in b/w", async ({
const userFieldLocator = allFieldsLocator.nth(5); page,
users,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible(); const webhookReceiver = await addWebhook(user);
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?"); await test.step("Go to First Team Event", async () => {
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible(); const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
}); const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
}); });
await test.step("Hide Question and see that it's not shown on Booking Page", async () => { await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
await toggleQuestionAndSave({ });
name: "how_are_you", });
page, });
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => { async function runTestStepsCommonForTeamAndUserEventType(
await toggleQuestionAndSave({ page: Page,
name: "how_are_you", context: PlaywrightTestArgs["context"],
page, webhookReceiver: {
}); port: number;
}); close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
) {
await page.click('[href$="tabName=advanced"]');
await test.step('Try to book without providing "How are you?" response', async () => { await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await doOnFreshPreview(page, context, async (page) => { await addQuestionAndSave({
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" }); page,
await expectErrorToBeThereFor({ page, name: "how_are_you" }); question: {
}); name: "how_are_you",
}); type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await test.step("Do a booking", async () => { await doOnFreshPreview(page, context, async (page) => {
await doOnFreshPreview(page, context, async (page) => { const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Make rescheduleReason required - It won't be required for a fresh booking", async () => {
await toggleQuestionRequireStatusAndSave({
required: true,
name: "rescheduleReason",
page,
});
});
const previewTabPage =
await test.step("Do a booking and notice that we can book without giving a value for rescheduleReason", async () => {
return await doOnFreshPreview(
page,
context,
async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]'); const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible(); await expect(formBuilderFieldLocator).toBeVisible();
expect( expect(
@@ -133,146 +185,16 @@ test.describe("Manage Booking Questions", () => {
value: "I am great!", value: "I am great!",
}, },
}); });
}); },
}); true
});
});
test.describe("For Team EventType", () => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
const webhookReceiver = await addWebhook(user);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
});
async function runTestStepsCommonForTeamAndUserEventType(
page: Page,
context: PlaywrightTestArgs["context"],
webhookReceiver: {
port: number;
close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
) {
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Do a booking", async () => {
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible();
expect(await formBuilderFieldLocator.locator('[name="how_are_you"]').getAttribute("placeholder")).toBe(
"I'm fine, thanks"
); );
expect(await formBuilderFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await formBuilderFieldLocator.locator('[name="how_are_you"]').fill("I am great!");
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
expect(
await page.locator('[data-testid="field-response"][data-fob-field="how_are_you"]').innerText()
).toBe("I am great!");
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const payload = (request.body as any).payload as any;
expect(payload.responses).toMatchObject({
email: {
label: "email_address",
value: "booker@example.com",
},
how_are_you: {
label: "How are you?",
value: "I am great!",
},
name: {
label: "your_name",
value: "Booker",
},
});
expect(payload.location).toBe("integrations:daily");
expect(payload.attendees[0]).toMatchObject({
name: "Booker",
email: "booker@example.com",
});
expect(payload.userFieldsResponses).toMatchObject({
how_are_you: {
label: "How are you?",
value: "I am great!",
},
});
}); });
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
const page = previewTabPage;
await rescheduleFromTheLinkOnPage({ page });
await page.pause();
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
}); });
} }
@@ -333,7 +255,6 @@ async function addQuestionAndSave({
required?: boolean; required?: boolean;
}; };
}) { }) {
await page.click('[href$="tabName=advanced"]');
await page.click('[data-testid="add-field"]'); await page.click('[data-testid="add-field"]');
if (question.type !== undefined) { if (question.type !== undefined) {
@@ -379,11 +300,15 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
async function doOnFreshPreview( async function doOnFreshPreview(
page: Page, page: Page,
context: PlaywrightTestArgs["context"], context: PlaywrightTestArgs["context"],
callback: (page: Page) => Promise<void> callback: (page: Page) => Promise<void>,
persistTab = false
) { ) {
const previewTabPage = await openBookingFormInPreviewTab(context, page); const previewTabPage = await openBookingFormInPreviewTab(context, page);
await callback(previewTabPage); await callback(previewTabPage);
await previewTabPage.close(); if (!persistTab) {
await previewTabPage.close();
}
return previewTabPage;
} }
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) { async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
@@ -391,6 +316,25 @@ async function toggleQuestionAndSave({ name, page }: { name: string; page: Page
await saveEventType(page); await saveEventType(page);
} }
async function toggleQuestionRequireStatusAndSave({
required,
name,
page,
}: {
required: boolean;
name: string;
page: Page;
}) {
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="edit-field-action"]').click();
await page
.locator('[data-testid="edit-field-dialog"]')
.locator('[data-testid="field-required"] button')
.locator(`text=${required ? "Yes" : "No"}`)
.click();
await page.locator('[data-testid="field-add-save"]').click();
await saveEventType(page);
}
async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) { async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) {
const user = await users.create(null, { const user = await users.create(null, {
hasTeam: true, hasTeam: true,
@@ -399,6 +343,16 @@ async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<t
return user; return user;
} }
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
await page.locator('[data-testid="reschedule-link"]').click();
await page.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
await page.click('[data-testid="confirm-reschedule-button"]');
}
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) { async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
const previewTabPromise = context.waitForEvent("page"); const previewTabPromise = context.waitForEvent("page");
await page.locator('[data-testid="preview-button"]').click(); await page.locator('[data-testid="preview-button"]').click();
@@ -103,7 +103,7 @@ export default class GoogleCalendarService implements Calendar {
reminders: { reminders: {
useDefault: true, useDefault: true,
}, },
guestsCanSeeOtherGuests: calEventRaw.seatsShowAttendees, guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true,
}; };
if (calEventRaw.location) { if (calEventRaw.location) {
@@ -193,7 +193,7 @@ export default class GoogleCalendarService implements Calendar {
reminders: { reminders: {
useDefault: true, useDefault: true,
}, },
guestsCanSeeOtherGuests: event.seatsShowAttendees, guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
}; };
if (event.location) { if (event.location) {
@@ -1,4 +1,5 @@
import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta"; import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta";
import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
@@ -38,6 +39,14 @@ interface IBatchResponse {
responses: ISettledResponse[]; responses: ISettledResponse[];
} }
const refreshTokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z
.number()
.transform((currentTimeOffsetInSeconds) => Math.round(+new Date() / 1000 + currentTimeOffsetInSeconds)),
refresh_token: z.string().optional(),
});
export default class Office365CalendarService implements Calendar { export default class Office365CalendarService implements Calendar {
private url = ""; private url = "";
private integrationName = ""; private integrationName = "";
@@ -217,14 +226,7 @@ export default class Office365CalendarService implements Calendar {
client_secret, client_secret,
}), }),
}); });
const responseBody = await handleErrorsJson<{ const o365AuthCredentials = refreshTokenResponseSchema.parse(await handleErrorsJson(response));
access_token: string;
expires_in: number;
refresh_token: string;
}>(response);
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.refresh_token = responseBody.refresh_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
await prisma.credential.update({ await prisma.credential.update({
where: { where: {
id: credential.id, id: credential.id,
@@ -1,6 +1,7 @@
--- ---
description: Adds a link to copy Typeform Redirect URL to integrate with Routing Forms. description: Adds a link to copy Typeform Redirect URL to integrate with Routing Forms.
items: items:
- iframe: { src: https://www.youtube.com/embed/vcV-N2gLPZc }
- /api/app-store/typeform/copy-typeform-redirect-url.png - /api/app-store/typeform/copy-typeform-redirect-url.png
- /api/app-store/typeform/how-it-looks-in-typeform.png - /api/app-store/typeform/how-it-looks-in-typeform.png
--- ---
@@ -60,7 +60,11 @@ export const BaseScheduledEmail = (
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}> subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
{props.calEvent.cancellationReason && ( {props.calEvent.cancellationReason && (
<Info <Info
label={t("cancellation_reason")} label={t(
props.calEvent.cancellationReason.startsWith("$RCH$")
? "reschedule_reason"
: "cancellation_reason"
)}
description={ description={
!!props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "") !!props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "")
} // Removing flag to distinguish reschedule from cancellation } // Removing flag to distinguish reschedule from cancellation
@@ -444,7 +444,7 @@ export const AUTH_OPTIONS: AuthOptions = {
identityProvider: idP, identityProvider: idP,
}, },
{ {
identityProviderId: account.providerAccountId as string, identityProviderId: account.providerAccountId,
}, },
], ],
}, },
@@ -523,11 +523,11 @@ export const AUTH_OPTIONS: AuthOptions = {
return "/auth/error?error=unverified-email"; return "/auth/error?error=unverified-email";
} }
const existingUser = await prisma.user.findFirst({ let existingUser = await prisma.user.findFirst({
include: { include: {
accounts: { accounts: {
where: { where: {
provider: idP, provider: account.provider,
}, },
}, },
}, },
@@ -537,6 +537,33 @@ export const AUTH_OPTIONS: AuthOptions = {
}, },
}); });
/* --- START FIX LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (!existingUser) {
existingUser = await prisma.user.findFirst({
include: {
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: idP,
identityProviderId: String(user.id),
},
});
if (existingUser) {
await prisma.user.update({
where: {
id: existingUser?.id,
},
data: {
identityProviderId: account.providerAccountId,
},
});
}
}
/* --- END FIXES LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (existingUser) { if (existingUser) {
// In this case there's an existing user and their email address // In this case there's an existing user and their email address
// hasn't changed since they last logged in. // hasn't changed since they last logged in.
@@ -617,7 +644,7 @@ export const AUTH_OPTIONS: AuthOptions = {
emailVerified: new Date(Date.now()), emailVerified: new Date(Date.now()),
name: user.name, name: user.name,
identityProvider: idP, identityProvider: idP,
identityProviderId: String(user.id), identityProviderId: account.providerAccountId,
}, },
}); });
@@ -640,7 +667,7 @@ export const AUTH_OPTIONS: AuthOptions = {
password: null, password: null,
email: user.email, email: user.email,
identityProvider: idP, identityProvider: idP,
identityProviderId: String(user.id), identityProviderId: account.providerAccountId,
}, },
}); });
if (existingUserWithEmail.twoFactorEnabled) { if (existingUserWithEmail.twoFactorEnabled) {
@@ -664,7 +691,7 @@ export const AUTH_OPTIONS: AuthOptions = {
name: user.name, name: user.name,
email: user.email, email: user.email,
identityProvider: idP, identityProvider: idP,
identityProviderId: String(user.id), identityProviderId: account.providerAccountId,
}, },
}); });
@@ -61,7 +61,7 @@ export const SystemFieldsEditability: Record<z.infer<typeof SystemField>, Fields
location: "system", location: "system",
notes: "system-but-optional", notes: "system-but-optional",
guests: "system-but-optional", guests: "system-but-optional",
rescheduleReason: "system", rescheduleReason: "system-but-optional",
smsReminderNumber: "system", smsReminderNumber: "system",
}; };
@@ -252,6 +252,12 @@ export const ensureBookingInputsHaveSystemFields = ({
name: "rescheduleReason", name: "rescheduleReason",
defaultPlaceholder: "reschedule_placeholder", defaultPlaceholder: "reschedule_placeholder",
required: false, required: false,
views: [
{
id: "reschedule",
label: "Reschedule View",
},
],
sources: [ sources: [
{ {
label: "Default", label: "Default",
@@ -264,9 +270,16 @@ export const ensureBookingInputsHaveSystemFields = ({
const missingSystemBeforeFields = []; const missingSystemBeforeFields = [];
for (const field of systemBeforeFields) { for (const field of systemBeforeFields) {
const existingBookingFieldIndex = bookingFields.findIndex((f) => f.name === field.name);
// Only do a push, we must not update existing system fields as user could have modified any property in it, // Only do a push, we must not update existing system fields as user could have modified any property in it,
if (!bookingFields.find((f) => f.name === field.name)) { if (existingBookingFieldIndex === -1) {
missingSystemBeforeFields.push(field); missingSystemBeforeFields.push(field);
} else {
// Adding the fields from Code first and then fields from DB. Allows, the code to push new properties to the field
bookingFields[existingBookingFieldIndex] = {
...field,
...bookingFields[existingBookingFieldIndex],
};
} }
} }
@@ -310,9 +323,16 @@ export const ensureBookingInputsHaveSystemFields = ({
const missingSystemAfterFields = []; const missingSystemAfterFields = [];
for (const field of systemAfterFields) { for (const field of systemAfterFields) {
const existingBookingFieldIndex = bookingFields.findIndex((f) => f.name === field.name);
// Only do a push, we must not update existing system fields as user could have modified any property in it, // Only do a push, we must not update existing system fields as user could have modified any property in it,
if (!bookingFields.find((f) => f.name === field.name)) { if (existingBookingFieldIndex === -1) {
missingSystemAfterFields.push(field); missingSystemAfterFields.push(field);
} else {
bookingFields[existingBookingFieldIndex] = {
// Adding the fields from Code first and then fields from DB. Allows, the code to push new properties to the field
...field,
...bookingFields[existingBookingFieldIndex],
};
} }
} }
@@ -1,22 +1,32 @@
import { isValidPhoneNumber } from "libphonenumber-js"; import { isValidPhoneNumber } from "libphonenumber-js";
import z from "zod"; import z from "zod";
import type { ALL_VIEWS } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses } from "@calcom/prisma/zod-utils"; import { bookingResponses } from "@calcom/prisma/zod-utils";
type EventType = Parameters<typeof preprocess>[0]["eventType"]; type EventType = Parameters<typeof preprocess>[0]["eventType"];
export const getBookingResponsesPartialSchema = (eventType: EventType) => { // eslint-disable-next-line @typescript-eslint/ban-types
type View = ALL_VIEWS | (string & {});
export const getBookingResponsesPartialSchema = ({
eventType,
view,
}: {
eventType: EventType;
view: View;
}) => {
const schema = bookingResponses.unwrap().partial().and(z.record(z.any())); const schema = bookingResponses.unwrap().partial().and(z.record(z.any()));
return preprocess({ schema, eventType, isPartialSchema: true }); return preprocess({ schema, eventType, isPartialSchema: true, view });
}; };
// Should be used when we know that not all fields responses are present // Should be used when we know that not all fields responses are present
// - Can happen when we are parsing the prefill query string // - Can happen when we are parsing the prefill query string
// - Can happen when we are parsing a booking's responses (which was created before we added a new required field) // - Can happen when we are parsing a booking's responses (which was created before we added a new required field)
export default function getBookingResponsesSchema(eventType: EventType) { export default function getBookingResponsesSchema({ eventType, view }: { eventType: EventType; view: View }) {
const schema = bookingResponses.and(z.record(z.any())); const schema = bookingResponses.and(z.record(z.any()));
return preprocess({ schema, eventType, isPartialSchema: false }); return preprocess({ schema, eventType, isPartialSchema: false, view });
} }
// TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder // TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder
@@ -25,12 +35,14 @@ function preprocess<T extends z.ZodType>({
schema, schema,
eventType, eventType,
isPartialSchema, isPartialSchema,
view: currentView,
}: { }: {
schema: T; schema: T;
isPartialSchema: boolean; isPartialSchema: boolean;
eventType: { eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">; bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
}; };
view: View;
}): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> { }): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> {
const preprocessed = z.preprocess( const preprocessed = z.preprocess(
(responses) => { (responses) => {
@@ -42,8 +54,15 @@ function preprocess<T extends z.ZodType>({
// If there is no response for the field, then we don't need to do any processing // If there is no response for the field, then we don't need to do any processing
return; return;
} }
// Turn a boolean in string to a real boolean const views = field.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
if (!isFieldApplicableToCurrentView) {
// If the field is not applicable in the current view, then we don't need to do any processing
return;
}
if (field.type === "boolean") { if (field.type === "boolean") {
// Turn a boolean in string to a real boolean
newResponses[field.name] = value === "true" || value === true; newResponses[field.name] = value === "true" || value === true;
} }
// Make sure that the value is an array // Make sure that the value is an array
@@ -76,8 +95,16 @@ function preprocess<T extends z.ZodType>({
: z.string().refine((val) => isValidPhoneNumber(val)); : z.string().refine((val) => isValidPhoneNumber(val));
// Tag the message with the input name so that the message can be shown at appropriate place // Tag the message with the input name so that the message can be shown at appropriate place
const m = (message: string) => `{${bookingField.name}}${message}`; const m = (message: string) => `{${bookingField.name}}${message}`;
const views = bookingField.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
// If the field is hidden, then it can never be required // If the field is hidden, then it can never be required
const isRequired = bookingField.hidden ? false : bookingField.required; const isRequired = bookingField.hidden
? false
: isFieldApplicableToCurrentView
? bookingField.required
: false;
if ((isPartialSchema || !isRequired) && value === undefined) { if ((isPartialSchema || !isRequired) && value === undefined) {
return; return;
} }
@@ -377,7 +377,10 @@ function getBookingData({
? extendedBookingCreateBody.merge( ? extendedBookingCreateBody.merge(
z.object({ z.object({
responses: getBookingResponsesSchema({ responses: getBookingResponsesSchema({
bookingFields: eventType.bookingFields, eventType: {
bookingFields: eventType.bookingFields,
},
view: req.body.rescheduleUid ? "reschedule" : "booking",
}), }),
}) })
) )
@@ -689,18 +692,15 @@ async function handler(
const guests = (reqGuests || []).reduce((guestArray, guest) => { const guests = (reqGuests || []).reduce((guestArray, guest) => {
// If it's a team event, remove the team member from guests // If it's a team event, remove the team member from guests
if (isTeamEventType) { if (isTeamEventType && users.some((user) => user.email === guest)) {
if (users.some((user) => user.email === guest)) { return guestArray;
return guestArray;
} else {
guestArray.push({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
});
}
} }
guestArray.push({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
});
return guestArray; return guestArray;
}, [] as typeof invitee); }, [] as typeof invitee);
@@ -1,4 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -13,10 +14,6 @@ const SAMLSSO = () => {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
const teamId = Number(router.query.id); const teamId = Number(router.query.id);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery( const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
@@ -28,6 +25,12 @@ const SAMLSSO = () => {
} }
); );
useEffect(() => {
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
}, []);
if (isLoading) { if (isLoading) {
return <SkeletonLoader />; return <SkeletonLoader />;
} }
@@ -1,4 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -11,9 +12,11 @@ const SAMLSSO = () => {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
if (HOSTED_CAL_FEATURES) { useEffect(() => {
router.push("/404"); if (HOSTED_CAL_FEATURES) {
} router.push("/404");
}
}, []);
return ( return (
<div className="w-full bg-white sm:mx-0 xl:mt-0"> <div className="w-full bg-white sm:mx-0 xl:mt-0">
@@ -65,21 +65,21 @@ export const EventTypeDescription = ({
{eventType.metadata?.multipleDuration ? ( {eventType.metadata?.multipleDuration ? (
eventType.metadata.multipleDuration.map((dur, idx) => ( eventType.metadata.multipleDuration.map((dur, idx) => (
<li key={idx}> <li key={idx}>
<Badge variant="gray" size="lg" startIcon={FiClock}> <Badge variant="gray" startIcon={FiClock}>
{dur}m {dur}m
</Badge> </Badge>
</li> </li>
)) ))
) : ( ) : (
<li> <li>
<Badge variant="gray" size="lg" startIcon={FiClock}> <Badge variant="gray" startIcon={FiClock}>
{eventType.length}m {eventType.length}m
</Badge> </Badge>
</li> </li>
)} )}
{eventType.schedulingType && ( {eventType.schedulingType && (
<li> <li>
<Badge variant="gray" size="lg" startIcon={FiUsers}> <Badge variant="gray" startIcon={FiUsers}>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")} {eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")} {eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
</Badge> </Badge>
@@ -87,7 +87,7 @@ export const EventTypeDescription = ({
)} )}
{recurringEvent?.count && recurringEvent.count > 0 && ( {recurringEvent?.count && recurringEvent.count > 0 && (
<li className="hidden xl:block"> <li className="hidden xl:block">
<Badge variant="gray" size="lg" startIcon={FiRefreshCw}> <Badge variant="gray" startIcon={FiRefreshCw}>
{t("repeats_up_to", { {t("repeats_up_to", {
count: recurringEvent.count, count: recurringEvent.count,
})} })}
@@ -96,7 +96,7 @@ export const EventTypeDescription = ({
)} )}
{stripeAppData.price > 0 && ( {stripeAppData.price > 0 && (
<li> <li>
<Badge variant="gray" size="lg" startIcon={FiCreditCard}> <Badge variant="gray" startIcon={FiCreditCard}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<FormattedNumber <FormattedNumber
value={stripeAppData.price / 100.0} value={stripeAppData.price / 100.0}
@@ -109,7 +109,7 @@ export const EventTypeDescription = ({
)} )}
{eventType.requiresConfirmation && ( {eventType.requiresConfirmation && (
<li className="hidden xl:block"> <li className="hidden xl:block">
<Badge variant="gray" size="lg" startIcon={FiClipboard}> <Badge variant="gray" startIcon={FiClipboard}>
{eventType.metadata?.requiresConfirmationThreshold {eventType.metadata?.requiresConfirmationThreshold
? t("may_require_confirmation") ? t("may_require_confirmation")
: t("requires_confirmation")} : t("requires_confirmation")}
@@ -119,7 +119,7 @@ export const EventTypeDescription = ({
{/* TODO: Maybe add a tool tip to this? */} {/* TODO: Maybe add a tool tip to this? */}
{eventType.requiresConfirmation || (recurringEvent?.count && recurringEvent.count) ? ( {eventType.requiresConfirmation || (recurringEvent?.count && recurringEvent.count) ? (
<li className="block xl:hidden"> <li className="block xl:hidden">
<Badge variant="gray" size="lg" startIcon={FiPlus}> <Badge variant="gray" startIcon={FiPlus}>
<p>{[eventType.requiresConfirmation, recurringEvent?.count].filter(Boolean).length}</p> <p>{[eventType.requiresConfirmation, recurringEvent?.count].filter(Boolean).length}</p>
</Badge> </Badge>
</li> </li>
@@ -128,7 +128,7 @@ export const EventTypeDescription = ({
)} )}
{eventType?.seatsPerTimeSlot ? ( {eventType?.seatsPerTimeSlot ? (
<li> <li>
<Badge variant="gray" size="lg" startIcon={FiUser}> <Badge variant="gray" startIcon={FiUser}>
<p>{t("event_type_seats", { numberOfSeats: eventType.seatsPerTimeSlot })} </p> <p>{t("event_type_seats", { numberOfSeats: eventType.seatsPerTimeSlot })} </p>
</Badge> </Badge>
</li> </li>
+20 -14
View File
@@ -284,21 +284,25 @@ export const FormBuilder = function FormBuilder({
return ( return (
<li <li
key={index} key={field.name}
data-testid={`field-${field.name}`} data-testid={`field-${field.name}`}
className="group relative flex items-center justify-between border-b p-4 last:border-b-0"> className="group relative flex items-center justify-between border-b p-4 last:border-b-0">
<button {index >= 1 && (
type="button" <button
className="invisible absolute -left-[12px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex" type="button"
onClick={() => swap(index, index - 1)}> className="invisible absolute -left-[12px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
<FiArrowUp className="h-5 w-5" /> onClick={() => swap(index, index - 1)}>
</button> <FiArrowUp className="h-5 w-5" />
<button </button>
type="button" )}
className="invisible absolute -left-[12px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex" {index < fields.length - 1 && (
onClick={() => swap(index, index + 1)}> <button
<FiArrowDown className="h-5 w-5" /> type="button"
</button> className="invisible absolute -left-[12px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index + 1)}>
<FiArrowDown className="h-5 w-5" />
</button>
)}
<div> <div>
<div className="flex flex-col lg:flex-row lg:items-center"> <div className="flex flex-col lg:flex-row lg:items-center">
<div className="text-sm font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"> <div className="text-sm font-semibold text-gray-700 ltr:mr-1 rtl:ml-1">
@@ -335,6 +339,7 @@ export const FormBuilder = function FormBuilder({
}} }}
/> />
<Button <Button
data-testid="edit-field-action"
color="secondary" color="secondary"
onClick={() => { onClick={() => {
editField(index, field); editField(index, field);
@@ -378,7 +383,7 @@ export const FormBuilder = function FormBuilder({
fieldIndex: -1, fieldIndex: -1,
}) })
}> }>
<DialogContent enableOverflow> <DialogContent enableOverflow data-testid="edit-field-dialog">
<DialogHeader title={t("add_a_booking_question")} subtitle={t("form_builder_field_add_subtitle")} /> <DialogHeader title={t("add_a_booking_question")} subtitle={t("form_builder_field_add_subtitle")} />
<div> <div>
<Form <Form
@@ -472,6 +477,7 @@ export const FormBuilder = function FormBuilder({
render={({ field: { value, onChange } }) => { render={({ field: { value, onChange } }) => {
return ( return (
<BooleanToggleGroupField <BooleanToggleGroupField
data-testid="field-required"
disabled={fieldForm.getValues("editable") === "system"} disabled={fieldForm.getValues("editable") === "system"}
value={value} value={value}
onValueChange={(val) => { onValueChange={(val) => {
@@ -23,7 +23,7 @@ export const EditableSchema = z.enum([
"user", // Fully editable "user", // Fully editable
"user-readonly", // All fields are readOnly. "user-readonly", // All fields are readOnly.
]); ]);
export type ALL_VIEWS = "ALL_VIEWS";
const fieldSchema = z.object({ const fieldSchema = z.object({
name: z.string(), name: z.string(),
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label. // TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
@@ -35,7 +35,14 @@ const fieldSchema = z.object({
*/ */
defaultLabel: z.string().optional(), defaultLabel: z.string().optional(),
defaultPlaceholder: z.string().optional(), defaultPlaceholder: z.string().optional(),
views: z
.object({
label: z.string(),
id: z.string(),
description: z.string().optional(),
})
.array()
.optional(),
type: fieldTypeEnum, type: fieldTypeEnum,
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(), options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
optionsInputs: z optionsInputs: z
+192 -206
View File
@@ -7,14 +7,17 @@ import {
KBarSearch, KBarSearch,
useKBar, useKBar,
useMatches, useMatches,
useRegisterActions,
} from "kbar"; } from "kbar";
import type { Action } from "kbar";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isMac } from "@calcom/lib/isMac"; import { isMac } from "@calcom/lib/isMac";
import { RouterOutputs, trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Tooltip } from "@calcom/ui"; import { Tooltip } from "@calcom/ui";
import { FiSearch, FiArrowUp, FiArrowDown, FiCornerDownLeft, FiCommand } from "@calcom/ui/components/icon"; import { FiSearch, FiArrowUp, FiArrowDown, FiCornerDownLeft, FiCommand } from "@calcom/ui/components/icon";
@@ -24,15 +27,6 @@ type shortcutArrayType = {
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"]; type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroup = EventTypeGroups[number]; type EventTypeGroup = EventTypeGroups[number];
type EventType = EventTypeGroup["eventTypes"][number];
type KBarAction = {
perform: () => Promise<boolean>;
id: string;
name: string;
section: string;
keywords: string;
};
const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({ const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({
id: slug, id: slug,
@@ -41,216 +35,208 @@ const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({
keywords: `app ${name}`, keywords: `app ${name}`,
})); }));
export const KBarRoot = ({ children }: { children: React.ReactNode }) => { const useEventTypesAction = () => {
const router = useRouter(); const router = useRouter();
const { data } = trpc.viewer.eventTypes.getByViewer.useQuery(); const { data } = trpc.viewer.eventTypes.getByViewer.useQuery();
const eventTypeActions = data?.eventTypeGroups.reduce<Action[]>((acc: Action[], group: EventTypeGroup) => {
const item: Action[] = group.eventTypes.map((item) => ({
id: `event-type-${item.id}`,
name: item.title,
section: "event_types_page_title",
keywords: "event types",
perform: () => router.push(`/event-types/${item.id}`),
}));
acc.push(...item);
return acc;
}, []);
const actions = eventTypeActions?.length ? eventTypeActions : [];
return useRegisterActions(actions);
};
export const KBarRoot = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
// grab link to events // grab link to events
// quick nested actions would be extremely useful // quick nested actions would be extremely useful
const appStoreActions = useMemo(
() => getApps.map((item) => ({ ...item, perform: () => router.push(`/apps/${item.id}`) })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!data) return null; const actions = useMemo(() => {
const appStoreActions = getApps.map((item) => ({
const eventTypes = ...item,
data?.eventTypeGroups.reduce((acc: EventType[], group: EventTypeGroup): EventType[] => { perform: () => router.push(`/apps/${item.id}`),
acc.push(...group.eventTypes); }));
return acc; return [
}, [] as EventType[]) || []; {
id: "workflows",
const eventTypeActions = name: "workflows",
eventTypes.map( section: "workflows",
(item: EventType): KBarAction => ({ shortcut: ["w", "f"],
id: `event-type-${item.id}`, keywords: "workflows automation",
name: item.title, perform: () => router.push("/workflows"),
},
{
id: "event-types",
name: "event_types_page_title",
section: "event_types_page_title", section: "event_types_page_title",
shortcut: ["e", "t"],
keywords: "event types", keywords: "event types",
perform: () => router.push(`/event-types/${item.id}`), perform: () => router.push("/event-types"),
}) },
) || []; {
id: "app-store",
const actions = [ name: "app_store",
// { section: "apps",
// id: "toggle-idle", shortcut: ["a", "s"],
// name: "Test Function", keywords: "app store",
// section: "Status", perform: () => router.push("/apps"),
// shortcut: ["t", "f"], },
// keywords: "set yourself away bookings", {
// perform: () => alert("Hello World"), id: "upcoming-bookings",
// }, name: "upcoming",
section: "bookings",
{ shortcut: ["u", "b"],
id: "workflows", keywords: "upcoming bookings",
name: "workflows", perform: () => router.push("/bookings/upcoming"),
section: "workflows", },
shortcut: ["w", "f"], {
keywords: "workflows automation", id: "recurring-bookings",
perform: () => router.push("/workflows"), name: "recurring",
}, section: "bookings",
{ shortcut: ["r", "b"],
id: "event-types", keywords: "recurring bookings",
name: "event_types_page_title", perform: () => router.push("/bookings/recurring"),
section: "event_types_page_title", },
shortcut: ["e", "t"], {
keywords: "event types", id: "past-bookings",
perform: () => router.push("/event-types"), name: "past",
}, section: "bookings",
{ shortcut: ["p", "b"],
id: "app-store", keywords: "past bookings",
name: "app_store", perform: () => router.push("/bookings/past"),
section: "apps", },
shortcut: ["a", "s"], {
keywords: "app store", id: "cancelled-bookings",
perform: () => router.push("/apps"), name: "cancelled",
}, section: "bookings",
{ shortcut: ["c", "b"],
id: "upcoming-bookings", keywords: "cancelled bookings",
name: "upcoming", perform: () => router.push("/bookings/cancelled"),
section: "bookings", },
shortcut: ["u", "b"], {
keywords: "upcoming bookings", id: "schedule",
perform: () => router.push("/bookings/upcoming"), name: "availability",
}, section: "availability",
{ shortcut: ["s", "a"],
id: "recurring-bookings", keywords: "schedule availability",
name: "recurring", perform: () => router.push("/availability"),
section: "bookings", },
shortcut: ["r", "b"], {
keywords: "recurring bookings", id: "profile",
perform: () => router.push("/bookings/recurring"), name: "profile",
}, section: "profile",
{ shortcut: ["p", "s"],
id: "past-bookings", keywords: "setting profile",
name: "past", perform: () => router.push("/settings/my-account/profile"),
section: "bookings", },
shortcut: ["p", "b"], {
keywords: "past bookings", id: "avatar",
perform: () => router.push("/bookings/past"), name: "change_avatar",
}, section: "profile",
{ shortcut: ["c", "a"],
id: "cancelled-bookings", keywords: "remove change modify avatar",
name: "cancelled", perform: () => router.push("/settings/my-account/profile"),
section: "bookings", },
shortcut: ["c", "b"], {
keywords: "cancelled bookings", id: "timezone",
perform: () => router.push("/bookings/cancelled"), name: "timezone",
}, section: "profile",
{ shortcut: ["c", "t"],
id: "schedule", keywords: "change modify timezone",
name: "availability", perform: () => router.push("/settings/my-account/general"),
section: "availability", },
shortcut: ["s", "a"], {
keywords: "schedule availability", id: "brand-color",
perform: () => router.push("/availability"), name: "brand_color",
}, section: "profile",
{ shortcut: ["b", "c"],
id: "profile", keywords: "change modify brand color",
name: "profile", perform: () => router.push("/settings/my-account/appearance"),
section: "profile", },
shortcut: ["p", "s"], {
keywords: "setting profile", id: "teams",
perform: () => router.push("/settings/my-account/profile"), name: "teams",
}, shortcut: ["t", "s"],
{ keywords: "add manage modify team",
id: "avatar", perform: () => router.push("/settings/teams"),
name: "change_avatar", },
section: "profile", {
shortcut: ["c", "a"], id: "password",
keywords: "remove change modify avatar", name: "change_password",
perform: () => router.push("/settings/my-account/profile"), section: "security",
}, shortcut: ["c", "p"],
{ keywords: "change modify password",
id: "timezone", perform: () => router.push("/settings/security/password"),
name: "timezone", },
section: "profile", {
shortcut: ["c", "t"], id: "two-factor",
keywords: "change modify timezone", name: "two_factor_auth",
perform: () => router.push("/settings/my-account/general"), section: "security",
}, shortcut: ["t", "f", "a"],
{ keywords: "two factor authentication",
id: "brand-color", perform: () => router.push("/settings/security/two-factor-auth"),
name: "brand_color", },
section: "profile", {
shortcut: ["b", "c"], id: "impersonation",
keywords: "change modify brand color", name: "user_impersonation_heading",
perform: () => router.push("/settings/my-account/appearance"), section: "security",
}, shortcut: ["u", "i"],
{ keywords: "user impersonation",
id: "teams", perform: () => router.push("/settings/security/impersonation"),
name: "teams", },
shortcut: ["t", "s"], {
keywords: "add manage modify team", id: "license",
perform: () => router.push("/settings/teams"), name: "choose_a_license",
}, section: "admin",
{ shortcut: ["u", "l"],
id: "password", keywords: "license",
name: "change_password", perform: () => router.push("/auth/setup?step=1"),
section: "security", },
shortcut: ["c", "p"], {
keywords: "change modify password", id: "webhooks",
perform: () => router.push("/settings/security/password"), name: "Webhooks",
}, section: "developer",
{ shortcut: ["w", "h"],
id: "two-factor", keywords: "webhook automation",
name: "two_factor_auth", perform: () => router.push("/settings/developer/webhooks"),
section: "security", },
shortcut: ["t", "f", "a"], {
keywords: "two factor authentication", id: "api-keys",
perform: () => router.push("/settings/security/two-factor-auth"), name: "api_keys",
}, section: "developer",
{ shortcut: ["a", "p", "i"],
id: "impersonation", keywords: "api keys",
name: "user_impersonation_heading", perform: () => router.push("/settings/developer/api-keys"),
section: "security", },
shortcut: ["u", "i"], {
keywords: "user impersonation", id: "billing",
perform: () => router.push("/settings/security/impersonation"), name: "manage_billing",
}, section: "billing",
{ shortcut: ["m", "b"],
id: "license", keywords: "billing view manage",
name: "choose_a_license", perform: () => router.push("/settings/billing"),
section: "admin", },
shortcut: ["u", "l"], ...appStoreActions,
keywords: "license", ];
perform: () => router.push("/auth/setup?step=1"), // eslint-disable-next-line react-hooks/exhaustive-deps
}, }, []);
{
id: "webhooks",
name: "Webhooks",
section: "developer",
shortcut: ["w", "h"],
keywords: "webhook automation",
perform: () => router.push("/settings/developer/webhooks"),
},
{
id: "api-keys",
name: "api_keys",
section: "developer",
shortcut: ["a", "p", "i"],
keywords: "api keys",
perform: () => router.push("/settings/developer/api-keys"),
},
{
id: "billing",
name: "manage_billing",
section: "billing",
shortcut: ["m", "b"],
keywords: "billing view manage",
perform: () => router.push("/settings/billing"),
},
...appStoreActions,
...eventTypeActions,
];
return <KBarProvider actions={actions}>{children}</KBarProvider>; return <KBarProvider actions={actions}>{children}</KBarProvider>;
}; };
export const KBarContent = () => { export const KBarContent = () => {
const { t } = useLocale(); const { t } = useLocale();
useEventTypesAction();
return ( return (
<KBarPortal> <KBarPortal>
<KBarPositioner> <KBarPositioner>
+4 -4
View File
@@ -20,14 +20,14 @@ const badgeStyles = cva("font-medium inline-flex items-center justify-center rou
error: "bg-red-100 text-red-800", error: "bg-red-100 text-red-800",
}, },
size: { size: {
sm: "px-1 py-0.5 text-xs", sm: "px-1 py-0.5 text-xs leading-3",
md: "py-1 px-1.5 text-xs", md: "py-1 px-1.5 text-xs leading-3",
lg: "py-1 px-2 text-sm", lg: "py-1 px-2 text-sm leading-4",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "sm", size: "md",
}, },
}); });
@@ -16,6 +16,7 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
disabled = false, disabled = false,
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
onValueChange = () => {}, onValueChange = () => {},
...passThrough
}: { }: {
defaultValue?: boolean; defaultValue?: boolean;
value?: boolean; value?: boolean;
@@ -44,7 +45,8 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
onValueChange={(yesNoValue: "yes" | "no") => { onValueChange={(yesNoValue: "yes" | "no") => {
setYesNoValue(yesNoValue); setYesNoValue(yesNoValue);
onValueChange(boolean(yesNoValue)); onValueChange(boolean(yesNoValue));
}}> }}
{...passThrough}>
<ToggleGroupItemPrimitive <ToggleGroupItemPrimitive
className={classNames(boolean(yesNoValue) ? selectedClass : unselectedClass)} className={classNames(boolean(yesNoValue) ? selectedClass : unselectedClass)}
disabled={disabled} disabled={disabled}