2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import { useSession } from "next-auth/react";
import { trpc } from "@calcom/trpc/react";
export default function useApp(appId: string) {
const { status } = useSession();
return trpc.viewer.appById.useQuery(
{ appId },
{
enabled: status === "authenticated",
}
);
}

View File

@@ -0,0 +1,7 @@
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
export const useBookerUrl = () => {
const orgBranding = useOrgBranding();
return orgBranding?.fullDomain ?? WEBSITE_URL ?? WEBAPP_URL;
};

View File

@@ -0,0 +1,14 @@
import { useRef } from "react";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
export const useCallbackRef = <C>(callback: C) => {
const callbackRef = useRef(callback);
useIsomorphicLayoutEffect(() => {
callbackRef.current = callback;
});
return callbackRef;
};
export default useCallbackRef;

View File

@@ -0,0 +1,67 @@
import { renderHook } from "@testing-library/react-hooks";
import { vi } from "vitest";
import { describe, expect, it } from "vitest";
import { useCompatSearchParams } from "./useCompatSearchParams";
vi.mock("next/navigation", () => ({
ReadonlyURLSearchParams: vi.fn((a) => a),
}));
describe("useCompatSearchParams hook", () => {
it("should return the searchParams in next@13.4.6 Pages Router, SSR", async () => {
const navigation = await import("next/navigation");
navigation.useSearchParams = vi.fn().mockReturnValue(new URLSearchParams("a=a&b=b"));
navigation.useParams = vi.fn().mockReturnValue(null);
const { result } = renderHook(() => useCompatSearchParams());
expect(result.current.toString()).toEqual("a=a&b=b");
});
it("should return both searchParams and params in next@13.4.6 App Router, SSR", async () => {
const navigation = await import("next/navigation");
navigation.useSearchParams = vi.fn().mockReturnValue(new URLSearchParams("a=a"));
navigation.useParams = vi.fn().mockReturnValue({ b: "b" });
const { result } = renderHook(() => useCompatSearchParams());
expect(result.current.toString()).toEqual("a=a&b=b");
});
it("params should always override searchParams in case of conflicting keys", async () => {
const navigation = await import("next/navigation");
navigation.useSearchParams = vi.fn().mockReturnValue(new URLSearchParams("a=a"));
navigation.useParams = vi.fn().mockReturnValue({ a: "b" });
const { result } = renderHook(() => useCompatSearchParams());
expect(result.current.toString()).toEqual("a=b");
});
it("should split paramsseparated with '/' (catch-all segments) in next@13.4.6 App Router, SSR", async () => {
const navigation = await import("next/navigation");
navigation.useSearchParams = vi.fn().mockReturnValue(new URLSearchParams());
// in next@13.4.6 useParams will return params separated by `/`
navigation.useParams = vi.fn().mockReturnValue({ a: "a/b/c" });
const { result } = renderHook(() => useCompatSearchParams());
expect(result.current.getAll("a")).toEqual(["a", "b", "c"]);
});
it("should include params and searchParams in next@13.5.4, Pages/App Router, SSR", async () => {
const navigation = await import("next/navigation");
navigation.useSearchParams = vi.fn().mockReturnValue(new URLSearchParams("a=a"));
navigation.useParams = vi.fn().mockReturnValue({ b: "b" });
const { result } = renderHook(() => useCompatSearchParams());
expect(result.current.toString()).toEqual("a=a&b=b");
});
});

View File

@@ -0,0 +1,22 @@
import { ReadonlyURLSearchParams, useParams, useSearchParams } from "next/navigation";
export const useCompatSearchParams = () => {
const _searchParams = useSearchParams() ?? new URLSearchParams();
const params = useParams() ?? {};
const searchParams = new URLSearchParams(_searchParams.toString());
Object.getOwnPropertyNames(params).forEach((key) => {
searchParams.delete(key);
// Though useParams is supposed to return a string/string[] as the key's value but it is found to return undefined as well.
// Maybe it happens for pages dir when using optional catch-all routes.
const param = params[key] || "";
const paramArr = typeof param === "string" ? param.split("/") : param;
paramArr.forEach((p) => {
searchParams.append(key, p);
});
});
return new ReadonlyURLSearchParams(searchParams);
};

