Feature/booking filters (#6099)

* EventTypeFilter UI working

* Tidy + fix avatar + fix overflow

* Component store

* WIP - switchin to query filtering instead of state

* Push working

* Checkbox approach - weird conditional toggle for all bookings

* WIP

* the master query hook :O

* useTypedQuery

* Working typed query

* Typed query hook

* Add optional keys

* safeParse query params

* Add use callback

* Fix re-renders

* Remove local version of local query and use util branch

* Working team filters!

* working people filter!

* Tidy up

* NIT

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
sean-brydon
2022-12-22 12:35:01 +00:00
committed by GitHub
co-authored by zomars
parent 83b60de640
commit cefcb1eaf8
20 changed files with 556 additions and 51 deletions
@@ -30,7 +30,7 @@ import useMeQuery from "@lib/hooks/useMeQuery";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
+14 -5
View File
@@ -5,9 +5,11 @@ import { Fragment } from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
import { filterQuerySchema, useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
import { Alert, BookingLayout, Button, EmptyScreen, Icon } from "@calcom/ui";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, Icon } from "@calcom/ui";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
@@ -16,7 +18,7 @@ import SkeletonLoader from "@components/booking/SkeletonLoader";
import { ssgInit } from "@server/lib/ssg";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
type BookingListingStatus = z.infer<typeof filterQuerySchema>["status"];
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
type RecurringInfo = {
@@ -41,12 +43,19 @@ const querySchema = z.object({
});
export default function Bookings() {
const { data: filterQuery } = useFilterQuery();
const router = useRouter();
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
const { t } = useLocale();
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{ status, limit: 10 },
{
limit: 10,
filters: {
...filterQuery,
status: filterQuery.status ?? status,
},
},
{
// first render has status `undefined`
enabled: router.isReady,
@@ -165,7 +174,7 @@ export default function Bookings() {
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:mx-6 xl:pt-0">
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
@@ -1443,6 +1443,9 @@
"enter_option": "Enter Option {{index}}",
"add_an_option": "Add an option",
"radio": "Radio",
"individual":"Individual",
"all_bookings_filter_label":"All Bookings",
"all_users_filter_label":"All Users",
"meeting_url_workflow": "Meeting url",
"meeting_url_info": "The event meeting conference url",
"date_overrides": "Date overrides",
@@ -0,0 +1,77 @@
import { useSession } from "next-auth/react";
import { Fragment, useState, useEffect } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc, RouterOutputs } from "@calcom/trpc/react";
import { AnimatedPopover } from "@calcom/ui";
import { groupBy } from "../groupBy";
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
export type IEventTypeFilter = IEventTypesFilters[0];
type GroupedEventTypeState = Record<
string,
{
team: {
id: number;
name: string;
} | null;
id: number;
title: string;
slug: string;
}[]
>;
export const EventTypeFilter = () => {
const { t } = useLocale();
const { data: user } = useSession();
const eventTypes = trpc.viewer.eventTypes.listWithTeam.useQuery();
const [groupedEventTypes, setGroupedEventTypes] = useState<GroupedEventTypeState>();
// Will be handled up the tree to redirect
useEffect(() => {
if (!eventTypes.data) return;
// Group event types by team
const grouped = groupBy<IEventTypeFilter>(
eventTypes.data.filter((el) => el.team),
(item) => item?.team?.name || ""
); // Add the team name
const individualEvents = eventTypes.data.filter((el) => !el.team);
// push indivdual events to the start of grouped array
setGroupedEventTypes({ user_own_event_types: individualEvents, ...grouped });
}, [eventTypes.data, user]);
if (!user) return null;
return (
<AnimatedPopover text={t("event_type")}>
<div className="">
{groupedEventTypes &&
Object.keys(groupedEventTypes).map((teamName) => (
<Fragment key={teamName}>
<div className="p-4 text-xs font-medium uppercase leading-none text-gray-500">
{teamName === "user_own_event_types" ? t("individual") : teamName}
</div>
{groupedEventTypes[teamName].map((eventType) => (
<Fragment key={eventType.id}>
<div className="item-center flex px-4 py-[6px]">
<p className="block self-center truncate text-sm font-medium text-gray-700">
{eventType.title}
</p>
<div className="ml-auto">
<input
type="checkbox"
name=""
id=""
className="text-primary-600 focus:ring-primary-500 mr-2 h-4 w-4 rounded border-gray-300 "
/>
</div>
</div>
</Fragment>
))}
</Fragment>
))}
</div>
</AnimatedPopover>
);
};
@@ -0,0 +1,43 @@
import { Fragment, ReactNode } from "react";
import { PeopleFilter } from "./PeopleFilter";
import { TeamsMemberFilter } from "./TeamFilter";
type FilterTypes = "teams" | "people";
type Filter = {
name: FilterTypes;
controllingQueryParams?: string[]; // this is what the filter controls - but also we show the filter if any of these query params are present
component: ReactNode;
showByDefault?: boolean;
};
const filters: Filter[] = [
{
name: "teams",
component: <TeamsMemberFilter />,
controllingQueryParams: ["teamId"],
showByDefault: true,
},
{
name: "people",
component: <PeopleFilter />,
controllingQueryParams: ["usersId"],
showByDefault: true,
},
];
export function FiltersContainer() {
return (
<div className="flex w-full space-x-2 overflow-x-scroll lg:justify-end ">
{filters.map((filter) => {
if (!filter.showByDefault) {
// TODO: check if any of the controllingQueryParams are present in the query params and show the filter if so
// TODO: Also check state to see if the user has toggled the filter
return null;
}
return <Fragment key={filter.name}>{filter.component}</Fragment>;
})}
</div>
);
}
@@ -0,0 +1,74 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Icon } from "@calcom/ui";
import { useFilterQuery } from "../lib/useFilterQuery";
export const PeopleFilter = () => {
const { t } = useLocale();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
const { data } = trpc.viewer.teams.listMembers.useQuery({});
if (!data || !data.length) return null;
// Get user names from query
const userNames = data?.filter((user) => query.userIds?.includes(user.id)).map((user) => user.name);
return (
<AnimatedPopover
text={userNames && userNames.length > 0 ? `${userNames.join(", ")}` : t("all_users_filter_label")}>
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100">
<div className="mr-2 flex h-6 w-6 items-center justify-center">
<Icon.FiUser className="h-full w-full" />
</div>
<label htmlFor="allUsers" className="mr-auto self-center truncate text-sm font-medium text-gray-700">
{t("all_users_filter_label")}
</label>
<input
id="allUsers"
type="checkbox"
checked={!query.userIds}
onChange={() => {
// Always clear userIds on toggle as this is the toggle box for all users. No params means we are currently selecting all users
removeByKey("userIds");
}}
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
/>
</div>
{data &&
data.map((user) => (
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100" key={`${user.id}`}>
<Avatar
imageSrc={user.avatar}
size="sm"
alt={`${user.name} Avatar`}
gravatarFallbackMd5="fallback"
className="self-center"
asChild
/>
<label
htmlFor={user.name ?? "NamelessUser"}
className="ml-2 mr-auto self-center truncate text-sm font-medium text-gray-700">
{user.name}
</label>
<input
id={user.name ?? "NamelessUser"}
name={user.name ?? "NamelessUser"}
type="checkbox"
checked={query.userIds?.includes(user.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("userIds", user.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", user.id);
}
}}
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
/>
</div>
))}
</AnimatedPopover>
);
};
@@ -0,0 +1,75 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Icon } from "@calcom/ui";
import { useFilterQuery } from "../lib/useFilterQuery";
export const TeamsMemberFilter = () => {
const { t } = useLocale();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
const { data } = trpc.viewer.teams.list.useQuery();
if (!data || !data.length) return null;
// get team names from query
const teamNames = data?.filter((team) => query.teamIds?.includes(team.id)).map((team) => team.name);
return (
<AnimatedPopover
text={teamNames && teamNames.length > 0 ? `${teamNames.join(", ")}` : t("all_bookings_filter_label")}>
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100">
<div className="mr-2 flex h-6 w-6 items-center justify-center">
<Icon.FiLayers className="h-full w-full" />
</div>
<label
htmlFor="allBookings"
className="mr-auto self-center truncate text-sm font-medium text-gray-700">
{t("all_bookings_filter_label")}
</label>
<input
id="allBookings"
type="checkbox"
checked={!query.teamIds}
onChange={() => {
removeByKey("teamIds"); // Always clear on toggle or not toggle (seems weird but when you know the behviour it works well )
}}
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
/>
</div>
{data &&
data.map((team) => (
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100" key={`${team.id}`}>
<Avatar
imageSrc={team.logo}
size="sm"
alt={`${team.name} Avatar`}
gravatarFallbackMd5="fallback"
className="self-center"
asChild
/>
<label
htmlFor={team.name}
className="ml-2 mr-auto self-center truncate text-sm font-medium text-gray-700">
{team.name}
</label>
<input
id={team.name}
name={team.name}
type="checkbox"
checked={query.teamIds?.includes(team.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
}
}}
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
/>
</div>
))}
</AnimatedPopover>
);
};
+18
View File
@@ -0,0 +1,18 @@
type KeySelector<T> = (item: T) => string;
export function groupBy<T>(array: Iterable<T>, keySelector: KeySelector<T>): Record<string, T[]> {
return Array.from(array).reduce(
(acc: Record<string, T[]>, item: T) => {
const key = keySelector(item);
if (key in acc) {
// found key, push new item into existing array
acc[key].push(item);
} else {
// did not find key, create new array
acc[key] = [item];
}
return acc;
},
{} // start with empty object
);
}
@@ -1,36 +1,30 @@
import React, { ComponentProps } from "react";
import type { VerticalTabItemProps } from "../";
import { HorizontalTabs, VerticalTabs } from "../";
import { Icon } from "../../../Icon";
import Shell from "../Shell";
import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
import { HorizontalTabs, Shell } from "@calcom/ui";
import { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui/v2";
import { FiltersContainer } from "../components/FiltersContainer";
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "upcoming",
href: "/bookings/upcoming",
icon: Icon.FiCalendar,
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
icon: Icon.FiInbox,
},
{
name: "recurring",
href: "/bookings/recurring",
icon: Icon.FiRotateCcw,
},
{
name: "past",
href: "/bookings/past",
icon: Icon.FiSunset,
},
{
name: "cancelled",
href: "/bookings/cancelled",
icon: Icon.FiSlash,
},
];
@@ -40,12 +34,10 @@ export default function BookingLayout({
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell {...rest}>
<div className="flex flex-col sm:space-x-2 xl:flex-row">
<div className="hidden xl:block">
<VerticalTabs tabs={tabs} sticky />
</div>
<div className="block xl:hidden">
<div className="flex max-w-6xl flex-col sm:space-x-2">
<div className="flex flex-col lg:flex-row">
<HorizontalTabs tabs={tabs} />
<FiltersContainer />
</div>
<main className="w-full max-w-6xl">{children}</main>
</div>
@@ -0,0 +1,15 @@
import z from "zod";
import { queryNumberArray, useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
// TODO: Move this to zod utils
export const filterQuerySchema = z.object({
teamIds: queryNumberArray.optional(),
userIds: queryNumberArray.optional(),
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
eventTypeIds: queryNumberArray.optional(),
});
export function useFilterQuery() {
return useTypedQuery(filterQuerySchema);
}
+4 -2
View File
@@ -24,7 +24,7 @@ export const queryStringArray = z
.preprocess((a) => z.string().parse(a).split(","), z.string().array())
.or(z.string().array());
export function useTypedQuery<T extends z.ZodType>(schema: T) {
export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
type Output = z.infer<typeof schema>;
type FullOutput = Required<Output>;
type OutputKeys = Required<keyof FullOutput>;
@@ -65,8 +65,10 @@ export function useTypedQuery<T extends z.ZodType>(schema: T) {
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]);
}
}
@@ -74,7 +76,7 @@ export function useTypedQuery<T extends z.ZodType>(schema: T) {
// 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)) {
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);
@@ -103,7 +103,12 @@ export const bookingsRouter = router({
get: authedProcedure
.input(
z.object({
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
filters: z.object({
teamIds: z.number().array().optional(),
userIds: z.number().array().optional(),
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
eventTypeIds: z.number().array().optional(),
}),
limit: z.number().min(1).max(100).nullish(),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
})
@@ -114,7 +119,7 @@ export const bookingsRouter = router({
const take = input.limit ?? 10;
const skip = input.cursor ?? 0;
const { prisma, user } = ctx;
const bookingListingByStatus = input.status;
const bookingListingByStatus = input.filters.status;
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput> = {
upcoming: {
endTime: { gte: new Date() },
@@ -175,7 +180,46 @@ export const bookingsRouter = router({
cancelled: { startTime: "desc" },
unconfirmed: { startTime: "asc" },
};
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
// TODO: Fix record typing
const bookingWhereInputFilters: Record<string, Prisma.BookingWhereInput> = {
teamIds: {
AND: [
{
eventType: {
team: {
id: {
in: input.filters?.teamIds,
},
},
},
},
],
},
userIds: {
AND: [
{
eventType: {
users: {
some: {
id: {
in: input.filters?.userIds,
},
},
},
},
},
],
},
};
const filtersCombined: Prisma.BookingWhereInput[] =
input.filters &&
Object.keys(input.filters).map((key) => {
return bookingWhereInputFilters[key];
});
const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus];
const orderBy = bookingListingOrderby[bookingListingByStatus];
const bookingsQuery = await prisma.booking.findMany({
@@ -204,7 +248,7 @@ export const bookingsRouter = router({
},
},
],
AND: [passedBookingsFilter],
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
},
select: {
...bookingMinimalSelect,
@@ -356,6 +356,35 @@ export const eventTypesRouter = router({
},
});
}),
listWithTeam: authedProcedure.query(async ({ ctx }) => {
return await ctx.prisma.eventType.findMany({
where: {
OR: [
{ userId: ctx.user.id },
{
team: {
members: {
some: {
userId: ctx.user.id,
},
},
},
},
],
},
select: {
id: true,
team: {
select: {
id: true,
name: true,
},
},
title: true,
slug: true,
},
});
}),
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
const { schedulingType, teamId, ...rest } = input;
const userId = ctx.user.id;
@@ -642,4 +642,45 @@ export const viewerTeamsRouter = router({
});
return teams;
}),
listMembers: authedProcedure
.input(
z.object({
teamIds: z.number().array().optional(),
})
)
.query(async ({ ctx, input }) => {
const teams = await ctx.prisma.team.findMany({
where: {
id: {
in: input.teamIds,
},
members: {
some: {
user: {
id: ctx.user.id,
},
},
},
},
select: {
members: {
select: {
user: {
select: {
id: true,
name: true,
avatar: true,
},
},
},
},
},
});
type UserMap = Record<number, typeof teams[number]["members"][number]["user"]>;
// flattern users to be unique by id
const users = teams
.flatMap((t) => t.members)
.reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap);
return Object.values(users);
}),
});
+1
View File
@@ -1,4 +1,5 @@
import superjson from "superjson";
import { ZodError } from "zod";
import rateLimit from "@calcom/lib/rateLimit";
+31 -23
View File
@@ -9,48 +9,56 @@ import { Maybe } from "@trpc/server";
export type AvatarProps = {
className?: string;
size: "sm" | "md" | "mdLg" | "lg";
size: "xs" | "sm" | "md" | "mdLg" | "lg";
imageSrc?: Maybe<string>;
title?: string;
alt: string;
gravatarFallbackMd5?: string;
fallback?: React.ReactNode;
accepted?: boolean;
asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling
};
const sizesPropsBySize = {
sm: "w-6", // 24px
md: "w-8", // 32px
mdLg: "w-10", //40px
lg: "w-16", // 64px
xs: "w-4 h-4", // 16px
sm: "w-6 h-6", // 24px
md: "w-8 h-8", // 32px
mdLg: "w-10 h-10", //40px
lg: "w-16 h-16", // 64px
} as const;
export function Avatar(props: AvatarProps) {
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
const sizeClassname = sizesPropsBySize[size];
const rootClass = classNames("rounded-full aspect-square", sizeClassname, "h-auto");
const rootClass = classNames("rounded-full aspect-square ", sizeClassname);
const avatar = (
<AvatarPrimitive.Root
className={classNames(
sizeClassname,
"dark:bg-darkgray-300 relative inline-block aspect-square overflow-hidden rounded-full"
"dark:bg-darkgray-300 item-center relative inline-flex aspect-square justify-center overflow-hidden rounded-full"
)}>
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
<AvatarPrimitive.Fallback delayMs={600}>
{gravatarFallbackMd5 && (
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={rootClass} />
)}
</AvatarPrimitive.Fallback>
{props.accepted && (
<div
className={classNames(
"absolute bottom-0 right-0 block rounded-full bg-green-400 text-white ring-2 ring-white",
size === "lg" ? "h-5 w-5" : "h-2 w-2"
)}>
<div className="flex h-full items-center justify-center p-[2px]">
{size === "lg" && <Icon.FiCheck className="" />}
<>
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
<AvatarPrimitive.Fallback delayMs={600} asChild={props.asChild}>
<>
{props.fallback && !gravatarFallbackMd5 && props.fallback}
{gravatarFallbackMd5 && (
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={rootClass} />
)}
</>
</AvatarPrimitive.Fallback>
{props.accepted && (
<div
className={classNames(
"absolute bottom-0 right-0 block rounded-full bg-green-400 text-white ring-2 ring-white",
size === "lg" ? "h-5 w-5" : "h-2 w-2"
)}>
<div className="flex h-full items-center justify-center p-[2px]">
{size === "lg" && <Icon.FiCheck />}
</div>
</div>
</div>
)}
)}
</>
</AvatarPrimitive.Root>
);
+1
View File
@@ -29,5 +29,6 @@ export {
} from "./form";
export { TopBanner } from "./top-banner";
export type { TopBannerProps } from "./top-banner";
export { AnimatedPopover } from "./popover/index";
export { TableActions, DropdownActions } from "./table/TableActions";
export type { ActionType } from "./table/TableActions";
+72
View File
@@ -0,0 +1,72 @@
import * as Popover from "@radix-ui/react-popover";
import React from "react";
import { classNames } from "@calcom/lib";
import { Icon } from "@calcom/ui";
export const AnimatedPopover = ({
text,
count,
children,
}: {
text: string;
count?: number;
children: React.ReactNode;
}) => {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
// calculate which aligment to open the popover with based on which half of the screen it is on (left or right)
const [align, setAlign] = React.useState<"start" | "end">("start");
React.useEffect(() => {
const handleResize = () => {
const halfWidth = window.innerWidth / 2;
const { x, y } = ref?.current?.getBoundingClientRect() || {
x: 0,
y: 0,
};
if (x > halfWidth) {
setAlign("end");
} else {
setAlign("start");
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [setAlign]);
return (
<Popover.Root onOpenChange={setOpen} modal={true}>
<Popover.Trigger asChild>
<div
ref={ref}
className="item-center mb-2 flex h-9 justify-between whitespace-nowrap rounded-md border border-gray-300 py-2 px-3
text-sm placeholder:text-gray-400 hover:cursor-pointer hover:border-gray-400
focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
<div className="max-w-36 flex items-center">
<div className="truncate">
{text}
{count && count > 0 && (
<div className="flex h-4 w-4 items-center justify-center rounded-full">{count}</div>
)}
</div>
<Icon.FiChevronDown
className={classNames("mt-auto ml-2 transition-transform duration-150", open && "rotate-180")}
/>
</div>
</div>
</Popover.Trigger>
<Popover.Content side="bottom" align={align} asChild>
<div
className={classNames(
"absolute z-50 mt-2 w-56 rounded-md bg-white shadow-sm ring-1 ring-black ring-opacity-5 focus-within:outline-none",
align === "end" && "-translate-x-[228px]"
)}>
{children}
</div>
</Popover.Content>
</Popover.Root>
);
};
+1 -1
View File
@@ -24,6 +24,7 @@ export {
TextAreaField,
TextField,
TopBanner,
AnimatedPopover,
Select,
SelectField,
SelectWithValidation,
@@ -100,7 +101,6 @@ export { ToggleGroup } from "./v2/core/form/ToggleGroup";
export { default as ImageUploader } from "./v2/core/ImageUploader";
export { default as AdminLayout, getLayout as getAdminLayout } from "./v2/core/layouts/AdminLayout";
export { default as AppsLayout } from "./v2/core/layouts/AppsLayout";
export { default as BookingLayout } from "./v2/core/layouts/BookingLayout";
export { default as InstalledAppsLayout } from "./v2/core/layouts/InstalledAppsLayout";
export { default as SettingsLayout, getLayout as getSettingsLayout } from "./v2/core/layouts/SettingsLayout";
export { default as WizardLayout, getLayout as getWizardLayout } from "./v2/core/layouts/WizardLayout";
+1
View File
@@ -17,6 +17,7 @@
"@calcom/trpc": "*",
"@formkit/auto-animate": "^1.0.0-beta.5",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-popover": "^1.0.2",
"@radix-ui/react-portal": "^1.0.0",
"@radix-ui/react-select": "^0.1.1",
"@tanstack/react-query": "^4.3.9",