feat: add LocationSelect component (#6571)
* feat: add LocationSelect component Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * fix: type error Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * chore: type error Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
This commit is contained in:
parent
0e04f5d338
commit
5bb6904ffd
|
@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { components } from "react-select";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -14,32 +13,25 @@ import {
|
||||||
LocationObject,
|
LocationObject,
|
||||||
LocationType,
|
LocationType,
|
||||||
} from "@calcom/app-store/locations";
|
} from "@calcom/app-store/locations";
|
||||||
import { classNames } from "@calcom/lib";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
import Select from "@components/ui/form/Select";
|
import LocationSelect, { LocationOption } from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||||
|
|
||||||
type OptionTypeBase = {
|
|
||||||
label: string;
|
|
||||||
value: EventLocationType["type"];
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ISetLocationDialog {
|
interface ISetLocationDialog {
|
||||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||||
selection?: OptionTypeBase;
|
selection?: LocationOption;
|
||||||
booking?: BookingItem;
|
booking?: BookingItem;
|
||||||
defaultValues?: LocationObject[];
|
defaultValues?: LocationObject[];
|
||||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||||
setEditingLocationType?: (param: string) => void;
|
setEditingLocationType?: (param: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,7 +203,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={(open) => setShowLocationModal(open)}>
|
||||||
<DialogContent disableOverflow>
|
<DialogContent disableOverflow>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
@ -296,38 +288,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||||
name="locationType"
|
name="locationType"
|
||||||
control={locationFormMethods.control}
|
control={locationFormMethods.control}
|
||||||
render={() => (
|
render={() => (
|
||||||
<Select<{ label: string; value: string; icon?: string }>
|
<LocationSelect
|
||||||
maxMenuHeight={300}
|
maxMenuHeight={300}
|
||||||
name="location"
|
name="location"
|
||||||
defaultValue={selection}
|
defaultValue={selection}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
components={{
|
|
||||||
Option: (props) => (
|
|
||||||
<components.Option {...props}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{props.data.icon && (
|
|
||||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"text-sm font-medium",
|
|
||||||
props.isSelected ? "text-white" : "text-gray-900"
|
|
||||||
)}>
|
|
||||||
{props.data.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</components.Option>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
formatOptionLabel={(e) => (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
|
||||||
<span>{e.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
formatGroupLabel={(e) => (
|
|
||||||
<p className="text-xs font-medium text-gray-600">{e.label}</p>
|
|
||||||
)}
|
|
||||||
isSearchable
|
isSearchable
|
||||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
|
|
|
@ -17,11 +17,21 @@ import { Button, Icon, Label, Select, SettingsToggle, Skeleton, TextField } from
|
||||||
import { slugify } from "@lib/slugify";
|
import { slugify } from "@lib/slugify";
|
||||||
|
|
||||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
|
import LocationSelect, {
|
||||||
|
SingleValueLocationOption,
|
||||||
|
LocationOption,
|
||||||
|
} from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
type OptionTypeBase = {
|
const getLocationFromType = (
|
||||||
label: string;
|
type: EventLocationType["type"],
|
||||||
value: EventLocationType["type"];
|
locationOptions: Pick<EventTypeSetupProps, "locationOptions">["locationOptions"]
|
||||||
disabled?: boolean;
|
) => {
|
||||||
|
for (const locationOption of locationOptions) {
|
||||||
|
const option = locationOption.options.find((option) => option.value === type);
|
||||||
|
if (option) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventSetupTab = (
|
export const EventSetupTab = (
|
||||||
|
@ -32,7 +42,7 @@ export const EventSetupTab = (
|
||||||
const { eventType, locationOptions, team } = props;
|
const { eventType, locationOptions, team } = props;
|
||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
|
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
|
||||||
|
|
||||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||||
|
@ -51,7 +61,8 @@ export const EventSetupTab = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const openLocationModal = (type: EventLocationType["type"]) => {
|
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||||
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
const option = getLocationFromType(type, locationOptions);
|
||||||
|
setSelectedLocation(option);
|
||||||
setShowLocationModal(true);
|
setShowLocationModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -131,14 +142,14 @@ export const EventSetupTab = (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{validLocations.length === 0 && (
|
{validLocations.length === 0 && (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Select
|
<LocationSelect
|
||||||
placeholder={t("select")}
|
placeholder={t("select")}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
onChange={(e) => {
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
if (e?.value) {
|
if (e?.value) {
|
||||||
const newLocationType: EventLocationType["type"] = e.value;
|
const newLocationType = e.value;
|
||||||
const eventLocationType = getEventLocationType(newLocationType);
|
const eventLocationType = getEventLocationType(newLocationType);
|
||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
return;
|
return;
|
||||||
|
@ -362,7 +373,9 @@ export const EventSetupTab = (
|
||||||
saveLocation={saveLocation}
|
saveLocation={saveLocation}
|
||||||
defaultValues={formMethods.getValues("locations")}
|
defaultValues={formMethods.getValues("locations")}
|
||||||
selection={
|
selection={
|
||||||
selectedLocation ? { value: selectedLocation.value, label: t(selectedLocation.label) } : undefined
|
selectedLocation
|
||||||
|
? { value: selectedLocation.value, label: t(selectedLocation.label), icon: selectedLocation.icon }
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
setSelectedLocation={setSelectedLocation}
|
setSelectedLocation={setSelectedLocation}
|
||||||
setEditingLocationType={setEditingLocationType}
|
setEditingLocationType={setEditingLocationType}
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { components, GroupBase, Props, SingleValue } from "react-select";
|
||||||
|
|
||||||
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
|
||||||
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
|
export type LocationOption = {
|
||||||
|
label: string;
|
||||||
|
value: EventLocationType["type"];
|
||||||
|
icon?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||||
|
|
||||||
|
export type GroupOptionType = GroupBase<LocationOption>;
|
||||||
|
|
||||||
|
const OptionWithIcon = ({
|
||||||
|
icon,
|
||||||
|
isSelected,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
icon?: string;
|
||||||
|
isSelected?: boolean;
|
||||||
|
label: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5" />}
|
||||||
|
<span className={classNames("text-sm font-medium", isSelected ? "text-white" : "text-gray-900")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocationSelect(props: Props<LocationOption, false, GroupOptionType>) {
|
||||||
|
return (
|
||||||
|
<Select<LocationOption>
|
||||||
|
name="location"
|
||||||
|
components={{
|
||||||
|
Option: (props) => (
|
||||||
|
<components.Option {...props}>
|
||||||
|
<OptionWithIcon icon={props.data.icon} label={props.data.label} isSelected={props.isSelected} />
|
||||||
|
</components.Option>
|
||||||
|
),
|
||||||
|
SingleValue: (props) => (
|
||||||
|
<components.SingleValue {...props}>
|
||||||
|
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||||
|
</components.SingleValue>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
formatOptionLabel={(e) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||||
|
<span>{e.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
formatGroupLabel={(e) => <p className="text-xs font-medium text-gray-600">{e.label}</p>}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,6 +10,13 @@ import { EventTypeModel } from "@calcom/prisma/zod";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { App, AppMeta } from "@calcom/types/App";
|
import type { App, AppMeta } from "@calcom/types/App";
|
||||||
|
|
||||||
|
type LocationOption = {
|
||||||
|
label: string;
|
||||||
|
value: EventLocationType["type"];
|
||||||
|
icon?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type EventTypeApps = NonNullable<NonNullable<z.infer<typeof EventTypeMetaDataSchema>>["apps"]>;
|
export type EventTypeApps = NonNullable<NonNullable<z.infer<typeof EventTypeMetaDataSchema>>["apps"]>;
|
||||||
export type EventTypeAppsList = keyof EventTypeApps;
|
export type EventTypeAppsList = keyof EventTypeApps;
|
||||||
|
|
||||||
|
@ -48,36 +55,6 @@ export const InstalledAppVariants = [
|
||||||
|
|
||||||
export const ALL_APPS = Object.values(ALL_APPS_MAP);
|
export const ALL_APPS = Object.values(ALL_APPS_MAP);
|
||||||
|
|
||||||
type OptionTypeBase = {
|
|
||||||
label: string;
|
|
||||||
value: EventLocationType["type"];
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function translateLocations(locations: OptionTypeBase[], t: TFunction) {
|
|
||||||
return locations.map((l) => ({
|
|
||||||
...l,
|
|
||||||
label: t(l.label),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocationOptions(integrations: ReturnType<typeof getApps>, t: TFunction) {
|
|
||||||
const locations: OptionTypeBase[] = [];
|
|
||||||
defaultLocations.forEach((l) => {
|
|
||||||
locations.push({
|
|
||||||
label: l.label,
|
|
||||||
value: l.type,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
integrations.forEach((app) => {
|
|
||||||
if (app.locationOption) {
|
|
||||||
locations.push(app.locationOption);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return translateLocations(locations, t);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocationGroupedOptions(integrations: ReturnType<typeof getApps>, t: TFunction) {
|
export function getLocationGroupedOptions(integrations: ReturnType<typeof getApps>, t: TFunction) {
|
||||||
const apps: Record<string, { label: string; value: string; disabled?: boolean; icon?: string }[]> = {};
|
const apps: Record<string, { label: string; value: string; disabled?: boolean; icon?: string }[]> = {};
|
||||||
integrations.forEach((app) => {
|
integrations.forEach((app) => {
|
||||||
|
@ -147,7 +124,7 @@ export function getLocationGroupedOptions(integrations: ReturnType<typeof getApp
|
||||||
function getApps(userCredentials: CredentialData[]) {
|
function getApps(userCredentials: CredentialData[]) {
|
||||||
const apps = ALL_APPS.map((appMeta) => {
|
const apps = ALL_APPS.map((appMeta) => {
|
||||||
const credentials = userCredentials.filter((credential) => credential.type === appMeta.type);
|
const credentials = userCredentials.filter((credential) => credential.type === appMeta.type);
|
||||||
let locationOption: OptionTypeBase | null = null;
|
let locationOption: LocationOption | null = null;
|
||||||
|
|
||||||
/** If the app is a globally installed one, let's inject it's key */
|
/** If the app is a globally installed one, let's inject it's key */
|
||||||
if (appMeta.isGlobal) {
|
if (appMeta.isGlobal) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Prisma, PrismaClient } from "@prisma/client";
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||||
import { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
|
import { getEventTypeAppData, getLocationGroupedOptions } from "@calcom/app-store/utils";
|
||||||
import { LocationObject } from "@calcom/core/location";
|
import { LocationObject } from "@calcom/core/location";
|
||||||
import { parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
|
import { parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
|
||||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||||
|
@ -251,7 +251,7 @@ export default async function getEventTypeById({
|
||||||
const currentUser = eventType.users.find((u) => u.id === userId);
|
const currentUser = eventType.users.find((u) => u.id === userId);
|
||||||
const t = await getTranslation(currentUser?.locale ?? "en", "common");
|
const t = await getTranslation(currentUser?.locale ?? "en", "common");
|
||||||
const integrations = await getEnabledApps(credentials);
|
const integrations = await getEnabledApps(credentials);
|
||||||
const locationOptions = getLocationOptions(integrations, t);
|
const locationOptions = getLocationGroupedOptions(integrations, t);
|
||||||
|
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user