View File

@@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
export function useCopy() {
const [isCopied, setIsCopied] = useState(false);
const copyToClipboard = (text: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard
.writeText(text)
.then(() => setIsCopied(true))
.catch((error) => console.error("Copy to clipboard failed:", error));
}
};
const resetCopyStatus = () => {
setIsCopied(false);
};
useEffect(() => {
if (isCopied) {
const timer = setTimeout(resetCopyStatus, 3000); // Reset copy status after 3 seconds
return () => clearTimeout(timer);
}
}, [isCopied]);
return { isCopied, copyToClipboard, resetCopyStatus };
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timeout = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeout);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,42 @@
import { trpc } from "@calcom/trpc/react";
import { IS_SELF_HOSTED } from "../constants";
import hasKeyInMetadata from "../hasKeyInMetadata";
export function useHasPaidPlan() {
if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true };
const { data: hasTeamPlan, isPending: isPendingTeamQuery } = trpc.viewer.teams.hasTeamPlan.useQuery();
const { data: user, isPending: isPendingUserQuery } = trpc.viewer.me.useQuery();
const isPending = isPendingTeamQuery || isPendingUserQuery;
const isCurrentUsernamePremium =
user && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const hasPaidPlan = hasTeamPlan?.hasTeamPlan || isCurrentUsernamePremium;
return { isPending, hasPaidPlan };
}
export function useTeamInvites() {
const listInvites = trpc.viewer.teams.listInvites.useQuery();
return { isPending: listInvites.isPending, listInvites: listInvites.data };
}
export function useHasTeamPlan() {
const { data: hasTeamPlan, isPending } = trpc.viewer.teams.hasTeamPlan.useQuery();
return { isPending, hasTeamPlan: hasTeamPlan?.hasTeamPlan };
}
export function useHasEnterprisePlan() {
// TODO: figure out how to get "has Enterprise / has Org" from the backend
const { data: hasTeamPlan, isPending } = trpc.viewer.teams.hasTeamPlan.useQuery();
return { isPending, hasTeamPlan: hasTeamPlan?.hasTeamPlan };
}
export default useHasPaidPlan;

View File

@@ -0,0 +1,3 @@
import { useEffect, useLayoutEffect } from "react";
export const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;

View File

@@ -0,0 +1,47 @@
import type { RefObject } from "react";
import { useState, useEffect } from "react";
export function useKeyPress(
targetKey: string,
ref?: RefObject<HTMLInputElement>,
handler?: () => void
): boolean {
// State for keeping track of whether key is pressed
const [keyPressed, setKeyPressed] = useState(false);
const placeHolderRef = ref?.current;
// If pressed key is our target key then set to true
function downHandler({ key }: { key: string }) {
if (key === targetKey) {
setKeyPressed(true);
handler && handler();
}
}
// If released key is our target key then set to false
const upHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
// Add event listeners
useEffect(() => {
if (ref && placeHolderRef) {
placeHolderRef.addEventListener("keydown", downHandler);
placeHolderRef.addEventListener("keyup", upHandler);
return () => {
placeHolderRef?.removeEventListener("keydown", downHandler);
placeHolderRef?.removeEventListener("keyup", upHandler);
};
} else {
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
// Remove event listeners on cleanup
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty array ensures that effect is only run on mount and unmount
return keyPressed;
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from "next-i18next";
import { useAtomsContext } from "@calcom/atoms/monorepo";
export const useLocale = (namespace: Parameters<typeof useTranslation>[0] = "common") => {
const context = useAtomsContext();
const { i18n, t } = useTranslation(namespace);
const isLocaleReady = Object.keys(i18n).length > 0;
if (context?.clientId) {
return { i18n: context.i18n, t: context.t, isLocaleReady: true } as unknown as {
i18n: ReturnType<typeof useTranslation>["i18n"];
t: ReturnType<typeof useTranslation>["t"];
isLocaleReady: boolean;
};
}
return {
i18n,
t,
isLocaleReady,
};
};

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, [matches, query]);
return matches;
};
export default useMediaQuery;

View File

@@ -0,0 +1,25 @@
import type React from "react";
import { useEffect } from "react";
export default function useOnClickOutside(
ref: React.RefObject<HTMLDivElement>,
handler: (e?: MouseEvent | TouchEvent) => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [ref, handler]);
}

View File

@@ -0,0 +1,28 @@
import type { PaginationState } from "@tanstack/react-table";
import { useState, useMemo } from "react";
export function usePagination({
defaultPageIndex = 1,
defaultPageSize = 20,
}: {
defaultPageIndex?: number;
defaultPageSize?: number;
}) {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: defaultPageIndex,
pageSize: defaultPageSize,
});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
);
return {
pagination,
setPagination,
};
}

