2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
---
items:
- 1.jpg
- 2.jpg
- 3.jpg
---
It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user

View File

@@ -0,0 +1,7 @@
# Routing Forms App
## How to run Tests
`yarn e2e:app-store` runs all Apps' tests. You can use `describe.only()` to run Routing Forms tests only.
Make sure that the app is running already with NEXT_PUBLIC_IS_E2E=1 so that the app is installable

View File

@@ -0,0 +1,27 @@
import prisma from "@calcom/prisma";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: async ({ user, appType, slug, teamId }) => {
return await prisma.credential.create({
data: {
type: appType,
key: {},
...(teamId ? { teamId } : { userId: user.id }),
appId: slug,
},
});
},
redirect: {
url: "/apps/routing-forms/forms",
},
};
export default handler;

View File

@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as responses } from "./responses/[formId]";

View File

@@ -0,0 +1,122 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import prisma from "@calcom/prisma";
import { getSerializableForm } from "../../lib/getSerializableForm";
import type { Response, SerializableForm } from "../../types/types";
function escapeCsvText(str: string) {
return str.replace(/,/, "%2C");
}
async function* getResponses(
formId: string,
headerFields: NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>
) {
let responses;
let skip = 0;
// Keep it small enough to be in Vercel limits of Serverless Function in terms of memory.
// To avoid limit in terms of execution time there is an RFC https://linear.app/calcom/issue/CAL-204/rfc-routing-form-improved-csv-exports
const take = 100;
while (
(responses = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId,
},
take: take,
skip: skip,
})) &&
responses.length
) {
const csv: string[] = [];
responses.forEach((response) => {
const fieldResponses = response.response as Response;
const csvCells: string[] = [];
headerFields.forEach((headerField) => {
const fieldResponse = fieldResponses[headerField.id];
const value = fieldResponse?.value || "";
let serializedValue = "";
if (value instanceof Array) {
serializedValue = value.map((val) => escapeCsvText(val)).join(" | ");
} else {
// value can be a number as well for type Number field
serializedValue = escapeCsvText(String(value));
}
csvCells.push(serializedValue);
});
csvCells.push(response.createdAt.toISOString());
csv.push(csvCells.join(","));
});
skip += take;
yield csv.join("\n");
}
return "";
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { args } = req.query;
if (!args) {
throw new Error("args must be set");
}
const formId = args[2];
if (!formId) {
throw new Error("formId must be provided");
}
const session = await getSession({ req });
if (!session) {
return res.status(401).json({ message: "Unauthorized" });
}
const { user } = session;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
...entityPrismaWhereClause({ userId: user.id }),
},
include: {
team: {
select: {
members: true,
},
},
},
});
if (!form) {
return res.status(404).json({ message: "Form not found or unauthorized" });
}
if (!canEditEntity(form, user.id)) {
return res.status(404).json({ message: "Form not found or unauthorized" });
}
const serializableForm = await getSerializableForm({ form, withDeletedFields: true });
res.setHeader("Content-Type", "text/csv; charset=UTF-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${serializableForm.name}-${serializableForm.id}.csv"`
);
res.setHeader("Transfer-Encoding", "chunked");
const headerFields = serializableForm.fields || [];
const csvIterator = getResponses(formId, headerFields);
// Make Header
res.write(
`${headerFields
.map((field) => `${field.label}${field.deleted ? "(Deleted)" : ""}`)
.concat(["Submission Time"])
.join(",")}\n`
);
for await (const partialCsv of csvIterator) {
res.write(partialCsv);
res.write("\n");
}
res.end();
}

View File

@@ -0,0 +1,520 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import { usePathname, useRouter } from "next/navigation";
import { createContext, forwardRef, useContext, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { RoutingFormEmbedButton, RoutingFormEmbedDialog } from "@calcom/features/embed/RoutingFormEmbed";
import { classNames } from "@calcom/lib";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import type { ButtonProps } from "@calcom/ui";
import {
Button,
ConfirmationDialogContent,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Form,
SettingsToggle,
showToast,
Switch,
TextAreaField,
TextField,
} from "@calcom/ui";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import type { SerializableForm } from "../types/types";
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
const newFormModalQuerySchema = z.object({
action: z.literal("new").or(z.literal("duplicate")),
target: z.string().optional(),
});
export const useOpenModal = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const openModal = (option: z.infer<typeof newFormModalQuerySchema>) => {
const newQuery = new URLSearchParams(searchParams ?? undefined);
newQuery.set("dialog", "new-form");
Object.keys(option).forEach((key) => {
newQuery.set(key, option[key as keyof typeof option] || "");
});
router.push(`${pathname}?${newQuery.toString()}`);
};
return openModal;
};
function NewFormDialog({ appUrl }: { appUrl: string }) {
const routerQuery = useRouterQuery();
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess: (_data, variables) => {
router.push(`${appUrl}/form-edit/${variables.id}`);
},
onError: (err) => {
showToast(err.message || t("something_went_wrong"), "error");
},
onSettled: () => {
utils.viewer.appRoutingForms.forms.invalidate();
},
});
const hookForm = useForm<{
name: string;
description: string;
shouldConnect: boolean;
}>();
const { action, target } = routerQuery as z.infer<typeof newFormModalQuerySchema>;
const formToDuplicate = action === "duplicate" ? target : null;
const teamId = action === "new" ? Number(target) : null;
const { register } = hookForm;
return (
<Dialog name="new-form" clearQueryParamsOnClose={["target", "action"]}>
<DialogContent className="overflow-y-auto">
<div className="mb-1">
<h3
className="text-emphasis !font-cal text-semibold leading-20 text-xl font-medium"
id="modal-title">
{teamId ? t("add_new_team_form") : t("add_new_form")}
</h3>
<div>
<p className="text-subtle text-sm">{t("form_description")}</p>
</div>
</div>
<Form
form={hookForm}
handleSubmit={(values) => {
const formId = uuidv4();
mutation.mutate({
id: formId,
...values,
addFallback: true,
teamId,
duplicateFrom: formToDuplicate,
});
}}>
<div className="mt-3 space-y-5">
<TextField label={t("title")} required placeholder={t("a_routing_form")} {...register("name")} />
<div className="mb-5">
<TextAreaField
id="description"
label={t("description")}
{...register("description")}
data-testid="description"
placeholder={t("form_description_placeholder")}
/>
</div>
{action === "duplicate" && (
<Controller
name="shouldConnect"
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("keep_me_connected_with_form")}
description={t("fields_in_form_duplicated")}
checked={value}
onCheckedChange={(checked) => {
onChange(checked);
}}
/>
);
}}
/>
)}
</div>
<DialogFooter showDivider className="mt-12">
<DialogClose />
<Button loading={mutation.isPending} data-testid="add-form" type="submit">
{t("continue")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
const dropdownCtx = createContext<{ dropdown: boolean }>({ dropdown: false });
export const FormActionsDropdown = ({
children,
disabled,
}: {
disabled?: boolean;
children: React.ReactNode;
}) => {
return (
<dropdownCtx.Provider value={{ dropdown: true }}>
<Dropdown>
<DropdownMenuTrigger disabled={disabled} data-testid="form-dropdown" asChild>
<Button
type="button"
variant="icon"
color="secondary"
className={classNames("radix-state-open:rounded-r-md", disabled && "opacity-30")}
StartIcon="ellipsis"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</Dropdown>
</dropdownCtx.Provider>
);
};
function Dialogs({
appUrl,
deleteDialogOpen,
setDeleteDialogOpen,
deleteDialogFormId,
}: {
appUrl: string;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
deleteDialogFormId: string | null;
}) {
const utils = trpc.useUtils();
const router = useRouter();
const { t } = useLocale();
const deleteMutation = trpc.viewer.appRoutingForms.deleteForm.useMutation({
onMutate: async ({ id: formId }) => {
await utils.viewer.appRoutingForms.forms.cancel();
const previousValue = utils.viewer.appRoutingForms.forms.getData();
if (previousValue) {
const filtered = previousValue.filtered.filter(({ form: { id } }) => id !== formId);
utils.viewer.appRoutingForms.forms.setData(
{},
{
...previousValue,
filtered,
}
);
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_deleted"), "success");
setDeleteDialogOpen(false);
router.push(`${appUrl}/forms`);
},
onSettled: () => {
utils.viewer.appRoutingForms.forms.invalidate();
setDeleteDialogOpen(false);
},
onError: (err, newTodo, context) => {
if (context?.previousValue) {
utils.viewer.appRoutingForms.forms.setData({}, context.previousValue);
}
showToast(err.message || t("something_went_wrong"), "error");
},
});
return (
<div id="form-dialogs">
<RoutingFormEmbedDialog />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
variety="danger"
title={t("delete_form")}
confirmBtnText={t("delete_form_action")}
loadingText={t("delete_form_action")}
onConfirm={(e) => {
if (!deleteDialogFormId) {
return;
}
e.preventDefault();
deleteMutation.mutate({
id: deleteDialogFormId,
});
}}>
<ul className="list-disc pl-3">
<li> {t("delete_form_confirmation")}</li>
<li> {t("delete_form_confirmation_2")}</li>
</ul>
</ConfirmationDialogContent>
</Dialog>
<NewFormDialog appUrl={appUrl} />
</div>
);
}
const actionsCtx = createContext({
appUrl: "",
_delete: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null }) => {},
isPending: false,
},
toggle: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null; checked: boolean }) => {},
isPending: false,
},
});
export function FormActionsProvider({ appUrl, children }: { appUrl: string; children: React.ReactNode }) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogFormId, setDeleteDialogFormId] = useState<string | null>(null);
const { t } = useLocale();
const utils = trpc.useUtils();
const toggleMutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onMutate: async ({ id: formId, disabled }) => {
await utils.viewer.appRoutingForms.forms.cancel();
const previousValue = utils.viewer.appRoutingForms.forms.getData();
if (previousValue) {
const formIndex = previousValue.filtered.findIndex(({ form: { id } }) => id === formId);
const previousListOfForms = [...previousValue.filtered];
if (formIndex !== -1 && previousListOfForms[formIndex] && disabled !== undefined) {
previousListOfForms[formIndex].form.disabled = disabled;
}
utils.viewer.appRoutingForms.forms.setData(
{},
{
filtered: previousListOfForms,
totalCount: previousValue.totalCount,
}
);
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_updated_successfully"), "success");
},
onSettled: (routingForm) => {
utils.viewer.appRoutingForms.forms.invalidate();
if (routingForm) {
utils.viewer.appRoutingForms.formQuery.invalidate({
id: routingForm.id,
});
}
},
onError: (err, value, context) => {
if (context?.previousValue) {
utils.viewer.appRoutingForms.forms.setData({}, context.previousValue);
}
showToast(err.message || t("something_went_wrong"), "error");
},
});
return (
<>
<actionsCtx.Provider
value={{
appUrl,
_delete: {
onAction: ({ routingForm }) => {
if (!routingForm) {
return;
}
setDeleteDialogOpen(true);
setDeleteDialogFormId(routingForm.id);
},
isPending: false,
},
toggle: {
onAction: ({ routingForm, checked }) => {
if (!routingForm) {
return;
}
toggleMutation.mutate({
...routingForm,
disabled: !checked,
});
},
isPending: toggleMutation.isPending,
},
}}>
{children}
</actionsCtx.Provider>
<Dialogs
appUrl={appUrl}
deleteDialogFormId={deleteDialogFormId}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
/>
</>
);
}
type FormActionType =
| "preview"
| "edit"
| "copyLink"
| "toggle"
| "_delete"
| "embed"
| "duplicate"
| "download"
| "copyRedirectUrl"
| "create";
type FormActionProps<T> = {
routingForm: RoutingForm | null;
as?: T;
label?: string;
//TODO: Provide types here
action: FormActionType;
children?: React.ReactNode;
render?: (props: {
routingForm: RoutingForm | null;
className?: string;
label?: string;
disabled?: boolean | null | undefined;
}) => JSX.Element;
extraClassNames?: string;
} & ButtonProps;
export const FormAction = forwardRef(function FormAction<T extends typeof Button>(
props: FormActionProps<T>,
forwardedRef: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
) {
const {
action: actionName,
routingForm,
children,
as: asFromElement,
extraClassNames,
...additionalProps
} = props;
const { appUrl, _delete, toggle } = useContext(actionsCtx);
const dropdownCtxValue = useContext(dropdownCtx);
const dropdown = dropdownCtxValue?.dropdown;
const embedLink = `forms/${routingForm?.id}`;
const orgBranding = useOrgBranding();
const formLink = `${orgBranding?.fullDomain ?? WEBSITE_URL}/${embedLink}`;
let redirectUrl = `${orgBranding?.fullDomain ?? WEBSITE_URL}/router?form=${routingForm?.id}`;
routingForm?.fields?.forEach((field) => {
redirectUrl += `&${getFieldIdentifier(field)}={Recalled_Response_For_This_Field}`;
});
const { t } = useLocale();
const openModal = useOpenModal();
const actionData: Record<
FormActionType,
ButtonProps & { as?: React.ElementType; render?: FormActionProps<unknown>["render"] }
> = {
preview: {
href: formLink,
},
copyLink: {
onClick: () => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(formLink);
},
},
duplicate: {
onClick: () => openModal({ action: "duplicate", target: routingForm?.id }),
},
embed: {
as: RoutingFormEmbedButton,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
embedUrl: embedLink,
// We are okay with namespace clashing here if just in case names clash
namespace: slugify((routingForm?.name || "").substring(0, 5)),
},
edit: {
href: `${appUrl}/form-edit/${routingForm?.id}`,
},
download: {
href: `/api/integrations/routing-forms/responses/${routingForm?.id}`,
},
_delete: {
onClick: () => _delete.onAction({ routingForm }),
loading: _delete.isPending,
},
create: {
onClick: () => openModal({ action: "new", target: "" }),
},
copyRedirectUrl: {
onClick: () => {
navigator.clipboard.writeText(redirectUrl);
showToast(t("typeform_redirect_url_copied"), "success");
},
},
toggle: {
render: ({ routingForm, label = "", disabled, ...restProps }) => {
if (!routingForm) {
return <></>;
}
return (
<div
{...restProps}
className={classNames(
"sm:hover:bg-subtle self-center rounded-md p-2 hover:bg-gray-200",
extraClassNames
)}>
<Switch
disabled={!!disabled}
checked={!routingForm.disabled}
label={label}
onCheckedChange={(checked) => toggle.onAction({ routingForm, checked })}
labelOnLeading
/>
</div>
);
},
loading: toggle.isPending,
},
};
const { as: asFromAction, ...action } = actionData[actionName];
const as = asFromElement || asFromAction;
const actionProps = {
...action,
...(additionalProps as ButtonProps),
} as ButtonProps & { render?: FormActionProps<unknown>["render"] };
if (actionProps.render) {
return actionProps.render({
routingForm,
...additionalProps,
});
}
const Component = as || Button;
if (!dropdown) {
return (
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
{children}
</Component>
);
}
return (
<DropdownMenuItem>
<Component
ref={forwardedRef}
{...actionProps}
className={classNames(
props.className,
"w-full transition-none",
props.color === "destructive" && "border-0"
)}>
{children}
</Component>
</DropdownMenuItem>
);
});

View File

@@ -0,0 +1,76 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import transformResponse from "../lib/transformResponse";
import type { SerializableForm, Response } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;
response: Response;
setResponse: Dispatch<SetStateAction<Response>>;
};
export default function FormInputFields(props: Props) {
const { form, response, setResponse } = props;
const queryBuilderConfig = getQueryBuilderConfig(form);
return (
<>
{form.fields?.map((field) => {
if (isRouterLinkedField(field)) {
// @ts-expect-error FIXME @hariombalhara
field = field.routerField;
}
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label id="slug-label" htmlFor="slug" className="text-default flex text-sm font-medium">
{field.label}
</label>
</div>
<Component
value={response[field.id]?.value}
placeholder={field.placeholder ?? ""}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid={`form-field-${getFieldIdentifier(field)}`}
setValue={(value: number | string | string[]) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value: transformResponse({ field, value }),
},
};
});
}}
/>
</div>
);
})}
</>
);
}

View File

@@ -0,0 +1,34 @@
import { HorizontalTabs } from "@calcom/ui";
import type { getSerializableForm } from "../lib/getSerializableForm";
export default function RoutingNavBar({
form,
appUrl,
}: {
form: Awaited<ReturnType<typeof getSerializableForm>>;
appUrl: string;
}) {
const tabs = [
{
name: "Form",
href: `${appUrl}/form-edit/${form?.id}`,
},
{
name: "Routing",
href: `${appUrl}/route-builder/${form?.id}`,
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
},
{
name: "Reporting",
target: "_blank",
href: `${appUrl}/reporting/${form?.id}`,
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
},
];
return (
<div className="mb-4">
<HorizontalTabs tabs={tabs} />
</div>
);
}

View File

@@ -0,0 +1,611 @@
import type { App_RoutingForms_Form, Team } from "@prisma/client";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import AddMembersWithSwitch from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
import { ShellMain } from "@calcom/features/shell/Shell";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import {
Alert,
Badge,
Button,
ButtonGroup,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DropdownMenuSeparator,
Form,
Meta,
SettingsToggle,
showToast,
TextAreaField,
TextField,
Tooltip,
VerticalDivider,
} from "@calcom/ui";
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
import { RoutingPages } from "../lib/RoutingPages";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { processRoute } from "../lib/processRoute";
import type { Response, Route, SerializableForm } from "../types/types";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar";
import { getServerSidePropsForSingleFormView } from "./getServerSidePropsSingleForm";
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
export type RoutingFormWithResponseCount = RoutingForm & {
team: {
slug: Team["slug"];
name: Team["name"];
} | null;
_count: {
responses: number;
};
};
const Actions = ({
form,
mutation,
}: {
form: RoutingFormWithResponseCount;
mutation: {
isPending: boolean;
};
}) => {
const { t } = useLocale();
const { data: typeformApp } = useApp("typeform");
return (
<div className="flex items-center">
<div className="hidden items-center sm:inline-flex">
<FormAction className="self-center" data-testid="toggle-form" action="toggle" routingForm={form} />
<VerticalDivider />
</div>
<ButtonGroup combined containerProps={{ className: "hidden md:inline-flex items-center" }}>
<Tooltip content={t("preview")}>
<FormAction
routingForm={form}
color="secondary"
target="_blank"
variant="icon"
type="button"
rel="noreferrer"
action="preview"
StartIcon="external-link"
/>
</Tooltip>
<FormAction
routingForm={form}
action="copyLink"
color="secondary"
variant="icon"
type="button"
StartIcon="link"
tooltip={t("copy_link_to_form")}
/>
<Tooltip content="Download Responses">
<FormAction
data-testid="download-responses"
routingForm={form}
action="download"
color="secondary"
variant="icon"
type="button"
StartIcon="download"
/>
</Tooltip>
<FormAction
routingForm={form}
action="embed"
color="secondary"
variant="icon"
StartIcon="code"
tooltip={t("embed")}
/>
<DropdownMenuSeparator />
<FormAction
routingForm={form}
action="_delete"
// className="mr-3"
variant="icon"
StartIcon="trash"
color="secondary"
type="button"
tooltip={t("delete")}
/>
{typeformApp?.isInstalled ? (
<FormActionsDropdown>
<FormAction
data-testid="copy-redirect-url"
routingForm={form}
action="copyRedirectUrl"
color="minimal"
type="button"
StartIcon="link">
{t("Copy Typeform Redirect Url")}
</FormAction>
</FormActionsDropdown>
) : null}
</ButtonGroup>
<div className="flex md:hidden">
<FormActionsDropdown>
<FormAction
routingForm={form}
color="minimal"
target="_blank"
type="button"
rel="noreferrer"
action="preview"
StartIcon="external-link">
{t("preview")}
</FormAction>
<FormAction
action="copyLink"
className="w-full"
routingForm={form}
color="minimal"
type="button"
StartIcon="link">
{t("copy_link_to_form")}
</FormAction>
<FormAction
action="download"
routingForm={form}
className="w-full"
color="minimal"
type="button"
StartIcon="download">
{t("download_responses")}
</FormAction>
<FormAction
action="embed"
routingForm={form}
color="minimal"
type="button"
className="w-full"
StartIcon="code">
{t("embed")}
</FormAction>
{typeformApp ? (
<FormAction
data-testid="copy-redirect-url"
routingForm={form}
action="copyRedirectUrl"
color="minimal"
type="button"
StartIcon="link">
{t("Copy Typeform Redirect Url")}
</FormAction>
) : null}
<DropdownMenuSeparator className="hidden sm:block" />
<FormAction
action="_delete"
routingForm={form}
className="w-full"
type="button"
color="destructive"
StartIcon="trash">
{t("delete")}
</FormAction>
<div className="block sm:hidden">
<DropdownMenuSeparator />
<FormAction
data-testid="toggle-form"
action="toggle"
routingForm={form}
label="Disable Form"
extraClassNames="hover:bg-subtle cursor-pointer rounded-[5px] pr-4"
/>
</div>
</FormActionsDropdown>
</div>
<VerticalDivider />
<Button data-testid="update-form" loading={mutation.isPending} type="submit" color="primary">
{t("save")}
</Button>
</div>
);
};
type SingleFormComponentProps = {
form: RoutingFormWithResponseCount;
appUrl: string;
Page: React.FC<{
form: RoutingFormWithResponseCount;
appUrl: string;
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
}>;
enrichedWithUserProfileForm?: inferSSRProps<
typeof getServerSidePropsForSingleFormView
>["enrichedWithUserProfileForm"];
};
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
const utils = trpc.useUtils();
const { t } = useLocale();
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<Response>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [eventTypeUrl, setEventTypeUrl] = useState("");
function testRouting() {
const action = processRoute({ form, response });
if (action.type === "eventTypeRedirectUrl") {
setEventTypeUrl(
enrichedWithUserProfileForm
? getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: action.value,
form: enrichedWithUserProfileForm,
allURLSearchParams: new URLSearchParams(),
})
: ""
);
}
setDecidedAction(action);
}
const hookForm = useFormContext<RoutingFormWithResponseCount>();
useEffect(() => {
// The first time a tab is opened, the hookForm copies the form data (saved version, from the backend),
// and then it is considered the source of truth.
// There are two events we need to overwrite the hookForm data with the form data coming from the server.
// 1 - When we change the edited form.
// 2 - When the form is saved elsewhere (such as in another browser tab)
// In the second case. We skipped the first execution of useEffect to differentiate a tab change from a form change,
// because each time a tab changes, a new component is created and another useEffect is executed.
// An update from the form always occurs after the first useEffect execution.
if (Object.keys(hookForm.getValues()).length === 0 || hookForm.getValues().id !== form.id) {
hookForm.reset(form);
}
if (skipFirstUpdate) {
setSkipFirstUpdate(false);
} else {
hookForm.reset(form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
const sendUpdatesTo = hookForm.watch("settings.sendUpdatesTo", []) as number[];
const sendToAll = hookForm.watch("settings.sendToAll", false) as boolean;
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast(t("form_updated_successfully"), "success");
},
onError(e) {
if (e.message) {
showToast(e.message, "error");
return;
}
showToast(`Something went wrong`, "error");
},
onSettled() {
utils.viewer.appRoutingForms.formQuery.invalidate({ id: form.id });
},
});
const connectedForms = form.connectedForms;
return (
<>
<Form
form={hookForm}
handleSubmit={(data) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
mutation.mutate({
...data,
});
}}>
<FormActionsProvider appUrl={appUrl}>
<Meta title={form.name} description={form.description || ""} />
<ShellMain
heading={
<div className="flex">
<div>{form.name}</div>
{form.team && (
<Badge className="ml-4 mt-1" variant="gray">
{form.team.name}
</Badge>
)}
</div>
}
subtitle={form.description || ""}
backPath={`/${appUrl}/forms`}
CTA={<Actions form={form} mutation={mutation} />}>
<div className="-mx-4 mt-4 px-4 sm:px-6 md:-mx-8 md:mt-0 md:px-8">
<div className="flex flex-col items-center items-baseline md:flex-row md:items-start">
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
<TextField
type="text"
containerClassName="mb-6"
placeholder={t("title")}
{...hookForm.register("name")}
/>
<TextAreaField
rows={3}
id="description"
data-testid="description"
placeholder={t("form_description_placeholder")}
{...hookForm.register("description")}
defaultValue={form.description || ""}
/>
<div className="mt-6">
{form.teamId ? (
<div className="flex flex-col">
<span className="text-emphasis mb-3 block text-sm font-medium leading-none">
{t("routing_forms_send_email_to")}
</span>
<AddMembersWithSwitch
teamMembers={form.teamMembers.map((member) => ({
value: member.id.toString(),
label: member.name || "",
avatar: member.avatarUrl || "",
email: member.email,
isFixed: true,
}))}
value={sendUpdatesTo.map((userId) => ({
isFixed: true,
userId: userId,
priority: 1,
}))}
onChange={(value) => {
hookForm.setValue(
"settings.sendUpdatesTo",
value.map((teamMember) => teamMember.userId),
{ shouldDirty: true }
);
hookForm.setValue("settings.emailOwnerOnSubmission", false, {
shouldDirty: true,
});
}}
assignAllTeamMembers={sendToAll}
setAssignAllTeamMembers={(value) => {
hookForm.setValue("settings.sendToAll", !!value, { shouldDirty: true });
}}
automaticAddAllEnabled={true}
isFixed={true}
onActive={() => {
hookForm.setValue(
"settings.sendUpdatesTo",
form.teamMembers.map((teamMember) => teamMember.id),
{ shouldDirty: true }
);
hookForm.setValue("settings.emailOwnerOnSubmission", false, {
shouldDirty: true,
});
}}
placeholder={t("select_members")}
containerClassName="!px-0 !pb-0 !pt-0"
/>
</div>
) : (
<Controller
name="settings.emailOwnerOnSubmission"
control={hookForm.control}
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("routing_forms_send_email_owner")}
description={t("routing_forms_send_email_owner_description")}
checked={value}
onCheckedChange={(val) => {
onChange(val);
hookForm.unregister("settings.sendUpdatesTo");
}}
/>
);
}}
/>
)}
</div>
{form.routers.length ? (
<div className="mt-6">
<div className="text-emphasis mb-2 block text-sm font-semibold leading-none ">
{t("routers")}
</div>
<p className="text-default -mt-1 text-xs leading-normal">
{t("modifications_in_fields_warning")}
</p>
<div className="flex">
{form.routers.map((router) => {
return (
<div key={router.id} className="mr-2">
<Link href={`${appUrl}/route-builder/${router.id}`}>
<Badge variant="gray">{router.name}</Badge>
</Link>
</div>
);
})}
</div>
</div>
) : null}
{connectedForms?.length ? (
<div className="mt-6">
<div className="text-emphasis mb-2 block text-sm font-semibold leading-none ">
{t("connected_forms")}
</div>
<p className="text-default -mt-1 text-xs leading-normal">
{t("form_modifications_warning")}
</p>
<div className="flex">
{connectedForms.map((router) => {
return (
<div key={router.id} className="mr-2">
<Link href={`${appUrl}/route-builder/${router.id}`}>
<Badge variant="default">{router.name}</Badge>
</Link>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-6">
<Button
color="secondary"
data-testid="test-preview"
onClick={() => setIsTestPreviewOpen(true)}>
{t("test_preview")}
</Button>
</div>
{form.routes?.every(isFallbackRoute) && (
<Alert
className="mt-6 !bg-orange-100 font-semibold text-orange-900"
iconClassName="!text-orange-900"
severity="neutral"
title={t("no_routes_defined")}
/>
)}
{!form._count?.responses && (
<>
<Alert
className="mt-2 px-4 py-3"
severity="neutral"
title={t("no_responses_yet")}
CustomIcon="message-circle"
/>
</>
)}
</div>
<div className="border-subtle bg-muted w-full rounded-md border p-8">
<RoutingNavBar appUrl={appUrl} form={form} />
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
</div>
</div>
</ShellMain>
</FormActionsProvider>
</Form>
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>
{decidedAction && (
<div className="bg-subtle text-default mt-5 rounded-md p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value !== decidedAction.type) return null;
return (
<span key={page.value} data-testid="test-routing-result-type">
{page.label}
</span>
);
})}
:{" "}
{decidedAction.type === "customPageMessage" ? (
<span className="text-default" data-testid="test-routing-result">
{decidedAction.value}
</span>
) : decidedAction.type === "externalRedirectUrl" ? (
<span className="text-default underline">
<a
target="_blank"
data-testid="test-routing-result"
href={
decidedAction.value.includes("https://") ||
decidedAction.value.includes("http://")
? decidedAction.value
: `http://${decidedAction.value}`
}
rel="noreferrer">
{decidedAction.value}
</a>
</span>
) : (
<span className="text-default underline">
<a
target="_blank"
href={eventTypeUrl}
rel="noreferrer"
data-testid="test-routing-result">
{decidedAction.value}
</a>
</span>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setDecidedAction(null);
setResponse({});
}}>
{t("close")}
</DialogClose>
<Button type="submit" data-testid="test-routing">
{t("test_routing")}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
</>
);
}
export default function SingleFormWrapper({ form: _form, ...props }: SingleFormComponentProps) {
const { data: form, isPending } = trpc.viewer.appRoutingForms.formQuery.useQuery(
{ id: _form.id },
{
initialData: _form,
trpc: {},
}
);
const { t } = useLocale();
if (isPending) {
// It shouldn't be possible because we are passing the data from SSR to it as initialData. So, no need for skeleton here
return null;
}
if (!form) {
throw new Error(t("something_went_wrong"));
}
return (
<LicenseRequired>
<SingleForm form={form} {...props} />
</LicenseRequired>
);
}
export { getServerSidePropsForSingleFormView };

