* Dont hit members matching endpoint for regular team forms * fix tests * Dont crash and instead show the form in readonly mode
1246 lines
41 KiB
TypeScript
1246 lines
41 KiB
TypeScript
"use client";
|
|
|
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|
import type { Prisma } from "@prisma/client";
|
|
import Link from "next/link";
|
|
import React, { useCallback, useState, useEffect } from "react";
|
|
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
|
|
import type { ImmutableTree, BuilderProps, Config } from "react-awesome-query-builder";
|
|
import type { JsonTree } from "react-awesome-query-builder";
|
|
import type { UseFormReturn } from "react-hook-form";
|
|
import type { z } from "zod";
|
|
|
|
import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
|
|
import { SchedulingType } from "@calcom/prisma/client";
|
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
|
import {
|
|
SelectField,
|
|
FormCard,
|
|
SelectWithValidation as Select,
|
|
TextArea,
|
|
TextField,
|
|
Badge,
|
|
Divider,
|
|
SettingsToggle,
|
|
} from "@calcom/ui";
|
|
|
|
import { routingFormAppComponents } from "../../appComponents";
|
|
import DynamicAppComponent from "../../components/DynamicAppComponent";
|
|
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
|
|
import SingleForm, {
|
|
getServerSidePropsForSingleFormView as getServerSideProps,
|
|
} from "../../components/SingleForm";
|
|
import {
|
|
withRaqbSettingsAndWidgets,
|
|
ConfigFor,
|
|
} from "../../components/react-awesome-query-builder/config/uiConfig";
|
|
import { RoutingPages } from "../../lib/RoutingPages";
|
|
import { createFallbackRoute } from "../../lib/createFallbackRoute";
|
|
import getEventTypeAppMetadata from "../../lib/getEventTypeAppMetadata";
|
|
import {
|
|
getQueryBuilderConfigForFormFields,
|
|
getQueryBuilderConfigForAttributes,
|
|
type FormFieldsQueryBuilderConfigWithRaqbFields,
|
|
type AttributesQueryBuilderConfigWithRaqbFields,
|
|
isDynamicOperandField,
|
|
} from "../../lib/getQueryBuilderConfig";
|
|
import isRouter from "../../lib/isRouter";
|
|
import type {
|
|
GlobalRoute,
|
|
LocalRoute,
|
|
SerializableRoute,
|
|
Attribute,
|
|
EditFormRoute,
|
|
AttributeRoutingConfig,
|
|
} from "../../types/types";
|
|
import type { zodRoutes } from "../../zod";
|
|
import { RouteActionType } from "../../zod";
|
|
|
|
type EventTypesByGroup = RouterOutputs["viewer"]["eventTypes"]["getByViewer"];
|
|
|
|
type Form = inferSSRProps<typeof getServerSideProps>["form"];
|
|
|
|
type SetRoute = (id: string, route: Partial<EditFormRoute>) => void;
|
|
|
|
type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
|
|
type FormFieldsQueryValue = LocalRoute["queryValue"];
|
|
|
|
/**
|
|
* We need eventTypeId in every redirect url action now for Rerouting to work smoothly.
|
|
* This hook ensures that it is there as soon as someone lands on a Routing Form and next save would automatically update it for them.
|
|
*/
|
|
function useEnsureEventTypeIdInRedirectUrlAction({
|
|
route,
|
|
eventOptions,
|
|
setRoute,
|
|
}: {
|
|
route: EditFormRoute;
|
|
eventOptions: { label: string; value: string; eventTypeId: number }[];
|
|
setRoute: SetRoute;
|
|
}) {
|
|
useEffect(() => {
|
|
if (isRouter(route)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
route.action.type !== RouteActionType.EventTypeRedirectUrl ||
|
|
// Must not be set already. Could be zero as well for custom
|
|
route.action.eventTypeId !== undefined
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const matchingOption = eventOptions.find((eventOption) => eventOption.value === route.action.value);
|
|
if (!matchingOption) {
|
|
return;
|
|
}
|
|
setRoute(route.id, {
|
|
action: { ...route.action, eventTypeId: matchingOption.eventTypeId },
|
|
});
|
|
}, [eventOptions, setRoute, route.id, (route as unknown as any).action?.value]);
|
|
}
|
|
|
|
const hasRules = (route: EditFormRoute) => {
|
|
if (isRouter(route)) return false;
|
|
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
|
|
};
|
|
|
|
function getEmptyQueryValue() {
|
|
return buildEmptyQueryValue();
|
|
}
|
|
|
|
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
|
|
const uuid = QbUtils.uuid();
|
|
const formFieldsQueryValue = getEmptyQueryValue() as FormFieldsQueryValue;
|
|
const attributesQueryValue = getEmptyQueryValue() as AttributesQueryValue;
|
|
const fallbackAttributesQueryValue = getEmptyQueryValue() as AttributesQueryValue;
|
|
|
|
return {
|
|
id: uuid,
|
|
action: {
|
|
type: RouteActionType.EventTypeRedirectUrl,
|
|
value: "",
|
|
},
|
|
// It is actually formFieldsQueryValue
|
|
queryValue: formFieldsQueryValue,
|
|
attributesQueryValue: attributesQueryValue,
|
|
fallbackAttributesQueryValue: fallbackAttributesQueryValue,
|
|
};
|
|
};
|
|
|
|
const buildEventsData = ({
|
|
eventTypesByGroup,
|
|
form,
|
|
route,
|
|
}: {
|
|
eventTypesByGroup: EventTypesByGroup | undefined;
|
|
form: Form;
|
|
route: EditFormRoute;
|
|
}) => {
|
|
const eventOptions: {
|
|
label: string;
|
|
value: string;
|
|
eventTypeId: number;
|
|
eventTypeAppMetadata?: Record<string, any>;
|
|
isRRWeightsEnabled: boolean;
|
|
}[] = [];
|
|
const eventTypesMap = new Map<
|
|
number,
|
|
{
|
|
schedulingType: SchedulingType | null;
|
|
eventTypeAppMetadata?: Record<string, any>;
|
|
}
|
|
>();
|
|
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) => {
|
|
if (eventType.teamId && eventType.schedulingType === SchedulingType.MANAGED) {
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
|
|
// Pass app data that works with routing forms
|
|
const eventTypeAppMetadata = getEventTypeAppMetadata(eventType.metadata as Prisma.JsonValue);
|
|
|
|
eventTypesMap.set(eventType.id, {
|
|
eventTypeAppMetadata,
|
|
schedulingType: eventType.schedulingType,
|
|
});
|
|
eventOptions.push({
|
|
label: uniqueSlug,
|
|
value: uniqueSlug,
|
|
eventTypeId: eventType.id,
|
|
eventTypeAppMetadata,
|
|
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
|
|
});
|
|
});
|
|
});
|
|
|
|
return { eventOptions, eventTypesMap };
|
|
};
|
|
|
|
const isValidAttributeIdForWeights = ({
|
|
attributeIdForWeights,
|
|
jsonTree,
|
|
}: {
|
|
attributeIdForWeights: string;
|
|
jsonTree: JsonTree;
|
|
}) => {
|
|
if (!attributeIdForWeights || !jsonTree.children1) {
|
|
return false;
|
|
}
|
|
|
|
return Object.values(jsonTree.children1).some((rule) => {
|
|
if (rule.type !== "rule" || rule?.properties?.field !== attributeIdForWeights) {
|
|
return false;
|
|
}
|
|
|
|
const values = rule.properties.value.flat();
|
|
return values.length === 1 && values.some((value: string) => isDynamicOperandField(value));
|
|
});
|
|
};
|
|
|
|
const WeightedAttributesSelector = ({
|
|
attributes,
|
|
route,
|
|
eventTypeRedirectUrlSelectedOption,
|
|
setRoute,
|
|
}: {
|
|
attributes?: Attribute[];
|
|
route: EditFormRoute;
|
|
eventTypeRedirectUrlSelectedOption: { isRRWeightsEnabled: boolean } | undefined;
|
|
setRoute: SetRoute;
|
|
}) => {
|
|
const [attributeIdForWeights, setAttributeIdForWeights] = useState(
|
|
"attributeIdForWeights" in route ? route.attributeIdForWeights : undefined
|
|
);
|
|
|
|
const { t } = useLocale();
|
|
if (isRouter(route)) {
|
|
return null;
|
|
}
|
|
|
|
let attributesWithWeightsEnabled: Attribute[] = [];
|
|
|
|
if (eventTypeRedirectUrlSelectedOption?.isRRWeightsEnabled) {
|
|
const validatedQueryValue = route.attributesQueryBuilderState?.tree
|
|
? QbUtils.getTree(route.attributesQueryBuilderState.tree)
|
|
: null;
|
|
|
|
if (
|
|
validatedQueryValue &&
|
|
raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue) &&
|
|
validatedQueryValue.children1
|
|
) {
|
|
const attributeIds = Object.values(validatedQueryValue.children1).map((rule) => {
|
|
if (rule.type === "rule" && rule?.properties?.field) {
|
|
if (
|
|
rule.properties.value.flat().length == 1 &&
|
|
rule.properties.value.flat().some((value) => isDynamicOperandField(value))
|
|
) {
|
|
return rule.properties.field;
|
|
}
|
|
}
|
|
});
|
|
|
|
attributesWithWeightsEnabled = attributes
|
|
? attributes.filter(
|
|
(attribute) =>
|
|
attribute.isWeightsEnabled && attributeIds.find((attributeId) => attributeId === attribute.id)
|
|
)
|
|
: [];
|
|
}
|
|
}
|
|
|
|
const onChangeAttributeIdForWeights = (
|
|
route: EditFormRoute & { attributeIdForWeights?: string },
|
|
attributeIdForWeights?: string
|
|
) => {
|
|
setRoute(route.id, {
|
|
attributeIdForWeights,
|
|
});
|
|
};
|
|
|
|
return attributesWithWeightsEnabled.length > 0 ? (
|
|
<div className="mt-8">
|
|
<SettingsToggle
|
|
title={t("use_attribute_weights")}
|
|
description={t("if_enabled_ignore_event_type_weights")}
|
|
checked={!!attributeIdForWeights}
|
|
onCheckedChange={(checked) => {
|
|
const attributeId = checked ? attributesWithWeightsEnabled[0].id : undefined;
|
|
setAttributeIdForWeights(attributeId);
|
|
onChangeAttributeIdForWeights(route, attributeId);
|
|
}}
|
|
/>
|
|
{!!attributeIdForWeights ? (
|
|
<SelectField
|
|
containerClassName="mb-6 mt-4 data-testid-select-router"
|
|
label={t("attribute_for_weights")}
|
|
options={attributesWithWeightsEnabled.map((attribute) => {
|
|
return { value: attribute.id, label: attribute.name };
|
|
})}
|
|
value={{
|
|
value: attributeIdForWeights,
|
|
label: attributesWithWeightsEnabled.find((attribute) => attribute.id === attributeIdForWeights)
|
|
?.name,
|
|
}}
|
|
onChange={(option) => {
|
|
if (option) {
|
|
setAttributeIdForWeights(option.value);
|
|
onChangeAttributeIdForWeights(route, option.value);
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<></>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
};
|
|
|
|
const Route = ({
|
|
form,
|
|
route,
|
|
routes,
|
|
setRoute,
|
|
setAttributeRoutingConfig,
|
|
formFieldsQueryBuilderConfig,
|
|
attributesQueryBuilderConfig,
|
|
setRoutes,
|
|
moveUp,
|
|
moveDown,
|
|
appUrl,
|
|
disabled = false,
|
|
fieldIdentifiers,
|
|
eventTypesByGroup,
|
|
attributes,
|
|
}: {
|
|
form: Form;
|
|
route: EditFormRoute;
|
|
routes: EditFormRoute[];
|
|
setRoute: SetRoute;
|
|
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
|
|
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
|
|
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
|
|
setRoutes: React.Dispatch<React.SetStateAction<EditFormRoute[]>>;
|
|
fieldIdentifiers: string[];
|
|
moveUp?: { fn: () => void; check: () => boolean } | null;
|
|
moveDown?: { fn: () => void; check: () => boolean } | null;
|
|
appUrl: string;
|
|
disabled?: boolean;
|
|
eventTypesByGroup: EventTypesByGroup;
|
|
attributes?: Attribute[];
|
|
}) => {
|
|
const { t } = useLocale();
|
|
const isTeamForm = form.teamId !== null;
|
|
const index = routes.indexOf(route);
|
|
|
|
const { eventOptions } = buildEventsData({ eventTypesByGroup, form, route });
|
|
|
|
// /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(() => {
|
|
const isCustom =
|
|
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
|
|
setCustomEventTypeSlug(isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : "");
|
|
}, []);
|
|
|
|
useEnsureEventTypeIdInRedirectUrlAction({
|
|
route,
|
|
eventOptions,
|
|
setRoute,
|
|
});
|
|
|
|
const onChangeFormFieldsQuery = (
|
|
route: EditFormRoute,
|
|
immutableTree: ImmutableTree,
|
|
config: FormFieldsQueryBuilderConfigWithRaqbFields
|
|
) => {
|
|
const jsonTree = QbUtils.getTree(immutableTree) as LocalRoute["queryValue"];
|
|
setRoute(route.id, {
|
|
formFieldsQueryBuilderState: { tree: immutableTree, config: config },
|
|
queryValue: jsonTree,
|
|
});
|
|
};
|
|
|
|
const setAttributeIdForWeights = (attributeIdForWeights: string | undefined) => {
|
|
setRoute(route.id, {
|
|
attributeIdForWeights,
|
|
});
|
|
};
|
|
|
|
const onChangeTeamMembersQuery = (
|
|
route: EditFormRoute,
|
|
immutableTree: ImmutableTree,
|
|
config: AttributesQueryBuilderConfigWithRaqbFields
|
|
) => {
|
|
const jsonTree = QbUtils.getTree(immutableTree);
|
|
const attributeIdForWeights = isRouter(route) ? null : route.attributeIdForWeights;
|
|
const _isValidAttributeIdForWeights =
|
|
attributeIdForWeights && isValidAttributeIdForWeights({ attributeIdForWeights, jsonTree });
|
|
|
|
if (attributeIdForWeights && !_isValidAttributeIdForWeights) {
|
|
setAttributeIdForWeights(undefined);
|
|
}
|
|
|
|
setRoute(route.id, {
|
|
attributesQueryBuilderState: { tree: immutableTree, config: config },
|
|
attributesQueryValue: jsonTree as AttributesQueryValue,
|
|
attributeIdForWeights: _isValidAttributeIdForWeights ? attributeIdForWeights : undefined,
|
|
});
|
|
};
|
|
|
|
const onChangeFallbackTeamMembersQuery = (
|
|
route: EditFormRoute,
|
|
immutableTree: ImmutableTree,
|
|
config: AttributesQueryBuilderConfigWithRaqbFields
|
|
) => {
|
|
const jsonTree = QbUtils.getTree(immutableTree);
|
|
setRoute(route.id, {
|
|
fallbackAttributesQueryBuilderState: { tree: immutableTree, config: config },
|
|
fallbackAttributesQueryValue: jsonTree as AttributesQueryValue,
|
|
});
|
|
};
|
|
|
|
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);
|
|
},
|
|
}}
|
|
isLabelEditable={false}
|
|
label={route.name ?? `Route ${index + 1}`}
|
|
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>
|
|
);
|
|
}
|
|
|
|
const shouldShowFormFieldsQueryBuilder = (route.isFallback && hasRules(route)) || !route.isFallback;
|
|
const eventTypeRedirectUrlOptions =
|
|
eventOptions.length !== 0
|
|
? [{ label: t("custom"), value: "custom", eventTypeId: 0, isRRWeightsEnabled: false }].concat(
|
|
eventOptions
|
|
)
|
|
: [];
|
|
|
|
const eventTypeRedirectUrlSelectedOption =
|
|
eventOptions.length !== 0 && route.action.value !== ""
|
|
? eventOptions.find(
|
|
(eventOption) => eventOption.value === route.action.value && !customEventTypeSlug.length
|
|
) || {
|
|
label: t("custom"),
|
|
value: "custom",
|
|
eventTypeId: 0,
|
|
isRRWeightsEnabled: false,
|
|
}
|
|
: undefined;
|
|
|
|
const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
|
|
<div>
|
|
<span className="text-emphasis flex w-full items-center text-sm">
|
|
For responses matching the following criteria (matches all by default)
|
|
</span>
|
|
<Query
|
|
{...withRaqbSettingsAndWidgets({
|
|
config: formFieldsQueryBuilderConfig,
|
|
configFor: ConfigFor.FormFields,
|
|
})}
|
|
value={route.formFieldsQueryBuilderState.tree}
|
|
onChange={(immutableTree, formFieldsQueryBuilderConfig) => {
|
|
onChangeFormFieldsQuery(
|
|
route,
|
|
immutableTree,
|
|
formFieldsQueryBuilderConfig as unknown as FormFieldsQueryBuilderConfigWithRaqbFields
|
|
);
|
|
}}
|
|
renderBuilder={renderBuilder}
|
|
/>
|
|
<Divider className="mt-6" />
|
|
<Divider className="mb-6 " />
|
|
</div>
|
|
) : null;
|
|
|
|
const attributesQueryBuilderConfigWithRaqbSettingsAndWidgets = attributesQueryBuilderConfig
|
|
? withRaqbSettingsAndWidgets({
|
|
config: attributesQueryBuilderConfig,
|
|
configFor: ConfigFor.Attributes,
|
|
})
|
|
: null;
|
|
|
|
const attributesQueryBuilder =
|
|
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
|
|
<div className="mt-4">
|
|
<span className="text-emphasis flex w-full items-center text-sm">
|
|
and use only the Team Members that match the following criteria (matches all by default)
|
|
</span>
|
|
|
|
{eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata &&
|
|
"salesforce" in eventTypeRedirectUrlSelectedOption.eventTypeAppMetadata ? (
|
|
<div className="mt-4 px-2.5">
|
|
<DynamicAppComponent
|
|
componentMap={routingFormAppComponents}
|
|
slug="salesforce"
|
|
appData={eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata["salesforce"]}
|
|
route={route}
|
|
setAttributeRoutingConfig={setAttributeRoutingConfig}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-2">
|
|
{route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
|
<Query
|
|
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
|
|
value={route.attributesQueryBuilderState.tree}
|
|
onChange={(immutableTree, attributesQueryBuilderConfig) => {
|
|
onChangeTeamMembersQuery(
|
|
route,
|
|
immutableTree,
|
|
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
|
|
);
|
|
}}
|
|
renderBuilder={renderBuilder}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
const fallbackAttributesQueryBuilder =
|
|
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
|
|
<div className="mt-4">
|
|
<span className="text-emphasis flex w-full items-center text-sm">
|
|
{t("fallback_attribute_logic_description")}
|
|
</span>
|
|
<div className="mt-2">
|
|
{route.fallbackAttributesQueryBuilderState &&
|
|
attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
|
<Query
|
|
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
|
|
value={route.fallbackAttributesQueryBuilderState.tree}
|
|
onChange={(immutableTree, attributesQueryBuilderConfig) => {
|
|
onChangeFallbackTeamMembersQuery(
|
|
route,
|
|
immutableTree,
|
|
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
|
|
);
|
|
}}
|
|
renderBuilder={renderBuilder}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<FormCard
|
|
className="mb-6"
|
|
moveUp={moveUp}
|
|
moveDown={moveDown}
|
|
label={route.name ?? (route.isFallback ? "Fallback Route" : `Route ${index + 1}`)}
|
|
isLabelEditable={!route.isFallback}
|
|
onLabelChange={(label) => {
|
|
setRoute(route.id, { name: label });
|
|
}}
|
|
deleteField={{
|
|
check: () => routes.length !== 1 && !route.isFallback,
|
|
fn: () => {
|
|
const newRoutes = routes.filter((r) => r.id !== route.id);
|
|
setRoutes(newRoutes);
|
|
},
|
|
}}>
|
|
<div className="mb-4 flex w-full items-center sm:mx-0">
|
|
<div className="cal-query-builder w-full ">
|
|
{formFieldsQueryBuilder}
|
|
<div>
|
|
<div className="flex w-full flex-col gap-2 text-sm lg:flex-row">
|
|
<div className="flex flex-grow items-center gap-2">
|
|
<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"
|
|
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}
|
|
/>
|
|
</div>
|
|
{route.action?.type ? (
|
|
route.action?.type === "customPageMessage" ? (
|
|
<TextArea
|
|
required
|
|
disabled={disabled}
|
|
name="customPageMessage"
|
|
className="border-default flex flex-grow lg:w-fit"
|
|
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 flex-grow text-sm"
|
|
containerClassName="flex-grow"
|
|
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="flex-grow">
|
|
<Select
|
|
required
|
|
className="data-testid-eventTypeRedirectUrl-select"
|
|
isDisabled={disabled}
|
|
options={eventTypeRedirectUrlOptions}
|
|
onChange={(option) => {
|
|
if (!option) {
|
|
return;
|
|
}
|
|
if (option.value !== "custom") {
|
|
setRoute(route.id, {
|
|
action: { ...route.action, value: option.value, eventTypeId: option.eventTypeId },
|
|
attributeRoutingConfig: {},
|
|
});
|
|
setCustomEventTypeSlug("");
|
|
} else {
|
|
setRoute(route.id, {
|
|
action: { ...route.action, value: "custom", eventTypeId: 0 },
|
|
attributeRoutingConfig: {},
|
|
});
|
|
setCustomEventTypeSlug("");
|
|
}
|
|
}}
|
|
value={eventTypeRedirectUrlSelectedOption}
|
|
/>
|
|
{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="flex-grow 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>
|
|
{attributesQueryBuilder}
|
|
<WeightedAttributesSelector
|
|
attributes={attributes}
|
|
route={route}
|
|
eventTypeRedirectUrlSelectedOption={eventTypeRedirectUrlSelectedOption}
|
|
setRoute={setRoute}
|
|
/>
|
|
<Divider className="mb-6 mt-6" />
|
|
{fallbackAttributesQueryBuilder}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</FormCard>
|
|
);
|
|
};
|
|
|
|
const buildState = <
|
|
T extends
|
|
| {
|
|
queryValue: FormFieldsQueryValue;
|
|
config: FormFieldsQueryBuilderConfigWithRaqbFields;
|
|
}
|
|
| {
|
|
queryValue: AttributesQueryValue;
|
|
config: AttributesQueryBuilderConfigWithRaqbFields;
|
|
}
|
|
>({
|
|
queryValue,
|
|
config,
|
|
}: T) => ({
|
|
tree: QbUtils.checkTree(QbUtils.loadTree(queryValue as JsonTree), config as unknown as Config),
|
|
config,
|
|
});
|
|
|
|
const deserializeRoute = ({
|
|
route,
|
|
formFieldsQueryBuilderConfig,
|
|
attributesQueryBuilderConfig,
|
|
}: {
|
|
route: Exclude<SerializableRoute, GlobalRoute>;
|
|
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
|
|
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
|
|
}): EditFormRoute => {
|
|
const attributesQueryBuilderState =
|
|
route.attributesQueryValue && attributesQueryBuilderConfig
|
|
? buildState({
|
|
queryValue: route.attributesQueryValue,
|
|
config: attributesQueryBuilderConfig,
|
|
})
|
|
: null;
|
|
|
|
const fallbackAttributesQueryBuilderState =
|
|
route.fallbackAttributesQueryValue && attributesQueryBuilderConfig
|
|
? buildState({
|
|
queryValue: route.fallbackAttributesQueryValue,
|
|
config: attributesQueryBuilderConfig,
|
|
})
|
|
: null;
|
|
|
|
return {
|
|
...route,
|
|
formFieldsQueryBuilderState: buildState({
|
|
queryValue: route.queryValue,
|
|
config: formFieldsQueryBuilderConfig,
|
|
}),
|
|
attributesQueryBuilderState,
|
|
fallbackAttributesQueryBuilderState,
|
|
};
|
|
};
|
|
|
|
function useRoutes({
|
|
serializedRoutes,
|
|
formFieldsQueryBuilderConfig,
|
|
attributesQueryBuilderConfig,
|
|
hookForm,
|
|
}: {
|
|
serializedRoutes: SerializableRoute[] | null | undefined;
|
|
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
|
|
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
|
|
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
|
|
}) {
|
|
const [routes, _setRoutes] = useState(() => {
|
|
const transformRoutes = () => {
|
|
const _routes = serializedRoutes || [getEmptyRoute()];
|
|
_routes.forEach((r) => {
|
|
if (isRouter(r)) return;
|
|
|
|
// Add default empty queries to existing routes otherwise they won't have 'Add Rule' button for those RAQB queries.
|
|
if (!r.queryValue?.id) {
|
|
r.queryValue = getEmptyQueryValue() as LocalRoute["queryValue"];
|
|
}
|
|
|
|
if (!r.attributesQueryValue) {
|
|
r.attributesQueryValue = getEmptyQueryValue() as LocalRoute["attributesQueryValue"];
|
|
}
|
|
|
|
if (!r.fallbackAttributesQueryValue) {
|
|
r.fallbackAttributesQueryValue = getEmptyQueryValue() as LocalRoute["fallbackAttributesQueryValue"];
|
|
}
|
|
});
|
|
return _routes;
|
|
};
|
|
|
|
return transformRoutes().map((route) => {
|
|
if (isRouter(route)) return route;
|
|
return deserializeRoute({
|
|
route,
|
|
formFieldsQueryBuilderConfig,
|
|
attributesQueryBuilderConfig,
|
|
});
|
|
});
|
|
});
|
|
|
|
const setRoutes: typeof _setRoutes = (newRoutes) => {
|
|
_setRoutes((routes) => {
|
|
if (typeof newRoutes === "function") {
|
|
const newRoutesValue = newRoutes(routes);
|
|
hookForm.setValue("routes", getRoutesToSave(newRoutesValue));
|
|
return newRoutesValue;
|
|
}
|
|
hookForm.setValue("routes", getRoutesToSave(newRoutes));
|
|
return newRoutes;
|
|
});
|
|
|
|
function getRoutesToSave(routes: EditFormRoute[]) {
|
|
return routes.map((route) => {
|
|
if (isRouter(route)) {
|
|
return route;
|
|
}
|
|
return {
|
|
id: route.id,
|
|
name: route.name,
|
|
attributeRoutingConfig: route.attributeRoutingConfig,
|
|
action: route.action,
|
|
isFallback: route.isFallback,
|
|
queryValue: route.queryValue,
|
|
attributesQueryValue: route.attributesQueryValue,
|
|
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
|
|
attributeIdForWeights: route.attributeIdForWeights,
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
return { routes, setRoutes };
|
|
}
|
|
|
|
const Routes = ({
|
|
form,
|
|
hookForm,
|
|
appUrl,
|
|
attributes,
|
|
eventTypesByGroup,
|
|
}: {
|
|
form: inferSSRProps<typeof getServerSideProps>["form"];
|
|
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
|
|
appUrl: string;
|
|
attributes?: Attribute[];
|
|
eventTypesByGroup: EventTypesByGroup;
|
|
}) => {
|
|
const { routes: serializedRoutes } = hookForm.getValues();
|
|
const { t } = useLocale();
|
|
|
|
const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(hookForm.getValues());
|
|
const attributesQueryBuilderConfig = attributes
|
|
? getQueryBuilderConfigForAttributes({
|
|
attributes: attributes,
|
|
dynamicOperandFields: hookForm.getValues().fields,
|
|
})
|
|
: null;
|
|
|
|
const { routes, setRoutes } = useRoutes({
|
|
serializedRoutes,
|
|
formFieldsQueryBuilderConfig,
|
|
attributesQueryBuilderConfig,
|
|
hookForm,
|
|
});
|
|
|
|
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
|
|
|
|
const notHaveAttributesQuery = ({ form }: { form: { routes: z.infer<typeof zodRoutes> } }) => {
|
|
return form.routes?.every((route) => {
|
|
if (isRouter(route)) {
|
|
return true;
|
|
}
|
|
return !route.attributesQueryValue;
|
|
});
|
|
};
|
|
|
|
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;
|
|
})
|
|
// We don't want to support picking forms that have attributes query. We can consider it later.
|
|
// This is mainly because the Router picker feature is pretty much not used and we don't want to complicate things
|
|
.filter(({ form }) => {
|
|
return notHaveAttributesQuery({ form: form });
|
|
})
|
|
.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({
|
|
route: createFallbackRoute(),
|
|
formFieldsQueryBuilderConfig,
|
|
attributesQueryBuilderConfig,
|
|
});
|
|
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<EditFormRoute>) => {
|
|
const index = routes.findIndex((route) => route.id === id);
|
|
const existingRoute = routes[index];
|
|
const newRoutes = [...routes];
|
|
newRoutes[index] = { ...existingRoute, ...route };
|
|
setRoutes(newRoutes);
|
|
};
|
|
|
|
const setAttributeRoutingConfig = (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => {
|
|
const existingRoute = routes.find((route) => route.id === id);
|
|
if (!existingRoute) {
|
|
throw new Error("Route not found");
|
|
}
|
|
|
|
const existingAttributeRoutingConfig =
|
|
"attributeRoutingConfig" in existingRoute ? existingRoute.attributeRoutingConfig : {};
|
|
|
|
setRoute(id, {
|
|
attributeRoutingConfig: { ...existingAttributeRoutingConfig, ...attributeRoutingConfig },
|
|
});
|
|
};
|
|
|
|
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 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}
|
|
attributes={attributes}
|
|
formFieldsQueryBuilderConfig={formFieldsQueryBuilderConfig}
|
|
attributesQueryBuilderConfig={attributesQueryBuilderConfig}
|
|
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}
|
|
setAttributeRoutingConfig={setAttributeRoutingConfig}
|
|
setRoutes={setRoutes}
|
|
eventTypesByGroup={eventTypesByGroup}
|
|
/>
|
|
);
|
|
})}
|
|
<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,
|
|
formFieldsQueryBuilderState: buildState({
|
|
queryValue: newEmptyRoute.queryValue,
|
|
config: formFieldsQueryBuilderConfig,
|
|
}),
|
|
attributesQueryBuilderState:
|
|
attributesQueryBuilderConfig && newEmptyRoute.attributesQueryValue
|
|
? buildState({
|
|
queryValue: newEmptyRoute.attributesQueryValue,
|
|
config: attributesQueryBuilderConfig,
|
|
})
|
|
: null,
|
|
fallbackAttributesQueryBuilderState:
|
|
attributesQueryBuilderConfig && newEmptyRoute.fallbackAttributesQueryValue
|
|
? buildState({
|
|
queryValue: newEmptyRoute.fallbackAttributesQueryValue,
|
|
config: attributesQueryBuilderConfig,
|
|
})
|
|
: null,
|
|
},
|
|
];
|
|
|
|
setRoutes(newRoutes);
|
|
} else {
|
|
const routerId = router;
|
|
if (!routerId) {
|
|
return;
|
|
}
|
|
setRoutes([
|
|
...routes,
|
|
{
|
|
isRouter: true,
|
|
id: routerId,
|
|
name: option.name,
|
|
description: option.description,
|
|
} as EditFormRoute,
|
|
]);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<div>
|
|
<Route
|
|
form={form}
|
|
formFieldsQueryBuilderConfig={formFieldsQueryBuilderConfig}
|
|
attributesQueryBuilderConfig={attributesQueryBuilderConfig}
|
|
route={fallbackRoute}
|
|
routes={routes}
|
|
setRoute={setRoute}
|
|
setRoutes={setRoutes}
|
|
appUrl={appUrl}
|
|
fieldIdentifiers={fieldIdentifiers}
|
|
setAttributeRoutingConfig={setAttributeRoutingConfig}
|
|
eventTypesByGroup={eventTypesByGroup}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function Page({
|
|
hookForm,
|
|
form,
|
|
appUrl,
|
|
}: {
|
|
form: RoutingFormWithResponseCount;
|
|
appUrl: string;
|
|
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
|
|
}) {
|
|
const { t } = useLocale();
|
|
const values = hookForm.getValues();
|
|
const { data: attributes, isPending: isAttributesLoading } =
|
|
trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery(
|
|
{ teamId: values.teamId! },
|
|
{ enabled: !!values.teamId }
|
|
);
|
|
|
|
const { data: eventTypesByGroup, isLoading: areEventsLoading } =
|
|
trpc.viewer.eventTypes.getByViewer.useQuery({
|
|
forRoutingForms: true,
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Only team form needs attributes
|
|
if (values.teamId) {
|
|
if (isAttributesLoading) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
if (!attributes) {
|
|
return <div>{t("something_went_wrong")}</div>;
|
|
}
|
|
}
|
|
|
|
if (areEventsLoading) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
|
|
if (!eventTypesByGroup) {
|
|
console.error("Events not available");
|
|
return <div>{t("something_went_wrong")}</div>;
|
|
}
|
|
return (
|
|
<div className="route-config">
|
|
<Routes
|
|
hookForm={hookForm}
|
|
appUrl={appUrl}
|
|
eventTypesByGroup={eventTypesByGroup}
|
|
form={form}
|
|
attributes={attributes}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function RouteBuilder({
|
|
form,
|
|
appUrl,
|
|
enrichedWithUserProfileForm,
|
|
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
|
|
return (
|
|
<SingleForm
|
|
form={form}
|
|
appUrl={appUrl}
|
|
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
|
|
Page={Page}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export { getServerSideProps };
|