View File

@@ -0,0 +1,63 @@
import { renderHook } from "@testing-library/react-hooks";
import { vi } from "vitest";
import { describe, expect, it } from "vitest";
import { useParamsWithFallback } from "./useParamsWithFallback";
describe("useParamsWithFallback hook", () => {
it("should return router.query when param is null", () => {
vi.mock("next/navigation", () => ({
useParams: vi.fn().mockReturnValue(null),
}));
vi.mock("next/compat/router", () => ({
useRouter: vi.fn().mockReturnValue({ query: { id: 1 } }),
}));
const { result } = renderHook(() => useParamsWithFallback());
expect(result.current).toEqual({ id: 1 });
});
it("should return router.query when param is undefined", () => {
vi.mock("next/navigation", () => ({
useParams: vi.fn().mockReturnValue(undefined),
}));
vi.mock("next/compat/router", () => ({
useRouter: vi.fn().mockReturnValue({ query: { id: 1 } }),
}));
const { result } = renderHook(() => useParamsWithFallback());
expect(result.current).toEqual({ id: 1 });
});
it("should return useParams() if it exists", () => {
vi.mock("next/navigation", () => ({
useParams: vi.fn().mockReturnValue({ id: 1 }),
}));
vi.mock("next/compat/router", () => ({
useRouter: vi.fn().mockReturnValue(null),
}));
const { result } = renderHook(() => useParamsWithFallback());
expect(result.current).toEqual({ id: 1 });
});
it("should return useParams() if it exists", () => {
vi.mock("next/navigation", () => ({
useParams: vi.fn().mockReturnValue({ id: 1 }),
}));
vi.mock("next/compat/router", () => ({
useRouter: vi.fn().mockReturnValue({ query: { id: 2 } }),
}));
const { result } = renderHook(() => useParamsWithFallback());
expect(result.current).toEqual({ id: 1 });
});
});

View File

@@ -0,0 +1,18 @@
"use client";
import { useRouter as useCompatRouter } from "next/compat/router";
import { useParams } from "next/navigation";
import type { ParsedUrlQuery } from "querystring";
interface Params {
[key: string]: string | string[];
}
/**
* This hook is a workaround until pages are migrated to app directory.
*/
export function useParamsWithFallback(): Params | ParsedUrlQuery {
const params = useParams(); // always `null` in pages router
const router = useCompatRouter(); // always `null` in app router
return params ?? router?.query ?? {};
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
const useResponsive = () => {
const [width, setWidth] = useState(window.innerWidth);
const handleWindowSizeChange = () => {
setWidth(window.innerWidth);
};
useEffect(() => {
window.addEventListener("resize", handleWindowSizeChange);
return () => {
window.removeEventListener("resize", handleWindowSizeChange);
};
}, []);
return {
isSm: width <= 640,
isMd: width > 640 && width <= 768,
isLg: width > 768 && width <= 1024,
isXl: width > 1024 && width <= 1280,
is2xl: width > 1280 && width < 1536,
};
};
export default useResponsive;

View File

@@ -0,0 +1,41 @@
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
/**
* An alternative to Object.fromEntries that allows duplicate keys.
*/
function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
const result: Record<string, string | string[]> = {};
if (entries === null) {
return result;
}
// Consider setting atleast ES2015 as target
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [key, value] of entries) {
if (result.hasOwnProperty(key)) {
let currentValue = result[key];
if (!Array.isArray(currentValue)) {
currentValue = [currentValue];
}
currentValue.push(value);
result[key] = currentValue;
} else {
result[key] = value;
}
}
return result;
}
/**
* This hook returns the query object from the router. It is an attempt to
* keep the original query object from the old useRouter hook.
* At least until everything is properly migrated to the new router.
* @returns {Object} routerQuery
*/
export const useRouterQuery = () => {
const searchParams = useCompatSearchParams();
const routerQuery = fromEntriesWithDuplicateKeys(searchParams?.entries() ?? null);
return routerQuery;
};

