fix: Custom Event Redirect in Routing Form with Dropdown Based fields (#17870)

Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-08-25 13:46:18 -04:00
committed by GitHub
co-authored by Devin AI
parent 8d2a072645
commit 13cbb59e5e
8 changed files with 599 additions and 73 deletions
@@ -51,6 +51,7 @@ function mockEventTypeRedirectUrlMatchingRoute() {
action: {
type: "eventTypeRedirectUrl",
value: "john/30min",
eventTypeId: 123,
},
});
}
@@ -421,5 +422,62 @@ describe("TestFormDialog", () => {
// When we support showing matching route we can add this back
// expect(screen.getByTestId("chosen-route")).toHaveTextContent("Route 2");
});
it("should substitute variables in event redirect URL", async () => {
// Mock a route with variables using the name field that already exists
mockMatchingRoute({
action: {
type: "eventTypeRedirectUrl",
value: "/team/{name}/meeting",
},
});
render(
<TestFormRenderer
isMobile={true}
testForm={mockRegularTeamForm}
isTestPreviewOpen={true}
setIsTestPreviewOpen={() => {
return;
}}
/>
);
// Fill in the name field
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "Sales Team" } });
fireEvent.click(screen.getByText("submit"));
// Verify the URL shows the substituted value, not the variable
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("/team/sales-team/meeting");
expect(screen.getByTestId("test-routing-result")).not.toHaveTextContent("{name}");
});
it("should NOT substitute variables in external redirect URL", async () => {
// Mock a route with variables for external redirect
mockMatchingRoute({
action: {
type: "externalRedirectUrl",
value: "https://example.com/user/{name}",
},
});
render(
<TestFormRenderer
isMobile={true}
testForm={mockRegularTeamForm}
isTestPreviewOpen={true}
setIsTestPreviewOpen={() => {
return;
}}
/>
);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("submit"));
// Verify the URL shows the variable as-is, without substitution
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("https://example.com/user/{name}");
expect(screen.getByTestId("test-routing-result")).not.toHaveTextContent("john-doe");
});
});
});
@@ -4,29 +4,14 @@ import { getSession } from "next-auth/react";
import { sanitizeValue } from "@calcom/lib/csvUtils";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import { getHumanReadableFieldResponseValue } from "@calcom/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue";
import prisma from "@calcom/prisma";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { ensureStringOrStringArray, getLabelsFromOptionIds } from "../../lib/reportingUtils";
import type { FormResponse, SerializableForm } from "../../types/types";
type Fields = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>;
function getHumanReadableFieldResponseValue({
field,
value,
}: {
field: Fields[number];
value: string | number | string[];
}) {
if (field.options) {
const optionIds = ensureStringOrStringArray(value);
return getLabelsFromOptionIds({ options: field.options, optionIds });
} else {
return (value instanceof Array ? value : [value]).map(String);
}
}
async function* getResponses(formId: string, fields: Fields) {
let responses;
let skip = 0;
@@ -50,7 +35,10 @@ async function* getResponses(formId: string, fields: Fields) {
fields.forEach((field) => {
const fieldResponse = fieldResponses[field.id];
const value = fieldResponse?.value || "";
const readableValues = getHumanReadableFieldResponseValue({ field, value });
const humanReadableResponseValue = getHumanReadableFieldResponseValue({ field, value });
const readableValues = Array.isArray(humanReadableResponseValue)
? humanReadableResponseValue
: [humanReadableResponseValue];
const serializedValue = readableValues.map((value) => sanitizeValue(value)).join(" | ");
csvCells.push(serializedValue);
});
@@ -16,8 +16,8 @@ import { showToast } from "@calcom/ui/components/toast";
import { TRPCClientError } from "@trpc/react-query";
import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
import { findMatchingRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import type { SingleFormComponentProps } from "../../types/shared";
import type { RoutingForm, FormResponse, NonRouterRoute } from "../../types/types";
import FormInputFields from "../FormInputFields";
@@ -92,7 +92,6 @@ export const TestForm = ({
const { t } = useLocale();
const [response, setResponse] = useState<FormResponse>({});
const [chosenRoute, setChosenRoute] = useState<NonRouterRoute | null>(null);
const [eventTypeUrlWithoutParams, setEventTypeUrlWithoutParams] = useState("");
const searchParams = useCompatSearchParams();
const [membersMatchResult, setMembersMatchResult] = useState<MembersMatchResultType | null>(null);
const [showResults, setShowResults] = useState(false);
@@ -128,25 +127,27 @@ export const TestForm = ({
function testRouting() {
const route = findMatchingRoute({ form, response });
let eventTypeRedirectUrl: string | null = null;
if (route?.action?.type === "eventTypeRedirectUrl") {
if ("team" in form) {
eventTypeRedirectUrl = getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: route.action.value,
form,
allURLSearchParams: new URLSearchParams(),
});
setEventTypeUrlWithoutParams(eventTypeRedirectUrl);
}
// Create a copy of the route with substituted variables for display
let displayRoute = route;
if (route && form.fields && route.action.type === "eventTypeRedirectUrl") {
const substitutedUrl = substituteVariables(route.action.value, response, form.fields);
displayRoute = {
...route,
action: {
...route.action,
value: substitutedUrl,
},
};
}
setChosenRoute(route || null);
setChosenRoute(displayRoute || null);
setShowResults(true);
if (!route) return;
if (supportsTeamMembersMatchingLogic) {
// Custom Event Type Redirect URL has eventTypeId=0. Also, findTeamMembersMatchingAttributeLogicMutation can't work without eventTypeId
if (supportsTeamMembersMatchingLogic && route.action.eventTypeId) {
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response,
@@ -1,41 +0,0 @@
import type { z } from "zod";
import type { zodNonRouterField } from "../zod";
type Field = z.infer<typeof zodNonRouterField>;
export function ensureStringOrStringArray(value: string | number | (string | number)[]): string | string[] {
if (typeof value === "string") {
return value;
} else if (value instanceof Array) {
return value.map((v) => v.toString());
}
return [value.toString()];
}
export function getLabelsFromOptionIds({
options,
optionIds,
}: {
options: NonNullable<Field["options"]>;
optionIds: string | string[];
}) {
if (optionIds instanceof Array) {
const labels = optionIds.map((optionId) => {
const foundOption = options.find((option) => option.id === optionId);
// It would mean that the optionId is actually a label which is why it isn't matching any option id.
// Fallback to optionId(i.e. label) which was the case with legacy options
if (!foundOption) {
return optionId;
}
return foundOption.label;
});
return labels;
} else {
const foundOption = options.find((option) => option.id === optionIds);
if (!foundOption) {
return [optionIds];
}
return [foundOption.label];
}
}
@@ -0,0 +1,229 @@
import { describe, it, expect } from "vitest";
import type { FormResponse, Field } from "../types/types";
import { substituteVariables } from "./substituteVariables";
describe("substituteVariables", () => {
const createSelectField = (
id: string,
identifier: string,
label: string,
options?: Array<{ id: string; label: string }>
): Field => ({
id,
identifier,
label,
type: "select",
options,
});
const createTextField = (id: string, identifier: string, label: string): Field => ({
id,
identifier,
label,
type: "text",
});
const createMultiselectField = (
id: string,
identifier: string,
label: string,
options?: Array<{ id: string; label: string }>
): Field => ({
id,
identifier,
label,
type: "multiselect",
options,
});
const createFormResponse = (
fieldId: string,
value: string | string[] | number,
label: string
): FormResponse => ({
[fieldId]: {
value,
label,
},
});
it("should substitute variables with option labels (not field labels or values)", () => {
const fields = [
createSelectField("field1", "department", "Department", [
{ id: "sales-123", label: "Sales Team" },
{ id: "eng-456", label: "Engineering" },
]),
];
const routeValue = "/team/{department}/meeting";
const response = createFormResponse("field1", "sales-123", "Department");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/team/sales-team/meeting");
expect(result).not.toBe("/team/sales-123/meeting");
expect(result).not.toBe("/team/department/meeting");
});
it("should handle multiple variable substitutions", () => {
const fields = [
createSelectField("field1", "department", "Department", [{ id: "eng-456", label: "Engineering" }]),
createTextField("field2", "teamName", "Team Name"),
];
const routeValue = "/{department}/{teamName}/book";
const response: FormResponse = {
...createFormResponse("field1", "eng-456", "Department"),
...createFormResponse("field2", "Backend Team", "Team Name"),
};
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/engineering/backend-team/book");
});
it("should handle special characters in labels by slugifying them", () => {
const fields = [
createSelectField("field1", "department", "Department", [{ id: "hr_dept", label: "HR & Recruitment" }]),
];
const routeValue = "/meeting/{department}";
const response = createFormResponse("field1", "hr_dept", "Department");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/meeting/hr-recruitment");
});
it("should handle case-insensitive variable matching", () => {
const fields = [
createSelectField("field1", "department", "Department", [
{ id: "support-001", label: "Customer Support" },
]),
];
const routeValue = "/team/{DEPARTMENT}/schedule";
const response = createFormResponse("field1", "support-001", "Department");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/team/customer-support/schedule");
});
it("should not substitute variables that don't have matching fields", () => {
const fields = [createTextField("field1", "knownField", "Known Field")];
const routeValue = "/team/{unknownField}/meeting";
const response = createFormResponse("field1", "sales-123", "Sales Team");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/team/{unknownField}/meeting");
});
it("should handle array values in response labels", () => {
const fields = [
createMultiselectField("field3", "priority", "Priority Level", [
{ id: "high", label: "High" },
{ id: "urgent", label: "Urgent" },
]),
];
const routeValue = "/priorities/{priority}";
const response = createFormResponse("field3", ["high", "urgent"], "Priority Level");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/priorities/high-urgent");
});
it("should handle numeric labels", () => {
const fields = [
createSelectField("field1", "department", "Department", [{ id: "room-id-123", label: "Room 404" }]),
];
const routeValue = "/room/{department}";
const response = createFormResponse("field1", "room-id-123", "Department");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/room/room-404");
});
it("should not modify the URL if no variables are present", () => {
const fields = [createTextField("field1", "someField", "Some Field")];
const routeValue = "/team/fixed/meeting";
const response = createFormResponse("field1", "sales-123", "Sales Team");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/team/fixed/meeting");
});
it("should handle complex URLs with query parameters", () => {
const fields = [
createSelectField("field1", "department", "Department", [
{ id: "marketing-789", label: "Marketing & PR" },
]),
];
const routeValue = "/event/{department}?type=meeting&priority=high";
const response = createFormResponse("field1", "marketing-789", "Department");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/event/marketing-pr?type=meeting&priority=high");
});
it("should substitute text field values directly", () => {
const fields = [createTextField("field1", "username", "Username")];
const routeValue = "/user/{username}/profile";
const response = createFormResponse("field1", "John Doe", "Username");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/user/john-doe/profile");
});
it("should handle number field values", () => {
const fields = [
{ id: "field1", identifier: "employeeId", label: "Employee ID", type: "number" as const },
];
const routeValue = "/employee/{employeeId}/details";
const response = createFormResponse("field1", 12345, "Employee ID");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/employee/12345/details");
});
it("should handle multiple text and number fields in one URL", () => {
const fields = [
createTextField("field1", "department", "Department Name"),
{ id: "field2", identifier: "roomNumber", label: "Room Number", type: "number" as const },
createTextField("field3", "building", "Building"),
];
const routeValue = "/{building}/floor/{roomNumber}/{department}";
const response: FormResponse = {
...createFormResponse("field1", "Engineering", "Department Name"),
...createFormResponse("field2", 404, "Room Number"),
...createFormResponse("field3", "Tower A", "Building"),
};
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/tower-a/floor/404/engineering");
});
it("should handle text fields with special characters", () => {
const fields = [createTextField("field1", "projectName", "Project Name")];
const routeValue = "/project/{projectName}/board";
const response = createFormResponse("field1", "Cal.com Platform & API", "Project Name");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/project/cal.com-platform-api/board");
});
it("should handle empty text field values", () => {
const fields = [createTextField("field1", "optional", "Optional Field")];
const routeValue = "/form/{optional}/submit";
const response = createFormResponse("field1", "", "Optional Field");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/form//submit");
});
it("should handle textarea fields like text fields", () => {
const fields = [
{ id: "field1", identifier: "description", label: "Description", type: "textarea" as const },
];
const routeValue = "/ticket/{description}";
const response = createFormResponse("field1", "Bug Report Summary", "Description");
const result = substituteVariables(routeValue, response, fields);
expect(result).toBe("/ticket/bug-report-summary");
});
});
@@ -1,3 +1,4 @@
import { getHumanReadableFieldResponseValue } from "@calcom/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue";
import slugify from "@calcom/lib/slugify";
import type { FormResponse, NonRouterRoute, Field } from "../types/types";
@@ -30,7 +31,13 @@ export const substituteVariables = (
}
const identifier = getFieldIdentifier(field);
if (identifier.toLowerCase() === variable.toLowerCase()) {
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, slugify(response[key].value.toString() || ""));
const humanReadableValues = getHumanReadableFieldResponseValue({
field,
value: response[key].value,
});
// ['abc', 'def'] ----toString---> 'abc,def' ----slugify---> 'abc-def'
const valueToSubstitute = slugify(humanReadableValues.toString());
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, valueToSubstitute);
}
}
});
@@ -0,0 +1,205 @@
import { describe, it, expect } from "vitest";
import type { Field, FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { getHumanReadableFieldResponseValue } from "./getHumanReadableFieldResponseValue";
describe("getHumanReadableFieldResponseValue", () => {
const createSelectField = (
options: Array<{ id: string; label: string }>
): Pick<Field, "type" | "options"> => ({
type: "select",
options,
});
const createMultiselectField = (
options: Array<{ id: string; label: string }>
): Pick<Field, "type" | "options"> => ({
type: "multiselect",
options,
});
const createRadioField = (
options: Array<{ id: string; label: string }>
): Pick<Field, "type" | "options"> => ({
type: "radio",
options,
});
describe("select fields", () => {
it("should return option label for select field with single value", () => {
const field = createSelectField([
{ id: "opt-1", label: "Option One" },
{ id: "opt-2", label: "Option Two" },
]);
const value: FormResponse[string]["value"] = "opt-1";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["Option One"]);
});
it("should handle select field when value is already a label (legacy)", () => {
const field = createSelectField([
{ id: "opt-1", label: "Option One" },
{ id: "opt-2", label: "Option Two" },
]);
const value: FormResponse[string]["value"] = "Option Two";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["Option Two"]);
});
it("should return value as-is when option not found", () => {
const field = createSelectField([{ id: "opt-1", label: "Option One" }]);
const value: FormResponse[string]["value"] = "unknown-id";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["unknown-id"]);
});
});
describe("multiselect fields", () => {
it("should return option labels for multiselect field with array value", () => {
const field = createMultiselectField([
{ id: "opt-1", label: "Option One" },
{ id: "opt-2", label: "Option Two" },
{ id: "opt-3", label: "Option Three" },
]);
const value: FormResponse[string]["value"] = ["opt-1", "opt-3"];
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["Option One", "Option Three"]);
});
it("should support legacy options with used to have labels directly", () => {
const field = createMultiselectField([
{ id: "opt-1", label: "Option One" },
{ id: "opt-2", label: "Option Two" },
]);
const value: FormResponse[string]["value"] = ["Option One", "Option Two", "unknown"];
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["Option One", "Option Two", "unknown"]);
});
it("should handle single value for multiselect (edge case)", () => {
const field = createMultiselectField([{ id: "opt-1", label: "Option One" }]);
const value: FormResponse[string]["value"] = "opt-1";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["Option One"]);
});
});
describe("radio fields", () => {
it("should return option label for radio field", () => {
const field = createRadioField([
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
]);
const value: FormResponse[string]["value"] = "yes";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["Yes"]);
});
});
describe("text and other fields", () => {
it("should return value as string for text field", () => {
const field: Pick<Field, "type" | "options"> = {
type: "text",
};
const value: FormResponse[string]["value"] = "John Doe";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual("John Doe");
});
it("should handle number field", () => {
const field: Pick<Field, "type" | "options"> = {
type: "number",
};
const value: FormResponse[string]["value"] = 42;
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["42"]);
});
it("should handle textarea field", () => {
const field: Pick<Field, "type" | "options"> = {
type: "textarea",
};
const value: FormResponse[string]["value"] = "Multi\nline\ntext";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual("Multi\nline\ntext");
});
it("should handle array values for non-option fields", () => {
const field: Pick<Field, "type" | "options"> = {
type: "text",
};
const value: FormResponse[string]["value"] = ["value1", "value2"];
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["value1", "value2"]);
});
});
describe("edge cases", () => {
it("should handle numeric values in options", () => {
const field = createSelectField([
{ id: "1", label: "First" },
{ id: "2", label: "Second" },
]);
const value: FormResponse[string]["value"] = 1;
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["First"]);
});
it("should handle empty string value", () => {
const field: Pick<Field, "type" | "options"> = {
type: "text",
};
const value: FormResponse[string]["value"] = "";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual("");
});
it("should handle field with empty options array", () => {
const field = createSelectField([]);
const value: FormResponse[string]["value"] = "some-value";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual(["some-value"]);
});
it("should handle field with undefined options", () => {
const field: Pick<Field, "type" | "options"> = {
type: "select",
options: undefined,
};
const value: FormResponse[string]["value"] = "some-value";
const result = getHumanReadableFieldResponseValue({ field, value });
expect(result).toEqual("some-value");
});
});
});
@@ -0,0 +1,79 @@
import type { Field, FormResponse } from "@calcom/app-store/routing-forms/types/types";
/**
* Ensures a value is either a string or string array.
* Converts numbers to strings.
*
* @param value - The value to normalize
* @returns String or string array
*/
function ensureStringOrStringArray(value: string | number | (string | number)[]): string | string[] {
if (typeof value === "string") {
return value;
} else if (value instanceof Array) {
return value.map((v) => v.toString());
}
return [value.toString()];
}
/**
* Get labels from option IDs for fields with options (select, multiselect, radio).
* Handles legacy cases where the value might already be a label.
*
* @param options - The field options array
* @param optionIds - The option ID(s) to get labels for
* @returns The label(s) corresponding to the option ID(s)
*/
function getLabelsFromOptionIds({
options,
optionIds,
}: {
options: NonNullable<Field["options"]>;
optionIds: string | string[];
}) {
if (optionIds instanceof Array) {
const labels = optionIds.map((optionId) => {
const foundOption = options.find((option) => option.id === optionId);
// It would mean that the optionId is actually a label which is why it isn't matching any option id.
// Fallback to optionId(i.e. label) which was the case with legacy options
if (!foundOption) {
return optionId;
}
return foundOption.label;
});
return labels;
} else {
const foundOption = options.find((option) => option.id === optionIds);
if (!foundOption) {
return [optionIds];
}
return [foundOption.label];
}
}
/**
* Get the human-readable value for a field response.
* For select/multiselect/radio fields, this returns the label of the selected option(s).
* For other fields, it returns the value as-is.
*
* @param field - The field definition with options
* @param value - The response value (typically an option ID for select fields)
* @returns Array of human-readable values
*/
export function getHumanReadableFieldResponseValue({
field,
value,
}: {
field: Pick<Field, "type" | "options">;
value: FormResponse[string]["value"];
}): string | string[] {
// For fields with options (select, multiselect, radio), use the existing getLabelsFromOptionIds function
if (field.options) {
const optionIds = ensureStringOrStringArray(value);
return getLabelsFromOptionIds({ options: field.options, optionIds });
}
return ensureStringOrStringArray(value);
}