This commit is contained in:
Peer Richelsen committed 2023-05-17 12:58:41 +01:00
commit 64418e0a85
32 files changed
+539 -130

No files matched your search

+105 -44
View File
@@ -26,7 +26,10 @@ const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children
);
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "Component"> & {
export type AppProps = Omit<
NextAppProps<WithNonceProps & { themeBasis?: string } & Record<string, unknown>>,
"Component"
> & {
Component: NextAppProps["Component"] & {
requiresLicense?: boolean;
isThemeSupported?: boolean;
@@ -72,58 +75,21 @@ const enum ThemeSupport {
Booking = "userConfigured",
}
const CalcomThemeProvider = (
props: PropsWithChildren<
WithNonceProps & {
isBookingPage?: boolean | ((arg: { router: NextRouter }) => boolean);
isThemeSupported?: boolean;
}
>
) => {
// We now support the inverse of how we handled it in the past. Setting this to false will disable theme.
// undefined or true means we use system theme
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
const router = useRouter();
const isBookingPage = (() => {
if (typeof props.isBookingPage === "function") {
return props.isBookingPage({ router: router });
}
return props.isBookingPage;
})();
const themeSupport = isBookingPage
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
// One such example is our Embeds Demo and Testing page at http://localhost:3100
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null;
const isEmbedMode = typeof embedNamespace === "string";
const storageKey = isEmbedMode
? `embed-theme-${embedNamespace}`
: themeSupport === ThemeSupport.App
? "app-theme"
: themeSupport === ThemeSupport.Booking
? "booking-theme"
: undefined;
return (
<ThemeProvider
nonce={props.nonce}
enableColorScheme={false}
enableSystem={themeSupport !== ThemeSupport.None}
forcedTheme={forcedTheme}
storageKey={storageKey}
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key={storageKey}
attribute="class">
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace, router })}>
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
{typeof window !== "undefined" && !isEmbedMode && (
@@ -140,6 +106,100 @@ const CalcomThemeProvider = (
);
};
/**
* The most important job for this fn is to generate correct storageKey for theme persistenc.
* `storageKey` is important because that key is listened for changes(using [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event) and any pages opened will change it's theme based on that(as part of next-themes implementation).
* Choosing the right storageKey avoids theme flickering caused by another page using different theme
* So, we handle all the cases here namely,
* - Both Booking Pages, /free/30min and /pro/30min but configured with different themes but being operated together.
* - Embeds using different namespace. They can be completely themed different on the same page.
* - Embeds using the same namespace but showing different cal.com links with different themes
* - Embeds using the same namespace and showing same cal.com links with different themes(Different theme is possible for same cal.com link in case of embed because of theme config available in embed)
* - App has different theme then Booking Pages.
*
* All the above cases have one thing in common, which is the origin and thus localStorage is shared and thus `storageKey` is critical to avoid theme flickering.
*
* Some things to note:
* - There is a side effect of so many factors in `storageKey` that many localStorage keys will be created if a user goes through all these scenarios(e.g like booking a lot of different users)
* - Some might recommend disabling localStorage persistence but that doesn't give good UX as then we would default to light theme always for a few seconds before switching to dark theme(if that's the user's preference).
* - We can't disable [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event handling as well because changing theme in one tab won't change the theme without refresh in other tabs. That's again a bad UX
* - Theme flickering becomes infinitely ongoing in case of embeds because of the browser's delay in processing `storage` event within iframes. Consider two embeds simulatenously opened with pages A and B. Note the timeline and keep in mind that it happened
* because 'setItem(A)' and 'Receives storageEvent(A)' allowed executing setItem(B) in b/w because of the delay.
* - t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A)
* - t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B)
* - t3 -> Receives storageEvent(A) & thus setItem(A) & thus fires storageEvent(A) (On Page B) - Current State(A)
* - t4 -> Receives storageEvent(B) & thus setItem(B) & thus fires storageEvent(B) (On Page A) - Current State(B)
* - ... and so on ...
*/
function getThemeProviderProps({
props,
isEmbedMode,
embedNamespace,
router,
}: {
props: Omit<CalcomThemeProps, "children">;
isEmbedMode: boolean;
embedNamespace: string | null;
router: NextRouter;
}) {
const isBookingPage = (() => {
if (typeof props.isBookingPage === "function") {
return props.isBookingPage({ router: router });
}
return props.isBookingPage;
})();
const themeSupport = isBookingPage
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const isBookingPageThemSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = props.themeBasis;
if ((isBookingPageThemSupportRequired || isEmbedMode) && !themeBasis) {
console.warn(
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
);
}
const appearanceIdSuffix = themeBasis ? ":" + themeBasis : "";
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
let embedExplicitlySetThemeSuffix = "";
if (typeof window !== "undefined") {
const embedTheme = window.getEmbedTheme();
if (embedTheme) {
embedExplicitlySetThemeSuffix = ":" + embedTheme;
}
}
const storageKey = isEmbedMode
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
: themeSupport === ThemeSupport.App
? "app-theme"
: isBookingPageThemSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
return {
storageKey,
forcedTheme,
themeSupport,
nonce: props.nonce,
enableColorScheme: false,
enableSystem: themeSupport !== ThemeSupport.None,
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key: storageKey,
attribute: "class",
};
}
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
@@ -157,6 +217,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.pageProps.themeBasis}
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage}>
+2
View File
@@ -356,6 +356,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
users,
safeBio,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: isDynamicGroup ? null : user.username,
user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
+4
View File
@@ -177,6 +177,8 @@ async function getUserPageProps(context: GetStaticPropsContext) {
slug: `${user.username}/${eventType.slug}`,
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
},
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: user.username,
away: user?.away,
isDynamic: false,
trpcState: ssg.dehydrate(),
@@ -307,6 +309,8 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
props: {
eventType: eventTypeObject,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: null,
isDynamic: true,
away: false,
trpcState: ssg.dehydrate(),
+2
View File
@@ -293,6 +293,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
props: {
away: user.away,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: isDynamicGroupBooking ? null : user.username,
eventType: eventTypeObject,
booking,
currentSlotBooking: currentSlotBooking,
+1
View File
@@ -1092,6 +1092,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
profile,
eventType,
+1
View File
@@ -160,6 +160,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
away: user.away,
themeBasis: user.username,
isDynamicGroup: false,
profile,
date,
+1
View File
@@ -122,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
profile,
themeBasis: user.username,
eventType: eventTypeObject,
booking: null,
currentSlotBooking: null,
+1
View File
@@ -207,6 +207,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
team: { ...team, safeBio, members },
themeBasis: team.slug,
trpcState: ssr.dehydrate(),
markdownStrippedBio,
},
+1
View File
@@ -196,6 +196,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
brandColor: team.brandColor,
darkBrandColor: team.darkBrandColor,
},
themeBasis: team.slug,
date: dateParam,
eventType: eventTypeObject,
workingHours,
+1
View File
@@ -153,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: eventTypeObject.team?.logo || null,
eventName: null,
},
themeBasis: eventTypeObject.team?.slug,
eventType: eventTypeObject,
recurringEventCount,
booking,
@@ -1815,6 +1815,7 @@
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"form_updated_successfully":"Form updated successfully.",
"email_not_cal_member_cta": "Join your team",
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
@@ -1,4 +1,5 @@
{
"identity_provider": "அடையாள வழங்குபவர்",
"trial_days_left": "உங்கள் மேம்படுத்தப்பட்ட பயன்பாட்டு சோதனையில் $t(day, {\"count\": {{days}} }) மீதமுள்ளது",
"day_one": "{{count}} நாள்",
"day_other": "{{count}} நாட்கள்",
@@ -7,6 +8,7 @@
"upgrade_now": "மேம்படுத்தவும்",
"accept_invitation": "அழைப்பை ஏற்கவும்",
"calcom_explained": "{{appName}} அனைவருக்கும் திட்டமிடல் உள்கட்டமைப்பை வழங்குகிறது.",
"calcom_explained_new_user": "உங்கள் {{appName}} கணக்கை அமைக்கவும்! உங்கள் திட்டமிடல் சிக்கல்கள் அனைத்தையும் தீர்க்க சில படிகள் மட்டுமே உள்ளன.",
"have_any_questions": "கேள்விகள் உள்ளதா? நாங்கள் உதவ தயாராக இருக்கிறோம்.",
"reset_password_subject": "{{appName}}: கடவுச்சொல் மீட்டமைக்கும் வழிமுறைகள்",
"event_declined_subject": "{{title}} {{date}} அன்று நிராகரிக்கப்பட்டது",
@@ -28,8 +30,58 @@
"your_event_has_been_scheduled": "உங்கள் நிகழ்வு திட்டமிடப்பட்டது",
"your_event_has_been_scheduled_recurring": "உங்கள் தொடர் நிகழ்வு திட்டமிடப்பட்டுள்ளது",
"accept_our_license": ".env மாறியை மாற்றுவதன் மூலம் எங்கள் உரிமத்தை ஏற்கவும்\n<1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
"error_message": "பிழை செய்தி: '{{errorMessage}}'",
"refund_failed_subject": "பணத்தைத் திரும்பப் பெற முடியவில்லை: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "{{date}} அன்று {{userName}} உடன் நடந்த {{eventType}} நிகழ்விற்கான பணத்தைத் திரும்பி அனுப்ப இயலவில்லை.",
"check_with_provider_and_user": "உங்கள் கட்டண வழங்குநர் மற்றும் {{user}} உடன் இனைந்து இதை எவ்வாறு கையாள்வது என்பதைச் சரிபார்க்கவும்.",
"a_refund_failed": "பணத்தைத் திரும்பப் பெற முடியவில்லை",
"awaiting_payment_subject": "பணம் செலுத்த காத்திருக்கிறது: {{date}} அன்று {{title}}",
"meeting_awaiting_payment": "உங்கள் மீட்டிங் கட்டணம் செலுத்த காத்திருக்கிறது",
"help": "உதவி",
"price": "விலை",
"paid": "பணம் செலுத்தப்பட்டது",
"refunded": "பணம் திருப்பிக் கொடுக்கப்பட்டது",
"payment": "பணம் செலுத்துதல்",
"missing_card_fields": "கார்டு புலங்கள் இல்லை",
"pay_now": "இப்போது பணம் செலுத்த",
"codebase_has_to_stay_opensource": "கோட்பேஸ் மாற்றப்பட்டாலும் இல்லாவிட்டாலும் திறந்த மூலமாக இருக்க வேண்டும்",
"cannot_repackage_codebase": "நீங்கள் கோட்பேஸை மீண்டும் பேக்கேஜ் செய்யவோ விற்கவோ முடியாது",
"acquire_license": "மின்னஞ்சல்மூலம் இந்த விதிமுறைகளை அகற்ற வணிக உரிமத்தைப் பெறவும்",
"terms_summary": "விதிமுறைகளின் சுருக்கம்",
"open_env": ".env ஐத் திறந்து எங்கள் உரிமத்தை ஏற்கவும்",
"env_changed": "நான் என் .env ஐ மாற்றிவிட்டேன்",
"accept_license": "உரிமத்தை ஏற்கவும்",
"still_waiting_for_approval": "ஒரு நிகழ்வு இன்னும் ஒப்புதலுக்காகக் காத்திருக்கிறது",
"event_is_still_waiting": "நிகழ்வு கோரிக்கை இன்னும் காத்திருக்கிறது: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "மேலும் முடிவுகள் இல்லை",
"no_results": "முடிவுகள் இல்லை",
"load_more_results": "மேலும் முடிவுகளை ஏற்றவும்",
"integration_meeting_id": "{{integrationName}} சந்திப்பு ஐடி: {{meetingId}}",
"confirmed_event_type_subject": "உறுதிப்படுத்தப்பட்டது: {{date}} அன்று {{name}} உடன் {{eventType}}",
"new_event_request": "புதிய நிகழ்வு கோரிக்கை: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "கோரிக்கையை உறுதிப்படுத்தவும் அல்லது நிராகரிக்கவும்",
"check_bookings_page_to_confirm_or_reject": "முன்பதிவை உறுதிப்படுத்த அல்லது நிராகரிக்க உங்கள் முன்பதிவு பக்கத்தைப் பார்க்கவும்.",
"event_awaiting_approval": "உங்கள் ஒப்புதலுக்காக ஒரு நிகழ்வு காத்திருக்கிறது",
"event_awaiting_approval_recurring": "தொடர்ச்சியான நிகழ்வு உங்கள் ஒப்புதலுக்காகக் காத்திருக்கிறது",
"someone_requested_an_event": "உங்கள் காலெண்டரில் ஒரு நிகழ்வைத் திட்டமிடுமாறு ஒருவர் கோரியுள்ளார்.",
"someone_requested_password_reset": "உங்கள் கடவுச்சொல்லை மாற்ற யாரோ ஒரு இணைப்பைக் கோரியுள்ளனர்.",
"password_reset_instructions": "நீங்கள் இதைக் கோரவில்லை எனில், இந்த மின்னஞ்சலைப் பாதுகாப்பாகப் புறக்கணிக்கலாம், மேலும் உங்கள் கடவுச்சொல் மாற்றப்படாது.",
"event_awaiting_approval_subject": "ஒப்புதலுக்காகக் காத்திருக்கிறது: {{title}} {{date}} இல்",
"event_still_awaiting_approval": "உங்கள் ஒப்புதலுக்காக ஒரு நிகழ்வு இன்னும் காத்திருக்கிறது",
"booking_submitted_subject": "முன்பதிவு சமர்ப்பிக்கப்பட்டது: {{title}} {{date}} அன்று",
"download_your_recording": "உங்கள் பதிவைப் பதிவிறக்கவும்",
"your_meeting_has_been_booked": "உங்கள் சந்திப்பு பதிவு செய்யப்பட்டுள்ளது",
"event_type_has_been_rescheduled_on_time_date": "உங்கள் {{title}}, {{date}} க்கு மாற்றியமைக்கப்பட்டுள்ளது.",
"event_has_been_rescheduled": "புதுப்பிக்கப்பட்டது - உங்கள் நிகழ்வு மீண்டும் திட்டமிடப்பட்டது",
"request_reschedule_title_attendee": "உங்கள் முன்பதிவை மாற்றியமைக்க கோரிக்கை",
"request_reschedule_subtitle": "{{organizer}} முன்பதிவை ரத்துசெய்துவிட்டு, மற்றொரு நேரத்தைத் தேர்ந்தெடுக்கும்படி கோரியுள்ளார்.",
"request_reschedule_title_organizer": "நீங்கள் {{attendee}} ஐ மறுஅட்டவணைக்குக் கோரியுள்ளீர்கள்",
"request_reschedule_subtitle_organizer": "நீங்கள் முன்பதிவை ரத்து செய்துவிட்டீர்கள், {{attendee}} உங்களுடன் புதிய முன்பதிவு நேரத்தைத் தேர்ந்தெடுக்க வேண்டும்.",
"rescheduled_event_type_subject": "மறுஅட்டவணைக்கான கோரிக்கை அனுப்பப்பட்டது: {{eventType}} உடன் {{name}} உடன் {{date}}",
"requested_to_reschedule_subject_attendee": "மறுஅட்டவணை செயல் தேவை: {{eventType}} க்கு {{name}} உடன் புதிய நேரத்தை முன்பதிவு செய்யவும்",
"reschedule_reason": "மறு அட்டவணைக்கான காரணம்",
"hi_user_name": "வணக்கம் {{name}}",
"turn_on": "இயக்கவும்",
"email_no_user_invite_heading": "நீங்கள் {{appName}} குழுவில் சேர அழைக்கப்பட்டுள்ளீர்கள்",
"email_no_user_invite_subheading": "{{invitedBy}} உங்களை {{appName}} குழுவில் சேர அழைத்துள்ளார். {{appName}} என்பது ஈமெயில் இல்லாமலேயே உங்களையும் உங்கள் குழுவையும் கூட்டங்களைத் திட்டமிடுவதற்கு உதவும் திட்டமிடல் பயன்பாடு.",
"email_no_user_invite_steps_intro": "சில சிறிய முயர்ச்சித்தல் மூலம் நாங்கள் உங்களை அழைத்துச் செல்வோம், எந்த நேரத்திலும் உங்கள் குழுவுடன் மன அழுத்தமில்லாத திட்டமிடலை அனுபவிப்பீர்கள்.",
@@ -114,6 +166,7 @@
"select_all": "அனைத்தையும் தேர்வுசெய்",
"default_conferencing_bulk_title": "தற்போதுள்ள நிகழ்வு வகைகளை மொத்தமாகப் புதுப்பிக்கவும்",
"default_conferencing_bulk_description": "தேர்ந்தெடுக்கப்பட்ட நிகழ்வு வகைகளுக்கான இருப்பிடங்களைப் புதுப்பிக்கவும்",
"locked_by_admin": "குழு நிர்வாகியால் பூட்டப்பட்டது",
"looking_for_more_analytics": "மேலும் பகுப்பாய்வுகளைத் தேடுகிறீர்களா?",
"add_filter": "வடிகட்டிச் சேர்க்கவும்",
"select_user": "பயனரைத் தேர்ந்தெடுக்கவும்",
@@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_updated_successfully"), "success");
},
onSettled: (routingForm) => {
utils.viewer.appRoutingForms.forms.invalidate();
if (routingForm) {
@@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
const Component = as || Button;
if (!dropdown) {
return (
<Component ref={forwardedRef} {...actionProps}>
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
{children}
</Component>
);
@@ -1,6 +1,7 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm, Response } from "../types/types";
@@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) {
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="form-field"
data-testid={`form-field-${getFieldIdentifier(field)}`}
setValue={(value) => {
setResponse((response) => {
response = response || {};
@@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast("Form updated successfully.", "success");
showToast(t("form_updated_successfully"), "success");
},
onError(e) {
if (e.message) {
@@ -157,7 +157,7 @@ const MultiSelectWidget = ({
};
});
const defaultValue = selectItems.filter((item) => value?.includes(item.value));
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
return (
<Select
@@ -165,7 +165,7 @@ const MultiSelectWidget = ({
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
defaultValue={defaultValue}
value={optionsFromList}
isMulti={true}
isDisabled={remainingProps.readOnly}
options={selectItems}
@@ -184,7 +184,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
value: item.value,
};
});
const defaultValue = selectItems.find((item) => item.value === value);
const optionFromList = selectItems.find((item) => item.value === value);
return (
<Select
@@ -196,7 +196,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
setValue(item.value);
}}
isDisabled={remainingProps.readOnly}
defaultValue={defaultValue}
value={optionFromList}
options={selectItems}
{...remainingProps}
/>
@@ -134,7 +134,7 @@ function Field({
<TextField
disabled={!!router}
label="Identifier"
name="identifier"
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
value={identifier}
@@ -175,7 +175,7 @@ const Route = ({
</div>
<Select
isDisabled={disabled}
className="block w-full flex-grow px-2"
className="data-testid-select-routing-action block w-full flex-grow px-2"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
@@ -16,10 +16,12 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
import FormInputFields from "../../components/FormInputFields";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import type { Response, Route } from "../../types/types";
type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({
brandColor,
darkBrandColor,
@@ -34,7 +36,7 @@ const useBrandColors = ({
useCalcomTheme(brandTheme);
};
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
function RoutingForm({ form, profile, ...restProps }: Props) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
const isEmbed = useIsEmbed(restProps.isEmbed);
@@ -43,12 +45,15 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
});
const [response, setResponse] = usePrefilledResponse(form);
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionRef = useRef<Route["action"]>();
const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>();
const router = useRouter();
const onSubmit = (response: Response) => {
@@ -65,7 +70,10 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
formFillerId,
response: response,
});
decidedActionRef.current = decidedAction;
decidedActionWithFormResponseRef.current = {
action: decidedAction,
response,
};
};
useEffect(() => {
@@ -75,19 +83,26 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
onSuccess: () => {
const decidedAction = decidedActionRef.current;
if (!decidedAction) {
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
if (!decidedActionWithFormResponse) {
return;
}
const fields = form.fields;
if (!fields) {
throw new Error("Routing Form fields must exist here");
}
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
const decidedAction = decidedActionWithFormResponse.action;
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
setCustomPageMessage(decidedAction.value);
} else if (decidedAction.type === "eventTypeRedirectUrl") {
router.push(`/${decidedAction.value}`);
router.push(`/${decidedAction.value}?${allURLSearchParams}`);
} else if (decidedAction.type === "externalRedirectUrl") {
window.parent.location.href = decidedAction.value;
window.parent.location.href = `${decidedAction.value}?${allURLSearchParams}`;
}
// We don't want to show this message as it doesn't look good in Embed.
// showToast("Form submitted successfully! Redirecting now ...", "success");
},
onError: (e) => {
@@ -97,12 +112,11 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
if (e?.data?.code === "CONFLICT") {
return void showToast("Form already submitted", "error");
}
// We don't want to show this error as it doesn't look good in Embed.
// showToast("Something went wrong", "error");
},
});
const [response, setResponse] = useState<Response>({});
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
@@ -161,6 +175,53 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
);
}
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(response).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
fieldResponse.value;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm {...props} />;
}
@@ -192,6 +253,7 @@ export const getServerSideProps = async function getServerSideProps(
include: {
user: {
select: {
username: true,
theme: true,
brandColor: true,
darkBrandColor: true,
@@ -209,6 +271,7 @@ export const getServerSideProps = async function getServerSideProps(
return {
props: {
isEmbed,
themeBasis: form.user.username,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
@@ -218,3 +281,19 @@ export const getServerSideProps = async function getServerSideProps(
},
};
};
const usePrefilledResponse = (form: Props["form"]) => {
const router = useRouter();
const prefillResponse: Response = {};
// Prefill the form from query params
form.fields?.forEach((field) => {
prefillResponse[field.id] = {
value: router.query[getFieldIdentifier(field)] || "",
label: field.label,
};
});
const [response, setResponse] = useState<Response>(prefillResponse);
return [response, setResponse] as const;
};
@@ -16,21 +16,19 @@ test.describe("Routing Forms", () => {
const formId = await addForm(page);
await page.click('[href="/apps/routing-forms/forms"]');
// TODO: Workaround for bug in https://github.com/calcom/cal.com/issues/3410
await page.click('[href="/apps/routing-forms/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
await gotoRoutingLink(page, formId);
await page.isVisible("text=Test Form Name");
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await page.click('[data-testid="toggle-form"] [value="on"]');
await gotoRoutingLink(page, formId);
await page.isVisible("text=ERROR 404");
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=ERROR 404")).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {
@@ -41,16 +39,18 @@ test.describe("Routing Forms", () => {
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
const { types } = await addMultipleFieldsAndSaveForm(formId, page, { description, label });
await page.reload();
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description,
label,
});
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
types.forEach((item, index) => {
createdFields[index] = { label: `Test Label ${index + 1}`, typeIndex: index };
fields.forEach((item, index) => {
createdFields[index] = { label: item.label, typeIndex: index };
});
await expectCurrentFormToHaveFields(page, createdFields, types);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
@@ -63,9 +63,7 @@ test.describe("Routing Forms", () => {
});
test.describe("F1<-F2 Relationship", () => {
// TODO: Fix this test, it is very flaky
// prettier-ignore
test.fixme("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
const form1Id = await addForm(page, { name: "F1" });
const form2Id = await addForm(page, { name: "F2" });
@@ -118,6 +116,63 @@ test.describe("Routing Forms", () => {
todo("Create relationship by using duplicate with live connect");
});
test("should be able to submit a prefilled form with all types of fields", async ({ page }) => {
const formId = await addForm(page);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
await selectNewRoute(page);
await selectOption({
selector: {
selector: ".data-testid-select-routing-action",
nth: 0,
},
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", "https://www.google.com");
await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description: "Description",
label: "Test Field",
});
const queryString =
"firstField=456&Test Field Number=456&Test Field Select=456&Test Field MultiSelect=456&Test Field MultiSelect=789&Test Field Phone=456&Test Field Email=456@example.com";
await gotoRoutingLink({ page, queryString });
await page.fill('[data-testid="form-field-Test Field Long Text"]', "manual-fill");
expect(await page.locator(`[data-testid="form-field-firstField"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Number"]`).inputValue()).toBe("456");
// TODO: Verify select and multiselect has prefilled values.
// expect(await page.locator(`[data-testid="form-field-Test Field Select"]`).inputValue()).toBe("456");
// expect(await page.locator(`[data-testid="form-field-Test Field MultiSelect"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Phone"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Email"]`).inputValue()).toBe(
"456@example.com"
);
await page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
const url = new URL(page.url());
// Coming from the response filled by booker
expect(url.searchParams.get("firstField")).toBe("456");
// All other params come from prefill URL
expect(url.searchParams.get("Test Field Number")).toBe("456");
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
expect(url.searchParams.get("Test Field Select")).toBe("456");
expect(url.searchParams.getAll("Test Field MultiSelect")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test Field Phone")).toBe("456");
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
});
// TODO: How to install the app just once?
test.beforeEach(async ({ page, users }) => {
const user = await users.create(
@@ -266,16 +321,16 @@ test.describe("Routing Forms", () => {
});
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await page.isVisible("text=Custom Page Result");
await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
await page.isVisible("text=Multiselect chosen");
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
});
test("Routing Link should validate fields", async ({ page, users }) => {
const user = await createUserAndLoginAndInstallApp({ users, page });
const routingForm = user.routingForms[0];
await gotoRoutingLink(page, routingForm.id);
await gotoRoutingLink({ page, formId: routingForm.id });
page.click('button[type="submit"]');
const firstInputMissingValue = await page.evaluate(() => {
return document.querySelectorAll("input")[0].validity.valueMissing;
@@ -291,40 +346,45 @@ test.describe("Routing Forms", () => {
await page.click('[data-testid="test-preview"]');
// //event redirect
await page.fill('[data-testid="form-field"]', "event-routing");
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
await page.click('[data-testid="test-routing"]');
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Event Redirect");
await expect(route).toBe("pro/30min");
expect(routingType).toBe("Event Redirect");
expect(route).toBe("pro/30min");
//custom page
await page.fill('[data-testid="form-field"]', "custom-page");
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Custom Page");
await expect(route).toBe("Custom Page Result");
expect(routingType).toBe("Custom Page");
expect(route).toBe("Custom Page Result");
//external redirect
await page.fill('[data-testid="form-field"]', "external-redirect");
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("External Redirect");
await expect(route).toBe("https://google.com");
expect(routingType).toBe("External Redirect");
expect(route).toBe("https://google.com");
//fallback route
await page.fill('[data-testid="form-field"]', "fallback");
await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Custom Page");
await expect(route).toBe("Fallback Message");
expect(routingType).toBe("Custom Page");
expect(route).toBe("Fallback Message");
});
});
});
async function disableForm(page: Page) {
await page.click('[data-testid="toggle-form"] [value="on"]');
await page.waitForSelector(".data-testid-toast-success");
}
async function expectCurrentFormToHaveFields(
page: Page,
fields: {
@@ -341,24 +401,24 @@ async function expectCurrentFormToHaveFields(
}
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "event-routing");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min");
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "external-redirect");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "custom-page");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
await expect(page.locator("text=Custom Page Result")).toBeVisible();
}
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
@@ -375,7 +435,7 @@ export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
return formId;
}
async function addMultipleFieldsAndSaveForm(
async function addAllTypesOfFieldsAndSaveForm(
formId: string,
page: Page,
form: { description: string; label: string }
@@ -384,33 +444,51 @@ async function addMultipleFieldsAndSaveForm(
await page.click('[data-testid="add-field"]');
await page.fill('[data-testid="description"]', form.description);
const { optionsInUi: types } = await verifySelectOptions(
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
page
);
await page.fill(`[name="fields.0.label"]`, `${form.label} 1`);
await page.click('[data-testid="add-field"]');
const fields = [];
for (let index = 0; index < fieldTypesList.length; index++) {
const fieldTypeLabel = fieldTypesList[index];
const nth = index;
const label = `${form.label} ${fieldTypeLabel}`;
let identifier = "";
const withoutFirstValue = [...types].filter((val) => val !== "Short Text");
if (index !== 0) {
identifier = label;
// Click on the field type dropdown.
await page.locator(".data-testid-field-type").nth(nth).click();
// Click on the dropdown option.
await page.locator(`[data-testid="select-option-${fieldTypeLabel}"]`).click();
} else {
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
// First field type is by default selected. So, no need to choose from dropdown
identifier = "firstField";
}
for (let index = 0; index < withoutFirstValue.length; index++) {
const fieldName = withoutFirstValue[index];
const nth = index + 1;
const label = `${form.label} ${index + 2}`;
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
}
await page.locator(".data-testid-field-type").nth(nth).click();
await page.locator(`[data-testid="select-option-${fieldName}"]`).click();
await page.fill(`[name="fields.${nth}.label"]`, label);
if (index !== withoutFirstValue.length - 1) {
if (identifier !== label) {
await page.fill(`[name="fields.${nth}.identifier"]`, identifier);
}
if (index !== fieldTypesList.length - 1) {
await page.click('[data-testid="add-field"]');
}
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
}
await saveCurrentForm(page);
return {
types,
fieldTypesList,
fields,
};
}
@@ -461,6 +539,9 @@ async function selectOption({
}: {
page: Page;
selector: { selector: string; nth: number };
/**
* Index of option to select. Starts from 1
*/
option: number;
}) {
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
@@ -513,8 +594,29 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
});
}
async function gotoRoutingLink(page: Page, formId: string) {
await page.goto(`/forms/${formId}`);
async function gotoRoutingLink({
page,
formId,
queryString = "",
}: {
page: Page;
formId?: string;
queryString?: string;
}) {
let previewLink = null;
if (!formId) {
// Instead of clicking on the preview link, we are going to the preview link directly because the earlier opens a new tab which is a bit difficult to manage with Playwright
const href = await page.locator('[data-testid="form-action-preview"]').getAttribute("href");
if (!href) {
throw new Error("Preview link not found");
}
previewLink = href;
} else {
previewLink = `/forms/${formId}`;
}
await page.goto(`${previewLink}${queryString ? `?${queryString}` : ""}`);
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
await new Promise((resolve) => setTimeout(resolve, 500));
}
+9 -5
View File
@@ -5,7 +5,7 @@ import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { RecurringEvent } from "@calcom/types/Calendar";
@@ -31,10 +31,14 @@ export function getRecurringWhen({
return "";
}
export function WhenInfo(props: { calEvent: CalendarEvent; timeZone: string; t: TFunction }) {
const { timeZone, t, calEvent: { recurringEvent } = {} } = props;
const timeFormat = props.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
const locale = props.calEvent.organizer.language.locale;
export function WhenInfo(props: {
calEvent: CalendarEvent;
timeZone: string;
t: TFunction;
locale: string;
timeFormat: TimeFormat;
}) {
const { timeZone, t, calEvent: { recurringEvent } = {}, locale, timeFormat } = props;
function getRecipientStart(format: string) {
return dayjs(props.calEvent.startTime).tz(timeZone).locale(locale).format(format);
@@ -9,6 +9,12 @@ export const AttendeeScheduledEmail = (
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
return (
<BaseScheduledEmail timeZone={props.attendee.timeZone} t={props.attendee.language.translate} {...props} />
<BaseScheduledEmail
locale={props.attendee.language.locale}
timeZone={props.attendee.timeZone}
t={props.attendee.language.translate}
timeFormat={props.attendee?.timeFormat}
{...props}
/>
);
};
@@ -1,6 +1,7 @@
import type { TFunction } from "next-i18next";
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import {
@@ -21,9 +22,13 @@ export const BaseScheduledEmail = (
timeZone: string;
includeAppsStatus?: boolean;
t: TFunction;
locale: string;
timeFormat: TimeFormat | undefined;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { t, timeZone } = props;
const { t, timeZone, locale, timeFormat: timeFormat_ } = props;
const timeFormat = timeFormat_ ?? TimeFormat.TWELVE_HOUR;
function getRecipientStart(format: string) {
return dayjs(props.calEvent.startTime).tz(timeZone).format(format);
@@ -73,7 +78,7 @@ export const BaseScheduledEmail = (
)}
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhenInfo timeFormat={timeFormat} calEvent={props.calEvent} t={t} timeZone={timeZone} locale={locale} />
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
@@ -1,4 +1,4 @@
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import { Trans } from "react-i18next";
import { AppStoreLocationType } from "@calcom/app-store/locations";
@@ -56,6 +56,8 @@ export const BrokenIntegrationEmail = (
) => {
const { calEvent, type } = props;
const t = calEvent.organizer.language.translate;
const locale = calEvent.organizer.language.locale;
const timeFormat = calEvent.organizer?.timeFormat;
if (type === "video") {
let location = calEvent.location ? getEnumKeyByEnumValue(AppStoreLocationType, calEvent.location) : " ";
@@ -70,6 +72,8 @@ export const BrokenIntegrationEmail = (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_adding_video_link")}
subtitle={<BrokenVideoIntegration location={location} eventTypeId={calEvent.eventTypeId} t={t} />}
@@ -94,6 +98,8 @@ export const BrokenIntegrationEmail = (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
subtitle={<BrokenCalendarIntegration calendar={calendar} eventTypeId={calEvent.eventTypeId} t={t} />}
@@ -107,6 +113,8 @@ export const BrokenIntegrationEmail = (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
headerType="xCircle"
@@ -10,13 +10,17 @@ export const NoShowFeeChargedEmail = (
) => {
const { calEvent } = props;
const t = props.attendee.language.translate;
const locale = props.attendee.language.locale;
const timeFormat = props.attendee?.timeFormat;
if (!calEvent.paymentInfo?.amount) throw new Error("No payment info");
return (
<BaseScheduledEmail
locale={locale}
title={t("no_show_fee_charged_text_body")}
headerType="calendarCircle"
timeFormat={timeFormat}
subtitle={
<>
{t("no_show_fee_charged_subtitle", {
@@ -29,13 +29,18 @@ export const OrganizerScheduledEmail = (
}
const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate;
const locale = props.teamMember?.language.locale || props.calEvent.organizer.language.locale;
const timeFormat = props.teamMember?.timeFormat || props.calEvent.organizer?.timeFormat;
return (
<BaseScheduledEmail
locale={locale}
timeZone={props.teamMember?.timeZone || props.calEvent.organizer.timeZone}
t={t}
subject={t(subject)}
title={t(title)}
includeAppsStatus
timeFormat={timeFormat}
subtitle={
<>
{props.attendeeCancelled
+12 -3
View File
@@ -9,7 +9,7 @@
const randomInt = Math.floor(Math.random() * 16777216);
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
const hexString = randomInt.toString(16).padStart(6, '0');
const hexString = randomInt.toString(16).padStart(6, "0");
// Return the hex string with a '#' prefix
return `#${hexString}`;
@@ -161,7 +161,7 @@
<h3>
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)]</a>
</h3>
<button onclick="Cal('ui',{theme:'light'})" >Toggle to Light</button>
<button onclick="Cal('ui',{theme:'light'})">Toggle to Light</button>
<i class="last-action"> You would see last Booking page action in my place </i>
<div>
@@ -187,7 +187,9 @@
<button onclick="(function () {Cal.ns.second('ui', {styles:{body:{background:'red'}}})})()">
Change <code>body</code> bg color[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'left'}})})()">Align left[Deprecated]</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'left'}})})()">
Align left[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'center'}})})()">
Align Center[Deprecated]
</button>
@@ -234,6 +236,13 @@
<h3><a href="?only=hideEventTypeDetails">Hide EventType Details Test</a></h3>
<div class="place"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-conflicting-theme">
<h3><a href="?only=conflicting-theme">You would be able to test out conflicting themes for the same namespace here.</a></h3>
<div class="light"></div>
<div class="dark"></div>
<i>Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace</i>
</div>
</div>
<script src="./playground.ts"></script>
</body>
</html>
+24
View File
@@ -245,6 +245,28 @@ if (only === "all" || only === "hideEventTypeDetails") {
);
}
if (only === "conflicting-theme") {
Cal("init", "conflictingTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.conflictingTheme("inline", {
elementOrSelector: "#cal-booking-place-conflicting-theme .dark",
calLink: "pro/30min",
config: {
theme: "dark",
},
});
Cal.ns.conflictingTheme("inline", {
elementOrSelector: "#cal-booking-place-conflicting-theme .light",
calLink: "pro/30min",
config: {
theme: "light",
},
});
}
Cal("init", "popupDarkTheme", {
debug: true,
origin: "http://localhost:3000",
@@ -268,10 +290,12 @@ Cal("init", "popupAutoTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: "http://localhost:3000",
@@ -70,11 +70,15 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const username = useBookerStore((state) => state.username);
const formValues = useBookerStore((state) => state.formValues);
const setFormValues = useBookerStore((state) => state.setFormValues);
const isRescheduling = !!rescheduleUid && !!rescheduleBooking;
const event = useEvent();
const eventType = event.data;
const defaultValues = useMemo(() => {
if (Object.keys(formValues).length) return formValues;
if (!eventType?.bookingFields) {
return {};
}
@@ -128,7 +132,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
email: defaultUserValues.email,
};
return defaults;
}, [eventType?.bookingFields, isRescheduling, rescheduleBooking, rescheduleUid]);
}, [eventType?.bookingFields, formValues, isRescheduling, rescheduleBooking, rescheduleUid]);
const bookingFormSchema = z
.object({
@@ -226,6 +230,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
);
const bookEvent = (values: BookingFormValues) => {
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
@@ -280,7 +286,18 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
return (
<div className="flex h-full flex-col">
<Form className="flex h-full flex-col" form={bookingForm} handleSubmit={bookEvent} noValidate>
<Form
className="flex h-full flex-col"
onChange={() => {
// Form data is saved in store. This way when user navigates back to
// still change the timeslot, and comes back to the form, all their values
// still exist. This gets cleared when the form is submitted.
const values = bookingForm.getValues();
setFormValues(values);
}}
form={bookingForm}
handleSubmit={bookEvent}
noValidate>
<BookingFields
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
fields={eventType.bookingFields}
@@ -79,6 +79,13 @@ type BookerStore = {
* Method called by booker component to set initial data.
*/
initialize: (data: StoreInitializeType) => void;
/**
* Stored form state, used when user navigates back and
* forth between timeslots and form. Get's cleared on submit
* to prevent sticky data.
*/
formValues: Record<string, any>;
setFormValues: (values: Record<string, any>) => void;
};
const validLayouts: BookerLayout[] = ["large_calendar", "large_timeslots", "small_calendar"];
@@ -179,6 +186,10 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
set({ selectedTimeslot });
updateQueryParam("slot", selectedTimeslot ?? "");
},
formValues: {},
setFormValues: (formValues: Record<string, any>) => {
set({ formValues });
},
}));
export const useInitializeBookerStore = ({
@@ -201,7 +201,7 @@ export const FormBuilder = function FormBuilder({
// It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now.
value.splice(index, 1, {
label: e.target.value,
value: e.target.value.toLowerCase().trim(),
value: e.target.value.trim(),
});
onChange(value);
}}
+3 -1
View File
@@ -53,7 +53,9 @@ if (IS_EMBED_REACT_TEST) {
const config: PlaywrightTestConfig = {
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: os.cpus().length,
// While debugging it should be focussed mode
// eslint-disable-next-line turbo/no-undeclared-env-vars
workers: process.env.PWDEBUG ? 1 : os.cpus().length,
timeout: DEFAULT_TEST_TIMEOUT,
maxFailures: headless ? 10 : undefined,
fullyParallel: true,