feat: team filters (#9543)

* feat: team filers

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>

* fix: remove filter from mobile

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: show heading when one item selected

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Udit TakkarandPeer Richelsen authored and GitHub committed 2023-06-20 16:39:18 +02:00
1 parent 4e529e5c6c
commit 8816a18854
6 files changed
+195 -251

No files matched your search

+54 -46
View File
@@ -13,7 +13,8 @@ import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { OrganizationEventTypeFilter } from "@calcom/features/eventtypes/components/OrganizationEventTypeFilter";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -47,7 +48,6 @@ import {
HeadSeo,
Skeleton,
Label,
VerticalDivider,
Alert,
} from "@calcom/ui";
import {
@@ -800,8 +800,7 @@ const CTA = () => {
const Actions = () => {
return (
<div className="hidden items-center md:flex">
<OrganizationEventTypeFilter />
<VerticalDivider />
<TeamsFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
</div>
);
};
@@ -834,9 +833,6 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any);
const EventTypesPage = () => {
const { t } = useLocale();
const router = useRouter();
@@ -862,6 +858,10 @@ const EventTypesPage = () => {
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filters = getTeamsFiltersFromQuery(router.query);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any, { filters });
return (
<div>
@@ -879,47 +879,55 @@ const EventTypesPage = () => {
CTA={<CTA />}>
<WithQuery
customLoader={<SkeletonLoader />}
success={({ data }) => (
<>
{data.eventTypeGroups.length > 1 ? (
<>
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
<div className="flex flex-col" key={group.profile.slug}>
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
orgSlug={orgBranding?.slug}
/>
success={({ data }) => {
const isFilteredByOnlyOneItem =
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
data.eventTypeGroups.length === 1;
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</div>
))
)}
</>
) : data.eventTypeGroups.length === 1 ? (
<EventTypeList
types={data.eventTypeGroups[0].eventTypes}
group={data.eventTypeGroups[0]}
groupIndex={0}
readOnly={data.eventTypeGroups[0].metadata.readOnly}
/>
) : (
<CreateFirstEventTypeView />
)}
return (
<>
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
<>
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
<div className="flex flex-col" key={group.profile.slug}>
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
orgSlug={orgBranding?.slug}
/>
<EmbedDialog />
{router.query.dialog === "duplicate" && <DuplicateDialog />}
</>
)}
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</div>
))
)}
</>
) : (
data.eventTypeGroups.length === 1 && (
<EventTypeList
types={data.eventTypeGroups[0].eventTypes}
group={data.eventTypeGroups[0]}
groupIndex={0}
readOnly={data.eventTypeGroups[0].metadata.readOnly}
/>
)
)}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
<EmbedDialog />
{router.query.dialog === "duplicate" && <DuplicateDialog />}
</>
);
}}
/>
</Shell>
</div>
@@ -1,118 +0,0 @@
import { useSession } from "next-auth/react";
import type { ReactNode, InputHTMLAttributes } from "react";
import { useState, forwardRef, Fragment } from "react";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar } from "@calcom/ui";
import { Layers, User } from "@calcom/ui/components/icon";
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
export type IEventTypeFilter = IEventTypesFilters[0];
export const OrganizationEventTypeFilter = () => {
const { t } = useLocale();
const session = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
const [dropdownTitle, setDropdownTitle] = useState<string>(t("all_apps"));
const { data: teams, status } = trpc.viewer.teams.list.useQuery();
const isNotEmpty = !!teams?.length;
return status === "success" ? (
<AnimatedPopover text={dropdownTitle} popoverTriggerClassNames="!mb-0">
<CheckboxFieldContainer>
<CheckboxField
id="all-eventtypes-checkbox"
icon={<Layers className="h-4 w-4" />}
checked={dropdownTitle === t("all_apps")}
onChange={(e) => {
removeAllQueryParams();
setDropdownTitle(t("all_apps"));
// TODO: What to do when all event types is unchecked
}}
label={t("all_apps")}
/>
</CheckboxFieldContainer>
<CheckboxFieldContainer>
<CheckboxField
id="all-eventtypes-checkbox"
icon={<User className="h-4 w-4" />}
checked={query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
setDropdownTitle(t("yours"));
if (e.target.checked) {
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
}
}}
label={t("yours")}
/>
</CheckboxFieldContainer>
{isNotEmpty && (
<Fragment>
<div className="text-subtle px-4 py-2.5 text-xs font-medium uppercase leading-none">TEAMS</div>
{teams?.map((team) => (
<CheckboxFieldContainer key={team.id}>
<CheckboxField
id={team.name}
label={team.name}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
checked={query.teamIds?.includes(team.id)}
onChange={(e) => {
setDropdownTitle(team.name);
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
}
}}
/>
</CheckboxFieldContainer>
))}
</Fragment>
)}
</AnimatedPopover>
) : null;
};
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string;
icon: ReactNode;
};
const CheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
return (
<label className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-default flex h-6 w-6 items-center justify-center ltr:mr-2 rtl:ml-2">{icon}</div>
<span className="text-sm">{label}</span>
</div>
<div className="flex h-5 items-center">
<input
{...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"
/>
</div>
</label>
);
});
const CheckboxFieldContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex items-center px-3 py-2">{children}</div>;
};
CheckboxField.displayName = "CheckboxField";
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Divider } from "@calcom/ui";
import { AnimatedPopover, Avatar, Divider, VerticalDivider } from "@calcom/ui";
import { Layers, User } from "@calcom/ui/components/icon";
import { filterQuerySchema } from "../lib/getTeamsFiltersFromQuery";
@@ -20,7 +20,13 @@ function useFilterQuery() {
return useTypedQuery(filterQuerySchema.passthrough());
}
export const TeamsFilter = () => {
export const TeamsFilter = ({
popoverTriggerClassNames,
showVerticalDivider = false,
}: {
popoverTriggerClassNames?: string;
showVerticalDivider?: boolean;
}) => {
const { t } = useLocale();
const session = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
@@ -40,7 +46,7 @@ export const TeamsFilter = () => {
if (selectedTeamsNames) {
checkedOptions.push(...selectedTeamsNames);
}
return checkedOptions.join(",");
return `${t("team")}: ${checkedOptions.join(",")}`;
}
if (query.userIds) {
return t("yours");
@@ -51,56 +57,59 @@ export const TeamsFilter = () => {
if (!teams || !teams.length) return null;
return (
<AnimatedPopover text={getCheckedOptionsNames()}>
<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();
}}
label={t("all_apps")}
/>
<FilterCheckboxField
id="yours"
icon={<User className="h-4 w-4" />}
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
}
}}
label={t("yours")}
/>
<Divider />
{teams?.map((team) => (
<div className="flex items-center">
<AnimatedPopover text={getCheckedOptionsNames()} popoverTriggerClassNames={popoverTriggerClassNames}>
<FilterCheckboxFieldsContainer>
<FilterCheckboxField
key={team.id}
id={team.name}
label={team.name}
checked={!!query.teamIds?.includes(team.id)}
id="all"
icon={<Layers className="h-4 w-4" />}
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
removeAllQueryParams();
}}
label={t("all_apps")}
/>
<FilterCheckboxField
id="yours"
icon={<User className="h-4 w-4" />}
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
}
}}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
label={t("yours")}
/>
))}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
<Divider />
{teams?.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name}
label={team.name}
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);
}
}}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
/>
))}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
{showVerticalDivider && <VerticalDivider />}
</div>
);
};
@@ -8,6 +8,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDuplicateInputSchema } from "./duplicate.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZEventTypeInputSchema } from "./getByViewer.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { eventOwnerProcedure } from "./util";
@@ -28,7 +29,7 @@ const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
export const eventTypesRouter = router({
// REVIEW: What should we name this procedure?
getByViewer: authedProcedure.query(async ({ ctx }) => {
getByViewer: authedProcedure.input(ZEventTypeInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
@@ -44,6 +45,7 @@ export const eventTypesRouter = router({
const result = await UNSTABLE_HANDLER_CACHE.getByViewer({
ctx,
input,
});
timer();
@@ -1,6 +1,7 @@
import { type PrismaClient, Prisma } from "@prisma/client";
import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { CAL_URL } from "@calcom/lib/constants";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
@@ -11,53 +12,56 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TEventTypeInputSchema } from "./getByViewer.schema";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TEventTypeInputSchema;
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
// Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering
position: true,
hashedLink: true,
locations: true,
destinationCalendar: true,
userId: true,
team: {
select: {
id: true,
name: true,
slug: true,
// logo: true, // Skipping to avoid 4mb limit
bio: true,
hideBranding: true,
},
},
metadata: true,
users: {
select: baseUserSelect,
},
children: {
include: {
users: true,
},
},
parentId: true,
hosts: {
select: {
user: {
select: baseUserSelect,
},
},
},
seatsPerTimeSlot: true,
...baseEventTypeSelect,
});
export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => {
const { prisma } = ctx;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
// Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering
position: true,
hashedLink: true,
locations: true,
destinationCalendar: true,
userId: true,
team: {
select: {
id: true,
name: true,
slug: true,
// logo: true, // Skipping to avoid 4mb limit
bio: true,
hideBranding: true,
},
},
metadata: true,
users: {
select: baseUserSelect,
},
children: {
include: {
users: true,
},
},
parentId: true,
hosts: {
select: {
user: {
select: baseUserSelect,
},
},
},
seatsPerTimeSlot: true,
...baseEventTypeSelect,
});
const user = await prisma.user.findUnique({
where: {
@@ -107,6 +111,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
eventTypes: {
where: {
team: null,
userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters),
},
select: eventTypeSelect,
orderBy: [
@@ -137,7 +142,8 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
const typesRaw = (
await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters),
team: null,
},
select: eventTypeSelect,
orderBy: [
@@ -167,11 +173,13 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
};
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id];
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap)
.map((eventType) => eventType)
.filter((evType) => evType.schedulingType !== SchedulingType.MANAGED);
@@ -204,6 +212,13 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
return mshipToNumber(mship1) > mshipToNumber(mship2);
};
const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType<typeof mapEventType>) => {
if (!input?.filters || !hasFilter(input?.filters)) {
return true;
}
return input?.filters?.teamIds?.includes(eventType?.team?.id || 0) ?? false;
};
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams
@@ -242,6 +257,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
},
eventTypes: membership.team.eventTypes
.map(mapEventType)
.filter(filterTeamsEventTypesBasedOnInput)
.filter((evType) => evType.userId === null || evType.userId === ctx.user.id)
.filter((evType) =>
membership.role === MembershipRole.MEMBER
@@ -251,6 +267,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
};
})
);
return {
// don't display event teams without event types,
eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
@@ -264,3 +281,15 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
})),
};
};
export function getPrismaWhereUserIdFromFilter(
userId: number,
filters: NonNullable<TEventTypeInputSchema>["filters"]
) {
if (!filters || !hasFilter(filters)) {
return userId;
} else if (filters.userIds?.[0] === userId) {
return userId;
}
return null;
}
@@ -1 +1,15 @@
export {};
import { z } from "zod";
export const filterQuerySchemaStrict = z.object({
teamIds: z.number().array().optional(),
// A user can only filter by only his userId
userIds: z.number().array().max(1).optional(),
});
export const ZEventTypeInputSchema = z
.object({
filters: filterQuerySchemaStrict.optional(),
})
.nullish();
export type TEventTypeInputSchema = z.infer<typeof ZEventTypeInputSchema>;