Handle children on event types, managed events, readonly and team count

This commit is contained in:
Alan 2023-09-06 17:25:17 -07:00
parent 9d1ebbebd1
commit 2a2cb14f6c
5 changed files with 85 additions and 40 deletions

View File

@ -29,7 +29,6 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { SchedulingType } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc, TRPCClientError } from "@calcom/trpc/react"; import { trpc, TRPCClientError } from "@calcom/trpc/react";
import { import {
@ -92,10 +91,12 @@ type EventType = EventTypeList[number];
interface EventTypeListProps { interface EventTypeListProps {
data: EventTypeList; data: EventTypeList;
readonly?: boolean;
} }
interface MobileTeamsTabProps { interface MobileTeamsTabProps {
teamEventTypes: EventTypeList[]; teamEventTypes: EventTypeList[];
readonly?: boolean;
} }
const querySchema = z.object({ const querySchema = z.object({
@ -103,7 +104,7 @@ const querySchema = z.object({
}); });
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props: MobileTeamsTabProps) => { const MobileTeamsTab: FC<MobileTeamsTabProps> = (props: MobileTeamsTabProps) => {
const { teamEventTypes } = props; const { teamEventTypes, readonly } = props;
const orgBranding = useOrgBranding(); const orgBranding = useOrgBranding();
const tabs = teamEventTypes const tabs = teamEventTypes
.filter((item) => item !== undefined) .filter((item) => item !== undefined)
@ -130,7 +131,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props: MobileTeamsTabProps) =>
return ( return (
<div> <div>
<HorizontalTabs tabs={tabs} /> <HorizontalTabs tabs={tabs} />
{events && events.length && <EventTypeList data={events} />} {events && events.length && <EventTypeList data={events} readonly={readonly} />}
</div> </div>
); );
}; };
@ -147,9 +148,8 @@ const getEventTypeSlug = (eventType: EventType, t: TFunction) => {
} }
}; };
const Item = ({ eventType }: { eventType: EventType }) => { const Item = ({ eventType, readonly }: { eventType: EventType; readonly?: boolean }) => {
const { t } = useLocale(); const { t } = useLocale();
const readOnly = false;
const content = () => ( const content = () => (
<div> <div>
@ -165,7 +165,7 @@ const Item = ({ eventType }: { eventType: EventType }) => {
{getEventTypeSlug(eventType, t)} {getEventTypeSlug(eventType, t)}
</small> </small>
{readOnly && ( {readonly && (
<Badge variant="gray" className="ml-2"> <Badge variant="gray" className="ml-2">
{t("readonly")} {t("readonly")}
</Badge> </Badge>
@ -173,7 +173,7 @@ const Item = ({ eventType }: { eventType: EventType }) => {
</div> </div>
); );
return readOnly ? ( return readonly ? (
<div className="flex-1 overflow-hidden pr-4 text-sm"> <div className="flex-1 overflow-hidden pr-4 text-sm">
{content()} {content()}
<EventTypeDescription <EventTypeDescription
@ -200,7 +200,7 @@ const Item = ({ eventType }: { eventType: EventType }) => {
{getEventTypeSlug(eventType, t)} {getEventTypeSlug(eventType, t)}
</small> </small>
{readOnly && ( {readonly && (
<Badge variant="gray" className="ml-2"> <Badge variant="gray" className="ml-2">
{t("readonly")} {t("readonly")}
</Badge> </Badge>
@ -220,7 +220,7 @@ const Item = ({ eventType }: { eventType: EventType }) => {
const MemoizedItem = memo(Item); const MemoizedItem = memo(Item);
export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => { export const EventTypeList = ({ data, readonly }: EventTypeListProps): JSX.Element => {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@ -391,11 +391,10 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
const calLink = `${orgBranding?.fullDomain ?? CAL_URL}/${embedLink}`; const calLink = `${orgBranding?.fullDomain ?? CAL_URL}/${embedLink}`;
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED; const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const eventTypeMetadata = EventTypeMetaDataSchema.safeParse(eventType.metadata);
const isChildrenManagedEventType = const isChildrenManagedEventType = !!eventType.parentId;
eventTypeMetadata.success && const isReadOnly = readonly || isChildrenManagedEventType;
eventTypeMetadata.data?.managedEventConfig !== undefined &&
eventType.schedulingType !== SchedulingType.MANAGED;
return ( return (
<li key={eventType.id}> <li key={eventType.id}>
<div className="hover:bg-muted flex w-full items-center justify-between"> <div className="hover:bg-muted flex w-full items-center justify-between">
@ -406,7 +405,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
{!(lastItem && lastItem.id === eventType.id) && ( {!(lastItem && lastItem.id === eventType.id) && (
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" /> <ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
)} )}
<MemoizedItem eventType={eventType} /> <MemoizedItem eventType={eventType} readonly={isReadOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex"> <div className="mt-4 hidden sm:mt-0 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse"> <div className="flex justify-between space-x-2 rtl:space-x-reverse">
{eventType.team && !isManagedEventType && ( {eventType.team && !isManagedEventType && (
@ -425,16 +424,20 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
)} )}
/> />
)} )}
{isManagedEventType && type?.children && type.children?.length > 0 && ( {isManagedEventType && eventType.children?.length > 0 && (
<AvatarGroup <AvatarGroup
className="relative right-3 top-1" className="relative right-3 top-1"
size="sm" size="sm"
truncateAfter={4} truncateAfter={4}
items={eventType.users.map((user: Pick<User, "name" | "username">) => ({ items={eventType.children.map(
alt: user.name || "", ({ users }: { users: Pick<User, "name" | "username">[] }) => ({
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${user.username}/avatar.png`, alt: users[0].name || "",
title: user.name || "", image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
}))} users[0].username
}/avatar.png`,
title: users[0].name || "",
})
)}
/> />
)} )}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse"> <div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
@ -485,7 +488,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
</Tooltip> </Tooltip>
</> </>
)} )}
<Dropdown modal={false}> <Dropdown modal={isReadOnly}>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + eventType.id}> <DropdownMenuTrigger asChild data-testid={"event-type-options-" + eventType.id}>
<Button <Button
type="button" type="button"
@ -496,7 +499,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
{!false && ( {!isReadOnly && (
<DropdownMenuItem> <DropdownMenuItem>
<DropdownItem <DropdownItem
type="button" type="button"
@ -534,8 +537,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */} {/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{/* @TODO: */} {isReadOnly && !isChildrenManagedEventType && (
{false && !isChildrenManagedEventType && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem>
@ -612,7 +614,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
</DropdownItem> </DropdownItem>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{!false && ( {!isReadOnly && (
<DropdownMenuItem className="outline-none"> <DropdownMenuItem className="outline-none">
<DropdownItem <DropdownItem
onClick={() => router.push("/event-types/" + eventType.id)} onClick={() => router.push("/event-types/" + eventType.id)}
@ -633,8 +635,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */} {/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{/* Readonly @TODO: */} {isReadOnly && !isChildrenManagedEventType && (
{false && !isChildrenManagedEventType && (
<> <>
<DropdownMenuItem className="outline-none"> <DropdownMenuItem className="outline-none">
<DropdownItem <DropdownItem
@ -913,7 +914,7 @@ const Main = ({ filters }: { filters: ReturnType<typeof getTeamsFiltersFromQuery
const teamEventTypesForTabs = eventTypePaginate const teamEventTypesForTabs = eventTypePaginate
.map((trpcFetch) => { .map((trpcFetch) => {
const { data } = trpcFetch; const { data } = trpcFetch;
return data; return data || [];
}) })
.filter((item) => item && item.length > 0); .filter((item) => item && item.length > 0);
return ( return (
@ -925,7 +926,8 @@ const Main = ({ filters }: { filters: ReturnType<typeof getTeamsFiltersFromQuery
<EventTypeListHeading <EventTypeListHeading
teamSlugOrUsername={mainUser.username || ""} teamSlugOrUsername={mainUser.username || ""}
teamNameOrUserName={mainUser.name || ""} teamNameOrUserName={mainUser.name || ""}
membershipCount={firstElementPersonalEventTypes.team?.members.length || 0} // Single event types have no teamMembershipCount
membershipCount={0}
teamId={firstElementPersonalEventTypes.teamId} teamId={firstElementPersonalEventTypes.teamId}
orgSlug={orgBranding?.slug} orgSlug={orgBranding?.slug}
/> />
@ -939,11 +941,13 @@ const Main = ({ filters }: { filters: ReturnType<typeof getTeamsFiltersFromQuery
{/* Then we list team event types */} {/* Then we list team event types */}
{eventTypePaginate.map((trpcFetch, index) => { {eventTypePaginate.map((trpcFetch, index) => {
const { data: teamEventTypes } = trpcFetch; const { data: teamEventTypes } = trpcFetch;
const [firstElementTeamEventTypes] = data || []; const [firstElementTeamEventTypes] = teamEventTypes || [];
if (!teamEventTypes || teamEventTypes.length === 0 || !firstElementTeamEventTypes.team) { if (!teamEventTypes || teamEventTypes.length === 0 || !firstElementTeamEventTypes.team) {
return null; return null;
} }
const teamDataMatchWithEventType = teams && teams.length > 0 && teams[index];
const membershipCount = teamDataMatchWithEventType?.membershipCount || 0;
return ( return (
<> <>
@ -951,13 +955,17 @@ const Main = ({ filters }: { filters: ReturnType<typeof getTeamsFiltersFromQuery
key={index} key={index}
teamSlugOrUsername={firstElementTeamEventTypes.team.slug || ""} teamSlugOrUsername={firstElementTeamEventTypes.team.slug || ""}
teamNameOrUserName={firstElementTeamEventTypes.team.name || ""} teamNameOrUserName={firstElementTeamEventTypes.team.name || ""}
membershipCount={firstElementTeamEventTypes.team?.members.length || 0} membershipCount={membershipCount}
teamId={firstElementTeamEventTypes.teamId} teamId={firstElementTeamEventTypes.teamId}
orgSlug={orgBranding?.slug} orgSlug={orgBranding?.slug}
/> />
{teamEventTypes.length > 0 ? ( {teamEventTypes.length > 0 ? (
<EventTypeList data={teamEventTypes} key={index} /> <EventTypeList
data={teamEventTypes}
key={index}
readonly={"MEMBER" === teamDataMatchWithEventType.role}
/>
) : ( ) : (
<EmptyEventTypeList <EmptyEventTypeList
teamId={firstElementTeamEventTypes.teamId} teamId={firstElementTeamEventTypes.teamId}

View File

@ -0,0 +1,12 @@
-- View: public.TeamMemberCount
-- DROP VIEW public."TeamMemberCount";
CREATE OR REPLACE VIEW public."TeamMemberCount"
AS
SELECT t.id,
count(*) AS count
FROM "Membership" m
LEFT JOIN "Team" t ON t.id = m."teamId"
WHERE m.accepted = true
GROUP BY t.id;

View File

@ -900,6 +900,7 @@ model SelectedSlots {
@@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique") @@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique")
} }
// Let's keep views at the bottom of the schema
view BookingTimeStatus { view BookingTimeStatus {
id Int @unique id Int @unique
uid String? uid String?
@ -919,3 +920,8 @@ view BookingTimeStatus {
timeStatus String? timeStatus String?
eventParentId Int? eventParentId Int?
} }
view TeamMemberCount {
id Int @unique
count Int
}

View File

@ -55,6 +55,7 @@ export const paginateHandler = async ({ ctx, input }: EventTypesPaginateProps) =
hidden: true, hidden: true,
metadata: true, metadata: true,
teamId: true, teamId: true,
parentId: true,
users: { users: {
select: { select: {
id: true, id: true,
@ -62,23 +63,29 @@ export const paginateHandler = async ({ ctx, input }: EventTypesPaginateProps) =
name: true, name: true,
}, },
}, },
children: {
select: {
id: true,
users: {
select: {
id: true,
username: true,
name: true,
},
},
},
},
team: { team: {
select: { select: {
id: true, id: true,
slug: true, slug: true,
name: true, name: true,
members: {
select: {
userId: true,
role: true,
},
},
}, },
}, },
}, },
skip, skip,
take: pageSize, take: pageSize,
}); });
console.log("result", result);
return result; return result;
}; };

View File

@ -59,6 +59,17 @@ export const listHandler = async ({ ctx }: ListOptions) => {
orderBy: { role: "desc" }, orderBy: { role: "desc" },
}); });
// This can be optimized by using a custom view between membership and team and teamMemberCount
const membershipCount = await prisma.teamMemberCount.findMany({
where: {
id: {
in: memberships.map((m) => m.teamId),
},
},
});
console.log({ membershipCount });
return memberships return memberships
.filter((mmship) => { .filter((mmship) => {
const metadata = teamMetadataSchema.parse(mmship.team.metadata); const metadata = teamMetadataSchema.parse(mmship.team.metadata);
@ -70,5 +81,6 @@ export const listHandler = async ({ ctx }: ListOptions) => {
..._team, ..._team,
/** To prevent breaking we only return non-email attached token here, if we have one */ /** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find((token) => token.identifier === "invite-link-for-teamId-" + _team.id), inviteToken: inviteTokens.find((token) => token.identifier === "invite-link-for-teamId-" + _team.id),
membershipCount: membershipCount.find((m) => m.id === _team.id)?.count || 0,
})); }));
}; };