View File

@@ -0,0 +1,120 @@
import type {
AppGetServerSidePropsContext,
AppPrisma,
AppSsrInit,
AppUser,
} from "@calcom/types/AppGetServerSideProps";
import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData";
import { getSerializableForm } from "../lib/getSerializableForm";
export const getServerSidePropsForSingleFormView = async function getServerSidePropsForSingleFormView(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isFormCreateEditAllowed = (await import("../lib/isFormCreateEditAllowed")).isFormCreateEditAllowed;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId, targetTeamId: null }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
movedToProfileId: true,
organization: {
select: {
slug: true,
},
},
username: true,
theme: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
},
},
team: {
select: {
slug: true,
name: true,
parent: {
select: { slug: true },
},
parentId: true,
metadata: true,
},
},
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
const { user: u, ...formWithoutUser } = form;
const formWithoutProfilInfo = {
...formWithoutUser,
team: form.team
? {
slug: form.team.slug,
name: form.team.name,
}
: null,
};
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const formWithUserInfoProfil = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
return {
props: {
trpcState: await ssr.dehydrate(),
form: await getSerializableForm({ form: formWithoutProfilInfo }),
enrichedWithUserProfileForm: await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserInfoProfil),
}),
},
};
};

View File

@@ -0,0 +1,156 @@
import type { ChangeEvent } from "react";
import type { Settings, Widgets, SelectWidgetProps } from "react-awesome-query-builder";
// Figure out why routing-forms/env.d.ts doesn't work
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
import BasicConfig from "react-awesome-query-builder/lib/config/basic";
import { EmailField } from "@calcom/ui";
import widgetsComponents from "../widgets";
const {
TextWidget,
TextAreaWidget,
MultiSelectWidget,
SelectWidget,
NumberWidget,
FieldSelect,
Conjs,
Button,
ButtonGroup,
Provider,
} = widgetsComponents;
const renderComponent = function <T1>(props: T1 | undefined, Component: React.FC<T1>) {
if (!props) {
return <div />;
}
return <Component {...props} />;
};
const settings: Settings = {
...BasicConfig.settings,
renderField: (props) => renderComponent(props, FieldSelect),
renderOperator: (props) => renderComponent(props, FieldSelect),
renderFunc: (props) => renderComponent(props, FieldSelect),
renderConjs: (props) => renderComponent(props, Conjs),
renderButton: (props) => renderComponent(props, Button),
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
renderProvider: (props) => renderComponent(props, Provider),
groupActionsPosition: "bottomCenter",
// Disable groups
maxNesting: 1,
};
// react-query-builder types have missing type property on Widget
//TODO: Reuse FormBuilder Components - FormBuilder components are built considering Cal.com design system and coding guidelines. But when awesome-query-builder renders these components, it passes its own props which are different from what our Components expect.
// So, a mapper should be written here that maps the props provided by awesome-query-builder to the props that our components expect.
const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string } } = {
...BasicConfig.widgets,
text: {
...BasicConfig.widgets.text,
factory: (props) => renderComponent(props, TextWidget),
},
textarea: {
...BasicConfig.widgets.textarea,
factory: (props) => renderComponent(props, TextAreaWidget),
},
number: {
...BasicConfig.widgets.number,
factory: (props) => renderComponent(props, NumberWidget),
},
multiselect: {
...BasicConfig.widgets.multiselect,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, MultiSelectWidget),
},
select: {
...BasicConfig.widgets.select,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, SelectWidget),
},
phone: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return <TextWidget type="tel" {...props} />;
},
valuePlaceholder: "Enter Phone Number",
},
email: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return (
<EmailField
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
props.setValue(val);
}}
containerClassName="w-full"
className="dark:placeholder:text-darkgray-600 focus:border-brand border-subtle dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"
{...props}
/>
);
},
},
};
const types = {
...BasicConfig.types,
phone: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
email: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
};
const operators = BasicConfig.operators;
operators.equal.label = operators.select_equals.label = "Equals";
operators.greater_or_equal.label = "Greater than or equal to";
operators.greater.label = "Greater than";
operators.less_or_equal.label = "Less than or equal to";
operators.less.label = "Less than";
operators.not_equal.label = operators.select_not_equals.label = "Does not equal";
operators.between.label = "Between";
delete operators.proximity;
delete operators.is_null;
delete operators.is_not_null;
/**
* Not supported with JSONLogic. Implement them and add these back -> https://github.com/jwadhams/json-logic-js/issues/81
*/
delete operators.starts_with;
delete operators.ends_with;
const config = {
conjunctions: BasicConfig.conjunctions,
operators,
types,
widgets,
settings,
};
export default config;

View File