View File

@@ -0,0 +1,57 @@
import { useTheme as useNextTheme } from "next-themes";
import { useEffect } from "react";
import { useEmbedTheme } from "@calcom/embed-core/embed-iframe";
/**
* It should be called once per route if you intend to use a theme different from `system` theme. `system` theme is automatically supported using <ThemeProvider />
* If needed you can also set system theme by passing 'system' as `themeToSet`
* It handles embed configured theme automatically
* To just read the values pass `getOnly` as `true` and `themeToSet` as `null`
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export default function useTheme(themeToSet: "system" | (string & {}) | undefined | null, getOnly = false) {
themeToSet = themeToSet ? themeToSet : "system";
const { resolvedTheme, setTheme, forcedTheme, theme: activeTheme } = useNextTheme();
const embedTheme = useEmbedTheme();
useEffect(() => {
// Undefined themeToSet allow the hook to be used where the theme is fetched after calling useTheme hook
if (getOnly || themeToSet === undefined) {
return;
}
// Embed theme takes precedence over theme configured in app.
// If embedTheme isn't set i.e. it's not explicitly configured with a theme, then it would use the theme configured in appearance.
// If embedTheme is set to "auto" then we consider it as null which then uses system theme.
const finalThemeToSet = embedTheme ? (embedTheme === "auto" ? "system" : embedTheme) : themeToSet;
if (!finalThemeToSet || finalThemeToSet === activeTheme) return;
setTheme(finalThemeToSet);
// We must not add `activeTheme` to the dependency list as it can cause an infinite loop b/w dark and theme switches
// because there might be another booking page with conflicting theme.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeToSet, setTheme, embedTheme]);
if (getOnly) {
return {
resolvedTheme,
forcedTheme,
activeTheme,
};
}
return;
}
/**
* Returns the currently set theme values.
*/
export function useGetTheme() {
const theme = useTheme(null, true);
if (!theme) {
throw new Error("useTheme must have a return value here");
}
return theme;
}

View File

@@ -0,0 +1,21 @@
import { useRef, useEffect } from "react";
export function useTraceUpdate(props: { [s: string]: unknown } | ArrayLike<unknown>) {
const prev = useRef(props);
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: fix this
if (prev.current[k] !== v) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: fix this
ps[k] = [prev.current[k], v];
}
return ps;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps);
}
prev.current = props;
});
}

View File

