feat: added ability to assign event to all members (#13219)

* feat: added ability to assign event to all members

* fix: types

* chore: incorporate assignment for collective and round robin event sas well

* fix: update event types query

* feat: round robin hosts

* fix: update

* fix: update

* fix: assignAllTeamMembers issue

* design fixes

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Pradumn Kumar
2024-01-26 17:37:38 +00:00
committed by GitHub
co-authored by Peer Richelsen CarinaWolli
parent 7c7ca79d48
commit f9359aaa7b
21 changed files with 321 additions and 47 deletions
+1
View File
@@ -62,6 +62,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
})
.merge(
z.object({
+186 -45
View File
@@ -1,8 +1,8 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef } from "react";
import type { ComponentProps } from "react";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
@@ -11,7 +11,7 @@ import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTea
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select } from "@calcom/ui";
import { Label, Select, SettingsToggle } from "@calcom/ui";
interface IUserToValue {
id: number | null;
@@ -97,6 +97,44 @@ const ChildrenEventTypesList = ({
);
};
const AssignAllTeamMembers = ({
assignAllTeamMembers,
setAssignAllTeamMembers,
onActive,
onInactive,
}: {
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
onActive: () => void;
onInactive?: () => void;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="assignAllTeamMembers"
render={() => (
<SettingsToggle
title={t("automatically_add_all_team_members")}
description={t("including_people_who_join_in_future")}
checked={assignAllTeamMembers}
onCheckedChange={(active) => {
formMethods.setValue("assignAllTeamMembers", active);
setAssignAllTeamMembers(active);
if (active) {
onActive();
} else if (!!onInactive) {
onInactive();
}
}}
toggleSwitchAtTheEnd
/>
)}
/>
);
};
const CheckedHostField = ({
labelText,
placeholder,
@@ -107,7 +145,7 @@ const CheckedHostField = ({
helperText,
...rest
}: {
labelText: string;
labelText?: string;
placeholder: string;
isFixed: boolean;
value: { isFixed: boolean; userId: number }[];
@@ -118,7 +156,7 @@ const CheckedHostField = ({
return (
<div className="bg-muted flex flex-col space-y-5 p-4">
<div>
<Label>{labelText}</Label>
{labelText ? <Label>{labelText}</Label> : <></>}
<CheckedTeamSelect
isOptionDisabled={(option) => !!value.find((host) => host.userId.toString() === option.value)}
onChange={(options) => {
@@ -165,6 +203,8 @@ const RoundRobinHosts = ({
teamMembers,
value,
onChange,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
value: { isFixed: boolean; userId: number }[];
onChange: (hosts: { isFixed: boolean; userId: number }[]) => void;
@@ -174,51 +214,102 @@ const RoundRobinHosts = ({
avatar: string;
email: string;
}[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
return (
<>
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
isFixed={true}
onChange={(changeValue) => {
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
}}
value={value}
placeholder={t("add_fixed_hosts")}
labelText={t("fixed_hosts")}
helperText={FixedHostHelper}
/>
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
onChange={(changeValue) => onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])}
value={value}
isFixed={false}
placeholder={t("add_attendees")}
labelText={t("round_robin_hosts")}
helperText={t("round_robin_helper")}
/>
</>
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-8">
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
isFixed={true}
onChange={(changeValue) => {
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
}}
value={value}
placeholder={t("add_fixed_hosts")}
labelText={t("fixed_hosts")}
helperText={FixedHostHelper}
/>
<div className="bg-muted flex flex-col">
<Label className="p-4">{t("round_robin_hosts")}</Label>
<div className="px-4 pb-2">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() =>
formMethods.setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
}))
)
}
onInactive={() => formMethods.setValue("hosts", [])}
/>
</div>
{assignAllTeamMembers ? (
<div className="mb-4" />
) : (
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
onChange={(changeValue) =>
onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])
}
value={value}
isFixed={false}
placeholder={t("add_attendees")}
helperText={t("round_robin_helper")}
/>
)}
</div>
</div>
</div>
);
};
const ChildrenEventTypes = ({
childrenEventTypeOptions,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
childrenEventTypeOptions: ReturnType<typeof mapMemberToChildrenOption>[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="children"
render={({ field: { onChange, value } }) => (
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
)}
/>
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-4">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() => formMethods.setValue("children", childrenEventTypeOptions)}
/>
{!assignAllTeamMembers ? (
<Controller<FormValues>
name="children"
render={({ field: { onChange, value } }) => (
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
)}
/>
) : (
<></>
)}
</div>
</div>
);
};
const Hosts = ({
teamMembers,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
teamMembers: {
value: string;
@@ -226,6 +317,8 @@ const Hosts = ({
avatar: string;
email: string;
}[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const {
@@ -255,24 +348,53 @@ const Hosts = ({
});
}, [schedulingType, resetField, getValues, submitCount]);
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="hosts"
render={({ field: { onChange, value } }) => {
const schedulingTypeRender = {
COLLECTIVE: (
<CheckedHostField
value={value}
onChange={onChange}
isFixed={true}
options={teamMembers.sort(sortByLabel)}
placeholder={t("add_attendees")}
labelText={t("team")}
/>
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-4">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() =>
formMethods.setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
}))
)
}
onInactive={() => formMethods.setValue("hosts", [])}
/>
{assignAllTeamMembers ? (
<></>
) : (
<CheckedHostField
value={value}
onChange={onChange}
isFixed={true}
options={teamMembers.sort(sortByLabel)}
placeholder={t("add_attendees")}
labelText={t("team")}
/>
)}
</div>
</div>
),
ROUND_ROBIN: (
<>
<RoundRobinHosts teamMembers={teamMembers} onChange={onChange} value={value} />
<RoundRobinHosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembers}
onChange={onChange}
value={value}
/>
{/*<TextField
required
type="number"
@@ -320,9 +442,18 @@ export const EventTeamTab = ({
.filter(pendingMembers)
.map((member) => mapUserToValue(member, t("pending")));
const childrenEventTypeOptions = teamMembers.filter(pendingMembers).map((member) => {
return mapMemberToChildrenOption(member, eventType.slug, t("pending"));
return mapMemberToChildrenOption(
{ ...member, eventTypes: member.eventTypes.filter((et) => et !== eventType.slug) },
eventType.slug,
t("pending")
);
});
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const formMethods = useFormContext<FormValues>();
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
formMethods.getValues("assignAllTeamMembers") ?? false
);
return (
<div>
{team && !isManagedEventType && (
@@ -338,16 +469,26 @@ export const EventTeamTab = ({
className="w-full"
onChange={(val) => {
onChange(val?.value);
formMethods.setValue("assignAllTeamMembers", false);
setAssignAllTeamMembers(false);
}}
/>
)}
/>
</div>
<Hosts teamMembers={teamMembersOptions} />
<Hosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembersOptions}
/>
</div>
)}
{team && isManagedEventType && (
<ChildrenEventTypes childrenEventTypeOptions={childrenEventTypeOptions} />
<ChildrenEventTypes
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
childrenEventTypeOptions={childrenEventTypeOptions}
/>
)}
</div>
);
@@ -145,6 +145,7 @@ export type FormValues = {
bookerLayouts: BookerLayoutSettings;
multipleDurationEnabled: boolean;
users: EventTypeSetup["users"];
assignAllTeamMembers: boolean;
};
export type CustomInputParsed = typeof customInputSchema._output;
@@ -195,6 +196,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
created: true,
}))
);
formMethods.setValue("assignAllTeamMembers", formMethods.getValues("assignAllTeamMembers") || false);
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
@@ -305,6 +307,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
},
})),
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
assignAllTeamMembers: eventType.assignAllTeamMembers,
};
}, [eventType, periodDates, metadata]);
@@ -463,6 +466,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
metadata,
customInputs,
children,
assignAllTeamMembers,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
@@ -532,6 +536,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
metadata,
customInputs,
children,
assignAllTeamMembers,
});
};
@@ -2235,6 +2235,8 @@
"add_a_redirect": "Add a redirect",
"create_entry": "Create entry",
"time_range": "Time range",
"automatically_add_all_team_members": "Automatically add all team members",
"including_people_who_join_in_future": "Including people who join the team in the future",
"redirect_to": "Redirect to",
"having_trouble_finding_time": "Having trouble finding a time?",
"show_more": "Show more",
+1
View File
@@ -57,6 +57,7 @@ const _getEventType = async (id: number) => {
seatsPerTimeSlot: true,
bookingLimits: true,
durationLimits: true,
assignAllTeamMembers: true,
timeZone: true,
length: true,
metadata: true,
@@ -1,5 +1,6 @@
import type z from "zod";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { prisma } from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -56,6 +57,7 @@ export const createOrUpdateMemberships = async ({
},
});
}
await updateNewTeamMemberEventTypes(user.id, team.id);
return { membership, orgMembership };
});
};
@@ -1,3 +1,4 @@
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import prisma from "@calcom/prisma";
export async function joinOrganization({
@@ -52,4 +53,6 @@ export async function joinAnyChildTeamOnOrgInvite({ userId, orgId }: { userId: n
},
}),
]);
await updateNewTeamMemberEventTypes(userId, orgId);
}
@@ -333,6 +333,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
seatsShowAvailabilityCount: true,
bookingLimits: true,
durationLimits: true,
assignAllTeamMembers: true,
parentId: true,
owner: {
select: {
@@ -112,6 +112,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
},
},
hidden: true,
assignAllTeamMembers: true,
});
export const getPublicEvent = async (
@@ -264,6 +265,7 @@ export const getPublicEvent = async (
},
isDynamic: false,
isInstantEvent: event.isInstantEvent,
assignAllTeamMembers: event.assignAllTeamMembers,
};
};
+1
View File
@@ -101,6 +101,7 @@ const commons = {
hosts: [],
metadata: EventTypeMetaDataSchema.parse({}),
bookingFields: [],
assignAllTeamMembers: false,
};
const dynamicEvent = {
+1
View File
@@ -105,6 +105,7 @@ export default async function getEventTypeById({
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
successRedirectUrl: true,
currency: true,
bookingFields: true,
+70 -1
View File
@@ -3,7 +3,12 @@ import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { _EventTypeModel } from "@calcom/prisma/zod";
import {
EventTypeMetaDataSchema,
allManagedEventTypeProps,
unlockedManagedEventTypeProps,
} from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
import { getBookerBaseUrlSync } from "../../../getBookerUrl/client";
@@ -247,3 +252,67 @@ export async function isTeamMember(userId: number, teamId: number) {
},
}));
}
export async function updateNewTeamMemberEventTypes(userId: number, teamId: number) {
const eventTypesToAdd = await prisma.eventType.findMany({
where: {
team: { id: teamId },
assignAllTeamMembers: true,
},
select: {
...allManagedEventTypeProps,
id: true,
schedulingType: true,
},
});
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps);
eventTypesToAdd.length > 0 &&
(await prisma.$transaction(
eventTypesToAdd.map((eventType) => {
if (eventType.schedulingType === "MANAGED") {
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
// Define the values for unlocked properties to use on creation, not updation
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new workflows for which assigned members will get too
const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId);
return prisma.eventType.create({
data: {
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false,
userId,
users: {
connect: [{ id: userId }],
},
parentId: eventType.parentId,
hidden: false,
workflows: currentWorkflowIds && {
create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })),
},
},
});
} else {
return prisma.eventType.update({
where: { id: eventType.id },
data: { hosts: { create: [{ userId, isFixed: eventType.schedulingType === "COLLECTIVE" }] } },
});
}
})
));
}
+1
View File
@@ -105,6 +105,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
scheduleId: null,
bookingLimits: null,
durationLimits: null,
assignAllTeamMembers: false,
price: 0,
currency: "usd",
slotInterval: null,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "assignAllTeamMembers" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -119,6 +119,7 @@ model EventType {
/// @zod.custom(imports.intervalLimitsType)
durationLimits Json?
isInstantEvent Boolean @default(false)
assignAllTeamMembers Boolean @default(false)
@@unique([userId, slug])
@@unique([teamId, slug])
+1
View File
@@ -611,6 +611,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
workflows: true,
bookingFields: true,
durationLimits: true,
assignAllTeamMembers: true,
};
// All properties that are defined as unlocked based on all managed props
@@ -37,6 +37,7 @@ export const EventTypeUpdateInput = _EventTypeModel
.optional(),
schedule: z.number().nullable().optional(),
hashedLink: z.string(),
assignAllTeamMembers: z.boolean().optional(),
})
.partial()
.extend({
@@ -39,6 +39,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
recurringEvent,
users,
children,
assignAllTeamMembers,
hosts,
id,
hashedLink,
@@ -65,9 +66,33 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
},
team: {
select: {
name: true,
id: true,
name: true,
slug: true,
parentId: true,
parent: {
select: {
slug: true,
},
},
members: {
select: {
role: true,
accepted: true,
user: {
select: {
name: true,
id: true,
email: true,
eventTypes: {
select: {
slug: true,
},
},
},
},
},
},
},
},
},
@@ -290,6 +315,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
}
data.assignAllTeamMembers = assignAllTeamMembers ?? false;
const updatedEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
slug: true,
schedulingType: true,
@@ -322,6 +349,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
children,
prisma: ctx.prisma,
});
const res = ctx.res as NextApiResponse;
if (typeof res?.revalidate !== "undefined") {
try {
@@ -153,6 +153,7 @@ export async function getEventType(
afterEventBuffer: true,
bookingLimits: true,
durationLimits: true,
assignAllTeamMembers: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
@@ -1,3 +1,4 @@
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -26,6 +27,8 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions)
},
});
await updateNewTeamMemberEventTypes(ctx.user.id, input.teamId);
closeComUpsertTeamUser(membership.team, ctx.user, membership.role);
} else {
try {
@@ -2,6 +2,7 @@ import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -131,6 +132,12 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
}),
});
await Promise.all(
autoJoinUsers.map(async (userToAutoJoin) => {
await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id);
})
);
await sendTeamInviteEmails({
currentUserName: ctx?.user?.name,
currentUserTeamName: team?.name,