From 13cbb59e5e3f3cd10e6319bcc563cc6427bccab7 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 25 Aug 2025 23:16:18 +0530 Subject: [PATCH] fix: Custom Event Redirect in Routing Form with Dropdown Based fields (#17870) Co-authored-by: hariom@cal.com Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../__tests__/TestFormDialog.test.tsx | 58 +++++ .../routing-forms/api/responses/[formId].ts | 22 +- .../components/_components/TestForm.tsx | 29 +-- .../routing-forms/lib/reportingUtils.ts | 41 ---- .../lib/substituteVariables.test.ts | 229 ++++++++++++++++++ .../routing-forms/lib/substituteVariables.ts | 9 +- ...getHumanReadableFieldResponseValue.test.ts | 205 ++++++++++++++++ .../getHumanReadableFieldResponseValue.ts | 79 ++++++ 8 files changed, 599 insertions(+), 73 deletions(-) delete mode 100644 packages/app-store/routing-forms/lib/reportingUtils.ts create mode 100644 packages/app-store/routing-forms/lib/substituteVariables.test.ts create mode 100644 packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.test.ts create mode 100644 packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.ts diff --git a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx index a981ac21cd..2625d23442 100644 --- a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx +++ b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx @@ -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( + { + 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( + { + 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"); + }); }); }); diff --git a/packages/app-store/routing-forms/api/responses/[formId].ts b/packages/app-store/routing-forms/api/responses/[formId].ts index 00b0edafce..e1f6316fee 100644 --- a/packages/app-store/routing-forms/api/responses/[formId].ts +++ b/packages/app-store/routing-forms/api/responses/[formId].ts @@ -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["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); }); diff --git a/packages/app-store/routing-forms/components/_components/TestForm.tsx b/packages/app-store/routing-forms/components/_components/TestForm.tsx index 6b37faa4b9..dbf424fc91 100644 --- a/packages/app-store/routing-forms/components/_components/TestForm.tsx +++ b/packages/app-store/routing-forms/components/_components/TestForm.tsx @@ -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({}); const [chosenRoute, setChosenRoute] = useState(null); - const [eventTypeUrlWithoutParams, setEventTypeUrlWithoutParams] = useState(""); const searchParams = useCompatSearchParams(); const [membersMatchResult, setMembersMatchResult] = useState(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, diff --git a/packages/app-store/routing-forms/lib/reportingUtils.ts b/packages/app-store/routing-forms/lib/reportingUtils.ts deleted file mode 100644 index 87f6c38e23..0000000000 --- a/packages/app-store/routing-forms/lib/reportingUtils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { z } from "zod"; - -import type { zodNonRouterField } from "../zod"; - -type Field = z.infer; - -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; - 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]; - } -} diff --git a/packages/app-store/routing-forms/lib/substituteVariables.test.ts b/packages/app-store/routing-forms/lib/substituteVariables.test.ts new file mode 100644 index 0000000000..c4caff09be --- /dev/null +++ b/packages/app-store/routing-forms/lib/substituteVariables.test.ts @@ -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"); + }); +}); diff --git a/packages/app-store/routing-forms/lib/substituteVariables.ts b/packages/app-store/routing-forms/lib/substituteVariables.ts index 3dbc40f030..19ef124f0e 100644 --- a/packages/app-store/routing-forms/lib/substituteVariables.ts +++ b/packages/app-store/routing-forms/lib/substituteVariables.ts @@ -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); } } }); diff --git a/packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.test.ts b/packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.test.ts new file mode 100644 index 0000000000..542b40a3df --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.test.ts @@ -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 => ({ + type: "select", + options, + }); + + const createMultiselectField = ( + options: Array<{ id: string; label: string }> + ): Pick => ({ + type: "multiselect", + options, + }); + + const createRadioField = ( + options: Array<{ id: string; label: string }> + ): Pick => ({ + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + type: "select", + options: undefined, + }; + const value: FormResponse[string]["value"] = "some-value"; + + const result = getHumanReadableFieldResponseValue({ field, value }); + + expect(result).toEqual("some-value"); + }); + }); +}); diff --git a/packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.ts b/packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.ts new file mode 100644 index 0000000000..10949ddf24 --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/getHumanReadableFieldResponseValue.ts @@ -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; + 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; + 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); +}