@@ -0,0 +1,128 @@
.cal-query-builder .query-builder,
.cal-query-builder .qb-draggable,
.cal-query-builder .qb-drag-handler {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* hide connectors */
.cal-query-builder .group-or-rule::before,
.cal-query-builder .group-or-rule::after {
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
content: unset !important;
}
.cal-query-builder .group--children {
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
padding-left: 0 !important;
}
/* Hide "and" for between numbers */
.cal-query-builder .widget--sep {
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
display: none !important;
}
/* Layout of all fields- Distance b/w them, positioning, width */
.cal-query-builder .rule--body--wrapper {
flex: 1;
display: flex;
flex-direction: column;
}
.cal-query-builder .rule--body {
display: flex;
align-items: center;
}
.cal-query-builder .rule--field,
.cal-query-builder .rule--operator,
.cal-query-builder .rule--value {
display: flex;
flex-grow: 1;
}
.cal-query-builder .rule--widget {
display: "inline-block";
width: 100%;
}
.cal-query-builder .widget--widget,
.cal-query-builder .widget--widget,
.cal-query-builder .widget--widget > * {
width: 100%;
}
.cal-query-builder .rule--drag-handler,
.cal-query-builder .rule--header {
display: flex;
align-items: center;
margin-right: 8px;
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
opacity: 1 !important;
}
/* Disable Reordering of rules - It is not required with the current functionality plus it's not working correctly even if someone wants to re-order */
.cal-query-builder .rule--drag-handler {
display: none;
}
.cal-query-builder .rule--func--wrapper,
.cal-query-builder .rule--func,
.cal-query-builder .rule--func--args,
.cal-query-builder .rule--func--arg,
.cal-query-builder .rule--func--arg-value,
.cal-query-builder .rule--func--bracket-before,
.cal-query-builder .rule--func--bracket-after,
.cal-query-builder .rule--func--arg-sep,
.cal-query-builder .rule--func--arg-label,
.cal-query-builder .rule--func--arg-label-sep {
display: inline-block;
}
.cal-query-builder .rule--field,
.cal-query-builder .group--field,
.cal-query-builder .rule--operator,
.cal-query-builder .rule--value,
.cal-query-builder .rule--operator-options,
.cal-query-builder .widget--widget,
.cal-query-builder .widget--valuesrc,
.cal-query-builder .operator--options--sep,
.cal-query-builder .rule--before-widget,
.cal-query-builder .rule--after-widget {
display: inline-block;
}
.cal-query-builder .rule--operator,
.cal-query-builder .widget--widget,
.cal-query-builder .widget--valuesrc,
.cal-query-builder .widget--sep {
padding-left: 10px;
}
.cal-query-builder .widget--valuesrc {
margin-right: -8px;
}
.cal-query-builder .group--header,
.cal-query-builder .group--footer {
padding-left: 10px;
padding-right: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.cal-query-builder .group-or-rule-container {
margin-top: 10px;
margin-bottom: 10px;
}
.cal-query-builder .rule {
border: 1px solid transparent;
padding: 10px;
flex: 1;
display: flex;
}

View File

@@ -0,0 +1,363 @@
import dynamic from "next/dynamic";
import type { ChangeEvent } from "react";
import type {
ButtonGroupProps,
ButtonProps,
ConjsProps,
FieldProps,
ProviderProps,
} from "react-awesome-query-builder";
import { Button as CalButton, TextField, TextArea } from "@calcom/ui";
import { Icon } from "@calcom/ui";
const Select = dynamic(
async () => (await import("@calcom/ui")).SelectWithValidation
) as unknown as typeof import("@calcom/ui").SelectWithValidation;
export type CommonProps<
TVal extends
| string
| boolean
| string[]
| {
value: string;
optionValue: string;
}
> = {
placeholder?: string;
readOnly?: boolean;
className?: string;
name?: string;
label?: string;
value: TVal;
setValue: (value: TVal) => void;
/**
* required and other validations are supported using zodResolver from react-hook-form
*/
// required?: boolean;
};
export type SelectLikeComponentProps<
TVal extends
| string
| string[]
| {
value: string;
optionValue: string;
} = string
> = {
options: {
label: string;
value: TVal extends (infer P)[]
? P
: TVal extends {
value: string;
}
? TVal["value"]
: TVal;
}[];
} & CommonProps<TVal>;
export type SelectLikeComponentPropsRAQB<TVal extends string | string[] = string> = {
listValues: { title: string; value: TVal extends (infer P)[] ? P : TVal }[];
} & CommonProps<TVal>;
export type TextLikeComponentProps<TVal extends string | string[] | boolean = string> = CommonProps<TVal> & {
name?: string;
};
export type TextLikeComponentPropsRAQB<TVal extends string | boolean = string> =
TextLikeComponentProps<TVal> & {
customProps?: object;
type?: "text" | "number" | "email" | "tel";
maxLength?: number;
noLabel?: boolean;
};
const TextAreaWidget = (props: TextLikeComponentPropsRAQB) => {
const { value, setValue, readOnly, placeholder, maxLength, customProps, ...remainingProps } = props;
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setValue(val);
};
const textValue = value || "";
return (
<TextArea
value={textValue}
placeholder={placeholder}
disabled={readOnly}
onChange={onChange}
maxLength={maxLength}
{...customProps}
{...remainingProps}
/>
);
};
const TextWidget = (props: TextLikeComponentPropsRAQB) => {
const {
value,
noLabel,
setValue,
readOnly,
placeholder,
customProps,
type = "text",
...remainingProps
} = props;
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setValue(val);
};
const textValue = value || "";
return (
<TextField
containerClassName="w-full"
type={type}
value={textValue}
labelSrOnly={noLabel}
placeholder={placeholder}
disabled={readOnly}
onChange={onChange}
{...remainingProps}
{...customProps}
/>
);
};
function NumberWidget({ value, setValue, ...remainingProps }: TextLikeComponentPropsRAQB) {
return (
<TextField
type="number"
labelSrOnly={remainingProps.noLabel}
containerClassName="w-full"
className="bg-default border-default disabled:bg-emphasis focus:ring-brand-default dark:focus:border-emphasis focus:border-subtle block w-full rounded-md text-sm disabled:hover:cursor-not-allowed"
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
{...remainingProps}
/>
);
}
const MultiSelectWidget = ({
listValues,
setValue,
value,
...remainingProps
}: SelectLikeComponentPropsRAQB<string[]>) => {
//TODO: Use Select here.
//TODO: Let's set listValue itself as label and value instead of using title.
if (!listValues) {
return null;
}
const selectItems = listValues.map((item) => {
return {
label: item.title,
value: item.value,
};
});
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
return (
<Select
aria-label="multi-select-dropdown"
className="mb-2"
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
value={optionsFromList}
isMulti={true}
isDisabled={remainingProps.readOnly}
options={selectItems}
{...remainingProps}
/>
);
};
function SelectWidget({ listValues, setValue, value, ...remainingProps }: SelectLikeComponentPropsRAQB) {
if (!listValues) {
return null;
}
const selectItems = listValues.map((item) => {
return {
label: item.title,
value: item.value,
};
});
const optionFromList = selectItems.find((item) => item.value === value);
return (
<Select
aria-label="select-dropdown"
className="data-testid-select mb-2"
onChange={(item) => {
if (!item) {
return;
}
setValue(item.value);
}}
isDisabled={remainingProps.readOnly}
value={optionFromList}
options={selectItems}
{...remainingProps}
/>
);
}
function Button({ config, type, label, onClick, readonly }: ButtonProps) {
if (type === "delRule" || type == "delGroup") {
return (
<button className="ml-5">
<Icon name="trash" className="text-subtle m-0 h-4 w-4" onClick={onClick} />
</button>
);
}
let dataTestId = "";
if (type === "addRule") {
label = config?.operators.__calReporting ? "Add Filter" : "Add rule";
dataTestId = "add-rule";
} else if (type == "addGroup") {
label = "Add rule group";
dataTestId = "add-rule-group";
}
return (
<CalButton
StartIcon="plus"
data-testid={dataTestId}
type="button"
color="secondary"
disabled={readonly}
onClick={onClick}>
{label}
</CalButton>
);
}
function ButtonGroup({ children }: ButtonGroupProps) {
if (!(children instanceof Array)) {
return null;
}
return (
<>
{children.map((button, key) => {
if (!button) {
return null;
}
return (
<div key={key} className="mb-2">
{button}
</div>
);
})}
</>
);
}
function Conjs({ not, setNot, config, conjunctionOptions, setConjunction, disabled }: ConjsProps) {
if (!config || !conjunctionOptions) {
return null;
}
const conjsCount = Object.keys(conjunctionOptions).length;
const lessThenTwo = disabled;
const { forceShowConj } = config.settings;
const showConj = forceShowConj || (conjsCount > 1 && !lessThenTwo);
const options = [
{ label: "All", value: "all" },
{ label: "Any", value: "any" },
{ label: "None", value: "none" },
];
const renderOptions = () => {
const { checked: andSelected } = conjunctionOptions["AND"];
const { checked: orSelected } = conjunctionOptions["OR"];
const notSelected = not;
// Default to All
let value = andSelected ? "all" : orSelected ? "any" : "all";
if (notSelected) {
// not of All -> None
// not of Any -> All
value = value == "any" ? "none" : "all";
}
const selectValue = options.find((option) => option.value === value);
const summary = !config.operators.__calReporting ? "Rule group when" : "Query where";
return (
<div className="flex items-center text-sm">
<span>{summary}</span>
<Select
className="flex px-2"
defaultValue={selectValue}
options={options}
onChange={(option) => {
if (!option) return;
if (option.value === "all") {
setConjunction("AND");
setNot(false);
} else if (option.value === "any") {
setConjunction("OR");
setNot(false);
} else if (option.value === "none") {
setConjunction("OR");
setNot(true);
}
}}
/>
<span>match</span>
</div>
);
};
return showConj ? renderOptions() : null;
}
const FieldSelect = function FieldSelect(props: FieldProps) {
const { items, setField, selectedKey } = props;
const selectItems = items.map((item) => {
return {
...item,
value: item.key,
};
});
const defaultValue = selectItems.find((item) => {
return item.value === selectedKey;
});
return (
<Select
className="data-testid-field-select mb-2"
menuPosition="fixed"
onChange={(item) => {
if (!item) {
return;
}
setField(item.value);
}}
defaultValue={defaultValue}
options={selectItems}
/>
);
};
const Provider = ({ children }: ProviderProps) => children;
const widgets = {
TextWidget,
TextAreaWidget,
SelectWidget,
NumberWidget,
MultiSelectWidget,
FieldSelect,
Button,
ButtonGroup,
Conjs,
Provider,
};
export default widgets;

View File

@@ -0,0 +1,22 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Routing Forms",
"title": "Routing Forms",
"isGlobal": true,
"slug": "routing-forms",
"type": "routing-forms_other",
"logo": "icon-dark.svg",
"url": "https://cal.com/resources/feature/routing-forms",
"variant": "other",
"categories": ["automation"],
"publisher": "Cal.com, Inc.",
"simplePath": "/apps/routing-forms",
"email": "help@cal.com",
"licenseRequired": true,
"teamsPlanRequired": {
"upgradeUrl": "/routing-forms/forms"
},
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user",
"__createdUsingCli": true,
"isOAuth": false
}

View File

@@ -0,0 +1,53 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import { BaseEmailHtml, Info } from "@calcom/emails/src/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { OrderedResponses } from "../../types/types";
export const ResponseEmail = ({
form,
orderedResponses,
...props
}: {
form: Pick<App_RoutingForms_Form, "id" | "name">;
orderedResponses: OrderedResponses;
subject: string;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>) => {
return (
<BaseEmailHtml
callToAction={
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "16px",
fontWeight: 500,
lineHeight: "0px",
textAlign: "left",
color: "#3e3e3e",
}}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<a href={`${WEBAPP_URL}/routing-forms/form-edit/${form.id}`} style={{ color: "#3e3e3e" }}>
<>Manage this form</>
</a>
</p>
</div>
}
title={form.name}
subtitle="New Response Received"
{...props}>
{orderedResponses.map((fieldResponse, index) => {
return (
<Info
withSpacer
key={index}
label={fieldResponse.label}
description={
fieldResponse.value instanceof Array ? fieldResponse.value.join(",") : fieldResponse.value
}
/>
);
})}
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1 @@
export { ResponseEmail } from "./ResponseEmail";

View File

@@ -0,0 +1,42 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import { renderEmail } from "@calcom/emails";
import BaseEmail from "@calcom/emails/templates/_base-email";
import type { OrderedResponses } from "../../types/types";
type Form = Pick<App_RoutingForms_Form, "id" | "name">;
export default class ResponseEmail extends BaseEmail {
orderedResponses: OrderedResponses;
toAddresses: string[];
form: Form;
constructor({
toAddresses,
orderedResponses,
form,
}: {
form: Form;
toAddresses: string[];
orderedResponses: OrderedResponses;
}) {
super();
this.form = form;
this.orderedResponses = orderedResponses;
this.toAddresses = toAddresses;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = this.toAddresses;
const subject = `${this.form.name} has a new response`;
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject,
html: await renderEmail("ResponseEmail", {
form: this.form,
orderedResponses: this.orderedResponses,
subject,
}),
};
}
}

View File

@@ -0,0 +1,57 @@
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { CAL_URL } from "@calcom/lib/constants";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
export const enrichFormWithMigrationData = <
T extends {
user: {
movedToProfileId: number | null;
metadata?: unknown;
username: string | null;
nonProfileUsername: string | null;
profile: {
organization: {
slug: string | null;
} | null;
};
};
team: {
parent: {
slug: string | null;
} | null;
metadata?: unknown;
} | null;
}
>(
form: T
) => {
const parsedUserMetadata = userMetadata.parse(form.user.metadata ?? null);
const parsedTeamMetadata = teamMetadataSchema.parse(form.team?.metadata ?? null);
const formOwnerOrgSlug = form.user.profile.organization?.slug ?? null;
const nonOrgUsername = parsedUserMetadata?.migratedToOrgFrom?.username ?? form.user.nonProfileUsername;
const nonOrgTeamslug = parsedTeamMetadata?.migratedToOrgFrom?.teamSlug ?? null;
return {
...form,
user: {
...form.user,
metadata: parsedUserMetadata,
},
team: {
...form.team,
metadata: teamMetadataSchema.parse(form.team?.metadata ?? null),
},
userOrigin: formOwnerOrgSlug
? getOrgFullOrigin(formOwnerOrgSlug, {
protocol: true,
})
: CAL_URL,
teamOrigin: form.team?.parent?.slug
? getOrgFullOrigin(form.team.parent.slug, {
protocol: true,
})
: CAL_URL,
nonOrgUsername,
nonOrgTeamslug,
};
};

View File

@@ -0,0 +1 @@
declare module "react-awesome-query-builder/lib/config/basic";

View File

@@ -0,0 +1,88 @@
import { CAL_URL } from "@calcom/lib/constants";
function getUserAndEventTypeSlug(eventTypeRedirectUrl: string) {
if (eventTypeRedirectUrl.startsWith("/")) {
eventTypeRedirectUrl = eventTypeRedirectUrl.slice(1);
}
const parts = eventTypeRedirectUrl.split("/");
const isTeamSlug = parts[0] == "team" ? true : false;
const teamSlug = isTeamSlug ? parts[1] : null;
const eventTypeSlug = teamSlug ? parts[2] : parts[1];
const username = isTeamSlug ? null : parts[0];
return { teamSlug, eventTypeSlug, username };
}
/**
* Handles the following cases
* 1. A team form where the team isn't a sub-team
* 1.1 A team form where team isn't a sub-team and the user is migrated. i.e. User has been migrated but not the team
* 1.2 A team form where team isn't a sub-team and the user is not migrated i.e. Both user and team are not migrated
* 2. A team form where the team is a sub-team
* 1.1 A team form where the team is a sub-team and the user is migrated i.e. Both user and team are migrated
* 1.2 A team form where the team is a sub-team and the user is not migrated i.e. Team has been migrated but not the user
* 3. A user form where the user is migrated
* 3.1 A user form where the user is migrated and the team is migrated i.e. Both user and team are migrated
* 3.2 A user form where the user is migrated and the team is not migrated i.e. User has been migrated but not the team
* 4. A user form where the user is not migrated
* 4.1 A user form where the user is not migrated and the team is migrated i.e. Team has been migrated but not the user
* 4.2 A user form where the user is not migrated and the team is not migrated i.e. Both user and team are not migrated
*/
export function getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl,
form,
allURLSearchParams,
}: {
eventTypeRedirectUrl: string;
form: {
team: {
// parentId is set if the team is a sub-team
parentId: number | null;
} | null;
/**
* Set only if user is migrated
*/
nonOrgUsername: string | null;
/**
* Set only if team is migrated
*/
nonOrgTeamslug: string | null;
/**
* The origin for the user
*/
userOrigin: string;
/**
* The origin for the team the form belongs to
*/
teamOrigin: string;
};
allURLSearchParams: URLSearchParams;
}) {
// It could be using the old(before migration) username/team-slug or it could be using the new one(after migration)
// If it's using the old one, it would work by redirection as long as we use CAL_URL(which is non-org domain)
// But if it's using the new one, it has to use the org domain.
// The format is /user/abc or /team/team1/abc
const { username: usernameInRedirectUrl, teamSlug: teamSlugInRedirectUrl } =
getUserAndEventTypeSlug(eventTypeRedirectUrl);
if (!teamSlugInRedirectUrl && !usernameInRedirectUrl) {
throw new Error("eventTypeRedirectUrl must have username or teamSlug");
}
if (teamSlugInRedirectUrl && form.nonOrgTeamslug) {
const isEventTypeRedirectToOldTeamSlug = teamSlugInRedirectUrl === form.nonOrgTeamslug;
if (isEventTypeRedirectToOldTeamSlug) {
return `${CAL_URL}/${eventTypeRedirectUrl}?${allURLSearchParams}`;
}
}
if (usernameInRedirectUrl && form.nonOrgUsername) {
const isEventTypeRedirectToOldUser = usernameInRedirectUrl === form.nonOrgUsername;
if (isEventTypeRedirectToOldUser) {
return `${CAL_URL}/${eventTypeRedirectUrl}?${allURLSearchParams}`;
}
}
const origin = teamSlugInRedirectUrl ? form.teamOrigin : form.userOrigin;
return `${origin}/${eventTypeRedirectUrl}?${allURLSearchParams}`;
}

View File

@@ -0,0 +1 @@
export * as api from "./api";

View File

@@ -0,0 +1,228 @@
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
type NegatedLogicData = {
"!": LogicData;
};
export type JsonLogicQuery = {
logic: {
and?: LogicData[];
or?: LogicData[];
"!"?: {
and?: LogicData[];
or?: LogicData[];
};
} | null;
};
type PrismaWhere = {
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
NOT?: PrismaWhere;
};
const OPERATOR_MAP = {
"==": {
operator: "equals",
secondaryOperand: null,
},
in: {
operator: "string_contains",
secondaryOperand: null,
},
"!=": {
operator: "NOT.equals",
secondaryOperand: null,
},
"!": {
operator: "equals",
secondaryOperand: "",
},
"!!": {
operator: "NOT.equals",
secondaryOperand: "",
},
">": {
operator: "gt",
secondaryOperand: null,
},
">=": {
operator: "gte",
secondaryOperand: null,
},
"<": {
operator: "lt",
secondaryOperand: null,
},
"<=": {
operator: "lte",
secondaryOperand: null,
},
all: {
operator: "array_contains",
secondaryOperand: null,
},
};
/**
* Operators supported on array of basic queries
*/
const GROUP_OPERATOR_MAP = {
and: "AND",
or: "OR",
"!": "NOT",
} as const;
const NumberOperators = [">", ">=", "<", "<="];
const convertSingleQueryToPrismaWhereClause = (
operatorName: keyof typeof OPERATOR_MAP,
logicData: LogicData,
isNegation: boolean
) => {
const mappedOperator = OPERATOR_MAP[operatorName].operator;
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
isNegation = isNegation || mappedOperator.startsWith("NOT.");
const prismaOperator = mappedOperator.replace("NOT.", "");
const operands =
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
const mainOperand = operatorName !== "in" ? operands[0].var : operands[1].var;
let secondaryOperand = staticSecondaryOperand || (operatorName !== "in" ? operands[1] : operands[0]) || "";
if (operatorName === "all") {
secondaryOperand = secondaryOperand.in[1];
}
const isNumberOperator = NumberOperators.includes(operatorName);
const secondaryOperandAsNumber = typeof secondaryOperand === "string" ? Number(secondaryOperand) : null;
let prismaWhere;
if (secondaryOperandAsNumber) {
// We know that it's number operator so Prisma should query number
// Note that if we get string values in DB(e.g. '100'), those values can't be filtered with number operators.
if (isNumberOperator) {
prismaWhere = {
response: {
path: [mainOperand, "value"],
[`${prismaOperator}`]: secondaryOperandAsNumber,
},
};
} else {
// We know that it's not number operator but the input field might have been a number and thus stored value in DB as number.
// Also, even for input type=number we might accidentally get string value(e.g. '100'). So, let reporting do it's best job with both number and string.
prismaWhere = {
OR: [
{
response: {
path: [mainOperand, "value"],
// Query as string e.g. equals '100'
[`${prismaOperator}`]: secondaryOperand,
},
},
{
response: {
path: [mainOperand, "value"],
// Query as number e.g. equals 100
[`${prismaOperator}`]: secondaryOperandAsNumber,
},
},
],
};
}
} else {
prismaWhere = {
response: {
path: [mainOperand, "value"],
[`${prismaOperator}`]: secondaryOperand,
},
};
}
if (isNegation) {
return {
NOT: {
...prismaWhere,
},
};
}
return prismaWhere;
};
const isNegation = (logicData: LogicData | NegatedLogicData) => {
if ("!" in logicData) {
const negatedLogicData = logicData["!"];
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
if (negatedLogicData[operatorName]) {
return true;
}
}
}
return false;
};
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
const _isNegation = isNegation(logicData);
if (_isNegation) {
logicData = logicData["!"];
}
for (const [key] of Object.entries(OPERATOR_MAP)) {
const operatorName = key as keyof typeof OPERATOR_MAP;
if (logicData[operatorName]) {
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
}
}
};
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
try {
let logic = query.logic;
if (!logic) {
return {};
}
let prismaWhere: PrismaWhere = {};
let negateLogic = false;
// Case: Negation of "Any of these"
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
if (logic["!"]) {
logic = logic["!"];
negateLogic = true;
}
// Case: All of these
if (logic.and) {
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
logic.and.forEach((and) => {
const res = convertQueriesToPrismaWhereClause(and);
if (!res) {
return;
}
where.push(res);
});
}
// Case: Any of these
else if (logic.or) {
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
logic.or.forEach((or) => {
const res = convertQueriesToPrismaWhereClause(or);
if (!res) {
return;
}
where.push(res);
});
}
if (negateLogic) {
prismaWhere = { NOT: { ...prismaWhere } };
}
return prismaWhere;
} catch (e) {
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
return {};
}
};

View File

@@ -0,0 +1,3 @@
import QueryBuilderInitialConfig from "../components/react-awesome-query-builder/config/config";
export const InitialConfig = QueryBuilderInitialConfig;

View File

@@ -0,0 +1,16 @@
import type { LocalRoute } from "../types/types";
export const RoutingPages: { label: string; value: NonNullable<LocalRoute["action"]>["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
},
{
label: "External Redirect",
value: "externalRedirectUrl",
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
},
];

View File

@@ -0,0 +1,16 @@
import { Utils as QbUtils } from "react-awesome-query-builder";
import type { GlobalRoute, SerializableRoute } from "../types/types";
export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
return {
id: uuid,
isFallback: true,
action: {
type: "customPageMessage",
value: "Thank you for your interest! We will be in touch soon.",
},
queryValue: { id: uuid, type: "group" },
};
};

View File

@@ -0,0 +1,20 @@
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
export default async function getConnectedForms(
prisma: typeof import("@calcom/prisma").default,
form: Pick<App_RoutingForms_Form, "id" | "userId">
) {
return await prisma.app_RoutingForms_Form.findMany({
where: {
userId: form.userId,
routes: {
array_contains: [
{
id: form.id,
isRouter: true,
},
],
},
},
});
}

