From 8816a188548c81bec931e74e415b03d538cadcbc Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 20 Jun 2023 20:09:18 +0530 Subject: [PATCH] feat: team filters (#9543) * feat: team filers Signed-off-by: Udit Takkar * fix: type error Signed-off-by: Udit Takkar * fix: remove filter from mobile Signed-off-by: Udit Takkar * fix: show heading when one item selected Signed-off-by: Udit Takkar --------- Signed-off-by: Udit Takkar Co-authored-by: Peer Richelsen --- apps/web/pages/event-types/index.tsx | 100 ++++++++------- .../OrganizationEventTypeFilter.tsx | 118 ------------------ .../filters/components/TeamsFilter.tsx | 101 ++++++++------- .../routers/viewer/eventTypes/_router.ts | 4 +- .../viewer/eventTypes/getByViewer.handler.ts | 107 ++++++++++------ .../viewer/eventTypes/getByViewer.schema.ts | 16 ++- 6 files changed, 195 insertions(+), 251 deletions(-) delete mode 100644 packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 3afa82789d..6e0427780b 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -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 (
- - +
); }; @@ -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 (
@@ -879,47 +879,55 @@ const EventTypesPage = () => { CTA={}> } - success={({ data }) => ( - <> - {data.eventTypeGroups.length > 1 ? ( - <> - {isMobile ? ( - - ) : ( - data.eventTypeGroups.map((group: EventTypeGroup, index: number) => ( -
- + success={({ data }) => { + const isFilteredByOnlyOneItem = + (filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && + data.eventTypeGroups.length === 1; - -
- )) - )} - - ) : data.eventTypeGroups.length === 1 ? ( - - ) : ( - - )} + return ( + <> + {data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? ( + <> + {isMobile ? ( + + ) : ( + data.eventTypeGroups.map((group: EventTypeGroup, index: number) => ( +
+ - - {router.query.dialog === "duplicate" && } - - )} + +
+ )) + )} + + ) : ( + data.eventTypeGroups.length === 1 && ( + + ) + )} + + {data.eventTypeGroups.length === 0 && } + + + {router.query.dialog === "duplicate" && } + + ); + }} />
diff --git a/packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx b/packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx deleted file mode 100644 index 7a8a6b6c27..0000000000 --- a/packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx +++ /dev/null @@ -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(t("all_apps")); - - const { data: teams, status } = trpc.viewer.teams.list.useQuery(); - const isNotEmpty = !!teams?.length; - - return status === "success" ? ( - - - } - 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")} - /> - - - } - 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")} - /> - - - {isNotEmpty && ( - -
TEAMS
- {teams?.map((team) => ( - - - } - 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); - } - }} - /> - - ))} -
- )} -
- ) : null; -}; - -type Props = InputHTMLAttributes & { - label: string; - icon: ReactNode; -}; - -const CheckboxField = forwardRef(({ label, icon, ...rest }, ref) => { - return ( - - ); -}); - -const CheckboxFieldContainer = ({ children }: { children: ReactNode }) => { - return
{children}
; -}; - -CheckboxField.displayName = "CheckboxField"; diff --git a/packages/features/filters/components/TeamsFilter.tsx b/packages/features/filters/components/TeamsFilter.tsx index 76ff1b4dd2..6e070c50fd 100644 --- a/packages/features/filters/components/TeamsFilter.tsx +++ b/packages/features/filters/components/TeamsFilter.tsx @@ -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 ( - - - } - checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)} - onChange={(e) => { - removeAllQueryParams(); - }} - label={t("all_apps")} - /> - - } - 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")} - /> - - {teams?.map((team) => ( +
+ + } + checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)} + onChange={(e) => { + removeAllQueryParams(); + }} + label={t("all_apps")} + /> + + } + 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={ - - } + label={t("yours")} /> - ))} - - + + {teams?.map((team) => ( + { + if (e.target.checked) { + pushItemToKey("teamIds", team.id); + } else if (!e.target.checked) { + removeItemByKeyAndValue("teamIds", team.id); + } + }} + icon={ + + } + /> + ))} + + + {showVerticalDivider && } +
); }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index 2e57337e94..d85d6f032a 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -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(); diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index 2e1bd0953c..9e1436a7f5 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -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; prisma: PrismaClient; }; + input: TEventTypeInputSchema; }; -export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { +const eventTypeSelect = Prisma.validator()({ + // 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()({ - // 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); + 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) => { + 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["filters"] +) { + if (!filters || !hasFilter(filters)) { + return userId; + } else if (filters.userIds?.[0] === userId) { + return userId; + } + return null; +} diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts index cb0ff5c3b5..1d3dd6f41b 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -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;