fix: change booking page filter ui to match Figma (#12134)

* fix: change booking page filter ui to match figma

* fix: style change for filters in mobile

* made all changes requested by reviewers

* fix: add clear filter

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
Amitabh Sahu 2023-12-12 16:59:13 +05:30 committed by GitHub
parent 50e405353c
commit 1aa8b8439a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 209 additions and 235 deletions

View File

@ -1,12 +1,13 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { GetStaticPaths, GetStaticProps } from "next";
import { Fragment } from "react";
import { Fragment, useState } from "react";
import React from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { getLayout } from "@calcom/features/MainLayout";
import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
@ -81,6 +82,7 @@ export default function Bookings() {
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
const { t } = useLocale();
const user = useMeQuery().data;
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
@ -151,12 +153,11 @@ export default function Bookings() {
return (
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
<div className="flex flex-col">
<div className="flex flex-col flex-wrap lg:flex-row">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
<div className="max-w-full overflow-x-auto xl:ml-auto">
<FiltersContainer />
</div>
<FilterToggle setIsFiltersVisible={setIsFiltersVisible} />
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (

View File

@ -1638,6 +1638,7 @@
"individual": "Individual",
"all_bookings_filter_label": "All Bookings",
"all_users_filter_label": "All Users",
"all_event_types_filter_label": "All Event Types",
"your_bookings_filter_label": "Your Bookings",
"meeting_url_variable": "Meeting url",
"meeting_url_info": "The event meeting conference url",
@ -1868,6 +1869,7 @@
"review_event_type": "Review Event Type",
"looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?",
"filters": "Filters",
"add_filter": "Add filter",
"remove_filters": "Clear all filters",
"select_user": "Select User",

View File

@ -1,52 +0,0 @@
import { create } from "zustand";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { User, Link } from "@calcom/ui/components/icon";
interface addFilterOption {
StartIcon?: SVGComponent;
label: "people" | "event_type" | "date_range" | "location";
}
export type BookingMultiFilterStore = {
addFilterOptions: addFilterOption[];
toggleOption: (option: addFilterOption) => void;
isFilterActive: (label: addFilterOption["label"]) => boolean;
};
export const useBookingMultiFilterStore = create<BookingMultiFilterStore>((set, get) => ({
addFilterOptions: [
{
label: "people",
StartIcon: User,
},
{
label: "event_type",
StartIcon: Link,
},
// {
// label: "date_range",
// StartIcon: Calendar,
// },
// {
// label: "location",
// StartIcon: MapPin,
// },
],
isFilterActive: (label: addFilterOption["label"]) => {
return !get().addFilterOptions.some((option) => option.label === label);
},
setState: (state: BookingMultiFilterStore) => set(state),
toggleOption: (option: addFilterOption) => {
const availableOptions = get().addFilterOptions;
const foundOption = availableOptions.find((activeOption) => activeOption.label === option.label);
if (foundOption) {
set({
addFilterOptions: availableOptions.filter((activeOption) => activeOption.label !== foundOption.label),
});
} else {
set({ addFilterOptions: [...availableOptions, option] });
}
},
}));

View File

@ -1,11 +1,16 @@
import { useSession } from "next-auth/react";
import { Fragment, useState } from "react";
import {
FilterCheckboxFieldsContainer,
FilterCheckboxField,
} from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover } from "@calcom/ui";
import { CheckboxField } from "@calcom/ui";
import { Divider } from "@calcom/ui";
import { Link } from "@calcom/ui/components/icon";
import { groupBy } from "../groupBy";
import { useFilterQuery } from "../lib/useFilterQuery";
@ -29,7 +34,7 @@ type GroupedEventTypeState = Record<
export const EventTypeFilter = () => {
const { t } = useLocale();
const { data: user } = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue } = useFilterQuery();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
const [groupedEventTypes, setGroupedEventTypes] = useState<GroupedEventTypeState>();
@ -56,24 +61,32 @@ export const EventTypeFilter = () => {
const getTextForPopover = () => {
const eventTypeIds = query.eventTypeIds;
if (eventTypeIds) {
return `${t("event_type")}: ${t("number_selected", { count: eventTypeIds.length })}`;
return `${t("number_selected", { count: eventTypeIds.length })}`;
}
return `${t("event_type")}: ${t("all")}`;
return `${t("all")}`;
};
return (
<AnimatedPopover text={getTextForPopover()}>
<div>
{groupedEventTypes &&
!isEmpty &&
Object.keys(groupedEventTypes).map((teamName) => (
<Fragment key={teamName}>
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{teamName === "user_own_event_types" ? t("individual") : teamName}
</div>
{groupedEventTypes[teamName].map((eventType) => (
<div key={eventType.id} className="flex items-center px-4 py-1.5">
<CheckboxField
<AnimatedPopover text={getTextForPopover()} prefix={`${t("event_type")}: `}>
{!isEmpty ? (
<FilterCheckboxFieldsContainer>
<FilterCheckboxField
id="all"
icon={<Link className="h-4 w-4" />}
checked={!query.eventTypeIds?.length}
onChange={removeAllQueryParams}
label={t("all_event_types_filter_label")}
/>
<Divider />
{groupedEventTypes &&
Object.keys(groupedEventTypes).map((teamName) => (
<Fragment key={teamName}>
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{teamName === "user_own_event_types" ? t("individual") : teamName}
</div>
{groupedEventTypes[teamName].map((eventType) => (
<FilterCheckboxField
key={eventType.id}
checked={query.eventTypeIds?.includes(eventType.id)}
onChange={(e) => {
if (e.target.checked) {
@ -82,16 +95,15 @@ export const EventTypeFilter = () => {
removeItemByKeyAndValue("eventTypeIds", eventType.id);
}
}}
description={eventType.title}
label={eventType.title}
/>
</div>
))}
</Fragment>
))}
{isEmpty && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</div>
))}
</Fragment>
))}
</FilterCheckboxFieldsContainer>
) : (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</AnimatedPopover>
);
};

View File

@ -0,0 +1,35 @@
import type { Dispatch, SetStateAction } from "react";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip, Badge, Button } from "@calcom/ui";
import { Filter } from "@calcom/ui/components/icon";
export interface FilterToggleProps {
setIsFiltersVisible: Dispatch<SetStateAction<boolean>>;
}
export function FilterToggle({ setIsFiltersVisible }: FilterToggleProps) {
const {
data: { teamIds, userIds, eventTypeIds },
} = useFilterQuery();
const { t } = useLocale();
function toggleFiltersVisibility() {
setIsFiltersVisible((prev) => !prev);
}
return (
<Button color="secondary" onClick={toggleFiltersVisibility} className="mb-4">
<Filter className="h-4 w-4" />
<Tooltip content={t("filters")}>
<div className="mx-2">{t("filters")}</div>
</Tooltip>
{(teamIds || userIds || eventTypeIds) && (
<Badge variant="gray" rounded>
{(teamIds ? 1 : 0) + (userIds ? 1 : 0) + (eventTypeIds ? 1 : 0)}
</Badge>
)}
</Button>
);
}

View File

@ -1,156 +1,41 @@
import { useState } from "react";
import { shallow } from "zustand/shallow";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PeopleFilter } from "@calcom/features/bookings/components/PeopleFilter";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import {
TeamsFilter,
FilterCheckboxFieldsContainer,
FilterCheckboxField,
} from "@calcom/features/filters/components/TeamsFilter";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuTrigger,
AnimatedPopover,
Avatar,
FilterSearchField,
Tooltip,
Button,
} from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
import { Tooltip, Button } from "@calcom/ui";
import { useBookingMultiFilterStore } from "../BookingMultiFiltersStore";
import { EventTypeFilter } from "./EventTypeFilter";
const PeopleFilter = () => {
const { t } = useLocale();
const orgBranding = useOrgBranding();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
const [searchText, setSearchText] = useState("");
const members = trpc.viewer.teams.listMembers.useQuery({});
const filteredMembers = members?.data
?.filter((member) => member.accepted)
?.filter((member) =>
searchText.trim() !== ""
? member?.name?.toLowerCase()?.includes(searchText.toLowerCase()) ||
member?.username?.toLowerCase()?.includes(searchText.toLowerCase())
: true
);
const getTextForPopover = () => {
const userIds = query.userIds;
if (userIds) {
return `${t("people")}: ${t("number_selected", { count: userIds.length })}`;
}
return `${t("people")}: ${t("all")}`;
};
return (
<AnimatedPopover text={getTextForPopover()}>
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
<FilterCheckboxFieldsContainer>
{filteredMembers?.map((member) => (
<FilterCheckboxField
key={member.id}
id={member.id.toString()}
label={member?.name ?? member.username ?? t("no_name")}
checked={!!query.userIds?.includes(member.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("userIds", member.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", member.id);
}
}}
icon={
<Avatar
alt={`${member?.id} avatar`}
imageSrc={
member.username
? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${member.username}/avatar.png`
: undefined
}
size="xs"
/>
}
/>
))}
{filteredMembers?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
);
};
export function FiltersContainer() {
const { t } = useLocale();
export interface FiltersContainerProps {
isFiltersVisible: boolean;
}
export function FiltersContainer({ isFiltersVisible }: FiltersContainerProps) {
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const { removeAllQueryParams } = useFilterQuery();
const [addFilterOptions, toggleOption, isFilterActive] = useBookingMultiFilterStore((state) => [
state.addFilterOptions,
state.toggleOption,
state.isFilterActive,
shallow,
]);
const isPeopleFilterActive = isFilterActive("people");
const isEventTypeFilterActive = isFilterActive("event_type");
const { t } = useLocale();
return (
<div className="flex w-full space-x-2 rtl:space-x-reverse">
{addFilterOptions.length > 0 && (
<Dropdown>
<DropdownMenuTrigger asChild>
<div className="hover:border-emphasis border-default text-default hover:text-emphasis mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
<Plus className="mr-2 h-4 w-4" />
<Tooltip content={t("add_filter")}>
<div>{t("add_filter")}</div>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
{addFilterOptions?.map((option) => (
<DropdownMenuItem key={option.label}>
<DropdownItem
type="button"
StartIcon={option.StartIcon}
onClick={() => {
toggleOption(option);
}}>
{t(option.label)}
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
)}
{isPeopleFilterActive && <PeopleFilter />}
{isEventTypeFilterActive && <EventTypeFilter />}
{(isPeopleFilterActive || isEventTypeFilterActive) && (
<Tooltip content={t("remove_filters")}>
<Button
color="secondary"
type="button"
onClick={() => {
removeAllQueryParams();
}}>
{t("remove_filters")}
</Button>
</Tooltip>
)}
<TeamsFilter />
<div ref={animationParentRef}>
{isFiltersVisible ? (
<div className="no-scrollbar flex w-full space-x-2 overflow-x-scroll rtl:space-x-reverse">
<PeopleFilter />
<EventTypeFilter />
<TeamsFilter />
<Tooltip content={t("remove_filters")}>
<Button
color="secondary"
type="button"
onClick={() => {
removeAllQueryParams();
}}>
{t("remove_filters")}
</Button>
</Tooltip>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,85 @@
import { useState } from "react";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import {
FilterCheckboxFieldsContainer,
FilterCheckboxField,
} from "@calcom/features/filters/components/TeamsFilter";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Divider, FilterSearchField } from "@calcom/ui";
import { User } from "@calcom/ui/components/icon";
export const PeopleFilter = () => {
const { t } = useLocale();
const orgBranding = useOrgBranding();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
const [searchText, setSearchText] = useState("");
const members = trpc.viewer.teams.listMembers.useQuery({});
const filteredMembers = members?.data
?.filter((member) => member.accepted)
?.filter((member) =>
searchText.trim() !== ""
? member?.name?.toLowerCase()?.includes(searchText.toLowerCase()) ||
member?.username?.toLowerCase()?.includes(searchText.toLowerCase())
: true
);
const getTextForPopover = () => {
const userIds = query.userIds;
if (userIds) {
return `${t("number_selected", { count: userIds.length })}`;
}
return `${t("all")}`;
};
return (
<AnimatedPopover text={getTextForPopover()} prefix={`${t("people")}: `}>
<FilterCheckboxFieldsContainer>
<FilterCheckboxField
id="all"
icon={<User className="h-4 w-4" />}
checked={!query.userIds?.length}
onChange={removeAllQueryParams}
label={t("all_users_filter_label")}
/>
<Divider />
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
{filteredMembers?.map((member) => (
<FilterCheckboxField
key={member.id}
id={member.id.toString()}
label={member?.name ?? member.username ?? t("no_name")}
checked={!!query.userIds?.includes(member.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("userIds", member.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", member.id);
}
}}
icon={
<Avatar
alt={`${member?.id} avatar`}
imageSrc={
member.username
? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${member.username}/avatar.png`
: undefined
}
size="xs"
/>
}
/>
))}
{filteredMembers?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
);
};

View File

@ -60,15 +60,16 @@ export const TeamsFilter = ({
return (
<div className="flex items-center">
<AnimatedPopover text={getCheckedOptionsNames()} popoverTriggerClassNames={popoverTriggerClassNames}>
<AnimatedPopover
text={getCheckedOptionsNames()}
popoverTriggerClassNames={popoverTriggerClassNames}
prefix={`${t("teams")}: `}>
<FilterCheckboxFieldsContainer>
<FilterCheckboxField
id="all"
icon={<Layers className="h-4 w-4" />}
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
removeAllQueryParams();
}}
onChange={removeAllQueryParams}
label={t("all")}
/>
@ -134,7 +135,7 @@ export const FilterCheckboxFieldsContainer = ({
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string;
icon: ReactNode;
icon?: ReactNode;
};
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
@ -142,9 +143,11 @@ export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label,
<div className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer">
<label className="flex w-full max-w-full items-center justify-between hover:cursor-pointer">
<div className="flex items-center truncate">
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
{icon}
</div>
{icon && (
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
{icon}
</div>
)}
<Tooltip content={label}>
<label
htmlFor={rest.id}
@ -158,7 +161,7 @@ export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label,
{...rest}
ref={ref}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
className="text-emphasis focus:ring-emphasis border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
/>
</div>
</label>

View File

@ -13,6 +13,7 @@ export const AnimatedPopover = ({
children,
Trigger,
defaultOpen,
prefix,
}: {
text: string;
count?: number;
@ -20,6 +21,7 @@ export const AnimatedPopover = ({
popoverTriggerClassNames?: string;
Trigger?: React.ReactNode;
defaultOpen?: boolean;
prefix?: string;
}) => {
const [open, setOpen] = React.useState(defaultOpen ?? false);
const ref = React.useRef<HTMLDivElement>(null);
@ -58,8 +60,9 @@ export const AnimatedPopover = ({
Trigger
) : (
<div className="max-w-36 flex items-center">
<Tooltip content={text}>
<Tooltip content={`${prefix}${text}`}>
<div className="flex select-none truncate font-medium">
{prefix && <span className="text-subtle">{prefix}&nbsp;</span>}
{text}
{count && count > 0 && (
<div className="text-emphasis flex items-center justify-center rounded-full font-semibold">