View File

@@ -0,0 +1,5 @@
import type { Field } from "../types/types";
const getFieldIdentifier = (field: Field) => field.identifier || field.label;
export default getFieldIdentifier;

View File

@@ -0,0 +1,69 @@
import { FieldTypes } from "../pages/form-edit/[...appPages]";
import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types";
import { InitialConfig } from "./InitialConfig";
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
const fields: Record<
string,
{
label: string;
type: string;
valueSources: ["value"];
fieldSettings: {
listValues?: {
value: string;
title: string;
}[];
};
}
> = {};
form.fields?.forEach((field) => {
if ("routerField" in field) {
field = field.routerField;
}
if (FieldTypes.map((f) => f.value).includes(field.type)) {
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
const widget = InitialConfig.widgets[field.type];
const widgetType = widget.type;
fields[field.id] = {
label: field.label,
type: widgetType,
valueSources: ["value"],
fieldSettings: {
listValues: options,
},
// preferWidgets: field.type === "textarea" ? ["textarea"] : [],
};
} else {
throw new Error(`Unsupported field type:${field.type}`);
}
});
const initialConfigCopy = { ...InitialConfig, operators: { ...InitialConfig.operators } };
if (forReporting) {
// Empty and Not empty doesn't work well with JSON querying in prisma. Try to implement these when we desperately need these operators.
delete initialConfigCopy.operators.is_empty;
delete initialConfigCopy.operators.is_not_empty;
// Between and Not between aren't directly supported by prisma. So, we need to update jsonLogicToPrisma to generate gte and lte query for between. It can be implemented later.
delete initialConfigCopy.operators.between;
delete initialConfigCopy.operators.not_between;
initialConfigCopy.operators.__calReporting = true;
}
// You need to provide your own config. See below 'Config format'
const config: QueryBuilderUpdatedConfig = {
...initialConfigCopy,
fields: fields,
};
return config;
}

View File

@@ -0,0 +1,161 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { z } from "zod";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import type { SerializableForm, SerializableFormTeamMembers } from "../types/types";
import type { zodRoutesView, zodFieldsView } from "../zod";
import { zodFields, zodRoutes } from "../zod";
import getConnectedForms from "./getConnectedForms";
import isRouter from "./isRouter";
import isRouterLinkedField from "./isRouterLinkedField";
/**
* Doesn't have deleted fields by default
*/
export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
form,
withDeletedFields = false,
}: {
form: TForm;
withDeletedFields?: boolean;
}) {
const prisma = (await import("@calcom/prisma")).default;
const routesParsed = zodRoutes.safeParse(form.routes);
if (!routesParsed.success) {
throw new Error("Error parsing routes");
}
const fieldsParsed = zodFields.safeParse(form.fields);
if (!fieldsParsed.success) {
throw new Error(`Error parsing fields: ${fieldsParsed.error}`);
}
const settings = RoutingFormSettings.parse(
form.settings || {
// Would have really loved to do it using zod. But adding .default(true) throws type error in prisma/zod/app_routingforms_form.ts
emailOwnerOnSubmission: true,
}
);
const parsedFields =
(withDeletedFields ? fieldsParsed.data : fieldsParsed.data?.filter((f) => !f.deleted)) || [];
const parsedRoutes = routesParsed.data;
const fields = parsedFields as NonNullable<z.infer<typeof zodFieldsView>>;
const fieldsExistInForm: Record<string, true> = {};
parsedFields?.forEach((f) => {
fieldsExistInForm[f.id] = true;
});
const { routes, routers } = await getEnrichedRoutesAndRouters(parsedRoutes, form.userId);
const connectedForms = (await getConnectedForms(prisma, form)).map((f) => ({
id: f.id,
name: f.name,
description: f.description,
}));
const finalFields = fields;
let teamMembers: SerializableFormTeamMembers[] = [];
if (form.teamId) {
teamMembers = await prisma.user.findMany({
where: {
teams: {
some: {
teamId: form.teamId,
accepted: true,
},
},
},
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
},
});
}
// Ideally we should't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
const serializableForm: SerializableForm<TForm> = {
...form,
settings,
fields: finalFields,
routes,
routers,
connectedForms,
teamMembers,
createdAt: form.createdAt.toString(),
updatedAt: form.updatedAt.toString(),
};
return serializableForm;
/**
* Enriches routes that are actually routers and returns a list of routers separately
*/
async function getEnrichedRoutesAndRouters(parsedRoutes: z.infer<typeof zodRoutes>, userId: number) {
const routers: { name: string; description: string | null; id: string }[] = [];
const routes: z.infer<typeof zodRoutesView> = [];
if (!parsedRoutes) {
return { routes, routers };
}
for (const [, route] of Object.entries(parsedRoutes)) {
if (isRouter(route)) {
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: route.id,
...entityPrismaWhereClause({ userId: userId }),
},
});
if (!router) {
throw new Error(`Form - ${route.id}, being used as router, not found`);
}
const parsedRouter = await getSerializableForm({ form: router });
routers.push({
name: parsedRouter.name,
description: parsedRouter.description,
id: parsedRouter.id,
});
// Enrichment
routes.push({
...route,
isRouter: true,
name: parsedRouter.name,
description: parsedRouter.description,
routes: parsedRouter.routes || [],
});
parsedRouter.fields?.forEach((field) => {
if (!fieldsExistInForm[field.id]) {
// Instead of throwing error, Log it instead of breaking entire routing forms feature
console.error(
"This is an impossible state. A router field must always be present in the connected form."
);
} else {
const currentFormField = fields.find((f) => f.id === field.id);
if (!currentFormField || !("routerId" in currentFormField)) {
return;
}
if (!isRouterLinkedField(field)) {
currentFormField.routerField = field;
}
currentFormField.router = {
id: parsedRouter.id,
name: router.name,
description: router.description || "",
};
}
});
} else {
routes.push(route);
}
}
return { routes, routers };
}
}

View File

@@ -0,0 +1,11 @@
import type { z } from "zod";
import type { zodRoute } from "../zod";
import isRouter from "./isRouter";
export const isFallbackRoute = (route: z.infer<typeof zodRoute>) => {
if (isRouter(route)) {
return false;
}
return route.isFallback;
};

View File

@@ -0,0 +1,41 @@
import type { App_RoutingForms_Form, User } from "@prisma/client";
import { canCreateEntity, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import prisma from "@calcom/prisma";
export async function isFormCreateEditAllowed({
formId,
userId,
/**
* Valid when a new form is being created for a team
*/
targetTeamId,
}: {
userId: User["id"];
formId: App_RoutingForms_Form["id"];
targetTeamId: App_RoutingForms_Form["teamId"] | null;
}) {
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
select: {
userId: true,
teamId: true,
team: {
select: {
members: true,
},
},
},
});
if (!form) {
return await canCreateEntity({
targetTeamId,
userId,
});
}
return canEditEntity(form, userId);
}

View File

@@ -0,0 +1,12 @@
import type { z } from "zod";
import type { zodRouterRouteView, zodRoute, zodRouterRoute, zodRouteView } from "../zod";
export default function isRouter(
route: z.infer<typeof zodRouteView> | z.infer<typeof zodRoute>
): route is z.infer<typeof zodRouterRouteView> | z.infer<typeof zodRouterRoute> {
if ("isRouter" in route) {
return route.isRouter;
}
return false;
}

View File

@@ -0,0 +1,12 @@
import type { z } from "zod";
import type { zodFieldView, zodField, zodRouterField, zodRouterFieldView } from "../zod";
export default function isRouterLinkedField(
field: z.infer<typeof zodFieldView> | z.infer<typeof zodField>
): field is z.infer<typeof zodRouterField> | z.infer<typeof zodRouterFieldView> {
if ("routerId" in field) {
return true;
}
return false;
}

View File

@@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import jsonLogic from "json-logic-js";
// converts input to lowercase if string
function normalize<T extends string | string[]>(input: T): T {
if (typeof input === "string") {
return input.toLowerCase() as T;
}
if (input instanceof Array) {
return input.map((item) => {
if (typeof item === "string") {
return item.toLowerCase();
}
// if array item is not a string, return it as is
return item;
}) as T;
}
return input;
}
/**
* Single Select equals and not equals uses it
* Short Text equals and not equals uses it
*/
jsonLogic.add_operation("==", function (a: any, b: any) {
return normalize(a) == normalize(b);
});
jsonLogic.add_operation("===", function (a: any, b: any) {
return normalize(a) === normalize(b);
});
jsonLogic.add_operation("!==", function (a: any, b: any) {
return normalize(a) !== normalize(b);
});
jsonLogic.add_operation("!=", function (a: any, b: any) {
return normalize(a) != normalize(b);
});
/**
* Multiselect "equals" and "not equals" uses it
* Singleselect "any in" and "not in" uses it
* Long Text/Short Text/Email/Phone "contains" also uses it.
*/
jsonLogic.add_operation("in", function (a: string, b: string | string[]) {
const first = normalize(a);
const second = normalize(b);
if (!second) return false;
return second.indexOf(first) !== -1;
});
export default jsonLogic;

View File

@@ -0,0 +1,81 @@
"use client";
import type { App_RoutingForms_Form } from "@prisma/client";
import { Utils as QbUtils } from "react-awesome-query-builder";
import type { z } from "zod";
import type { Response, Route, SerializableForm } from "../types/types";
import type { zodNonRouterRoute } from "../zod";
import { getQueryBuilderConfig } from "./getQueryBuilderConfig";
import { isFallbackRoute } from "./isFallbackRoute";
import isRouter from "./isRouter";
import jsonLogic from "./jsonLogicOverrides";
export function processRoute({
form,
response,
}: {
form: SerializableForm<App_RoutingForms_Form>;
response: Record<string, Pick<Response[string], "value">>;
}) {
const queryBuilderConfig = getQueryBuilderConfig(form);
const routes = form.routes || [];
let decidedAction: Route["action"] | null = null;
const fallbackRoute = routes.find(isFallbackRoute);
if (!fallbackRoute) {
throw new Error("Fallback route is missing");
}
const routesWithFallbackInEnd = routes
.flatMap((r) => {
// For a router, use it's routes instead.
if (isRouter(r)) return r.routes;
return r;
})
// Use only non fallback routes
.filter((route) => route && !isFallbackRoute(route))
// After above flat map, all routes are non router routes.
.concat([fallbackRoute]) as z.infer<typeof zodNonRouterRoute>[];
routesWithFallbackInEnd.some((route) => {
if (!route) {
return false;
}
const state = {
tree: QbUtils.checkTree(QbUtils.loadTree(route.queryValue), queryBuilderConfig),
config: queryBuilderConfig,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
let result = false;
const responseValues: Record<string, Response[string]["value"]> = {};
for (const [uuid, { value }] of Object.entries(response)) {
responseValues[uuid] = value;
}
if (logic) {
// Leave the logs for debugging of routing form logic test in production
console.log("Checking logic with response", logic, responseValues);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = jsonLogic.apply(logic as any, responseValues);
} else {
// If no logic is provided, then consider it a match
result = true;
}
if (result) {
decidedAction = route.action;
return true;
}
});
if (!decidedAction) {
return null;
}
// Without type assertion, it is never. See why https://github.com/microsoft/TypeScript/issues/16928
return decidedAction as Route["action"];
}

View File

@@ -0,0 +1,26 @@
import slugify from "@calcom/lib/slugify";
import type { Response, Route, Field } from "../types/types";
import getFieldIdentifier from "./getFieldIdentifier";
export const substituteVariables = (
routeValue: Route["action"]["value"],
response: Response,
fields: Field[]
) => {
const regex = /\{([^\}]+)\}/g;
const variables: string[] = routeValue.match(regex)?.map((match: string) => match.slice(1, -1)) || [];
let eventTypeUrl = routeValue;
variables.forEach((variable) => {
for (const key in response) {
const identifier = getFieldIdentifier(fields.find((field) => field.id === key));
if (identifier.toLowerCase() === variable.toLowerCase()) {
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, slugify(response[key].value.toString() || ""));
}
}
});
return eventTypeUrl;
};

View File

@@ -0,0 +1,30 @@
import type { Field, Response } from "../types/types";
export default function transformResponse({
field,
value,
}: {
field: Field;
value: Response[string]["value"] | undefined;
}) {
if (!value) {
return "";
}
// type="number" still gives value as a string but we need to store that as number so that number operators can work.
if (field.type === "number") {
if (typeof value === "string") {
return Number(value);
}
return value;
}
if (field.type === "multiselect") {
if (value instanceof Array) {
return value;
}
return value
.toString()
.split(",")
.map((v) => v.trim());
}
return value;
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/routing-forms",
"version": "0.0.0",
"main": "./index.ts",
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user ",
"dependencies": {
"@calcom/lib": "*",
"dotenv": "^16.3.1",
"json-logic-js": "^2.0.2",
"react-awesome-query-builder": "^5.1.2"
},
"devDependencies": {
"@calcom/types": "*",
"@types/json-logic-js": "^1.2.1"
}
}

View File

@@ -0,0 +1,36 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import { getServerSidePropsForSingleFormView as getServerSidePropsSingleForm } from "../components/getServerSidePropsSingleForm";
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
// extracts getServerSideProps function from the client component
import { getServerSideProps as getServerSidePropsForms } from "./forms/getServerSideProps";
import * as LayoutHandler from "./layout-handler/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as Router from "./router/[...appPages]";
import { getServerSideProps as getServerSidePropsRouter } from "./router/getServerSideProps";
import * as RoutingLink from "./routing-link/[...appPages]";
import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps";
const routingConfig = {
"form-edit": formEdit,
"route-builder": RouteBuilder,
forms: forms,
"routing-link": RoutingLink,
router: Router,
reporting: Reporting,
layoutHandler: LayoutHandler,
};
export const serverSidePropsConfig: Record<string, AppGetServerSideProps> = {
forms: getServerSidePropsForms,
"form-edit": getServerSidePropsSingleForm,
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
router: getServerSidePropsRouter,
reporting: getServerSidePropsSingleForm,
};
export default routingConfig;

View File

