first commit
This commit is contained in:
38
calcom/apps/web/components/ui/AuthContainer.tsx
Normal file
38
calcom/apps/web/components/ui/AuthContainer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import classNames from "classnames";
|
||||
|
||||
import { HeadSeo, Logo } from "@calcom/ui";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
footerText?: React.ReactNode | string;
|
||||
showLogo?: boolean;
|
||||
heading?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<HeadSeo title={props.title} description={props.description} />
|
||||
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
|
||||
|
||||
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
|
||||
{props.heading && <h2 className="font-cal text-emphasis text-center text-3xl">{props.heading}</h2>}
|
||||
</div>
|
||||
{props.loading && (
|
||||
<div className="bg-muted absolute z-50 flex h-screen w-full items-center">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
calcom/apps/web/components/ui/LinkIconButton.tsx
Normal file
21
calcom/apps/web/components/ui/LinkIconButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
Icon: React.ComponentProps<typeof Icon>["name"];
|
||||
}
|
||||
|
||||
export default function LinkIconButton(props: LinkIconButtonProps) {
|
||||
return (
|
||||
<div className="-ml-2">
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
className="text-md hover:bg-emphasis hover:text-emphasis text-default flex items-center rounded-sm px-2 py-1 text-sm font-medium">
|
||||
<Icon name={props.Icon} className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
{props.children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
calcom/apps/web/components/ui/ModalContainer.tsx
Normal file
36
calcom/apps/web/components/ui/ModalContainer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import classNames from "classnames";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { Dialog, DialogContent } from "@calcom/ui";
|
||||
|
||||
export default function ModalContainer(
|
||||
props: PropsWithChildren<{
|
||||
wide?: boolean;
|
||||
scroll?: boolean;
|
||||
noPadding?: boolean;
|
||||
isOpen: boolean;
|
||||
onExit: () => void;
|
||||
}>
|
||||
) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default inline-block w-full transform text-left align-bottom transition-all sm:align-middle",
|
||||
{
|
||||
"sm:w-full sm:max-w-lg ": !props.wide,
|
||||
"sm:w-4xl sm:max-w-4xl": props.wide,
|
||||
"overflow-auto": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
calcom/apps/web/components/ui/SettingInputContainer.tsx
Normal file
25
calcom/apps/web/components/ui/SettingInputContainer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export default function SettingInputContainer({
|
||||
Input,
|
||||
Icon,
|
||||
label,
|
||||
htmlFor,
|
||||
}: {
|
||||
Input: React.ReactNode;
|
||||
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element | null;
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label htmlFor={htmlFor} className="text-default mt-1 flex text-sm font-medium">
|
||||
<Icon className="text-subtle mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full flex-grow">{Input}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import classNames from "classnames";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Input, Label } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
UPGRADE = "UPGRADE",
|
||||
}
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername?: (newUsername: string) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: RefCallback<HTMLInputElement>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const obtainNewUsernameChangeCondition = ({
|
||||
userIsPremium,
|
||||
isNewUsernamePremium,
|
||||
}: {
|
||||
userIsPremium: boolean;
|
||||
isNewUsernamePremium: boolean;
|
||||
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
|
||||
}) => {
|
||||
if (!userIsPremium && isNewUsernamePremium) {
|
||||
return UsernameChangeStatusEnum.UPGRADE;
|
||||
}
|
||||
};
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { update } = useSession();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
inputUsernameValue,
|
||||
setInputUsernameValue,
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
readonly: disabled,
|
||||
} = props;
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const recentAttemptPaymentStatus = searchParams?.get("recentAttemptPaymentStatus");
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
||||
const isCurrentUsernamePremium =
|
||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
||||
// debounce the username input, set the delay to 600ms to be consistent with signup form
|
||||
const debouncedUsername = useDebounce(inputUsernameValue, 600);
|
||||
|
||||
useEffect(() => {
|
||||
// Use the current username or if it's not set, use the one available from stripe
|
||||
setInputUsernameValue(currentUsername || stripeCustomer?.username || "");
|
||||
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkUsername(username: string | undefined) {
|
||||
if (!username) {
|
||||
setUsernameIsAvailable(false);
|
||||
setMarkAsError(false);
|
||||
setIsInputUsernamePremium(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await fetchUsername(username, null);
|
||||
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
|
||||
setIsInputUsernamePremium(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}
|
||||
|
||||
checkUsername(debouncedUsername);
|
||||
}, [debouncedUsername, currentUsername]);
|
||||
|
||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
await update({ username: inputUsernameValue });
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
});
|
||||
|
||||
// when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
|
||||
const paymentRequired = !currentUsername && stripeCustomer?.isPremium;
|
||||
|
||||
const usernameChangeCondition = obtainNewUsernameChangeCondition({
|
||||
userIsPremium: isCurrentUsernamePremium,
|
||||
isNewUsernamePremium: isInputUsernamePremium,
|
||||
stripeCustomer,
|
||||
});
|
||||
|
||||
const usernameFromStripe = stripeCustomer?.username;
|
||||
|
||||
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
|
||||
inputUsernameValue || usernameFromStripe
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${pathname}`;
|
||||
|
||||
const ActionButtons = () => {
|
||||
if (paymentRequired) {
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
className="mx-2"
|
||||
href={paymentLink}
|
||||
data-testid="reserve-username-btn">
|
||||
{t("Reserve")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if ((usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue) {
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid="update-username-btn">
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const saveUsername = () => {
|
||||
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
}
|
||||
};
|
||||
|
||||
let paymentMsg = !currentUsername ? (
|
||||
<span className="text-xs text-orange-400">
|
||||
You need to reserve your premium username for {getPremiumPlanPriceValue()}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid") {
|
||||
paymentMsg = (
|
||||
<span className="text-sm text-red-500">
|
||||
Your payment could not be completed. Your username is still not reserved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-items-center">
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
isInputUsernamePremium ? "border border-orange-400 " : "",
|
||||
"border-default bg-muted text-subtle hidden h-9 items-center rounded-l-md border border-r-0 px-3 text-sm md:inline-flex"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
</span>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
"border-l-1 mb-0 mt-0 rounded-md rounded-l-none font-sans text-sm leading-4 focus:!ring-0",
|
||||
isInputUsernamePremium
|
||||
? "border border-orange-400 focus:border focus:border-orange-400"
|
||||
: "border focus:border",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none"
|
||||
: "border-l-default",
|
||||
disabled ? "bg-subtle text-muted focus:border-0" : ""
|
||||
)}
|
||||
value={inputUsernameValue}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
// Reset payment status
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
_searchParams.delete("paymentStatus");
|
||||
if (searchParams?.toString() !== _searchParams.toString()) {
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
}
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
<div className="absolute right-2 top-0 flex flex-row">
|
||||
<span
|
||||
className={classNames(
|
||||
"mx-2 py-2",
|
||||
isInputUsernamePremium ? "text-transparent" : "",
|
||||
usernameIsAvailable ? "" : ""
|
||||
)}>
|
||||
{isInputUsernamePremium ? (
|
||||
<Icon name="star" className="mt-[2px] h-4 w-4 fill-orange-400" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{!isInputUsernamePremium && usernameIsAvailable ? (
|
||||
<Icon name="check" className="mt-[2px] h-4 w-4" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue && (
|
||||
<div className="flex justify-end">
|
||||
<ActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{paymentMsg}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent
|
||||
Icon="pencil"
|
||||
title={t("confirm_username_change_dialog_title")}
|
||||
description={
|
||||
<>
|
||||
{usernameChangeCondition && usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<p className="text-default mb-4 text-sm">{t("change_username_standard_to_premium")}</p>
|
||||
)}
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-row">
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<p className="text-subtle">{t("current_username")}</p>
|
||||
<p className="text-emphasis mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<p className="text-subtle" data-testid="new-username">
|
||||
{t("new_username")}
|
||||
</p>
|
||||
<p className="text-emphasis">{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
{/* redirect to checkout */}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isPending}
|
||||
data-testid="go-to-billing"
|
||||
href={paymentLink}>
|
||||
<>
|
||||
{t("go_to_stripe_billing")} <Icon name="external-link" className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
{/* Normal save */}
|
||||
{usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isPending}
|
||||
data-testid="save-username"
|
||||
onClick={() => {
|
||||
saveUsername();
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { PremiumTextfield };
|
||||
@@ -0,0 +1,206 @@
|
||||
import classNames from "classnames";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, TextField, DialogFooter, Tooltip } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername?: (newUsername: string) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: RefCallback<HTMLInputElement>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
}
|
||||
|
||||
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
||||
const { t } = useLocale();
|
||||
const { update } = useSession();
|
||||
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
inputUsernameValue,
|
||||
setInputUsernameValue,
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
...rest
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
|
||||
// debounce the username input, set the delay to 600ms to be consistent with signup form
|
||||
const debouncedUsername = useDebounce(inputUsernameValue, 600);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkUsername(username: string | undefined) {
|
||||
if (!username) {
|
||||
setUsernameIsAvailable(false);
|
||||
setMarkAsError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUsername !== username) {
|
||||
const { data } = await fetchUsername(username, null);
|
||||
setMarkAsError(!data.available);
|
||||
setUsernameIsAvailable(data.available);
|
||||
} else {
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkUsername(debouncedUsername);
|
||||
}, [debouncedUsername, currentUsername]);
|
||||
|
||||
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setOpenDialogSaveUsername(false);
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
await update({ username: inputUsernameValue });
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = () => {
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="relative bottom-[6px] me-2 ms-2 flex flex-row space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid="update-username-btn">
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const updateUsername = async () => {
|
||||
updateUsernameMutation.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex rounded-md">
|
||||
<div className="relative w-full">
|
||||
<TextField
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
value={inputUsernameValue}
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
)}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
{...rest}
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-6 flex h-7 flex-row">
|
||||
<span className={classNames("bg-default mx-0 p-3")}>
|
||||
{usernameIsAvailable ? (
|
||||
<Icon name="check" className="relative bottom-[6px] h-4 w-4" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-7 hidden md:inline">
|
||||
<ActionButtons />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">{t("username_already_taken")}</p>}
|
||||
|
||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
<ActionButtons />
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent type="confirmation" Icon="pencil" title={t("confirm_username_change_dialog_title")}>
|
||||
<div className="flex flex-row">
|
||||
<div className="mb-4 w-full pt-1">
|
||||
<div className="bg-subtle flex w-full flex-wrap justify-between gap-6 rounded-sm px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="text-subtle">{t("current_username")}</p>
|
||||
<Tooltip content={currentUsername}>
|
||||
<p
|
||||
className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis"
|
||||
data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-subtle" data-testid="new-username">
|
||||
{t("new_username")}
|
||||
</p>
|
||||
<Tooltip content={inputUsernameValue}>
|
||||
<p className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis">
|
||||
{inputUsernameValue}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsernameMutation.isPending}
|
||||
data-testid="save-username"
|
||||
onClick={updateUsername}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { UsernameTextfield };
|
||||
71
calcom/apps/web/components/ui/UsernameAvailability/index.tsx
Normal file
71
calcom/apps/web/components/ui/UsernameAvailability/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
interface UsernameAvailabilityFieldProps {
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
}
|
||||
|
||||
export const getUsernameAvailabilityComponent = (isPremium: boolean) => {
|
||||
if (isPremium)
|
||||
return dynamic(() => import("./PremiumTextfield").then((m) => m.PremiumTextfield), { ssr: false });
|
||||
return dynamic(() => import("./UsernameTextfield").then((m) => m.UsernameTextfield), { ssr: false });
|
||||
};
|
||||
|
||||
export const UsernameAvailabilityField = ({
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
}: UsernameAvailabilityFieldProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const [currentUsernameState, setCurrentUsernameState] = useState(user.username || "");
|
||||
const { username: usernameFromQuery, setQuery: setUsernameFromQuery } = useRouterQuery("username");
|
||||
const { username: currentUsername, setQuery: setCurrentUsername } =
|
||||
searchParams?.get("username") && user.username === null
|
||||
? { username: usernameFromQuery, setQuery: setUsernameFromQuery }
|
||||
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
username: currentUsername,
|
||||
},
|
||||
});
|
||||
|
||||
const UsernameAvailability = getUsernameAvailabilityComponent(!IS_SELF_HOSTED && !user.organization?.id);
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const usernamePrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="username"
|
||||
render={({ field: { ref, onChange, value } }) => {
|
||||
return (
|
||||
<UsernameAvailability
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={onChange}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
disabled={!!user.organization?.id}
|
||||
addOnLeading={`${usernamePrefix}/`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
67
calcom/apps/web/components/ui/form/CheckboxField.tsx
Normal file
67
calcom/apps/web/components/ui/form/CheckboxField.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { InfoBadge } from "@calcom/ui";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: React.ReactNode;
|
||||
description: string;
|
||||
descriptionAsLabel?: boolean;
|
||||
informationIconText?: string;
|
||||
};
|
||||
|
||||
const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
({ label, description, informationIconText, ...rest }, ref) => {
|
||||
const descriptionAsLabel = !label || rest.descriptionAsLabel;
|
||||
return (
|
||||
<div className="block items-center sm:flex">
|
||||
{label && (
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
{React.createElement(
|
||||
descriptionAsLabel ? "div" : "label",
|
||||
{
|
||||
className: "flex text-sm font-medium text-default",
|
||||
...(!descriptionAsLabel
|
||||
? {
|
||||
htmlFor: rest.id,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
label
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="relative flex items-start">
|
||||
{React.createElement(
|
||||
descriptionAsLabel ? "label" : "div",
|
||||
{
|
||||
className: classNames(
|
||||
"relative flex items-start",
|
||||
descriptionAsLabel ? "text-default" : "text-emphasis"
|
||||
),
|
||||
},
|
||||
<>
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded"
|
||||
/>
|
||||
</div>
|
||||
<span className="ms-2 text-sm">{description}</span>
|
||||
</>
|
||||
)}
|
||||
{informationIconText && <InfoBadge content={informationIconText} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CheckboxField.displayName = "CheckboxField";
|
||||
|
||||
export default CheckboxField;
|
||||
57
calcom/apps/web/components/ui/form/CheckedSelect.tsx
Normal file
57
calcom/apps/web/components/ui/form/CheckedSelect.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import type { Props } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type CheckedSelectOption = {
|
||||
avatar: string;
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const CheckedSelect = ({
|
||||
options = [],
|
||||
value = [],
|
||||
...props
|
||||
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
|
||||
value?: readonly CheckedSelectOption[];
|
||||
onChange: (value: readonly CheckedSelectOption[]) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
name={props.name}
|
||||
placeholder={props.placeholder || t("select")}
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
value={value}
|
||||
isMulti
|
||||
{...props}
|
||||
/>
|
||||
{value.map((option) => (
|
||||
<div key={option.value} className="border p-2 font-medium">
|
||||
<Avatar
|
||||
className="inline ltr:mr-2 rtl:ml-2"
|
||||
size="sm"
|
||||
imageSrc={option.avatar}
|
||||
alt={option.label}
|
||||
/>
|
||||
{option.label}
|
||||
<Icon
|
||||
name="x"
|
||||
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
|
||||
className="text-subtle float-right mt-0.5 h-5 w-5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckedSelect;
|
||||
33
calcom/apps/web/components/ui/form/DatePicker.tsx
Normal file
33
calcom/apps/web/components/ui/form/DatePicker.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import "react-calendar/dist/Calendar.css";
|
||||
import "react-date-picker/dist/DatePicker.css";
|
||||
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
|
||||
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
type Props = {
|
||||
date: Date;
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
};
|
||||
|
||||
export const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
"focus:ring-primary-500 focus:border-primary-500 border-default rounded-sm border p-1 pl-2 text-sm",
|
||||
className
|
||||
)}
|
||||
clearIcon={null}
|
||||
calendarIcon={<Icon name="calendar" className="text-subtle h-5 w-5" />}
|
||||
value={date}
|
||||
minDate={minDate}
|
||||
disabled={disabled}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
72
calcom/apps/web/components/ui/form/LocationSelect.tsx
Normal file
72
calcom/apps/web/components/ui/form/LocationSelect.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { GroupBase, Props, SingleValue } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
label: string;
|
||||
value: EventLocationType["type"];
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
address?: string;
|
||||
credentialId?: number;
|
||||
teamName?: string;
|
||||
};
|
||||
|
||||
export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||
|
||||
export type GroupOptionType = GroupBase<LocationOption>;
|
||||
|
||||
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <img src={icon} alt="cover" className={classNames("h-3.5 w-3.5", invertLogoOnDark(icon))} />}
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LocationSelect(props: Props<LocationOption, false, GroupOptionType>) {
|
||||
return (
|
||||
<Select<LocationOption>
|
||||
name="location"
|
||||
id="location-select"
|
||||
data-testid="location-select"
|
||||
components={{
|
||||
Option: (props) => {
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div data-testid={`location-select-item-${props.data.value}`}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
},
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<div data-testid={`location-select-item-${props.data.value}`}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</div>
|
||||
</components.SingleValue>
|
||||
),
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && (
|
||||
<img
|
||||
src={e.icon}
|
||||
alt="app-icon"
|
||||
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
|
||||
/>
|
||||
)}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
formatGroupLabel={(e) => <p className="text-default text-xs font-medium">{e.label}</p>}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
calcom/apps/web/components/ui/form/MinutesField.tsx
Normal file
43
calcom/apps/web/components/ui/form/MinutesField.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import classNames from "classnames";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="block sm:flex">
|
||||
{!!label && (
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="text-default flex h-full items-center text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="relative rounded-sm">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="number"
|
||||
className={classNames(
|
||||
"border-default block w-full rounded-sm pl-2 pr-12 text-sm",
|
||||
rest.className
|
||||
)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-subtle text-sm" id="duration">
|
||||
mins
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MinutesField.displayName = "MinutesField";
|
||||
|
||||
export default MinutesField;
|
||||
192
calcom/apps/web/components/ui/form/Select.tsx
Normal file
192
calcom/apps/web/components/ui/form/Select.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import type { GroupBase, Props, InputProps, SingleValue, MultiValue } from "react-select";
|
||||
import ReactSelect, { components } from "react-select";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
|
||||
export type SelectProps<
|
||||
Option,
|
||||
IsMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
> = Props<Option, IsMulti, Group>;
|
||||
|
||||
export const InputComponent = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>({
|
||||
inputClassName,
|
||||
...props
|
||||
}: InputProps<Option, IsMulti, Group>) => {
|
||||
return (
|
||||
<components.Input
|
||||
// disables our default form focus hightlight on the react-select input element
|
||||
inputClassName={classNames("focus:ring-0 focus:ring-offset-0", inputClassName)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function Select<
|
||||
Option,
|
||||
IsMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
||||
const darkThemeColors = {
|
||||
/** Dark Theme starts */
|
||||
//primary - Border when selected and Selected Option background
|
||||
primary: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
neutral0: "rgb(62 62 62 / var(--tw-bg-opacity))",
|
||||
// Down Arrow hover color
|
||||
neutral5: "white",
|
||||
|
||||
neutral10: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
// neutral20 - border color + down arrow default color
|
||||
neutral20: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
// neutral30 - hover border color
|
||||
neutral30: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
neutral40: "white",
|
||||
|
||||
danger: "white",
|
||||
|
||||
// Cross button in multiselect
|
||||
dangerLight: "rgb(41 41 41 / var(--tw-border-opacity))",
|
||||
|
||||
// neutral50 - MultiSelect - "Select Text" color
|
||||
neutral50: "white",
|
||||
|
||||
// neutral60 - Down Arrow color
|
||||
neutral60: "white",
|
||||
|
||||
neutral70: "red",
|
||||
|
||||
// neutral80 - Selected option
|
||||
neutral80: "white",
|
||||
|
||||
neutral90: "blue",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
/** Dark Theme ends */
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Till we know in JS the theme is ready, we can't render react-select as it would render with light theme instead
|
||||
if (!mounted) {
|
||||
return <input type="text" className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 6,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
...(hasDarkTheme
|
||||
? darkThemeColors
|
||||
: {
|
||||
/** Light Theme starts */
|
||||
primary: "var(--brand-color)",
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
/** Light Theme Ends */
|
||||
}),
|
||||
},
|
||||
})}
|
||||
styles={{
|
||||
option: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isSelected ? "var(--brand-text-color)" : "black",
|
||||
":active": {
|
||||
backgroundColor: state.isSelected ? "" : "var(--brand-color)",
|
||||
color: "var(--brand-text-color)",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
...components,
|
||||
IndicatorSeparator: () => null,
|
||||
Input: InputComponent,
|
||||
}}
|
||||
className={classNames("border-0 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectWithValidation<
|
||||
Option extends { label: string; value: string },
|
||||
isMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>({
|
||||
required = false,
|
||||
onChange,
|
||||
value,
|
||||
...remainingProps
|
||||
}: SelectProps<Option, isMulti, Group> & { required?: boolean }) {
|
||||
const [hiddenInputValue, _setHiddenInputValue] = useState(() => {
|
||||
if (value instanceof Array || !value) {
|
||||
return;
|
||||
}
|
||||
return value.value || "";
|
||||
});
|
||||
|
||||
const setHiddenInputValue = useCallback((value: MultiValue<Option> | SingleValue<Option>) => {
|
||||
let hiddenInputValue = "";
|
||||
if (value instanceof Array) {
|
||||
hiddenInputValue = value.map((val) => val.value).join(",");
|
||||
} else {
|
||||
hiddenInputValue = value?.value || "";
|
||||
}
|
||||
_setHiddenInputValue(hiddenInputValue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setHiddenInputValue(value);
|
||||
}, [value, setHiddenInputValue]);
|
||||
|
||||
return (
|
||||
<div className={classNames("relative", remainingProps.className)}>
|
||||
<Select
|
||||
value={value}
|
||||
{...remainingProps}
|
||||
onChange={(value, ...remainingArgs) => {
|
||||
setHiddenInputValue(value);
|
||||
if (onChange) {
|
||||
onChange(value, ...remainingArgs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{required && (
|
||||
<input
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{
|
||||
opacity: 0,
|
||||
width: "100%",
|
||||
height: 1,
|
||||
position: "absolute",
|
||||
}}
|
||||
value={hiddenInputValue}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onChange={() => {}}
|
||||
// TODO:Not able to get focus to work
|
||||
// onFocus={() => selectRef.current?.focus()}
|
||||
required={required}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default Select;
|
||||
Reference in New Issue
Block a user