cal.pub0.org/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx

570 lines
17 KiB
TypeScript

import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import React, { useCallback, useState } 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,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
config: QueryBuilderUpdatedConfig;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
appUrl: string;
disabled?: boolean;
}) => {
const index = routes.indexOf(route);
const { data: eventTypesByGroup } = trpc.viewer.eventTypes.getByViewer.useQuery();
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,
});
});
});
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>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}
onChange={(option) => {
if (!option) {
return;
}
setRoute(route.id, { action: { ...route.action, value: option.value } });
}}
value={eventOptions.find((eventOption) => eventOption.value === route.action.value)}
/>
</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);
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}
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}
/>
</div>
</div>
</div>
);
};
export default function RouteBuilder({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
Page={({ hookForm, form }) => (
<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>
);
};