@@ -0,0 +1,471 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useFieldArray, useWatch } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import Shell from "@calcom/features/shell/Shell";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
BooleanToggleGroupField,
Button,
EmptyScreen,
FormCard,
Icon,
Label,
SelectField,
Skeleton,
TextField,
} from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
export { getServerSideProps };
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
type SelectOption = { placeholder: string; value: string; id: string };
export const FieldTypes = [
{
label: "Short Text",
value: "text",
},
{
label: "Number",
value: "number",
},
{
label: "Long Text",
value: "textarea",
},
{
label: "Single Selection",
value: "select",
},
{
label: "Multiple Selection",
value: "multiselect",
},
{
label: "Phone",
value: "phone",
},
{
label: "Email",
value: "email",
},
];
function Field({
hookForm,
hookFieldNamespace,
deleteField,
moveUp,
moveDown,
appUrl,
}: {
fieldIndex: number;
hookForm: HookForm;
hookFieldNamespace: `fields.${number}`;
deleteField: {
check: () => boolean;
fn: () => void;
};
moveUp: {
check: () => boolean;
fn: () => void;
};
moveDown: {
check: () => boolean;
fn: () => void;
};
appUrl: string;
}) {
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
const [options, setOptions] = useState<SelectOption[]>([
{ placeholder: "< 10", value: "", id: uuidv4() },
{ placeholder: "10-100", value: "", id: uuidv4() },
{ placeholder: "100-500", value: "", id: uuidv4() },
{ placeholder: "> 500", value: "", id: uuidv4() },
]);
const handleRemoveOptions = (index: number) => {
const updatedOptions = options.filter((_, i) => i !== index);
setOptions(updatedOptions);
updateSelectText(updatedOptions);
};
const handleAddOptions = () => {
setOptions((prevState) => [
...prevState,
{
placeholder: "New Option",
value: "",
id: uuidv4(),
},
]);
};
useEffect(() => {
const originalValues = hookForm.getValues(`${hookFieldNamespace}.selectText`);
if (originalValues) {
const values: SelectOption[] = originalValues.split("\n").map((fieldValue) => ({
value: fieldValue,
placeholder: "",
id: uuidv4(),
}));
setOptions(values);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, optionIndex: number) => {
const updatedOptions = options.map((opt, index) => ({
...opt,
...(index === optionIndex ? { value: e.target.value } : {}),
}));
setOptions(updatedOptions);
updateSelectText(updatedOptions);
};
const updateSelectText = (updatedOptions: SelectOption[]) => {
hookForm.setValue(
`${hookFieldNamespace}.selectText`,
updatedOptions
.filter((opt) => opt.value)
.map((opt) => opt.value)
.join("\n")
);
};
const label = useWatch({
control: hookForm.control,
name: `${hookFieldNamespace}.label`,
});
const identifier = useWatch({
control: hookForm.control,
name: `${hookFieldNamespace}.identifier`,
});
function move(index: number, increment: 1 | -1) {
const newList = [...options];
const type = options[index];
const tmp = options[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
setOptions(newList);
updateSelectText(newList);
}
return (
<div
data-testid="field"
className="bg-default group mb-4 flex w-full items-center justify-between ltr:mr-2 rtl:ml-2">
<FormCard
label="Field"
moveUp={moveUp}
moveDown={moveDown}
badge={
router ? { text: router.name, variant: "gray", href: `${appUrl}/form-edit/${router.id}` } : null
}
deleteField={router ? null : deleteField}>
<div className="w-full">
<div className="mb-6 w-full">
<TextField
data-testid={`${hookFieldNamespace}.label`}
disabled={!!router}
label="Label"
className="flex-grow"
placeholder={t("this_is_what_your_users_would_see")}
/**
* This is a bit of a hack to make sure that for routerField, label is shown from there.
* For other fields, value property is used because it exists and would take precedence
*/
defaultValue={label || routerField?.label || ""}
required
{...hookForm.register(`${hookFieldNamespace}.label`)}
/>
</div>
<div className="mb-6 w-full">
<TextField
disabled={!!router}
label="Identifier"
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
//This change has the same effects that already existed in relation to this field,
// but written in a different way.
// The identifier field will have the same value as the label field until it is changed
value={identifier || routerField?.identifier || label || routerField?.label || ""}
onChange={(e) => {
hookForm.setValue(`${hookFieldNamespace}.identifier`, e.target.value);
}}
/>
</div>
<div className="mb-6 w-full ">
<Controller
name={`${hookFieldNamespace}.type`}
control={hookForm.control}
defaultValue={routerField?.type}
render={({ field: { value, onChange } }) => {
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
return (
<SelectField
maxMenuHeight={200}
styles={{
singleValue: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
option: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
}}
label="Type"
isDisabled={!!router}
containerClassName="data-testid-field-type"
options={FieldTypes}
onChange={(option) => {
if (!option) {
return;
}
onChange(option.value);
}}
defaultValue={defaultValue}
/>
);
}}
/>
</div>
{["select", "multiselect"].includes(hookForm.watch(`${hookFieldNamespace}.type`)) ? (
<div className="mt-2 w-full">
<Skeleton as={Label} loadingClassName="w-16" title={t("Options")}>
{t("options")}
</Skeleton>
<ul ref={animationRef}>
{options.map((field, index) => (
<li key={`select-option-${field.id}`} className="group mt-2 flex items-center gap-2">
<div className="flex flex-col gap-2">
{options.length && index !== 0 ? (
<button
type="button"
onClick={() => move(index, -1)}
className="bg-default text-muted hover:text-emphasis invisible flex h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible group-hover:scale-100 ">
<Icon name="arrow-up" />
</button>
) : null}
{options.length && index !== options.length - 1 ? (
<button
type="button"
onClick={() => move(index, 1)}
className="bg-default text-muted hover:text-emphasis invisible flex h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible group-hover:scale-100 ">
<Icon name="arrow-down" />
</button>
) : null}
</div>
<div className="w-full">
<TextField
disabled={!!router}
containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400"
className="border-0 focus:ring-0 focus:ring-offset-0"
labelSrOnly
placeholder={field.placeholder.toString()}
value={field.value}
type="text"
addOnClassname="bg-transparent border-0"
onChange={(e) => handleChange(e, index)}
addOnSuffix={
<button
type="button"
onClick={() => handleRemoveOptions(index)}
aria-label={t("remove")}>
<Icon name="x" className="h-4 w-4" />
</button>
}
/>
</div>
</li>
))}
</ul>
<div className={classNames("flex")}>
<Button
data-testid="add-attribute"
className="border-none"
type="button"
StartIcon="plus"
color="secondary"
onClick={handleAddOptions}>
Add an option
</Button>
</div>
</div>
) : null}
<div className="w-[106px]">
<Controller
name={`${hookFieldNamespace}.required`}
control={hookForm.control}
defaultValue={routerField?.required}
render={({ field: { value, onChange } }) => {
return (
<BooleanToggleGroupField
variant="small"
disabled={!!router}
label={t("required")}
value={value}
onValueChange={onChange}
/>
);
}}
/>
</div>
</div>
</FormCard>
</div>
);
}
const FormEdit = ({
hookForm,
form,
appUrl,
}: {
hookForm: HookForm;
form: inferSSRProps<typeof getServerSideProps>["form"];
appUrl: string;
}) => {
const fieldsNamespace = "fields";
const {
fields: hookFormFields,
append: appendHookFormField,
remove: removeHookFormField,
swap: swapHookFormField,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore https://github.com/react-hook-form/react-hook-form/issues/6679
} = useFieldArray({
control: hookForm.control,
name: fieldsNamespace,
});
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const addField = () => {
appendHookFormField({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
id: uuidv4(),
// This is same type from react-awesome-query-builder
type: "text",
label: "",
});
};
// hookForm.reset(form);
if (!form.fields) {
form.fields = [];
}
return hookFormFields.length ? (
<div className="flex flex-col-reverse lg:flex-row">
<div className="w-full ltr:mr-2 rtl:ml-2">
<div ref={animationRef} className="flex w-full flex-col rounded-md">
{hookFormFields.map((field, key) => {
return (
<Field
appUrl={appUrl}
fieldIndex={key}
hookForm={hookForm}
hookFieldNamespace={`${fieldsNamespace}.${key}`}
deleteField={{
check: () => hookFormFields.length > 1,
fn: () => {
removeHookFormField(key);
},
}}
moveUp={{
check: () => key !== 0,
fn: () => {
swapHookFormField(key, key - 1);
},
}}
moveDown={{
check: () => key !== hookFormFields.length - 1,
fn: () => {
if (key === hookFormFields.length - 1) {
return;
}
swapHookFormField(key, key + 1);
},
}}
key={key}
/>
);
})}
</div>
{hookFormFields.length ? (
<div className={classNames("flex")}>
<Button
data-testid="add-field"
type="button"
StartIcon="plus"
color="secondary"
onClick={addField}>
Add field
</Button>
</div>
) : null}
</div>
</div>
) : (
<div className="bg-default w-full">
<EmptyScreen
Icon="file-text"
headline="Create your first field"
description="Fields are the form fields that the booker would see."
buttonRaw={
<Button data-testid="add-field" onClick={addField}>
Create Field
</Button>
}
/>
</div>
);
};
export default function FormEditPage({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
/>
);
}
FormEditPage.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};

View File

@@ -0,0 +1,360 @@
"use client";
// TODO: i18n
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect } from "react";
import { useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
import { FilterResults } from "@calcom/features/filters/components/FilterResults";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import useApp from "@calcom/lib/hooks/useApp";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react";
import {
ArrowButton,
Badge,
Button,
ButtonGroup,
CreateButtonWithTeamsList,
EmptyScreen,
Icon,
List,
ListLinkItem,
Tooltip,
} from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import {
FormAction,
FormActionsDropdown,
FormActionsProvider,
useOpenModal,
} from "../../components/FormActions";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import { isFallbackRoute } from "../../lib/isFallbackRoute";
import { getServerSideProps } from "./getServerSideProps";
function NewFormButton() {
const { t } = useLocale();
const openModal = useOpenModal();
return (
<CreateButtonWithTeamsList
subtitle={t("create_routing_form_on").toUpperCase()}
data-testid="new-routing-form"
createFunction={(teamId) => {
openModal({ action: "new", target: teamId ? String(teamId) : "" });
}}
/>
);
}
export default function RoutingForms({
appUrl,
}: inferSSRProps<typeof getServerSideProps> & {
appUrl: string;
}) {
const { t } = useLocale();
const { hasPaidPlan } = useHasPaidPlan();
const routerQuery = useRouterQuery();
const hookForm = useFormContext<RoutingFormWithResponseCount>();
const utils = trpc.useUtils();
const [parent] = useAutoAnimate<HTMLUListElement>();
const mutation = trpc.viewer.routingFormOrder.useMutation({
onError: async (err) => {
console.error(err.message);
await utils.viewer.appRoutingForms.forms.cancel();
await utils.viewer.appRoutingForms.invalidate();
},
onSettled: () => {
utils.viewer.appRoutingForms.invalidate();
},
});
useEffect(() => {
hookForm.reset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filters = getTeamsFiltersFromQuery(routerQuery);
const queryRes = trpc.viewer.appRoutingForms.forms.useQuery({
filters,
});
const { data: typeformApp } = useApp("typeform");
const forms = queryRes.data?.filtered;
const features = [
{
icon: <Icon name="file-text" className="h-5 w-5 text-orange-500" />,
title: t("create_your_first_form"),
description: t("create_your_first_form_description"),
},
{
icon: <Icon name="shuffle" className="h-5 w-5 text-lime-500" />,
title: t("create_your_first_route"),
description: t("route_to_the_right_person"),
},
{
icon: <Icon name="bar-chart" className="h-5 w-5 text-blue-500" />,
title: t("reporting"),
description: t("reporting_feature"),
},
{
icon: <Icon name="circle-check" className="h-5 w-5 text-teal-500" />,
title: t("test_routing_form"),
description: t("test_preview_description"),
},
{
icon: <Icon name="mail" className="h-5 w-5 text-yellow-500" />,
title: t("routing_forms_send_email_owner"),
description: t("routing_forms_send_email_owner_description"),
},
{
icon: <Icon name="download" className="h-5 w-5 text-violet-500" />,
title: t("download_responses"),
description: t("download_responses_description"),
},
];
async function moveRoutingForm(index: number, increment: 1 | -1) {
const types = forms?.map((type) => {
return type.form;
});
if (types?.length) {
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
await utils.viewer.appRoutingForms.forms.cancel();
mutation.mutate({
ids: newList?.map((type) => type.id),
});
}
}
return (
<LicenseRequired>
<ShellMain
heading="Routing Forms"
CTA={hasPaidPlan && forms?.length ? <NewFormButton /> : null}
subtitle={t("routing_forms_description")}>
<UpgradeTip
plan="team"
title={t("teams_plan_required")}
description={t("routing_forms_are_a_great_way")}
features={features}
background="/tips/routing-forms"
isParentLoading={<SkeletonLoaderTeamList />}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")}
</Button>
<Button color="minimal" href="https://bls.media/cal#teams" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<FormActionsProvider appUrl={appUrl}>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 w-full px-4 pb-2 sm:px-6 md:px-8">
<div className="flex">
<TeamsFilter />
</div>
<FilterResults
queryRes={queryRes}
emptyScreen={
<EmptyScreen
Icon="git-merge"
headline={t("create_your_first_form")}
description={t("create_your_first_form_description")}
buttonRaw={<NewFormButton />}
/>
}
noResultsScreen={
<EmptyScreen
Icon="git-merge"
headline={t("no_results_for_filter")}
description={t("change_filter_common")}
/>
}
SkeletonLoader={SkeletonLoaderTeamList}>
<div className="bg-default mb-16 overflow-hidden">
<List data-testid="routing-forms-list" ref={parent}>
{forms?.map(({ form, readOnly }, index) => {
if (!form) {
return null;
}
const description = form.description || "";
form.routes = form.routes || [];
const fields = form.fields || [];
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
const firstItem = forms[0].form;
const lastItem = forms[forms.length - 1].form;
return (
<div
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
key={form.id}>
{!(firstItem && firstItem.id === form.id) && (
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === form.id) && (
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
)}
<ListLinkItem
href={`${appUrl}/form-edit/${form.id}`}
heading={form.name}
disabled={readOnly}
subHeading={description}
className="space-x-2 rtl:space-x-reverse"
actions={
<>
{form.team?.name && (
<div className="border-subtle border-r-2">
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{form.team.name}
</Badge>
</div>
)}
<FormAction
disabled={readOnly}
className="self-center"
action="toggle"
routingForm={form}
/>
<ButtonGroup combined>
<Tooltip content={t("preview")}>
<FormAction
action="preview"
routingForm={form}
target="_blank"
StartIcon="external-link"
color="secondary"
variant="icon"
/>
</Tooltip>
<FormAction
routingForm={form}
action="copyLink"
color="secondary"
variant="icon"
StartIcon="link"
tooltip={t("copy_link_to_form")}
/>
<FormAction
routingForm={form}
action="embed"
color="secondary"
variant="icon"
StartIcon="code"
tooltip={t("embed")}
/>
<FormActionsDropdown disabled={readOnly}>
<FormAction
action="edit"
routingForm={form}
color="minimal"
className="!flex"
StartIcon="pencil">
{t("edit")}
</FormAction>
<FormAction
action="download"
routingForm={form}
color="minimal"
StartIcon="download">
{t("download_responses")}
</FormAction>
<FormAction
action="duplicate"
routingForm={form}
color="minimal"
className="w-full"
StartIcon="copy">
{t("duplicate")}
</FormAction>
{typeformApp?.isInstalled ? (
<FormAction
data-testid="copy-redirect-url"
routingForm={form}
action="copyRedirectUrl"
color="minimal"
type="button"
StartIcon="link">
{t("Copy Typeform Redirect Url")}
</FormAction>
) : null}
<FormAction
action="_delete"
routingForm={form}
color="destructive"
className="w-full"
StartIcon="trash">
{t("delete")}
</FormAction>
</FormActionsDropdown>
</ButtonGroup>
</>
}>
<div className="flex flex-wrap gap-1">
<Badge variant="gray" startIcon="menu">
{fields.length} {fields.length === 1 ? "field" : "fields"}
</Badge>
<Badge variant="gray" startIcon="git-merge">
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
</Badge>
<Badge variant="gray" startIcon="message-circle">
{form._count.responses}{" "}
{form._count.responses === 1 ? "response" : "responses"}
</Badge>
</div>
</ListLinkItem>
</div>
);
})}
</List>
</div>
</FilterResults>
</div>
</div>
</FormActionsProvider>
</UpgradeTip>
</ShellMain>
</LicenseRequired>
);
}
RoutingForms.getLayout = (page: React.ReactElement) => {
return (
<Shell
title="Routing Forms"
description="Create forms to direct attendees to the correct destinations."
withoutMain={true}
hideHeadingOnMobile>
{page}
</Shell>
);
};
export { getServerSideProps };

View File

@@ -0,0 +1,37 @@
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import type {
AppGetServerSidePropsContext,
AppPrisma,
AppSsrInit,
AppUser,
} from "@calcom/types/AppGetServerSideProps";
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const ssr = await ssrInit(context);
const filters = getTeamsFiltersFromQuery(context.query);
await ssr.viewer.appRoutingForms.forms.prefetch({
filters,
});
// Prefetch this so that New Button is immediately available
await ssr.viewer.teamsAndUserProfilesQuery.prefetch();
return {
props: {
trpcState: await ssr.dehydrate(),
},
};
};

View File

@@ -0,0 +1,60 @@
"use client";
import type { GetServerSidePropsContext } from "next";
import React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { AppPrisma, AppSsrInit, AppUser } from "@calcom/types/AppGetServerSideProps";
import type { AppProps } from "@lib/app-providers";
import RoutingFormsRoutingConfig from "../app-routing.config";
const DEFAULT_ROUTE = "forms";
type GetServerSidePropsRestArgs = [AppPrisma, AppUser, AppSsrInit];
type Component = {
default: React.ComponentType & Pick<AppProps["Component"], "getLayout">;
getServerSideProps?: (context: GetServerSidePropsContext, ...rest: GetServerSidePropsRestArgs) => void;
};
const getComponent = (route: string): Component => {
return (RoutingFormsRoutingConfig as unknown as Record<string, Component>)[route];
};
export default function LayoutHandler(props: { [key: string]: unknown }) {
const params = useParamsWithFallback();
const methods = useForm();
const pageKey = Array.isArray(params.pages)
? params.pages[0]
: params.pages?.split("/")[0] ?? DEFAULT_ROUTE;
const PageComponent = getComponent(pageKey).default;
return (
<FormProvider {...methods}>
<PageComponent {...props} />
</FormProvider>
);
}
LayoutHandler.getLayout = (page: React.ReactElement) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const params = useParamsWithFallback();
const pageKey = Array.isArray(params.pages)
? params.pages[0]
: params.pages?.split("/")[0] ?? DEFAULT_ROUTE;
const component = getComponent(pageKey).default;
if (component && "getLayout" in component) {
return component.getLayout?.(page);
} else {
return page;
}
};
export async function getServerSideProps(
context: GetServerSidePropsContext,
...rest: GetServerSidePropsRestArgs
) {
const component = getComponent(context.params?.pages?.[0] || "");
return component.getServerSideProps?.(context, ...rest) || { props: {} };
}

View File

@@ -0,0 +1,206 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import type {
BuilderProps,
Config,
ImmutableTree,
JsonLogicResult,
JsonTree,
} from "react-awesome-query-builder";
import { Builder, Query, Utils as QbUtils } from "react-awesome-query-builder";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
import type QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import type { JsonLogicQuery } from "../../jsonLogicToPrisma";
import { getQueryBuilderConfig } from "../../lib/getQueryBuilderConfig";
export { getServerSideProps };
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: JsonLogicQuery | null }) => {
const { t } = useLocale();
const { isPending, status, data, isFetching, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
trpc.viewer.appRoutingForms.report.useInfiniteQuery(
{
formId: formId,
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
jsonLogicQuery: jsonLogicQuery?.logic
? jsonLogicQuery
: {
logic: {},
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const buttonInView = useInViewObserver(() => {
if (!isFetching && hasNextPage && status === "success") {
fetchNextPage();
}
});
const headers = useRef<string[] | null>(null);
if (!isPending && !data) {
return <div>Error loading report {error?.message} </div>;
}
headers.current = (data?.pages && data?.pages[0]?.headers) || headers.current;
return (
<div className="w-full max-w-[2000px] overflow-x-scroll">
<table
data-testid="reporting-table"
className="border-default bg-subtle mx-3 mb-4 table-fixed border-separate border-spacing-0 overflow-hidden rounded-md border">
<tr
data-testid="reporting-header"
className="border-default text-default bg-emphasis rounded-md border-b">
{headers.current?.map((header, index) => (
<th
className={classNames(
"border-default border-b px-2 py-3 text-left text-base font-medium",
index !== (headers.current?.length || 0) - 1 ? "border-r" : ""
)}
key={index}>
{header}
</th>
))}
</tr>
{!isPending &&
data?.pages.map((page) => {
return page.responses?.map((responses, rowIndex) => {
const isLastRow = page.responses.length - 1 === rowIndex;
return (
<tr
key={rowIndex}
data-testid="reporting-row"
className={classNames(
"text-center text-sm",
rowIndex % 2 ? "" : "bg-default",
isLastRow ? "" : "border-b"
)}>
{responses.map((r, columnIndex) => {
const isLastColumn = columnIndex === responses.length - 1;
return (
<td
className={classNames(
"border-default overflow-x-hidden px-2 py-3 text-left",
isLastRow ? "" : "border-b",
isLastColumn ? "" : "border-r"
)}
key={columnIndex}>
{r}
</td>
);
})}
</tr>
);
});
})}
</table>
{isPending ? <div className="text-default p-2">{t("loading")}</div> : ""}
{hasNextPage && (
<Button
type="button"
color="minimal"
ref={buttonInView.ref}
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
)}
</div>
);
};
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfig>) => {
const uuid = QbUtils.uuid();
const queryValue: JsonTree = { id: uuid, type: "group" } as JsonTree;
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config);
return {
state: { tree, config },
queryValue,
};
};
const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["form"] }) => {
const config = getQueryBuilderConfig(form, true);
const [query, setQuery] = useState(getInitialQuery(config));
const [jsonLogicQuery, setJsonLogicQuery] = useState<JsonLogicResult | null>(null);
const onChange = (immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const jsonTree = QbUtils.getTree(immutableTree);
setQuery(() => {
const newValue = {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
};
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config));
return newValue;
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
return (
<div className="cal-query-builder bg-default fixed inset-0 w-full overflow-scroll pt-12 ltr:mr-2 rtl:ml-2 sm:pt-0">
<Query
{...config}
value={query.state.tree}
onChange={(immutableTree, config) => {
onChange(immutableTree, config as QueryBuilderUpdatedConfig);
}}
renderBuilder={renderBuilder}
/>
<Result formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
</div>
);
};
export default function ReporterWrapper({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
Page={({ form }) => (
<div className="route-config">
<Reporter form={form} />
</div>
)}
/>
);
}
ReporterWrapper.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};

View File

