refactor: simplify workflow page and improve load time (#16095)

* fix: simplify workflow page and improve load time

* chore: use new endpoint

* chore: save progress

* refactor: code

* refactor: remove not requried code

* chore: remove schema

* chore: fix typ

* chore: improve

* chore: change name

* chore: remove unused

* chore: remove page

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2024-08-09 14:48:48 +02:00
committed by GitHub
co-authored by Joe Au-Yeung
parent b6ab1ef80a
commit 0e62800af6
8 changed files with 519 additions and 156 deletions
@@ -27,7 +27,7 @@ import {
} from "@calcom/ui";
import { WORKFLOW_ACTIONS } from "../lib/constants";
import { onlyLettersNumbersSpaces } from "../pages/workflow";
import { onlyLettersNumbersSpaces } from "../lib/schema";
interface IAddActionDialog {
isOpenDialog: boolean;
@@ -0,0 +1,49 @@
import { isValidPhoneNumber } from "libphonenumber-js";
import { z } from "zod";
import { TimeUnit, WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
export function onlyLettersNumbersSpaces(str: string) {
if (str.length <= 11 && /^[A-Za-z0-9\s]*$/.test(str)) {
return true;
}
return false;
}
export const formSchema = z.object({
name: z.string(),
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
trigger: z.nativeEnum(WorkflowTriggerEvents),
time: z.number().gte(0).optional(),
timeUnit: z.nativeEnum(TimeUnit).optional(),
steps: z
.object({
id: z.number(),
stepNumber: z.number(),
action: z.nativeEnum(WorkflowActions),
workflowId: z.number(),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
template: z.nativeEnum(WorkflowTemplates),
numberRequired: z.boolean().nullable(),
includeCalendarEvent: z.boolean().nullable(),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional()
.nullable(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.optional()
.nullable(),
senderName: z.string().optional().nullable(),
})
.array(),
selectAll: z.boolean(),
});
export const querySchema = z.object({
workflow: stringOrNumber,
});
+12 -110
View File
@@ -1,10 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { WorkflowStep } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
@@ -12,14 +10,8 @@ import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { HttpError } from "@calcom/lib/http-error";
import {
MembershipRole,
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import type { TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { MembershipRole, WorkflowActions } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
@@ -30,6 +22,7 @@ import LicenseRequired from "../../common/components/LicenseRequired";
import SkeletonLoader from "../components/SkeletonLoaderEdit";
import WorkflowDetailsPage from "../components/WorkflowDetailsPage";
import { isSMSAction, isSMSOrWhatsappAction } from "../lib/actionHelperFunctions";
import { formSchema, querySchema } from "../lib/schema";
import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableTranslations";
export type FormValues = {
@@ -42,50 +35,6 @@ export type FormValues = {
selectAll: boolean;
};
export function onlyLettersNumbersSpaces(str: string) {
if (str.length <= 11 && /^[A-Za-z0-9\s]*$/.test(str)) {
return true;
}
return false;
}
const formSchema = z.object({
name: z.string(),
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
trigger: z.nativeEnum(WorkflowTriggerEvents),
time: z.number().gte(0).optional(),
timeUnit: z.nativeEnum(TimeUnit).optional(),
steps: z
.object({
id: z.number(),
stepNumber: z.number(),
action: z.nativeEnum(WorkflowActions),
workflowId: z.number(),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
template: z.nativeEnum(WorkflowTemplates),
numberRequired: z.boolean().nullable(),
includeCalendarEvent: z.boolean().nullable(),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional()
.nullable(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.optional()
.nullable(),
senderName: z.string().optional().nullable(),
})
.array(),
selectAll: z.boolean(),
});
const querySchema = z.object({
workflow: stringOrNumber,
});
function WorkflowPage() {
const { t, i18n } = useLocale();
const session = useSession();
@@ -129,69 +78,22 @@ function WorkflowPage() {
teamId: workflow?.team?.id,
});
const { data: eventTypeGroups, isPending: isPendingEventTypes } =
trpc.viewer.eventTypes.getByViewer.useQuery();
const { data: otherTeams, isPending: isPendingTeams } = trpc.viewer.organizations.listOtherTeams.useQuery();
const isOrg = workflow?.team?.isOrganization ?? false;
const teamId = workflow?.teamId ?? undefined;
const profileTeamsOptions =
isOrg && eventTypeGroups
? eventTypeGroups?.profiles
.filter((profile) => !!profile.teamId)
.map((profile) => {
return {
value: String(profile.teamId) || "",
label: profile.name || profile.slug || "",
};
})
: [];
const otherTeamsOptions = otherTeams
? otherTeams.map((team) => {
return {
value: String(team.id) || "",
label: team.name || team.slug || "",
};
})
: [];
const teamOptions = profileTeamsOptions.concat(otherTeamsOptions);
const eventTypeOptions = useMemo(
() =>
eventTypeGroups?.eventTypeGroups.reduce((options, group) => {
/** don't show team event types for user workflow */
if (!teamId && group.teamId) return options;
/** only show correct team event types for team workflows */
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...group.eventTypes
.filter(
(evType) =>
!evType.metadata?.managedEventConfig ||
!!evType.metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
)
.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})),
];
}, [] as Option[]) || [],
[eventTypeGroups]
const { data, isPending: isPendingEventTypes } = trpc.viewer.eventTypes.getTeamAndEventTypeOptions.useQuery(
{ teamId, isOrg },
{ enabled: !isPendingWorkflow }
);
let allEventTypeOptions = eventTypeOptions;
const teamOptions = data?.teamOptions ?? [];
let allEventTypeOptions = data?.eventTypeOptions ?? [];
const distinctEventTypes = new Set();
if (!teamId && isMixedEventType) {
allEventTypeOptions = [...eventTypeOptions, ...selectedOptions];
allEventTypeOptions = [...allEventTypeOptions, ...selectedOptions];
allEventTypeOptions = allEventTypeOptions.filter((option) => {
const duplicate = distinctEventTypes.has(option.value);
distinctEventTypes.add(option.value);
@@ -203,7 +105,7 @@ function WorkflowPage() {
workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role ===
MembershipRole.MEMBER;
const isPending = isPendingWorkflow || isPendingEventTypes || isPendingTeams;
const isPending = isPendingWorkflow || isPendingEventTypes;
useEffect(() => {
if (!isPending) {
+104
View File
@@ -216,6 +216,110 @@ export class EventTypeRepository {
}
}
static async findAllByUpIdWithMinimalData(
{ upId, userId }: { upId: string; userId: number },
{
orderBy,
where = {},
cursor: cursorId,
limit,
}: {
orderBy?: Prisma.EventTypeOrderByWithRelationInput[];
where?: Prisma.EventTypeWhereInput;
cursor?: number | null;
limit?: number | null;
} = {}
) {
if (!upId) return [];
const lookupTarget = ProfileRepository.getLookupTarget(upId);
const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id;
const select = {
...eventTypeSelect,
hashedLink: true,
};
log.debug(
"findAllByUpIdWithMinimalData",
safeStringify({
upId,
orderBy,
argumentWhere: where,
})
);
const cursor = cursorId ? { id: cursorId } : undefined;
const take = limit ? limit + 1 : undefined; // We take +1 as it'll be used for the next cursor
if (!profileId) {
// Lookup is by userId
return await prisma.eventType.findMany({
where: {
userId: lookupTarget.id,
...where,
},
select,
cursor,
take,
orderBy,
});
}
const profile = await ProfileRepository.findById(profileId);
if (profile?.movedFromUser) {
// Because the user has been moved to this profile, we need to get all user events except those that belong to some other profile
// This is because those event-types that are created after moving to profile would have profileId but existing event-types would have profileId set to null
return await prisma.eventType.findMany({
where: {
OR: [
// Existing events
{
userId: profile.movedFromUser.id,
profileId: null,
},
// New events
{
profileId,
},
// Fetch children event-types by userId because profileId is wrong
{
userId,
parentId: {
not: null,
},
},
],
...where,
},
select,
cursor,
take,
orderBy,
});
} else {
return await prisma.eventType.findMany({
where: {
OR: [
{
profileId,
},
// Fetch children event-types by userId because profileId is wrong
{
userId: userId,
parentId: {
not: null,
},
},
],
...where,
},
select,
cursor,
take,
orderBy,
});
}
}
static async findTeamEventTypes({
teamId,
parentId,
+92 -45
View File
@@ -40,6 +40,32 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
});
const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipWhereInput) => {
const lookupTarget = ProfileRepository.getLookupTarget(upId);
let prismaWhere;
if (lookupTarget.type === LookupTarget.Profile) {
/**
* TODO: When we add profileId to membership, we lookup by profileId
* If the profile is movedFromUser, we lookup all memberships without profileId as well.
*/
const profile = await ProfileRepository.findById(lookupTarget.id);
if (!profile) {
return [];
}
prismaWhere = {
userId: profile.user.id,
...where,
};
} else {
prismaWhere = {
userId: lookupTarget.id,
...where,
};
}
return prismaWhere;
};
export class MembershipRepository {
static async create(data: IMembership) {
return await prisma.membership.create({
@@ -60,26 +86,9 @@ export class MembershipRepository {
{ upId }: { upId: string },
{ where }: { where?: Prisma.MembershipWhereInput } = {}
) {
const lookupTarget = ProfileRepository.getLookupTarget(upId);
let prismaWhere;
if (lookupTarget.type === LookupTarget.Profile) {
/**
* TODO: When we add profileId to membership, we lookup by profileId
* If the profile is movedFromUser, we lookup all memberships without profileId as well.
*/
const profile = await ProfileRepository.findById(lookupTarget.id);
if (!profile) {
return [];
}
prismaWhere = {
userId: profile.user.id,
...where,
};
} else {
prismaWhere = {
userId: lookupTarget.id,
...where,
};
const prismaWhere = await getWhereForfindAllByUpId(upId, where);
if (Array.isArray(prismaWhere)) {
return prismaWhere;
}
log.debug(
@@ -132,41 +141,79 @@ export class MembershipRepository {
});
}
static async findAllByUpIdIncludeMinimalEventTypes(
{ upId }: { upId: string },
{ where, skipEventTypes = false }: { where?: Prisma.MembershipWhereInput; skipEventTypes?: boolean } = {}
) {
const prismaWhere = await getWhereForfindAllByUpId(upId, where);
if (Array.isArray(prismaWhere)) {
return prismaWhere;
}
log.debug(
"findAllByUpIdIncludeMinimalEventTypes",
safeStringify({
prismaWhere,
})
);
const select = Prisma.validator<Prisma.MembershipSelect>()({
id: true,
teamId: true,
userId: true,
accepted: true,
role: true,
disableImpersonation: true,
team: {
select: {
...teamParentSelect,
isOrganization: true,
parent: {
select: teamParentSelect,
},
...(!skipEventTypes
? {
eventTypes: {
select: {
...eventTypeSelect,
hashedLink: true,
children: { select: { id: true } },
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
},
}
: {}),
},
},
});
return await prisma.membership.findMany({
where: prismaWhere,
select,
});
}
static async findAllByUpIdIncludeTeam(
{ upId }: { upId: string },
{ where }: { where?: Prisma.MembershipWhereInput } = {}
) {
const lookupTarget = ProfileRepository.getLookupTarget(upId);
let prismaWhere;
if (lookupTarget.type === LookupTarget.Profile) {
/**
* TODO: When we add profileId to membership, we lookup by profileId
* If the profile is movedFromUser, we lookup all memberships without profileId as well.
*/
const profile = await ProfileRepository.findById(lookupTarget.id);
if (!profile) {
return [];
}
prismaWhere = {
userId: profile.user.id,
...where,
};
} else {
prismaWhere = {
userId: lookupTarget.id,
...where,
};
const prismaWhere = await getWhereForfindAllByUpId(upId, where);
if (Array.isArray(prismaWhere)) {
return prismaWhere;
}
return await prisma.membership.findMany({
where: prismaWhere,
include: {
team: {
include: {
// could slow down the query
// members: {
// select: membershipSelect,
// },
parent: {
select: teamParentSelect,
},
@@ -8,6 +8,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDuplicateInputSchema } from "./duplicate.schema";
import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema";
import { ZGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema";
import { get } from "./procedures/get";
import { ZUpdateInputSchema } from "./update.schema";
import { eventOwnerProcedure } from "./util";
@@ -16,6 +17,7 @@ type BookingsRouterHandlerCache = {
getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler;
getUserEventGroups?: typeof import("./getUserEventGroups.handler").getUserEventGroups;
getEventTypesFromGroup?: typeof import("./getEventTypesFromGroup.handler").getEventTypesFromGroup;
getTeamAndEventTypeOptions?: typeof import("./getTeamAndEventTypeOptions.handler").getTeamAndEventTypeOptions;
list?: typeof import("./list.handler").listHandler;
listWithTeam?: typeof import("./listWithTeam.handler").listWithTeamHandler;
create?: typeof import("./create.handler").createHandler;
@@ -104,6 +106,32 @@ export const eventTypesRouter = router({
return result;
}),
getTeamAndEventTypeOptions: authedProcedure
.input(ZGetTeamAndEventTypeOptionsSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getTeamAndEventTypeOptions) {
UNSTABLE_HANDLER_CACHE.getTeamAndEventTypeOptions = await import(
"./getTeamAndEventTypeOptions.handler"
).then((mod) => mod.getTeamAndEventTypeOptions);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getTeamAndEventTypeOptions) {
throw new Error("Failed to load handler");
}
const timer = logP(`getTeamAndEventTypeOptions(${ctx.user.id})`);
const result = await UNSTABLE_HANDLER_CACHE.getTeamAndEventTypeOptions({
ctx,
input,
});
timer();
return result;
}),
list: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.list) {
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
@@ -0,0 +1,223 @@
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import { listOtherTeamHandler } from "../organizations/listOtherTeams.handler";
import type { TGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema";
type GetTeamAndEventTypeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetTeamAndEventTypeOptionsSchema;
};
type Option = {
value: string;
label: string;
};
type res = Awaited<
ReturnType<typeof MembershipRepository.findAllByUpIdIncludeMinimalEventTypes>
>[number]["team"]["eventTypes"][number];
type EventType = Omit<res, "forwardParamsSuccessRedirect"> & { children?: { id: number }[] };
export const getTeamAndEventTypeOptions = async ({ ctx, input }: GetTeamAndEventTypeOptions) => {
await checkRateLimitAndThrowError({
identifier: `eventTypes:getTeamAndEventTypeOptions.handler:${ctx.user.id}`,
rateLimitingType: "common",
});
const user = ctx.user;
const teamId = input?.teamId;
const isOrg = input?.isOrg;
const skipTeamOptions = !isOrg;
const skipEventTypes = !!isOrg;
const userProfile = ctx.user.profile;
const profile = await ProfileRepository.findByUpId(userProfile.upId);
const parentOrgHasLockedEventTypes =
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeMinimalEventTypes(
{
upId: userProfile.upId,
},
{
where: {
accepted: true,
},
skipEventTypes,
}
),
teamId
? []
: EventTypeRepository.findAllByUpIdWithMinimalData(
{
upId: userProfile.upId,
userId: user.id,
},
{
where: {
teamId: null,
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
}
),
]);
if (!profile) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const memberships = profileMemberships.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
type EventTypeGroup = {
teamId?: number | null;
parentId?: number | null;
bookerUrl?: string;
profile: {
slug?: (typeof profile)["username"] | null;
name: (typeof profile)["name"];
image?: string;
eventTypesLockedByOrg?: boolean;
};
eventTypes?: EventType[];
};
let eventTypeGroups: EventTypeGroup[] = [];
eventTypeGroups.push({
teamId: null,
profile: {
slug: profile.username,
name: profile.name,
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
eventTypes: profileEventTypes,
});
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
await Promise.all(
memberships
.filter((mmship) => {
if (mmship?.team?.isOrganization) {
return false;
}
return true;
})
.map(async (membership) => {
const team = {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
const eventTypes = team.eventTypes;
return {
teamId: team.id,
parentId: team.parentId,
profile: {
name: team.name,
},
eventTypes: eventTypes
?.filter((evType) => {
const res = evType.userId === null || evType.userId === user.id;
return res;
})
?.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
),
};
})
)
);
let teamOptions: Option[] = [];
if (!skipTeamOptions) {
const profileTeamsOptions = eventTypeGroups
.map((group) => ({
...group.profile,
teamId: group.teamId,
}))
.filter((profile) => !!profile.teamId)
.map((profile) => {
return {
value: String(profile.teamId) || "",
label: profile.name || profile.slug || "",
};
});
const otherTeams = await listOtherTeamHandler({ ctx });
const otherTeamsOptions = otherTeams
? otherTeams.map((team) => {
return {
value: String(team.id) || "",
label: team.name || team.slug || "",
};
})
: [];
teamOptions = profileTeamsOptions.concat(otherTeamsOptions);
}
const eventTypeOptions =
eventTypeGroups.reduce((options, group) => {
// /** don't show team event types for user workflow */
if (!teamId && group.teamId) return options;
// /** only show correct team event types for team workflows */
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...(group?.eventTypes
?.filter((evType) => {
const metadata = EventTypeMetaDataSchema.parse(evType.metadata);
return (
!metadata?.managedEventConfig ||
!!metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
);
})
?.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType?.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})) ?? []),
];
}, [] as Option[]) || [];
return {
eventTypeOptions,
teamOptions,
};
};
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ZGetTeamAndEventTypeOptionsSchema = z
.object({
teamId: z.number().optional(),
isOrg: z.boolean().default(false),
})
.nullish();
export type TGetTeamAndEventTypeOptionsSchema = z.infer<typeof ZGetTeamAndEventTypeOptionsSchema>;