Skip to content

Commit

Permalink
Improvements: Prefill Routing Forms and connect prefilling with Booki…
Browse files Browse the repository at this point in the history
…ng Form (#8780)

* Support prefilling routing form and prefilling Booking form through routing form

* Use Option Value as is instead of lowercasing

* Fix prefill validation issue

* Add prefill tests

* Fix Routing Form tests

* Small fix
  • Loading branch information
hariombalhara authored and sean-brydon committed May 18, 2023
1 parent b9091b4 commit 6c6fbe9
Show file tree
Hide file tree
Showing 11 changed files with 259 additions and 73 deletions.
1 change: 1 addition & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,6 +1820,7 @@
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"invite_as":"Invite as",
"form_updated_successfully":"Form updated successfully.",
"email_not_cal_member_cta": "Join your team",
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
Expand Down
5 changes: 4 additions & 1 deletion packages/app-store/routing-forms/components/FormActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_updated_successfully"), "success");
},
onSettled: (routingForm) => {
utils.viewer.appRoutingForms.forms.invalidate();
if (routingForm) {
Expand Down Expand Up @@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
const Component = as || Button;
if (!dropdown) {
return (
<Component ref={forwardedRef} {...actionProps}>
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
{children}
</Component>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 type { SerializableForm, Response } from "../types/types";
Expand Down Expand Up @@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) {
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="form-field"
data-testid={`form-field-${getFieldIdentifier(field)}`}
setValue={(value) => {
setResponse((response) => {
response = response || {};
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/routing-forms/components/SingleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {

const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast("Form updated successfully.", "success");
showToast(t("form_updated_successfully"), "success");
},
onError(e) {
if (e.message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,15 @@ const MultiSelectWidget = ({
};
});

const defaultValue = selectItems.filter((item) => value?.includes(item.value));
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));

return (
<Select
className="mb-2"
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
defaultValue={defaultValue}
value={optionsFromList}
isMulti={true}
isDisabled={remainingProps.readOnly}
options={selectItems}
Expand All @@ -184,7 +184,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
value: item.value,
};
});
const defaultValue = selectItems.find((item) => item.value === value);
const optionFromList = selectItems.find((item) => item.value === value);

return (
<Select
Expand All @@ -196,7 +196,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
setValue(item.value);
}}
isDisabled={remainingProps.readOnly}
defaultValue={defaultValue}
value={optionFromList}
options={selectItems}
{...remainingProps}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function Field({
<TextField
disabled={!!router}
label="Identifier"
name="identifier"
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
value={identifier}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ const Route = ({
</div>
<Select
isDisabled={disabled}
className="block w-full flex-grow px-2"
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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, showToast, useCalcomTheme } from "@calcom/ui";

import FormInputFields from "../../components/FormInputFields";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import type { Response, Route } from "../../types/types";

type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({
brandColor,
darkBrandColor,
Expand All @@ -34,7 +36,7 @@ const useBrandColors = ({
useCalcomTheme(brandTheme);
};

function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
function RoutingForm({ form, profile, ...restProps }: Props) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
const isEmbed = useIsEmbed(restProps.isEmbed);
Expand All @@ -43,12 +45,15 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
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 decidedActionRef = useRef<Route["action"]>();
const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>();
const router = useRouter();

const onSubmit = (response: Response) => {
Expand All @@ -65,7 +70,10 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
formFillerId,
response: response,
});
decidedActionRef.current = decidedAction;
decidedActionWithFormResponseRef.current = {
action: decidedAction,
response,
};
};

useEffect(() => {
Expand All @@ -75,19 +83,26 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe

const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
onSuccess: () => {
const decidedAction = decidedActionRef.current;
if (!decidedAction) {
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;

//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
setCustomPageMessage(decidedAction.value);
} else if (decidedAction.type === "eventTypeRedirectUrl") {
router.push(`/${decidedAction.value}`);
router.push(`/${decidedAction.value}?${allURLSearchParams}`);
} else if (decidedAction.type === "externalRedirectUrl") {
window.parent.location.href = decidedAction.value;
window.parent.location.href = `${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) => {
Expand All @@ -97,12 +112,11 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
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 [response, setResponse] = useState<Response>({});

const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
Expand Down Expand Up @@ -161,6 +175,53 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
);
}

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;
}
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
fieldResponse.value;
});

// 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} />;
}
Expand Down Expand Up @@ -220,3 +281,19 @@ export const getServerSideProps = async function getServerSideProps(
},
};
};

const usePrefilledResponse = (form: Props["form"]) => {
const router = useRouter();

const prefillResponse: Response = {};

// Prefill the form from query params
form.fields?.forEach((field) => {
prefillResponse[field.id] = {
value: router.query[getFieldIdentifier(field)] || "",
label: field.label,
};
});
const [response, setResponse] = useState<Response>(prefillResponse);
return [response, setResponse] as const;
};

0 comments on commit 6c6fbe9

Please sign in to comment.