@@ -0,0 +1,661 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import React, { useCallback, useState, useEffect } from "react";
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import type { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import type { UseFormReturn } from "react-hook-form";
import Shell from "@calcom/features/shell/Shell";
import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import {
SelectField,
FormCard,
SelectWithValidation as Select,
TextArea,
TextField,
Badge,
Divider,
} from "@calcom/ui";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
import "../../components/react-awesome-query-builder/styles.css";
import { RoutingPages } from "../../lib/RoutingPages";
import { createFallbackRoute } from "../../lib/createFallbackRoute";
import { getQueryBuilderConfig } from "../../lib/getQueryBuilderConfig";
import isRouter from "../../lib/isRouter";
import type {
GlobalRoute,
LocalRoute,
QueryBuilderUpdatedConfig,
SerializableRoute,
} from "../../types/types";
export { getServerSideProps };
const hasRules = (route: Route) => {
if (isRouter(route)) return false;
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
};
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
return {
id: uuid,
action: {
type: "eventTypeRedirectUrl",
value: "",
},
queryValue: { id: uuid, type: "group" },
};
};
type Route =
| (LocalRoute & {
// This is what's persisted
queryValue: JsonTree;
// `queryValue` is parsed to create state
state: {
tree: ImmutableTree;
config: QueryBuilderUpdatedConfig;
};
})
| GlobalRoute;
const Route = ({
form,
route,
routes,
setRoute,
config,
setRoutes,
moveUp,
moveDown,
appUrl,
disabled = false,
fieldIdentifiers,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
config: QueryBuilderUpdatedConfig;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
fieldIdentifiers: string[];
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
appUrl: string;
disabled?: boolean;
}) => {
const { t } = useLocale();
const index = routes.indexOf(route);
const { data: eventTypesByGroup, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery({
forRoutingForms: true,
});
const eventOptions: { label: string; value: string }[] = [];
eventTypesByGroup?.eventTypeGroups.forEach((group) => {
const eventTypeValidInContext = areTheySiblingEntitites({
entity1: {
teamId: group.teamId ?? null,
// group doesn't have userId. The query ensures that it belongs to the user only, if teamId isn't set. So, I am manually setting it to the form userId
userId: form.userId,
},
entity2: {
teamId: form.teamId ?? null,
userId: form.userId,
},
});
group.eventTypes.forEach((eventType) => {
const uniqueSlug = `${group.profile.slug}/${eventType.slug}`;
const isRouteAlreadyInUse = isRouter(route) ? false : uniqueSlug === route.action.value;
// If Event is already in use, we let it be so as to not break the existing setup
if (!isRouteAlreadyInUse && !eventTypeValidInContext) {
return;
}
eventOptions.push({
label: uniqueSlug,
value: uniqueSlug,
});
});
});
// /team/{TEAM_SLUG}/{EVENT_SLUG} -> /team/{TEAM_SLUG}
const eventTypePrefix =
eventOptions.length !== 0
? eventOptions[0].value.substring(0, eventOptions[0].value.lastIndexOf("/") + 1)
: "";
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>("");
useEffect(() => {
if (!isLoading) {
const isCustom =
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
setCustomEventTypeSlug(isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : "");
}
}, [isLoading]);
const onChange = (route: Route, immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const jsonTree = QbUtils.getTree(immutableTree);
setRoute(route.id, {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
if (isRouter(route)) {
return (
<div>
<FormCard
moveUp={moveUp}
moveDown={moveDown}
deleteField={{
check: () => routes.length !== 1,
fn: () => {
const newRoutes = routes.filter((r) => r.id !== route.id);
setRoutes(newRoutes);
},
}}
label={
<div>
<span className="mr-2">{`Route ${index + 1}`}</span>
</div>
}
className="mb-6">
<div className="-mt-3">
<Link href={`${appUrl}/route-builder/${route.id}`}>
<Badge variant="gray">
<span className="font-semibold">{route.name}</span>
</Badge>
</Link>
<p className="text-subtle mt-2 text-sm">
Fields available in <span className="font-bold">{route.name}</span> will be added to this form.
</p>
</div>
</FormCard>
</div>
);
}
return (
<FormCard
className="mb-6"
moveUp={moveUp}
moveDown={moveDown}
label={route.isFallback ? "Fallback Route" : `Route ${index + 1}`}
deleteField={{
check: () => routes.length !== 1 && !route.isFallback,
fn: () => {
const newRoutes = routes.filter((r) => r.id !== route.id);
setRoutes(newRoutes);
},
}}>
<div className="-mx-4 mb-4 flex w-full items-center sm:mx-0">
<div className="cal-query-builder w-full ">
<div>
<div className="text-emphasis flex w-full items-center text-sm">
<div className="flex flex-grow-0 whitespace-nowrap">
<span>{t("send_booker_to")}</span>
</div>
<Select
isDisabled={disabled}
className="data-testid-select-routing-action block w-full flex-grow px-2"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
if (!item) {
return;
}
const action: LocalRoute["action"] = {
type: item.value,
value: "",
};
if (action.type === "customPageMessage") {
action.value = "We are not ready for you yet :(";
} else {
action.value = "";
}
setRoute(route.id, { action });
}}
options={RoutingPages}
/>
{route.action?.type ? (
route.action?.type === "customPageMessage" ? (
<TextArea
required
disabled={disabled}
name="customPageMessage"
className="border-default flex w-full flex-grow"
value={route.action.value}
onChange={(e) => {
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
}}
/>
) : route.action?.type === "externalRedirectUrl" ? (
<TextField
disabled={disabled}
name="externalRedirectUrl"
className="border-default flex w-full flex-grow text-sm"
containerClassName="w-full mt-2"
type="url"
required
labelSrOnly
value={route.action.value}
onChange={(e) => {
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
}}
placeholder="https://example.com"
/>
) : (
<div className="block w-full">
<Select
required
isDisabled={disabled}
options={
eventOptions.length !== 0
? [{ label: t("custom"), value: "custom" }].concat(eventOptions)
: []
}
onChange={(option) => {
if (!option) {
return;
}
if (option.value !== "custom") {
setRoute(route.id, { action: { ...route.action, value: option.value } });
setCustomEventTypeSlug("");
} else {
setRoute(route.id, { action: { ...route.action, value: "custom" } });
setCustomEventTypeSlug("");
}
}}
value={
eventOptions.length !== 0 && route.action.value !== ""
? eventOptions.find(
(eventOption) =>
eventOption.value === route.action.value && !customEventTypeSlug.length
) || {
label: t("custom"),
value: "custom",
}
: undefined
}
/>
{eventOptions.length !== 0 &&
route.action.value !== "" &&
(!eventOptions.find((eventOption) => eventOption.value === route.action.value) ||
customEventTypeSlug.length) ? (
<>
<TextField
disabled={disabled}
className="border-default flex w-full flex-grow text-sm"
containerClassName="w-full mt-2"
addOnLeading={eventTypePrefix}
required
value={customEventTypeSlug}
onChange={(e) => {
setCustomEventTypeSlug(e.target.value);
setRoute(route.id, {
action: { ...route.action, value: `${eventTypePrefix}${e.target.value}` },
});
}}
placeholder="event-url"
/>
<div className="mt-2 ">
<p className="text-subtle text-xs">
{fieldIdentifiers.length
? t("field_identifiers_as_variables_with_example", {
variable: `{${fieldIdentifiers[0]}}`,
})
: t("field_identifiers_as_variables")}
</p>
</div>
</>
) : (
<></>
)}
</div>
)
) : null}
</div>
{((route.isFallback && hasRules(route)) || !route.isFallback) && (
<>
<Divider className="mb-6 mt-3" />
<Query
{...config}
value={route.state.tree}
onChange={(immutableTree, config) => {
onChange(route, immutableTree, config as QueryBuilderUpdatedConfig);
}}
renderBuilder={renderBuilder}
/>
</>
)}
</div>
</div>
</div>
</FormCard>
);
};
const deserializeRoute = (
route: Exclude<SerializableRoute, GlobalRoute>,
config: QueryBuilderUpdatedConfig
): Route => {
return {
...route,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(route.queryValue), config),
config: config,
},
};
};
const Routes = ({
form,
hookForm,
appUrl,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
appUrl: string;
}) => {
const { routes: serializedRoutes } = hookForm.getValues();
const { t } = useLocale();
const config = getQueryBuilderConfig(hookForm.getValues());
const [routes, setRoutes] = useState(() => {
const transformRoutes = () => {
const _routes = serializedRoutes || [getEmptyRoute()];
_routes.forEach((r) => {
if (isRouter(r)) return;
if (!r.queryValue?.id) {
r.queryValue = { id: QbUtils.uuid(), type: "group" };
}
});
return _routes;
};
return transformRoutes().map((route) => {
if (isRouter(route)) return route;
return deserializeRoute(route, config);
});
});
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
const availableRouters =
allForms?.filtered
.filter(({ form: router }) => {
const routerValidInContext = areTheySiblingEntitites({
entity1: {
teamId: router.teamId ?? null,
// group doesn't have userId. The query ensures that it belongs to the user only, if teamId isn't set. So, I am manually setting it to the form userId
userId: router.userId,
},
entity2: {
teamId: hookForm.getValues().teamId ?? null,
userId: hookForm.getValues().userId,
},
});
return router.id !== hookForm.getValues().id && routerValidInContext;
})
.map(({ form: router }) => {
return {
value: router.id,
label: router.name,
name: router.name,
description: router.description,
isDisabled: false,
};
}) || [];
const isConnectedForm = (id: string) => form.connectedForms.map((f) => f.id).includes(id);
const routerOptions = (
[
{
label: "Create a New Route",
value: "newRoute",
name: null,
description: null,
},
] as {
label: string;
value: string;
name: string | null;
description: string | null;
isDisabled?: boolean;
}[]
).concat(
availableRouters.map((r) => {
// Reset disabled state
r.isDisabled = false;
// Can't select a form as router that is already a connected form. It avoids cyclic dependency
if (isConnectedForm(r.value)) {
r.isDisabled = true;
}
// A route that's already used, can't be reselected
if (routes.find((route) => route.id === r.value)) {
r.isDisabled = true;
}
return r;
})
);
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const mainRoutes = routes.filter((route) => {
if (isRouter(route)) return true;
return !route.isFallback;
});
let fallbackRoute = routes.find((route) => {
if (isRouter(route)) return false;
return route.isFallback;
});
if (!fallbackRoute) {
fallbackRoute = deserializeRoute(createFallbackRoute(), config);
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes, fallbackRoute!];
});
return null;
} else if (routes.indexOf(fallbackRoute) !== routes.length - 1) {
// Ensure fallback is last
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes.filter((route) => route.id !== fallbackRoute!.id), fallbackRoute!];
});
}
const setRoute = (id: string, route: Partial<Route>) => {
const index = routes.findIndex((route) => route.id === id);
const newRoutes = [...routes];
newRoutes[index] = { ...routes[index], ...route };
setRoutes(newRoutes);
};
const swap = (from: number, to: number) => {
setRoutes((routes) => {
const newRoutes = [...routes];
const routeToSwap = newRoutes[from];
newRoutes[from] = newRoutes[to];
newRoutes[to] = routeToSwap;
return newRoutes;
});
};
const routesToSave = routes.map((route) => {
if (isRouter(route)) {
return route;
}
return {
id: route.id,
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
};
});
hookForm.setValue("routes", routesToSave);
const fields = hookForm.getValues("fields");
const fieldIdentifiers = fields ? fields.map((field) => field.identifier ?? field.label) : [];
return (
<div className="bg-default border-subtle flex flex-col-reverse rounded-md border p-8 md:flex-row">
<div ref={animationRef} className="w-full ltr:mr-2 rtl:ml-2">
{mainRoutes.map((route, key) => {
return (
<Route
form={form}
appUrl={appUrl}
key={route.id}
config={config}
route={route}
fieldIdentifiers={fieldIdentifiers}
moveUp={{
check: () => key !== 0,
fn: () => {
swap(key, key - 1);
},
}}
moveDown={{
// routes.length - 1 is fallback route always. So, routes.length - 2 is the last item that can be moved down
check: () => key !== routes.length - 2,
fn: () => {
swap(key, key + 1);
},
}}
routes={routes}
setRoute={setRoute}
setRoutes={setRoutes}
/>
);
})}
<SelectField
placeholder={t("select_a_router")}
containerClassName="mb-6 data-testid-select-router"
isOptionDisabled={(option) => !!option.isDisabled}
label={t("add_a_new_route")}
options={routerOptions}
key={mainRoutes.length}
onChange={(option) => {
if (!option) {
return;
}
const router = option.value;
if (router === "newRoute") {
const newEmptyRoute = getEmptyRoute();
const newRoutes = [
...routes,
{
...newEmptyRoute,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
config,
},
},
];
setRoutes(newRoutes);
} else {
const routerId = router;
if (!routerId) {
return;
}
setRoutes([
...routes,
{
isRouter: true,
id: routerId,
name: option.name,
description: option.description,
} as Route,
]);
}
}}
/>
<div>
<Route
form={form}
config={config}
route={fallbackRoute}
routes={routes}
setRoute={setRoute}
setRoutes={setRoutes}
appUrl={appUrl}
fieldIdentifiers={fieldIdentifiers}
/>
</div>
</div>
</div>
);
};
export default function RouteBuilder({
form,
appUrl,
enrichedWithUserProfileForm,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
Page={({ hookForm, form }) => {
// If hookForm hasn't been initialized, don't render anything
// This is important here because some states get initialized which aren't reset when the hookForm is reset with the form values and they don't get the updated values
if (!hookForm.getValues().id) {
return null;
}
return (
<div className="route-config">
<Routes hookForm={hookForm} appUrl={appUrl} form={form} />
</div>
);
}}
/>
);
}
RouteBuilder.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};

View File

@@ -0,0 +1,26 @@
"use client";
import Head from "next/head";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { getServerSideProps } from "./getServerSideProps";
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
return (
<>
<Head>
<title>{form.name} | Cal.com Forms</title>
</Head>
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div>{message}</div>
</div>
</div>
</div>
</>
);
}
export { getServerSideProps };

View File