@@ -0,0 +1,124 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useMemo, useEffect } from "react";
import { z } from "zod";
import { useRouterQuery } from "./useRouterQuery";
type OptionalKeys<T> = {
[K in keyof T]-?: Record<string, unknown> extends Pick<T, K> ? K : never;
}[keyof T];
type FilteredKeys<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
// Take array as a string and return zod array
export const queryNumberArray = z
.string()
.or(z.number())
.or(z.array(z.number()))
.transform((a) => {
if (typeof a === "string") return a.split(",").map((a) => Number(a));
if (Array.isArray(a)) return a;
return [a];
});
// Take array as a string and return zod number array
// Take string and return return zod string array - comma separated
export const queryStringArray = z
.preprocess((a) => z.string().parse(a).split(","), z.string().array())
.or(z.string().array());
export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
type Output = z.infer<typeof schema>;
type FullOutput = Required<Output>;
type OutputKeys = Required<keyof FullOutput>;
type OutputOptionalKeys = OptionalKeys<Output>;
type ArrayOutput = FilteredKeys<FullOutput, Array<unknown>>;
type ArrayOutputKeys = keyof ArrayOutput;
const router = useRouter();
const unparsedQuery = useRouterQuery();
const pathname = usePathname();
const parsedQuerySchema = schema.safeParse(unparsedQuery);
let parsedQuery: Output = useMemo(() => {
return {} as Output;
}, []);
useEffect(() => {
if (parsedQuerySchema.success && parsedQuerySchema.data) {
Object.entries(parsedQuerySchema.data).forEach(([key, value]) => {
if (key in unparsedQuery || !value) return;
const search = new URLSearchParams(parsedQuery);
search.set(String(key), String(value));
router.replace(`${pathname}?${search.toString()}`);
});
}
}, [parsedQuerySchema, schema, router, pathname, unparsedQuery, parsedQuery]);
if (parsedQuerySchema.success) parsedQuery = parsedQuerySchema.data;
else if (!parsedQuerySchema.success) console.error(parsedQuerySchema.error);
// Set the query based on schema values
const setQuery = useCallback(
function setQuery<J extends OutputKeys>(key: J, value: Output[J]) {
// Remove old value by key so we can merge new value
const search = new URLSearchParams(parsedQuery);
search.set(String(key), String(value));
router.replace(`${pathname}?${search.toString()}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[parsedQuery, router]
);
// Delete a key from the query
function removeByKey(key: OutputOptionalKeys) {
const search = new URLSearchParams(parsedQuery);
search.delete(String(key));
router.replace(`${pathname}?${search.toString()}`);
}
// push item to existing key
function pushItemToKey<J extends ArrayOutputKeys>(key: J, value: ArrayOutput[J][number]) {
const existingValue = parsedQuery[key];
if (Array.isArray(existingValue)) {
if (existingValue.includes(value)) return; // prevent adding the same value to the array
// @ts-expect-error this is too much for TS it seems
setQuery(key, [...existingValue, value]);
} else {
// @ts-expect-error this is too much for TS it seems
setQuery(key, [value]);
}
}
// Remove item by key and value
function removeItemByKeyAndValue<J extends ArrayOutputKeys>(key: J, value: ArrayOutput[J][number]) {
const existingValue = parsedQuery[key];
if (Array.isArray(existingValue) && existingValue.length > 1) {
// @ts-expect-error this is too much for TS it seems
const newValue = existingValue.filter((item) => item !== value);
setQuery(key, newValue);
} else {
// @ts-expect-error this is too much for TS it seems
removeByKey(key);
}
}
// Remove all query params from the URL
function removeAllQueryParams() {
if (pathname !== null) {
router.replace(pathname);
}
}
return {
data: parsedQuery,
setQuery,
removeByKey,
pushItemToKey,
removeItemByKeyAndValue,
removeAllQueryParams,
};
}

View File

@@ -0,0 +1,21 @@
"use client";
import { usePathname } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export const useUrlMatchesCurrentUrl = (url: string) => {
// I don't know why usePathname ReturnType doesn't include null.
// It can certainly have null value https://nextjs.org/docs/app/api-reference/functions/use-pathname#:~:text=usePathname%20can%20return%20null%20when%20a%20fallback%20route%20is%20being%20rendered%20or%20when%20a%20pages%20directory%20page%20has%20been%20automatically%20statically%20optimized%20by%20Next.js%20and%20the%20router%20is%20not%20ready.
const pathname = usePathname() as null | string;
const searchParams = useCompatSearchParams();
const query = searchParams?.toString();
let pathnameWithQuery;
if (query) {
pathnameWithQuery = `${pathname}?${query}`;
} else {
pathnameWithQuery = pathname;
}
// TODO: It should actually re-order the params before comparing ?a=1&b=2 should match with ?b=2&a=1
return pathnameWithQuery ? pathnameWithQuery.includes(url) : false;
};