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:
co-authored by
Devin AI
parent
8d2a072645
commit
13cbb59e5e
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+205
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user