@@ -0,0 +1,179 @@
import { stringify } from "querystring";
import z from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import { TRPCError } from "@calcom/trpc/server";
import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import transformResponse from "../../lib/transformResponse";
import type { Response } from "../../types/types";
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
const querySchema = z
.object({
form: z.string(),
slug: z.string(),
pages: z.array(z.string()),
})
.catchall(z.string().or(z.array(z.string())));
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma
) {
const queryParsed = querySchema.safeParse(context.query);
if (!queryParsed.success) {
log.warn("Error parsing query", queryParsed.error);
return {
notFound: true,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
const { currentOrgDomain } = orgDomainConfig(context.req);
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
username: true,
movedToProfileId: true,
metadata: true,
organization: {
select: {
slug: true,
},
},
},
},
team: {
select: {
parentId: true,
parent: {
select: {
slug: true,
},
},
slug: true,
metadata: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) {
return {
notFound: true,
};
}
const serializableForm = await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserProfile),
});
const response: Response = {};
if (!serializableForm.fields) {
throw new Error("Form has no fields");
}
serializableForm.fields.forEach((field) => {
const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || "";
response[field.id] = {
label: field.label,
value: transformResponse({ field, value: fieldResponse }),
};
});
const decidedAction = processRoute({ form: serializableForm, response });
if (!decidedAction) {
throw new Error("No matching route could be found");
}
const { createContext } = await import("@calcom/trpc/server/createContext");
const ctx = await createContext(context);
const { default: trpcRouter } = await import("@calcom/app-store/routing-forms/trpc/_router");
const caller = trpcRouter.createCaller(ctx);
const { v4: uuidv4 } = await import("uuid");
try {
await caller.public.response({
formId: form.id,
formFillerId: uuidv4(),
response: response,
});
} catch (e) {
if (e instanceof TRPCError) {
return {
props: {
form: serializableForm,
message: e.message,
},
};
}
}
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
return {
props: {
form: serializableForm,
message: decidedAction.value,
},
};
} else if (decidedAction.type === "eventTypeRedirectUrl") {
const eventTypeUrlWithResolvedVariables = substituteVariables(
decidedAction.value,
response,
serializableForm.fields
);
return {
redirect: {
destination: getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables,
form: serializableForm,
allURLSearchParams: new URLSearchParams(stringify(context.query)),
}),
permanent: false,
},
};
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(context.query)}`,
permanent: false,
},
};
}
return {
props: {
form: serializableForm,
},
};
};

View File

@@ -0,0 +1,268 @@
"use client";
import Head from "next/head";
import { useRouter } from "next/navigation";
import type { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { Toaster } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import classNames from "@calcom/lib/classNames";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { navigateInTopWindow } from "@calcom/lib/navigateInTopWindow";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
import FormInputFields from "../../components/FormInputFields";
import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { processRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import transformResponse from "../../lib/transformResponse";
import type { NonRouterRoute, Response } from "../../types/types";
import { getServerSideProps } from "./getServerSideProps";
type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({
brandColor,
darkBrandColor,
}: {
brandColor?: string | null;
darkBrandColor?: string | null;
}) => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
useCalcomTheme(brandTheme);
};
function RoutingForm({ form, profile, ...restProps }: Props) {
const [customPageMessage, setCustomPageMessage] = useState<NonRouterRoute["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
const isEmbed = useIsEmbed(restProps.isEmbed);
useTheme(profile.theme);
useBrandColors({
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
});
const [response, setResponse] = usePrefilledResponse(form);
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionWithFormResponseRef = useRef<{ action: NonRouterRoute["action"]; response: Response }>();
const router = useRouter();
const onSubmit = (response: Response) => {
const decidedAction = processRoute({ form, response });
if (!decidedAction) {
// FIXME: Make sure that when a form is created, there is always a fallback route and then remove this.
alert("Define atleast 1 route");
return;
}
responseMutation.mutate({
formId: form.id,
formFillerId,
response: response,
});
decidedActionWithFormResponseRef.current = {
action: decidedAction,
response,
};
};
useEffect(() => {
// Custom Page doesn't actually change Route, so fake it so that embed can adjust the scroll to make the content visible
sdkActionManager?.fire("__routeChanged", {});
}, [customPageMessage]);
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
onSuccess: async () => {
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
if (!decidedActionWithFormResponse) {
return;
}
const fields = form.fields;
if (!fields) {
throw new Error("Routing Form fields must exist here");
}
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
const decidedAction = decidedActionWithFormResponse.action;
sdkActionManager?.fire("routed", {
actionType: decidedAction.type,
actionValue: decidedAction.value,
});
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
setCustomPageMessage(decidedAction.value);
} else if (decidedAction.type === "eventTypeRedirectUrl") {
const eventTypeUrlWithResolvedVariables = substituteVariables(decidedAction.value, response, fields);
router.push(
getAbsoluteEventTypeRedirectUrl({
form,
eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables,
allURLSearchParams,
})
);
} else if (decidedAction.type === "externalRedirectUrl") {
navigateInTopWindow(`${decidedAction.value}?${allURLSearchParams}`);
}
// We don't want to show this message as it doesn't look good in Embed.
// showToast("Form submitted successfully! Redirecting now ...", "success");
},
onError: (e) => {
if (e?.message) {
return void showToast(e?.message, "error");
}
if (e?.data?.code === "CONFLICT") {
return void showToast("Form already submitted", "error");
}
// We don't want to show this error as it doesn't look good in Embed.
// showToast("Something went wrong", "error");
},
});
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
};
const { t } = useLocale();
return (
<div>
<div>
{!customPageMessage ? (
<>
<Head>
<title>{`${form.name} | Cal.com Forms`}</title>
</Head>
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main border-booker md:border-booker-width dark:bg-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
<Toaster position="bottom-right" />
<form onSubmit={handleOnSubmit}>
<div className="mb-8">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold tracking-wide">
{form.name}
</h1>
{form.description ? (
<p className="min-h-10 text-subtle text-sm ltr:mr-4 rtl:ml-4">{form.description}</p>
) : null}
</div>
<FormInputFields form={form} response={response} setResponse={setResponse} />
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast dark:border-transparent"
loading={responseMutation.isPending}
type="submit"
color="primary">
{t("submit")}
</Button>
</div>
</form>
</div>
</div>
</div>
</>
) : (
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main dark:bg-darkgray-100 sm:border-subtle bg-default -mx-4 rounded-md border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div className="text-emphasis">{customPageMessage}</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(response).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
const valueAsStringOrStringArray =
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
valueAsStringOrStringArray;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm {...props} />;
}
RoutingLink.isBookingPage = true;
export { getServerSideProps };
const usePrefilledResponse = (form: Props["form"]) => {
const searchParams = useCompatSearchParams();
const prefillResponse: Response = {};
// Prefill the form from query params
form.fields?.forEach((field) => {
const valuesFromQuery = searchParams?.getAll(getFieldIdentifier(field)).filter(Boolean) ?? [];
// We only want to keep arrays if the field is a multi-select
const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0];
prefillResponse[field.id] = {
value: transformResponse({ field, value }),
label: field.label,
};
});
const [response, setResponse] = useState<Response>(prefillResponse);
return [response, setResponse] as const;
};

View File

@@ -0,0 +1,125 @@
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import type { Prisma } from "@calcom/prisma/client";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
import { getSerializableForm } from "../../lib/getSerializableForm";
export async function isAuthorizedToViewTheForm({
user,
currentOrgDomain,
}: {
user: {
username: string | null;
metadata: Prisma.JsonValue;
movedToProfileId: number | null;
profile: {
organization: { slug: string | null; requestedSlug: string | null } | null;
};
id: number;
};
currentOrgDomain: string | null;
}) {
const formUser = {
...user,
metadata: userMetadata.parse(user.metadata),
};
const orgSlug = formUser.profile.organization?.slug ?? formUser.profile.organization?.requestedSlug ?? null;
if (!currentOrgDomain) {
// If not on org domain, let's allow serving any form belong to any organization so that even if the form owner is migrate to an organization, old links for the form keep working
return true;
} else if (currentOrgDomain !== orgSlug) {
// If on org domain,
// We don't serve the form that is of another org
// We don't serve the form that doesn't belong to any org
return false;
}
return true;
}
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma
) {
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 2) {
return {
notFound: true,
};
}
const { currentOrgDomain } = orgDomainConfig(context.req);
const isEmbed = params.appPages[1] === "embed";
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
movedToProfileId: true,
organization: {
select: {
slug: true,
},
},
username: true,
theme: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
},
},
team: {
select: {
slug: true,
parent: {
select: { slug: true },
},
parentId: true,
metadata: true,
},
},
},
});
if (!form || form.disabled) {
return {
notFound: true,
};
}
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) {
return {
notFound: true,
};
}
return {
props: {
isEmbed,
themeBasis: form.user.username,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
},
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
},
};
};

View File

@@ -0,0 +1,518 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
import {
addForm,
saveCurrentForm,
verifySelectOptions,
addOneFieldAndDescriptionAndSaveForm,
} from "./testUtils";
function todo(title: string) {
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
test.skip(title, () => {});
}
test.describe("Routing Forms", () => {
test.describe("Zero State Routing Forms", () => {
test("should be able to add a new form and view it", async ({ page }) => {
await page.waitForSelector('[data-testid="empty-screen"]');
const formId = await addForm(page);
await page.click('[href*="/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
expect(await page.locator('[data-testid="routing-forms-list"] > div').count()).toBe(1);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.getByTestId(`404-page`)).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {
const formId = await addForm(page);
const description = "Test Description";
const label = "Test Label";
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description,
label,
});
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
fields.forEach((item, index) => {
createdFields[index] = { label: item.label, typeIndex: index };
});
await expectCurrentFormToHaveFields(page, createdFields, types);
await page.click('[href*="/route-builder/"]');
await selectNewRoute(page);
await page.click('[data-testid="add-rule"]');
const options = Object.values(createdFields).map((item) => item.label);
await verifyFieldOptionsInRule(options, page);
});
test.describe("F1<-F2 Relationship", () => {
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
const form1Id = await addForm(page, { name: "F1" });
const form2Id = await addForm(page, { name: "F2" });
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
description: "Form 1 Description",
field: {
label: "F1 Field1",
typeIndex: 1,
},
});
const { types } = await addOneFieldAndDescriptionAndSaveForm(form2Id, page, {
description: "Form 2 Description",
field: {
label: "F2 Field1",
//TODO: Maybe choose some other type and choose type by it's name and not index
typeIndex: 1,
},
});
// Add F1 as Router to F2
await page.goto(`/routing-forms/route-builder/${form2Id}`);
await selectNewRoute(page, {
// It should be F1. TODO: Verify that it's F1
routeSelectNumber: 2,
});
await saveCurrentForm(page);
// Expect F1 fields to be available in F2
await page.goto(`/routing-forms/form-edit/${form2Id}`);
//FIXME: Figure out why this delay is required. Without it field count comes out to be 1 only
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(await page.locator('[data-testid="field"]').count()).toBe(2);
await expectCurrentFormToHaveFields(page, { 1: { label: "F1 Field1", typeIndex: 1 } }, types);
// Add 1 more field in F1
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
field: {
label: "F1 Field2",
typeIndex: 1,
},
});
await page.goto(`/routing-forms/form-edit/${form2Id}`);
//FIXME: Figure out why this delay is required. Without it field count comes out to be 1 only
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(await page.locator('[data-testid="field"]').count()).toBe(3);
await expectCurrentFormToHaveFields(page, { 2: { label: "F1 Field2", typeIndex: 1 } }, types);
});
todo("Create relationship by using duplicate with live connect");
});
test("should be able to submit a prefilled form with all types of fields", async ({ page }) => {
const formId = await addForm(page);
await page.click('[href*="/route-builder/"]');
await selectNewRoute(page);
await selectOption({
selector: {
selector: ".data-testid-select-routing-action",
nth: 0,
},
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", "https://www.google.com");
await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description: "Description",
label: "Test Field",
});
const queryString =
"firstField=456&Test Field Number=456&Test Field Single Selection=456&Test Field Multiple Selection=456&Test Field Multiple Selection=789&Test Field Phone=456&Test Field Email=456@example.com";
await gotoRoutingLink({ page, queryString });
await page.fill('[data-testid="form-field-Test Field Long Text"]', "manual-fill");
expect(await page.locator(`[data-testid="form-field-firstField"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Number"]`).inputValue()).toBe("456");
// TODO: Verify select and multiselect has prefilled values.
// expect(await page.locator(`[data-testid="form-field-Test Field Select"]`).inputValue()).toBe("456");
// expect(await page.locator(`[data-testid="form-field-Test Field MultiSelect"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Phone"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Email"]`).inputValue()).toBe(
"456@example.com"
);
await page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
const url = new URL(page.url());
// Coming from the response filled by booker
expect(url.searchParams.get("firstField")).toBe("456");
// All other params come from prefill URL
expect(url.searchParams.get("Test Field Number")).toBe("456");
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
expect(url.searchParams.get("Test Field Multiple Selection")).toBe("456");
expect(url.searchParams.getAll("Test Field Multiple Selection")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test Field Phone")).toBe("456");
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
});
// TODO: How to install the app just once?
test.beforeEach(async ({ page, users }) => {
const user = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
}
);
await user.apiLogin();
await page.goto(`/routing-forms/forms`);
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
});
todo("should be able to duplicate form");
test.describe("Seeded Routing Form ", () => {
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
const createUserAndLogin = async function ({ users, page }: { users: Fixtures["users"]; page: Page }) {
const user = await users.create(
{ username: "routing-forms" },
{ seedRoutingForms: true, hasTeam: true }
);
await user.apiLogin();
return user;
};
test("Routing Link - Reporting and CSV Download ", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
test.setTimeout(120000);
// Fill form when you are logged out
await users.logout();
await fillSeededForm(page, routingForm.id);
// Log back in to view form responses.
await user.apiLogin();
await page.goto(`/routing-forms/reporting/${routingForm.id}`);
const headerEls = page.locator("[data-testid='reporting-header'] th");
// Once the response is there, React would soon render it, so 500ms is enough
// FIXME: Sometimes it takes more than 500ms, so added a timeout of 1000ms for now. There might be something wrong with rendering.
await headerEls.first().waitFor({
timeout: 1000,
});
const numHeaderEls = await headerEls.count();
const headers = [];
for (let i = 0; i < numHeaderEls; i++) {
headers.push(await headerEls.nth(i).innerText());
}
const responses = [];
const responseRows = page.locator("[data-testid='reporting-row']");
const numResponseRows = await responseRows.count();
for (let i = 0; i < numResponseRows; i++) {
const rowLocator = responseRows.nth(i).locator("td");
const numRowEls = await rowLocator.count();
const rowResponses = [];
for (let j = 0; j < numRowEls; j++) {
rowResponses.push(await rowLocator.nth(j).innerText());
}
responses.push(rowResponses);
}
expect(headers).toEqual(["Test field", "Multi Select"]);
expect(responses).toEqual([
["event-routing", ""],
["external-redirect", ""],
["custom-page", ""],
]);
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
const [download] = await Promise.all([
// Start waiting for the download
page.waitForEvent("download"),
// Perform the action that initiates download
page.click('[data-testid="download-responses"]'),
]);
const downloadStream = await download.createReadStream();
expect(download.suggestedFilename()).toEqual(`${routingForm.name}-${routingForm.id}.csv`);
const csv: string = await new Promise((resolve) => {
let body = "";
downloadStream?.on("data", (chunk) => {
body += chunk;
});
downloadStream?.on("end", () => {
resolve(body);
});
});
const csvRows = csv.trim().split("\n");
const csvHeaderRow = csvRows[0];
expect(csvHeaderRow).toEqual("Test field,Multi Select,Submission Time");
const firstResponseCells = csvRows[1].split(",");
const secondResponseCells = csvRows[2].split(",");
const thirdResponseCells = csvRows[3].split(",");
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,");
expect(new Date(firstResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,");
expect(new Date(secondResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,");
expect(new Date(thirdResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
});
test("Router URL should work", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
// Router should be publicly accessible
await users.logout();
page.goto(`/router?form=${routingForm.id}&Test field=event-routing`);
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min") && url.searchParams.get("Test field") === "event-routing";
});
page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
await page.waitForURL((url) => {
return (
url.hostname.includes("google.com") && url.searchParams.get("Test field") === "external-redirect"
);
});
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
});
test("Routing Link should validate fields", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
await gotoRoutingLink({ page, formId: routingForm.id });
page.click('button[type="submit"]');
const firstInputMissingValue = await page.evaluate(() => {
return document.querySelectorAll("input")[0].validity.valueMissing;
});
expect(firstInputMissingValue).toBe(true);
expect(await page.locator('button[type="submit"][disabled]').count()).toBe(0);
});
test("Test preview should return correct route", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
page.goto(`apps/routing-forms/form-edit/${routingForm.id}`);
await page.click('[data-testid="test-preview"]');
await page.waitForLoadState("networkidle");
// //event redirect
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
await page.click('[data-testid="test-routing"]');
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Event Redirect");
expect(route).toBe("pro/30min");
//custom page
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Custom Page Result");
//external redirect
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("External Redirect");
expect(route).toBe("https://google.com");
//fallback route
await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Fallback Message");
});
});
});
async function disableForm(page: Page) {
await page.click('[data-testid="toggle-form"] [value="on"]');
await page.waitForSelector(".data-testid-toast-success");
}
async function expectCurrentFormToHaveFields(
page: Page,
fields: {
[key: number]: { label: string; typeIndex: number };
},
types: string[]
) {
for (const [index, field] of Object.entries(fields)) {
expect(await page.inputValue(`[data-testid="fields.${index}.label"]`)).toBe(field.label);
expect(await page.locator(".data-testid-field-type").nth(+index).locator("div").nth(1).innerText()).toBe(
types[field.typeIndex]
);
}
}
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min");
});
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('button[type="submit"]');
await expect(page.locator("text=Custom Page Result")).toBeVisible();
}
async function addAllTypesOfFieldsAndSaveForm(
formId: string,
page: Page,
form: { description: string; label: string }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
await page.fill('[data-testid="description"]', form.description);
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
const fields = [];
for (let index = 0; index < fieldTypesList.length; index++) {
const fieldTypeLabel = fieldTypesList[index];
const nth = index;
const label = `${form.label} ${fieldTypeLabel}`;
let identifier = "";
if (index !== 0) {
identifier = label;
// Click on the field type dropdown.
await page.locator(".data-testid-field-type").nth(nth).click();
// Click on the dropdown option.
await page.locator(`[data-testid^="select-option-"]`).filter({ hasText: fieldTypeLabel }).click();
} else {
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
// First field type is by default selected. So, no need to choose from dropdown
identifier = "firstField";
}
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
}
await page.fill(`[name="fields.${nth}.label"]`, label);
if (identifier !== label) {
await page.fill(`[name="fields.${nth}.identifier"]`, identifier);
}
if (index !== fieldTypesList.length - 1) {
await page.click('[data-testid="add-field"]');
}
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
}
await saveCurrentForm(page);
return {
fieldTypesList,
fields,
};
}
async function selectOption({
page,
selector,
option,
}: {
page: Page;
selector: { selector: string; nth: number };
/**
* Index of option to select. Starts from 1
*/
option: number;
}) {
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
await locatorForSelect.click();
await locatorForSelect
.locator('[id*="react-select-"][aria-disabled]')
.nth(option - 1)
.click();
}
async function verifyFieldOptionsInRule(options: string[], page: Page) {
await verifySelectOptions(
{
selector: ".rule-container .data-testid-field-select",
nth: 0,
},
options,
page
);
}
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
await selectOption({
selector: {
selector: ".data-testid-select-router",
nth: 0,
},
option: routeSelectNumber,
page,
});
}

View File

@@ -0,0 +1,83 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
await page.goto("/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
// Choose to create the Form for the user(which is the first option) and not the team
await page.click('[data-testid="option-0"]');
await page.fill("input[name]", name);
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url();
const formId = new URL(url).pathname.split("/").at(-1);
if (!formId) {
throw new Error("Form ID couldn't be determined from url");
}
return formId;
}
export async function addOneFieldAndDescriptionAndSaveForm(
formId: string,
page: Page,
form: { description?: string; field?: { typeIndex: number; label: string } }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
if (form.description) {
await page.fill('[data-testid="description"]', form.description);
}
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
if (form.field) {
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator(".data-testid-field-type")
.click();
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator('[id*="react-select-"][aria-disabled]')
.nth(form.field.typeIndex)
.click();
}
await saveCurrentForm(page);
return {
types,
};
}
export async function saveCurrentForm(page: Page) {
await page.click('[data-testid="update-form"]');
await page.waitForSelector(".data-testid-toast-success");
}
export async function verifySelectOptions(
selector: { selector: string; nth: number },
expectedOptions: string[],
page: Page
) {
await page.locator(selector.selector).nth(selector.nth).click();
const selectOptions = await page
.locator(selector.selector)
.nth(selector.nth)
.locator('[id*="react-select-"][aria-disabled]')
.allInnerTexts();
const sortedSelectOptions = [...selectOptions].sort();
const sortedExpectedOptions = [...expectedOptions].sort();
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
return {
optionsInUi: selectOptions,
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" />
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1,309 @@
import { describe, expect, it, afterEach, vi } from "vitest";
import { jsonLogicToPrisma } from "../../jsonLogicToPrisma";
afterEach(() => {
vi.resetAllMocks();
});
describe("jsonLogicToPrisma(Reporting)", () => {
describe("Text Operand", () => {
it("should support 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "A",
},
},
],
});
});
it("should support 'Not Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "abc",
},
},
},
],
});
});
it("should support 'Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "A",
},
},
],
});
});
it("should support 'Not Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "a",
},
},
},
],
});
});
describe("Number Type", () => {
it("should support 'greater than' operator", () => {
let prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
});
prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// A number would also work
100,
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
});
});
it("should support 'greater than or equal to' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">=": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gte: 100 } }],
});
});
it("should support 'less than' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
"<": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lt: 100 } }],
});
});
it("should support 'less than or equal to' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
"<=": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lte: 100 } }],
});
});
it("'Equals' operator should query with string as well as number", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "1"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "1",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: 1,
},
},
],
},
],
});
});
});
});
describe("MultiSelect", () => {
it("should support 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] },
},
],
});
});
});
it("should support where All Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where None Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
"!": {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
],
},
},
});
expect(prismaWhere).toEqual({
NOT: {
OR: [
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
],
},
});
});
});

View File

@@ -0,0 +1 @@
export { default } from "./trpc/_router";

View File

@@ -0,0 +1,73 @@
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
import { ZFormMutationInputSchema } from "./formMutation.schema";
import { ZFormQueryInputSchema } from "./formQuery.schema";
import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZResponseInputSchema } from "./response.schema";
// eslint-disable-next-line @typescript-eslint/ban-types
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
// TODO: Move getHandler and UNSTABLE_HANDLER_CACHE to a common utils file making sure that there is no name collision across routes
/**
* This function will import the module defined in importer just once and then cache the default export of that module.
*
* It gives you the default export of the module.
*
* **Note: It is your job to ensure that the name provided is unique across all routes.**
*/
const getHandler = async <
T extends {
// eslint-disable-next-line @typescript-eslint/ban-types
default: Function;
}
>(
/**
* The name of the handler in cache. It has to be unique across all routes
*/
name: string,
importer: () => Promise<T>
) => {
const nameInCache = name as keyof typeof UNSTABLE_HANDLER_CACHE;
if (!UNSTABLE_HANDLER_CACHE[nameInCache]) {
const importedModule = await importer();
UNSTABLE_HANDLER_CACHE[nameInCache] = importedModule.default;
return importedModule.default as T["default"];
}
return UNSTABLE_HANDLER_CACHE[nameInCache] as unknown as T["default"];
};
const appRoutingForms = router({
public: router({
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("response", () => import("./response.handler"));
return handler({ ctx, input });
}),
}),
forms,
formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
return handler({ ctx, input });
}),
formMutation: authedProcedure.input(ZFormMutationInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("formMutation", () => import("./formMutation.handler"));
return handler({ ctx, input });
}),
deleteForm: authedProcedure.input(ZDeleteFormInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("deleteForm", () => import("./deleteForm.handler"));
return handler({ ctx, input });
}),
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("report", () => import("./report.handler"));
return handler({ ctx, input });
}),
});
export default appRoutingForms;

View File

@@ -0,0 +1,55 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import getConnectedForms from "../lib/getConnectedForms";
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
import type { TDeleteFormInputSchema } from "./deleteForm.schema";
interface DeleteFormHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TDeleteFormInputSchema;
}
export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => {
const { user, prisma } = ctx;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: input.id, targetTeamId: null }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
const areFormsUsingIt = (
await getConnectedForms(prisma, {
id: input.id,
userId: user.id,
})
).length;
if (areFormsUsingIt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This form is being used by other forms. Please remove it's usage from there first.",
});
}
const deletedRes = await prisma.app_RoutingForms_Form.deleteMany({
where: {
id: input.id,
...entityPrismaWhereClause({ userId: user.id }),
},
});
if (!deletedRes.count) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Form seems to be already deleted.",
});
}
return deletedRes;
};
export default deleteFormHandler;

View File

@@ -0,0 +1,7 @@
import z from "zod";
export const ZDeleteFormInputSchema = z.object({
id: z.string(),
});
export type TDeleteFormInputSchema = z.infer<typeof ZDeleteFormInputSchema>;

View File

@@ -0,0 +1,390 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { createFallbackRoute } from "../lib/createFallbackRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
import isRouter from "../lib/isRouter";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm } from "../types/types";
import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
import type { TFormMutationInputSchema } from "./formMutation.schema";
interface FormMutationHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TFormMutationInputSchema;
}
export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => {
const { user, prisma } = ctx;
const { name, id, description, disabled, addFallback, duplicateFrom, shouldConnect } = input;
let teamId = input.teamId;
const settings = input.settings;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: id, targetTeamId: teamId }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
let { routes: inputRoutes } = input;
let { fields: inputFields } = input;
inputFields = inputFields || [];
inputRoutes = inputRoutes || [];
type InputFields = typeof inputFields;
type InputRoutes = typeof inputRoutes;
let routes: InputRoutes;
let fields: InputFields;
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: id,
},
select: {
id: true,
user: true,
name: true,
description: true,
userId: true,
disabled: true,
createdAt: true,
updatedAt: true,
routes: true,
fields: true,
settings: true,
teamId: true,
position: true,
},
});
const dbSerializedForm = dbForm
? await getSerializableForm({ form: dbForm, withDeletedFields: true })
: null;
if (duplicateFrom) {
({ teamId, routes, fields } = await getRoutesAndFieldsForDuplication({ duplicateFrom, userId: user.id }));
} else {
[fields, routes] = [inputFields, inputRoutes];
if (dbSerializedForm) {
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
}
}
if (dbSerializedForm) {
// If it's an existing form being mutated, update fields in the connected forms(if any).
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
}
fields = await getUpdatedRouterLinkedFields(fields, routes);
if (addFallback) {
// Add a fallback route if there is none
if (!routes.find(isFallbackRoute)) {
routes.push(createFallbackRoute());
}
}
// Validate the users passed
if (teamId && settings?.sendUpdatesTo?.length) {
const sendUpdatesTo = await prisma.membership.findMany({
where: {
teamId,
userId: {
in: settings.sendUpdatesTo,
},
},
select: {
userId: true,
},
});
settings.sendUpdatesTo = sendUpdatesTo.map((member) => member.userId);
// If its not a team, the user is sending the value, we will just ignore it
} else if (!teamId && settings?.sendUpdatesTo) {
delete settings.sendUpdatesTo;
}
return await prisma.app_RoutingForms_Form.upsert({
where: {
id: id,
},
create: {
user: {
connect: {
id: user.id,
},
},
fields,
name: name,
description,
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
routes: routes === null ? Prisma.JsonNull : routes,
id: id,
...(teamId
? {
team: {
connect: {
id: teamId ?? undefined,
},
},
}
: null),
},
update: {
disabled: disabled,
fields,
name: name,
description,
settings: settings === null ? Prisma.JsonNull : settings,
routes: routes === null ? Prisma.JsonNull : routes,
},
});
/**
* If Form has Router Linked fields, enrich them with the latest info from the Router
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
*/
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
const routerLinkedFields: Record<string, boolean> = {};
for (const [, field] of Object.entries(fields)) {
if (!isRouterLinkedField(field)) {
continue;
}
routerLinkedFields[field.routerId] = true;
if (!routes.some((route) => route.id === field.routerId)) {
// If the field is from a router that is not available anymore, mark it as deleted
field.deleted = true;
continue;
}
// Get back deleted field as now the Router is there for it.
if (field.deleted) field.deleted = false;
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: field.routerId,
userId: user.id,
},
});
if (router) {
assertIfInvalidRouter(router);
const parsedRouterFields = zodFields.parse(router.fields);
// There is a field from some router available, make sure that the field has up-to-date info from the router
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
// Update local field(cache) with router field on every mutation
Object.assign(field, routerField);
}
}
for (const [, route] of Object.entries(routes)) {
if (!isRouter(route)) {
continue;
}
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
if (routerLinkedFields[route.id]) {
continue;
}
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: route.id,
userId: user.id,
},
});
if (router) {
assertIfInvalidRouter(router);
const parsedRouterFields = zodFields.parse(router.fields);
const fieldsFromRouter = parsedRouterFields
?.filter((f) => !f.deleted)
.map((f) => {
return {
...f,
routerId: route.id,
};
});
if (fieldsFromRouter) {
fields = fields.concat(fieldsFromRouter);
}
}
}
return fields;
}
function findFieldWithId(id: string, fields: InputFields) {
return fields.find((field) => field.id === id);
}
/**
* Update fields in connected forms as per the inputFields
*/
async function updateFieldsInConnectedForms(
serializedForm: SerializableForm<App_RoutingForms_Form>,
inputFields: InputFields
) {
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: connectedForm.id,
},
});
if (!connectedFormDb) {
continue;
}
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
const fieldsThatAreNotInConnectedForm = (
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
).map((f) => ({
...f,
routerId: serializedForm.id,
}));
const updatedConnectedFormFields = connectedFormFields
// Update fields that are already in connected form
?.map((field) => {
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
return {
...field,
...findFieldWithId(field.id, inputFields || []),
};
}
return field;
})
// Add fields that are not there
.concat(fieldsThatAreNotInConnectedForm);
await prisma.app_RoutingForms_Form.update({
where: {
id: connectedForm.id,
},
data: {
fields: updatedConnectedFormFields,
},
});
}
}
async function getRoutesAndFieldsForDuplication({
duplicateFrom,
userId,
}: {
duplicateFrom: DuplicateFrom;
userId: number;
}) {
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
where: {
...entityPrismaWhereClause({ userId }),
id: duplicateFrom,
},
select: {
id: true,
fields: true,
routes: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
members: true,
},
},
},
});
if (!sourceForm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Form to duplicate: ${duplicateFrom} not found`,
});
}
if (!canEditEntity(sourceForm, userId)) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Form to duplicate: ${duplicateFrom} not found or you are unauthorized`,
});
}
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
if (!fieldsParsed.success || !routesParsed.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Could not parse source form's fields or routes",
});
}
let fields, routes;
if (shouldConnect) {
routes = [
// This connected route would automatically link the fields
zodRouterRoute.parse({
id: sourceForm.id,
isRouter: true,
}),
];
fields =
fieldsParsed.data
// Deleted fields in the form shouldn't be added to the new form
?.filter((f) => !f.deleted)
.map((f) => {
return {
id: f.id,
routerId: sourceForm.id,
label: "",
type: "",
};
}) || [];
} else {
// Duplicate just routes and fields
// We don't want name, description and responses to be copied
routes = routesParsed.data || [];
// FIXME: Deleted fields shouldn't come in duplicate
fields = fieldsParsed.data ? fieldsParsed.data.filter((f) => !f.deleted) : [];
}
return { teamId: sourceForm.teamId, routes, fields };
}
function markMissingFieldsDeleted(
serializedForm: SerializableForm<App_RoutingForms_Form>,
fields: InputFields
) {
// Find all fields that are in DB(including deleted) but not in the mutation
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
const deletedFields =
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
// Add back deleted fields in the end and mark them deleted.
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
fields = fields.concat(
deletedFields.map((f) => {
f.deleted = true;
return f;
})
);
return fields;
}
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
const routesOfRouter = zodRoutes.parse(router.routes);
if (routesOfRouter) {
if (routesOfRouter.find(isRouter)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
});
}
}
}
};
export default formMutationHandler;

View File

@@ -0,0 +1,21 @@
import z from "zod";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { zodFields, zodRoutes } from "../zod";
export const ZFormMutationInputSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
disabled: z.boolean().optional(),
fields: zodFields,
routes: zodRoutes,
addFallback: z.boolean().optional(),
duplicateFrom: z.string().nullable().optional(),
teamId: z.number().nullish().default(null),
shouldConnect: z.boolean().optional(),
settings: RoutingFormSettings.optional(),
});
export type TFormMutationInputSchema = z.infer<typeof ZFormMutationInputSchema>;

View File

@@ -0,0 +1,44 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFormQueryInputSchema } from "./formQuery.schema";
interface FormsHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TFormQueryInputSchema;
}
export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
const { prisma, user } = ctx;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
AND: [
entityPrismaWhereClause({ userId: user.id }),
{
id: input.id,
},
],
},
include: {
team: { select: { slug: true, name: true } },
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return null;
}
return await getSerializableForm({ form });
};
export default formQueryHandler;

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZFormQueryInputSchema = z.object({
id: z.string(),
});
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;

View File

@@ -0,0 +1,148 @@
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { entries } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFormSchema } from "./forms.schema";
interface FormsHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TFormSchema;
}
const log = logger.getSubLogger({ prefix: ["[formsHandler]"] });
export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
const { prisma, user } = ctx;
const where = getPrismaWhereFromFilters(user, input?.filters);
log.debug("Getting forms where", JSON.stringify(where));
const forms = await prisma.app_RoutingForms_Form.findMany({
where,
orderBy: [
{
position: "desc",
},
{
createdAt: "asc",
},
],
include: {
team: {
include: {
members: true,
},
},
_count: {
select: {
responses: true,
},
},
},
});
const totalForms = await prisma.app_RoutingForms_Form.count({
where: entityPrismaWhereClause({
userId: user.id,
}),
});
const serializableForms = [];
for (let i = 0; i < forms.length; i++) {
const form = forms[i];
const hasWriteAccess = canEditEntity(form, user.id);
serializableForms.push({
form: await getSerializableForm({ form: forms[i] }),
readOnly: !hasWriteAccess,
});
}
return {
filtered: serializableForms,
totalCount: totalForms,
};
};
export default formsHandler;
type SupportedFilters = Omit<NonNullable<NonNullable<TFormSchema>["filters"]>, "upIds"> | undefined;
export function getPrismaWhereFromFilters(
user: {
id: number;
},
filters: SupportedFilters
) {
const where = {
OR: [] as Prisma.App_RoutingForms_FormWhereInput[],
};
const prismaQueries: Record<
keyof NonNullable<typeof filters>,
(...args: [number[]]) => Prisma.App_RoutingForms_FormWhereInput
> & {
all: () => Prisma.App_RoutingForms_FormWhereInput;
} = {
userIds: (userIds: number[]) => ({
userId: {
in: userIds,
},
teamId: null,
}),
teamIds: (teamIds: number[]) => ({
team: {
id: {
in: teamIds ?? [],
},
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
}),
all: () => ({
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
],
}),
};
if (!filters || !hasFilter(filters)) {
where.OR.push(prismaQueries.all());
} else {
for (const entry of entries(filters)) {
if (!entry) {
continue;
}
const [filterName, filter] = entry;
const getPrismaQuery = prismaQueries[filterName];
// filter might be accidentally set undefined as well
if (!getPrismaQuery || !filter) {
continue;
}
where.OR.push(getPrismaQuery(filter));
}
}
return where;
}

View File

@@ -0,0 +1,13 @@
"use client";
import { z } from "zod";
import { filterQuerySchemaStrict } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
export const ZFormsInputSchema = z
.object({
filters: filterQuerySchemaStrict.optional(),
})
.nullish();
export type TFormSchema = z.infer<typeof ZFormsInputSchema>;

View File

@@ -0,0 +1,8 @@
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { ZFormsInputSchema } from "../forms.schema";
export const forms = authedProcedure.input(ZFormsInputSchema).query(async ({ ctx, input }) => {
const handler = (await import("../forms.handler")).default;
return handler({ ctx, input });
});

View File

@@ -0,0 +1,81 @@
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { Response } from "../types/types";
import type { TReportInputSchema } from "./report.schema";
interface ReportHandlerOptions {
ctx: {
prisma: PrismaClient;
};
input: TReportInputSchema;
}
export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
)}`
);
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
take,
skip,
});
const fields = serializedForm?.fields || [];
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: (string | number)[][] = [];
rows.forEach((r) => {
const rowResponses: (string | number)[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r.response) {
return;
}
const response = r.response as Response;
const value = response[field.id]?.value || "";
let transformedValue;
if (value instanceof Array) {
transformedValue = value.join(", ");
} else {
transformedValue = value;
}
rowResponses.push(transformedValue);
});
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
};
export default reportHandler;

View File

@@ -0,0 +1,11 @@
import z from "zod";
export const ZReportInputSchema = z.object({
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
}),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
});
export type TReportInputSchema = z.infer<typeof ZReportInputSchema>;

View File

@@ -0,0 +1,135 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import type { PrismaClient } from "@calcom/prisma";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { Response } from "../types/types";
import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission } from "./utils";
interface ResponseHandlerOptions {
ctx: {
prisma: PrismaClient;
};
input: TResponseInputSchema;
}
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
const { prisma } = ctx;
try {
const { response, formId } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
});
}
const serializableForm = await getSerializableForm({ form });
if (!serializableForm.fields) {
// There is no point in submitting a form that doesn't have fields defined
throw new TRPCError({
code: "BAD_REQUEST",
});
}
const serializableFormWithFields = {
...serializableForm,
fields: serializableForm.fields,
};
const missingFields = serializableFormWithFields.fields
.filter((field) => !(field.required ? response[field.id]?.value : true))
.map((f) => f.label);
if (missingFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Missing required fields ${missingFields.join(", ")}`,
});
}
const invalidFields = serializableFormWithFields.fields
.filter((field) => {
const fieldValue = response[field.id]?.value;
// The field isn't required at this point. Validate only if it's set
if (!fieldValue) {
return false;
}
let schema;
if (field.type === "email") {
schema = z.string().email();
} else if (field.type === "phone") {
schema = z.any();
} else {
schema = z.any();
}
return !schema.safeParse(fieldValue).success;
})
.map((f) => ({ label: f.label, type: f.type, value: response[f.id]?.value }));
if (invalidFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid value for fields ${invalidFields
.map((f) => `'${f.label}' with value '${f.value}' should be valid ${f.type}`)
.join(", ")}`,
});
}
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: input,
});
const settings = RoutingFormSettings.parse(form.settings);
let userWithEmails: string[] = [];
if (form.teamId && settings?.sendUpdatesTo?.length) {
const userEmails = await prisma.membership.findMany({
where: {
teamId: form.teamId,
userId: {
in: settings.sendUpdatesTo,
},
},
select: {
user: {
select: {
email: true,
},
},
},
});
userWithEmails = userEmails.map((userEmail) => userEmail.user.email);
}
await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as Response
);
return dbFormResponse;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "CONFLICT",
});
}
}
throw e;
}
};
export default responseHandler;

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const ZResponseInputSchema = z.object({
formId: z.string(),
formFillerId: z.string(),
response: z.record(
z.object({
label: z.string(),
value: z.union([z.string(), z.number(), z.array(z.string())]),
})
),
});
export type TResponseInputSchema = z.infer<typeof ZResponseInputSchema>;

View File

@@ -0,0 +1,114 @@
import type { App_RoutingForms_Form, User } from "@prisma/client";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import type { OrderedResponses } from "../types/types";
import type { Response, SerializableForm } from "../types/types";
export async function onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
"fields"
>,
response: Response
) {
const fieldResponsesByName: Record<
string,
{
value: Response[keyof Response]["value"];
}
> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = {
value: fieldResponse.value,
};
}
const { userId, teamId } = getWebhookTargetEntity(form);
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const subscriberOptions = {
userId,
teamId,
orgId,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) => {
sendGenericWebhookPayload({
secretKey: webhook.secret,
triggerEvent: "FORM_SUBMITTED",
createdAt: new Date().toISOString(),
webhook,
data: {
formId: form.id,
formName: form.name,
teamId: form.teamId,
responses: fieldResponsesByName,
},
rootData: {
// Send responses unwrapped at root level for backwards compatibility
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
acc[key] = value.value;
return acc;
}, {} as Record<string, Response[keyof Response]["value"]>),
},
}).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e);
});
});
await Promise.all(promises);
const orderedResponses = form.fields.reduce((acc, field) => {
acc.push(response[field.id]);
return acc;
}, [] as OrderedResponses);
if (form.settings?.emailOwnerOnSubmission) {
logger.debug(
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
);
await sendResponseEmail(form, orderedResponses, [form.user.email]);
} else if (form.userWithEmails?.length) {
logger.debug(
`Preparing to send Form Response email for Form:${form.id} to users: ${form.userWithEmails.join(",")}`
);
await sendResponseEmail(form, orderedResponses, form.userWithEmails);
}
}
export const sendResponseEmail = async (
form: Pick<App_RoutingForms_Form, "id" | "name">,
orderedResponses: OrderedResponses,
toAddresses: string[]
) => {
try {
if (typeof window === "undefined") {
const { default: ResponseEmail } = await import("../emails/templates/response-email");
const email = new ResponseEmail({ form: form, toAddresses, orderedResponses });
await email.sendEmail();
}
} catch (e) {
logger.error("Error sending response email", e);
}
};
function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) {
// If it's a team form, the target must be team webhook
// If it's a user form, the target must be user webhook
const isTeamForm = form.teamId;
return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null };
}

View File

@@ -0,0 +1,58 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type z from "zod";
import type { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import type QueryBuilderInitialConfig from "../components/react-awesome-query-builder/config/config";
import type { zodRouterRouteView, zodNonRouterRoute, zodFieldsView, zodRoutesView } from "../zod";
export type RoutingForm = SerializableForm<App_RoutingForms_Form>;
export type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export type Response = Record<
// Field ID
string,
{
value: number | string | string[];
label: string;
}
>;
export type Fields = z.infer<typeof zodFieldsView>;
export type Field = Fields[number];
export type Routes = z.infer<typeof zodRoutesView>;
export type Route = Routes[0];
export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>;
export type SerializableFormTeamMembers = {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
};
export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
T,
"fields" | "routes" | "createdAt" | "updatedAt" | "settings"
> & {
routes: Routes;
fields: Fields;
settings: z.infer<typeof RoutingFormSettings>;
createdAt: string;
updatedAt: string;
connectedForms: { name: string; description: string | null; id: string }[];
routers: { name: string; description: string | null; id: string }[];
teamMembers: SerializableFormTeamMembers[];
};
export type LocalRoute = z.infer<typeof zodNonRouterRoute>;
export type GlobalRoute = z.infer<typeof zodRouterRouteView>;
export type SerializableRoute =
| (LocalRoute & {
queryValue: LocalRoute["queryValue"];
isFallback?: LocalRoute["isFallback"];
})
| GlobalRoute;
export type OrderedResponses = Response[string][];

View File

@@ -0,0 +1,85 @@
import { z } from "zod";
export const zodNonRouterField = z.object({
id: z.string(),
label: z.string(),
identifier: z.string().optional(),
placeholder: z.string().optional(),
type: z.string(),
selectText: z.string().optional(),
required: z.boolean().optional(),
deleted: z.boolean().optional(),
});
export const zodRouterField = zodNonRouterField.extend({
routerId: z.string(),
});
// This ordering is important - If routerId is present then it should be in the parsed object. Moving zodNonRouterField to first position doesn't do that
export const zodField = z.union([zodRouterField, zodNonRouterField]);
export const zodFields = z.array(zodField).optional();
export const zodNonRouterFieldView = zodNonRouterField;
export const zodRouterFieldView = zodRouterField.extend({
routerField: zodNonRouterFieldView,
router: z.object({
name: z.string(),
description: z.string(),
id: z.string(),
}),
});
/**
* Has some additional fields that are not supposed to be saved to DB but are required for the UI
*/
export const zodFieldView = z.union([zodNonRouterFieldView, zodRouterFieldView]);
export const zodFieldsView = z.array(zodFieldView).optional();
export const zodNonRouterRoute = z.object({
id: z.string(),
queryValue: z.object({
id: z.string().optional(),
type: z.union([z.literal("group"), z.literal("switch_group")]),
children1: z.any(),
properties: z.any(),
}),
isFallback: z.boolean().optional(),
action: z.object({
// TODO: Make it a union type of "customPageMessage" and ..
type: z.union([
z.literal("customPageMessage"),
z.literal("externalRedirectUrl"),
z.literal("eventTypeRedirectUrl"),
]),
value: z.string(),
}),
});
export const zodNonRouterRouteView = zodNonRouterRoute;
export const zodRouterRoute = z.object({
// This is the id of the Form being used as router
id: z.string(),
isRouter: z.literal(true),
});
export const zodRoute = z.union([zodNonRouterRoute, zodRouterRoute]);
export const zodRouterRouteView = zodRouterRoute.extend({
//TODO: Extend it from form
name: z.string(),
description: z.string().nullable(),
routes: z.array(z.union([zodRoute, z.null()])),
});
export const zodRoutes = z.union([z.array(zodRoute), z.null()]).optional();
export const zodRouteView = z.union([zodNonRouterRouteView, zodRouterRouteView]);
export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional();
// TODO: This is a requirement right now that zod.ts file (if it exists) must have appDataSchema export(which is only required by apps having EventTypeAppCard interface)
// This is a temporary solution and will be removed in future
export const appDataSchema = z.any();
export const appKeysSchema = z.object({});