feat: Multiple Options Paste and Routing Form improvements related to Select fields (#15706)
* Add handlePaste function and integrate it with options fields * Prevents function running if user does not paste a list, allows list items to be pasted without deleting following items * Subs useState for rhf useWatch to fix issues with multiple form fields * Sets options array as optional property in zod schema * Assigns default value to options array to fix possibly undefined type errors * Sets placedholder property as optional * maps selectText to options on mount * Removes comma & semicolon delimiting for pasted lists (new line only) & defines regex as a constant * Sets default value of placeholder to empty string, provides default fallback for component text field if placeholder is omitted. * Fix type error on placeholder * Extracts handlePaste to service function & tests thoroughly * Some improvements and tests arent needed * More fixes * Fix reporting as well * TS fixes * Do transforms to and fro from label * Add unit tests * Unit test reporting * Test Improvements * Self review feedback addressed --------- Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
This commit is contained in:
co-authored by
Hariom Balhara
Anik Dhabal Babu
parent
4dd762681c
commit
cf76cf7aae
@@ -33,6 +33,10 @@ export const createRoutingFormsFixture = () => {
|
||||
label: string;
|
||||
identifier?: string;
|
||||
required: boolean;
|
||||
options?: {
|
||||
label: string;
|
||||
id: string | null;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return await prisma.app_RoutingForms_Form.create({
|
||||
|
||||
@@ -301,6 +301,10 @@ export const createUsersFixture = (
|
||||
}
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
const option2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
|
||||
const option1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
routes: [
|
||||
@@ -366,6 +370,26 @@ export const createUsersFixture = (
|
||||
},
|
||||
{
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: multiSelectLegacyFieldUuid,
|
||||
value: [["Option-2"]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
@@ -374,8 +398,8 @@ export const createUsersFixture = (
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "d4292635-9f12-17b1-9153-c3a854649182",
|
||||
value: [["Option-2"]],
|
||||
field: multiSelectFieldUuid,
|
||||
value: [[option2Uuid]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
@@ -399,13 +423,30 @@ export const createUsersFixture = (
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "d4292635-9f12-17b1-9153-c3a854649182",
|
||||
id: multiSelectLegacyFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
label: "Multi Select(with Legacy `selectText`)",
|
||||
identifier: "multi",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: multiSelectFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi-new-format",
|
||||
options: [
|
||||
{
|
||||
id: option1Uuid,
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: option2Uuid,
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
|
||||
@@ -647,6 +647,7 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
response: "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -673,14 +674,33 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
label: "Option-1",
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
label: "Option-2",
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
const textFieldIdentifier = "name";
|
||||
const multiSelectFieldIdentifier = "multi";
|
||||
await page.fill(`[data-testid="form-field-${textFieldIdentifier}"]`, "John Doe");
|
||||
await page.click(`[data-testid="form-field-${multiSelectFieldIdentifier}"]`); // Open dropdown
|
||||
await page.click("text=Option-2"); // Select option
|
||||
page.click('button[type="submit"]');
|
||||
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
@@ -699,10 +719,23 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
response: "John Doe",
|
||||
},
|
||||
multi: {
|
||||
value: ["Option-2"],
|
||||
response: [
|
||||
{
|
||||
id: "2",
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
/* Legacy fields Start */
|
||||
name: "John Doe",
|
||||
multi: ["Option-2"],
|
||||
/* Legacy fields End */
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermis
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import type { Response, SerializableForm } from "../../types/types";
|
||||
import type { FormResponse, SerializableForm } from "../../types/types";
|
||||
|
||||
function escapeCsvText(str: string) {
|
||||
return str.replace(/,/, "%2C");
|
||||
@@ -32,7 +32,7 @@ async function* getResponses(
|
||||
) {
|
||||
const csv: string[] = [];
|
||||
responses.forEach((response) => {
|
||||
const fieldResponses = response.response as Response;
|
||||
const fieldResponses = response.response as FormResponse;
|
||||
const csvCells: string[] = [];
|
||||
headerFields.forEach((headerField) => {
|
||||
const fieldResponse = fieldResponses[headerField.id];
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import getFieldIdentifier from "../lib/getFieldIdentifier";
|
||||
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import transformResponse from "../lib/transformResponse";
|
||||
import type { SerializableForm, Response } from "../types/types";
|
||||
import { getUIOptionsForSelect } from "../lib/selectOptions";
|
||||
import { getFieldResponseForJsonLogic } from "../lib/transformResponse";
|
||||
import type { SerializableForm, FormResponse } from "../types/types";
|
||||
|
||||
type Props = {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
response: Response;
|
||||
setResponse: Dispatch<SetStateAction<Response>>;
|
||||
response: FormResponse;
|
||||
setResponse: Dispatch<SetStateAction<FormResponse>>;
|
||||
};
|
||||
|
||||
export default function FormInputFields(props: Props) {
|
||||
@@ -31,14 +32,7 @@ export default function FormInputFields(props: Props) {
|
||||
}
|
||||
const Component = widget.factory;
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
const options = getUIOptionsForSelect(field);
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
@@ -62,7 +56,7 @@ export default function FormInputFields(props: Props) {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value: transformResponse({ field, value }),
|
||||
value: getFieldResponseForJsonLogic({ field, value }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||
import { RoutingPages } from "../lib/RoutingPages";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { processRoute } from "../lib/processRoute";
|
||||
import type { Response, Route, SerializableForm } from "../types/types";
|
||||
import type { FormResponse, Route, SerializableForm } from "../types/types";
|
||||
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
|
||||
import FormInputFields from "./FormInputFields";
|
||||
import RoutingNavBar from "./RoutingNavBar";
|
||||
@@ -238,7 +238,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
|
||||
const { t } = useLocale();
|
||||
|
||||
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
|
||||
const [response, setResponse] = useState<Response>({});
|
||||
const [response, setResponse] = useState<FormResponse>({});
|
||||
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
|
||||
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
|
||||
const [eventTypeUrl, setEventTypeUrl] = useState("");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FieldTypes } from "../pages/form-edit/[...appPages]";
|
||||
import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types";
|
||||
import { InitialConfig } from "./InitialConfig";
|
||||
import { getUIOptionsForSelect } from "./selectOptions";
|
||||
|
||||
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
const fields: Record<
|
||||
@@ -22,14 +23,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
field = field.routerField;
|
||||
}
|
||||
if (FieldTypes.map((f) => f.value).includes(field.type)) {
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
const options = getUIOptionsForSelect(field);
|
||||
|
||||
const widget = InitialConfig.widgets[field.type];
|
||||
const widgetType = widget.type;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { zodFields, zodRoutes } from "../zod";
|
||||
import getConnectedForms from "./getConnectedForms";
|
||||
import isRouter from "./isRouter";
|
||||
import isRouterLinkedField from "./isRouterLinkedField";
|
||||
import { getFieldWithOptions } from "./selectOptions";
|
||||
|
||||
/**
|
||||
* Doesn't have deleted fields by default
|
||||
@@ -57,7 +58,9 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
}));
|
||||
const finalFields = fields;
|
||||
|
||||
const finalFields = fields.map((field) => getFieldWithOptions(field));
|
||||
|
||||
let teamMembers: SerializableFormTeamMembers[] = [];
|
||||
if (form.teamId) {
|
||||
teamMembers = await prisma.user.findMany({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { Response, Route, SerializableForm } from "../types/types";
|
||||
import type { FormResponse, Route, SerializableForm } from "../types/types";
|
||||
import type { zodNonRouterRoute } from "../zod";
|
||||
import { getQueryBuilderConfig } from "./getQueryBuilderConfig";
|
||||
import { isFallbackRoute } from "./isFallbackRoute";
|
||||
@@ -16,7 +16,7 @@ export function processRoute({
|
||||
response,
|
||||
}: {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
response: Record<string, Pick<Response[string], "value">>;
|
||||
response: Record<string, Pick<FormResponse[string], "value">>;
|
||||
}) {
|
||||
const queryBuilderConfig = getQueryBuilderConfig(form);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function processRoute({
|
||||
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
|
||||
const logic = jsonLogicQuery.logic;
|
||||
let result = false;
|
||||
const responseValues: Record<string, Response[string]["value"]> = {};
|
||||
const responseValues: Record<string, FormResponse[string]["value"]> = {};
|
||||
for (const [uuid, { value }] of Object.entries(response)) {
|
||||
responseValues[uuid] = value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
*
|
||||
* This file holds the utilities to build the options to render in the select field and it could be loaded on client side as well.
|
||||
*/
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { zodFieldView } from "../zod";
|
||||
|
||||
type Field = z.infer<typeof zodFieldView>;
|
||||
|
||||
const buildOptionsFromLegacySelectText = ({ legacySelectText }: { legacySelectText: string }) => {
|
||||
return legacySelectText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((fieldValue) => ({
|
||||
label: fieldValue,
|
||||
id: null,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getFieldWithOptions = <T extends Field>(field: T) => {
|
||||
const legacySelectText = field.selectText;
|
||||
if (field.options) {
|
||||
return {
|
||||
...field,
|
||||
options: field.options,
|
||||
};
|
||||
} else if (legacySelectText) {
|
||||
const options = buildOptionsFromLegacySelectText({ legacySelectText });
|
||||
return {
|
||||
...field,
|
||||
options,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
};
|
||||
};
|
||||
|
||||
export function getUIOptionsForSelect(field: Field) {
|
||||
return getFieldWithOptions(field).options?.map((option) => {
|
||||
// We prefer option.id as that doesn't change when we change the option text/label.
|
||||
// Fallback to option.label for fields saved in DB in old format which didn't have `options`
|
||||
const value = option.id ?? option.label;
|
||||
return {
|
||||
value,
|
||||
title: option.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
import type { Response, Route, Field } from "../types/types";
|
||||
import type { FormResponse, Route, Field } from "../types/types";
|
||||
import getFieldIdentifier from "./getFieldIdentifier";
|
||||
|
||||
export const substituteVariables = (
|
||||
routeValue: Route["action"]["value"],
|
||||
response: Response,
|
||||
response: FormResponse,
|
||||
fields: Field[]
|
||||
) => {
|
||||
const regex = /\{([^\}]+)\}/g;
|
||||
@@ -15,7 +15,11 @@ export const substituteVariables = (
|
||||
|
||||
variables.forEach((variable) => {
|
||||
for (const key in response) {
|
||||
const identifier = getFieldIdentifier(fields.find((field) => field.id === key));
|
||||
const field = fields.find((field) => field.id === key)
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
const identifier = getFieldIdentifier(field);
|
||||
if (identifier.toLowerCase() === variable.toLowerCase()) {
|
||||
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, slugify(response[key].value.toString() || ""));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getFieldResponseForJsonLogic } from "./transformResponse";
|
||||
|
||||
describe("getFieldResponseForJsonLogic", () => {
|
||||
it("should return an empty string if value is undefined", () => {
|
||||
const field = { type: "text", options: undefined };
|
||||
const value = undefined;
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should transform value for a number type field to number", () => {
|
||||
const field = { type: "number", options: undefined };
|
||||
const value = "123";
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
describe("multiselect", () => {
|
||||
describe("non-legacy options", () => {
|
||||
it("should return option ids for the field if the value is an array of option ids", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["1", "2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("should return ids if matching labels are provided in value", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["Option 1", "Option 2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("should return the value as it is if it doesn't match any of the labels or option ids", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["Option 10", "Option 11"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["Option 10", "Option 11"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy options", () => {
|
||||
it("should return what is given for a field that has legacy options but the values don't match labels", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["1", "2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("should return matching labels for a field that has legacy options", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = ["Option 1", "Option 2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["Option 1", "Option 2"]);
|
||||
});
|
||||
|
||||
it("should handle multiselect field with legacy options", () => {
|
||||
const field = {
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ label: "Option 1", id: null },
|
||||
{ label: "Option 2", id: null },
|
||||
],
|
||||
};
|
||||
const value = ["Option 1", "Option 2"];
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toEqual(["Option 1", "Option 2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("select", () => {
|
||||
it("should handle select field with options", () => {
|
||||
const field = {
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = "1";
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
|
||||
it("should handle select field with legacy options", () => {
|
||||
const field = {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Option 1", id: null },
|
||||
{ label: "Option 2", id: null },
|
||||
],
|
||||
};
|
||||
const value = "Option 1";
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("Option 1");
|
||||
});
|
||||
|
||||
it("should handle select field with number as string", () => {
|
||||
const field = {
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
],
|
||||
};
|
||||
const value = 1;
|
||||
const result = getFieldResponseForJsonLogic({ field, value });
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,59 @@
|
||||
import type { Field, Response } from "../types/types";
|
||||
import type { Field, FormResponse } from "../types/types";
|
||||
|
||||
export default function transformResponse({
|
||||
/**
|
||||
* It takes care of correctly transforming the input to label or id depending on various cases
|
||||
* - It allows us to prefill with ID or Label
|
||||
* - It allows backward compatibility with legacy routes(with labels for options)
|
||||
*/
|
||||
function transformSelectValue({
|
||||
field,
|
||||
idOrLabel,
|
||||
}: {
|
||||
field: Pick<Field, "options" | "type">;
|
||||
idOrLabel: string;
|
||||
}) {
|
||||
idOrLabel = idOrLabel.trim();
|
||||
const options = field.options;
|
||||
if (!options) {
|
||||
return idOrLabel;
|
||||
}
|
||||
const areOptionsInLegacyFormat = !!options.find((option) => !option.id);
|
||||
// Because for legacy saved options, routes must have labels in them instead of ids
|
||||
const shouldUseLabelAsValue = areOptionsInLegacyFormat;
|
||||
const foundOptionById = options.find((option) => option.id === idOrLabel);
|
||||
if (foundOptionById) {
|
||||
if (shouldUseLabelAsValue) {
|
||||
return foundOptionById.label;
|
||||
} else {
|
||||
// If shouldUseLabelAsValue is false, then we must use id as value
|
||||
// Because shouldUseLabelAsValue is false, id must be set already
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return foundOptionById.id!;
|
||||
}
|
||||
} else {
|
||||
// No option was found that matches ID
|
||||
// So check if the label is provided
|
||||
const foundOptionByLabel = options.find((option) => {
|
||||
return option.label === idOrLabel;
|
||||
});
|
||||
if (foundOptionByLabel) {
|
||||
if (!shouldUseLabelAsValue) {
|
||||
// If shouldUseLabelAsValue is false, then we must use id as value
|
||||
// Because shouldUseLabelAsValue is false, id must be set already
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return foundOptionByLabel.id!;
|
||||
}
|
||||
}
|
||||
}
|
||||
return idOrLabel;
|
||||
}
|
||||
|
||||
export function getFieldResponseForJsonLogic({
|
||||
field,
|
||||
value,
|
||||
}: {
|
||||
field: Field;
|
||||
value: Response[string]["value"] | undefined;
|
||||
field: Pick<Field, "options" | "type">;
|
||||
value: FormResponse[string]["value"] | undefined;
|
||||
}) {
|
||||
if (!value) {
|
||||
return "";
|
||||
@@ -18,13 +66,24 @@ export default function transformResponse({
|
||||
return value;
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
if (value instanceof Array) {
|
||||
return value;
|
||||
}
|
||||
return value
|
||||
.toString()
|
||||
.split(",")
|
||||
.map((v) => v.trim());
|
||||
// Could be option id(i.e. a UUIDv4) or option label for ease of prefilling
|
||||
let valueOrLabelArray = value instanceof Array ? value : value.toString().split(",");
|
||||
|
||||
valueOrLabelArray = valueOrLabelArray.map((idOrLabel) => {
|
||||
return transformSelectValue({ field, idOrLabel });
|
||||
});
|
||||
|
||||
return valueOrLabelArray;
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
const valueAsStringOrStringArray = typeof value === "number" ? String(value) : value;
|
||||
const valueAsString =
|
||||
valueAsStringOrStringArray instanceof Array
|
||||
? valueAsStringOrStringArray[0]
|
||||
: valueAsStringOrStringArray;
|
||||
|
||||
return transformSelectValue({ field, idOrLabel: valueAsString });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ClipboardEvent } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Controller, useFieldArray, useWatch } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -29,8 +29,26 @@ import SingleForm, {
|
||||
} from "../../components/SingleForm";
|
||||
|
||||
export { getServerSideProps };
|
||||
type SelectOption = { label: string; id: string | null };
|
||||
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
|
||||
type SelectOption = { placeholder: string; value: string; id: string };
|
||||
|
||||
const appendArray = <T,>({
|
||||
target,
|
||||
arrayToAppend,
|
||||
appendAt,
|
||||
}: {
|
||||
arrayToAppend: T[];
|
||||
target: T[];
|
||||
appendAt: number;
|
||||
}) => {
|
||||
// Avoid mutating the original array
|
||||
const copyOfTarget = [...target];
|
||||
const numItemsToRemove = arrayToAppend.length;
|
||||
copyOfTarget.splice(appendAt, numItemsToRemove, ...arrayToAppend);
|
||||
return copyOfTarget;
|
||||
};
|
||||
|
||||
const PASTE_OPTIONS_SEPARATOR_REGEX = /\n+/;
|
||||
|
||||
export const FieldTypes = [
|
||||
{
|
||||
@@ -90,65 +108,51 @@ function Field({
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||
const watchedOptions =
|
||||
useWatch({
|
||||
control: hookForm.control,
|
||||
name: `${hookFieldNamespace}.options`,
|
||||
defaultValue: [
|
||||
{ label: "", id: uuidv4() },
|
||||
{ label: "", id: uuidv4() },
|
||||
{ label: "", id: uuidv4() },
|
||||
{ label: "", id: uuidv4() },
|
||||
],
|
||||
}) || [];
|
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>([
|
||||
{ placeholder: "< 10", value: "", id: uuidv4() },
|
||||
{ placeholder: "10-100", value: "", id: uuidv4() },
|
||||
{ placeholder: "100-500", value: "", id: uuidv4() },
|
||||
{ placeholder: "> 500", value: "", id: uuidv4() },
|
||||
]);
|
||||
|
||||
const handleRemoveOptions = (index: number) => {
|
||||
const updatedOptions = options.filter((_, i) => i !== index);
|
||||
setOptions(updatedOptions);
|
||||
updateSelectText(updatedOptions);
|
||||
const setOptions = (updatedOptions: SelectOption[]) => {
|
||||
hookForm.setValue(`${hookFieldNamespace}.options`, updatedOptions);
|
||||
};
|
||||
|
||||
const handleAddOptions = () => {
|
||||
setOptions((prevState) => [
|
||||
...prevState,
|
||||
const handleRemoveOptions = (index: number) => {
|
||||
const updatedOptions = watchedOptions.filter((_, i) => i !== index);
|
||||
// We can't let the user remove the last option
|
||||
if (updatedOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
setOptions(updatedOptions);
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setOptions([
|
||||
...watchedOptions,
|
||||
{
|
||||
placeholder: "New Option",
|
||||
value: "",
|
||||
label: "",
|
||||
id: uuidv4(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const originalValues = hookForm.getValues(`${hookFieldNamespace}.selectText`);
|
||||
if (originalValues) {
|
||||
const values: SelectOption[] = originalValues.split("\n").map((fieldValue) => ({
|
||||
value: fieldValue,
|
||||
placeholder: "",
|
||||
id: uuidv4(),
|
||||
}));
|
||||
setOptions(values);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
|
||||
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, optionIndex: number) => {
|
||||
const updatedOptions = options.map((opt, index) => ({
|
||||
const updateLabelAtIndex = ({ label, optionIndex }: { label: string; optionIndex: number }) => {
|
||||
const updatedOptions = watchedOptions.map((opt, index) => ({
|
||||
...opt,
|
||||
...(index === optionIndex ? { value: e.target.value } : {}),
|
||||
...(index === optionIndex ? { label } : {}),
|
||||
}));
|
||||
|
||||
setOptions(updatedOptions);
|
||||
updateSelectText(updatedOptions);
|
||||
};
|
||||
|
||||
const updateSelectText = (updatedOptions: SelectOption[]) => {
|
||||
hookForm.setValue(
|
||||
`${hookFieldNamespace}.selectText`,
|
||||
updatedOptions
|
||||
.filter((opt) => opt.value)
|
||||
.map((opt) => opt.value)
|
||||
.join("\n")
|
||||
);
|
||||
};
|
||||
|
||||
const label = useWatch({
|
||||
@@ -162,18 +166,49 @@ function Field({
|
||||
});
|
||||
|
||||
function move(index: number, increment: 1 | -1) {
|
||||
const newList = [...options];
|
||||
const newList = [...watchedOptions];
|
||||
|
||||
const type = options[index];
|
||||
const tmp = options[index + increment];
|
||||
const type = watchedOptions[index];
|
||||
const tmp = watchedOptions[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = tmp;
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
setOptions(newList);
|
||||
updateSelectText(newList);
|
||||
}
|
||||
|
||||
const handlePasteInOptionAtIndex = ({
|
||||
event,
|
||||
optionIndex,
|
||||
}: {
|
||||
event: ClipboardEvent;
|
||||
optionIndex: number;
|
||||
}) => {
|
||||
const paste = event.clipboardData.getData("text");
|
||||
// The value being pasted could be a list of options
|
||||
const optionsBeingPasted = paste
|
||||
.split(PASTE_OPTIONS_SEPARATOR_REGEX)
|
||||
.map((optionLabel) => optionLabel.trim())
|
||||
.filter((optionLabel) => optionLabel)
|
||||
.map((optionLabel) => ({ label: optionLabel.trim(), id: uuidv4() }));
|
||||
if (optionsBeingPasted.length === 1) {
|
||||
// If there is only one option, we would just let that option be pasted
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow pasting that value, as we would update the options through state update
|
||||
event.preventDefault();
|
||||
|
||||
const updatedOptions = appendArray({
|
||||
target: watchedOptions,
|
||||
arrayToAppend: optionsBeingPasted,
|
||||
appendAt: optionIndex,
|
||||
});
|
||||
setOptions(updatedOptions);
|
||||
};
|
||||
|
||||
const optionsPlaceholders = ["< 10", "10 - 100", "100 - 500", "> 500"];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="field"
|
||||
@@ -261,10 +296,15 @@ function Field({
|
||||
{t("options")}
|
||||
</Skeleton>
|
||||
<ul ref={animationRef}>
|
||||
{options.map((field, index) => (
|
||||
<li key={`select-option-${field.id}`} className="group mt-2 flex items-center gap-2">
|
||||
{watchedOptions.map((option, index) => (
|
||||
<li
|
||||
key={`select-option-${option.id}`}
|
||||
className="group mt-2 flex items-center gap-2"
|
||||
onPaste={(event: ClipboardEvent) =>
|
||||
handlePasteInOptionAtIndex({ event, optionIndex: index })
|
||||
}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.length && index !== 0 ? (
|
||||
{watchedOptions.length && index !== 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
@@ -272,7 +312,7 @@ function Field({
|
||||
<Icon name="arrow-up" />
|
||||
</button>
|
||||
) : null}
|
||||
{options.length && index !== options.length - 1 ? (
|
||||
{watchedOptions.length && index !== watchedOptions.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
@@ -287,18 +327,22 @@ function Field({
|
||||
containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400"
|
||||
className="border-0 focus:ring-0 focus:ring-offset-0"
|
||||
labelSrOnly
|
||||
placeholder={field.placeholder.toString()}
|
||||
value={field.value}
|
||||
placeholder={optionsPlaceholders[index] ?? "New Option"}
|
||||
value={option.label}
|
||||
type="text"
|
||||
required
|
||||
addOnClassname="bg-transparent border-0"
|
||||
onChange={(e) => handleChange(e, index)}
|
||||
onChange={(e) => updateLabelAtIndex({ label: e.target.value, optionIndex: index })}
|
||||
dataTestid={`${hookFieldNamespace}.options.${index}`}
|
||||
addOnSuffix={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveOptions(index)}
|
||||
aria-label={t("remove")}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</button>
|
||||
watchedOptions.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveOptions(index)}
|
||||
aria-label={t("remove")}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -312,7 +356,7 @@ function Field({
|
||||
type="button"
|
||||
StartIcon="plus"
|
||||
color="secondary"
|
||||
onClick={handleAddOptions}>
|
||||
onClick={addOption}>
|
||||
Add an option
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import { processRoute } from "../../lib/processRoute";
|
||||
import { substituteVariables } from "../../lib/substituteVariables";
|
||||
import transformResponse from "../../lib/transformResponse";
|
||||
import type { Response } from "../../types/types";
|
||||
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
|
||||
import type { FormResponse } from "../../types/types";
|
||||
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
|
||||
@@ -96,7 +96,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
form: enrichFormWithMigrationData(formWithUserProfile),
|
||||
});
|
||||
|
||||
const response: Response = {};
|
||||
const response: FormResponse = {};
|
||||
if (!serializableForm.fields) {
|
||||
throw new Error("Form has no fields");
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
|
||||
response[field.id] = {
|
||||
label: field.label,
|
||||
value: transformResponse({ field, value: fieldResponse }),
|
||||
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
|
||||
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import { processRoute } from "../../lib/processRoute";
|
||||
import { substituteVariables } from "../../lib/substituteVariables";
|
||||
import transformResponse from "../../lib/transformResponse";
|
||||
import type { NonRouterRoute, Response } from "../../types/types";
|
||||
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
|
||||
import type { NonRouterRoute, FormResponse } from "../../types/types";
|
||||
import { getServerSideProps } from "./getServerSideProps";
|
||||
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
const useBrandColors = ({
|
||||
@@ -59,10 +60,13 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
// - like a network error
|
||||
// - or he abandoned booking flow in between
|
||||
const formFillerId = formFillerIdRef.current;
|
||||
const decidedActionWithFormResponseRef = useRef<{ action: NonRouterRoute["action"]; response: Response }>();
|
||||
const decidedActionWithFormResponseRef = useRef<{
|
||||
action: NonRouterRoute["action"];
|
||||
response: FormResponse;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = (response: Response) => {
|
||||
const onSubmit = (response: FormResponse) => {
|
||||
const decidedAction = processRoute({ form, response });
|
||||
|
||||
if (!decidedAction) {
|
||||
@@ -97,7 +101,11 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
if (!fields) {
|
||||
throw new Error("Routing Form fields must exist here");
|
||||
}
|
||||
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
|
||||
const allURLSearchParams = getUrlSearchParamsToForward({
|
||||
formResponse: decidedActionWithFormResponse.response,
|
||||
fields,
|
||||
searchParams: new URLSearchParams(window.location.search),
|
||||
});
|
||||
const decidedAction = decidedActionWithFormResponse.action;
|
||||
sdkActionManager?.fire("routed", {
|
||||
actionType: decidedAction.type,
|
||||
@@ -191,55 +199,6 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const valueAsStringOrStringArray =
|
||||
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
|
||||
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
|
||||
valueAsStringOrStringArray;
|
||||
});
|
||||
|
||||
// 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} />;
|
||||
}
|
||||
@@ -250,7 +209,7 @@ export { getServerSideProps };
|
||||
|
||||
const usePrefilledResponse = (form: Props["form"]) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const prefillResponse: Response = {};
|
||||
const prefillResponse: FormResponse = {};
|
||||
|
||||
// Prefill the form from query params
|
||||
form.fields?.forEach((field) => {
|
||||
@@ -259,10 +218,10 @@ const usePrefilledResponse = (form: Props["form"]) => {
|
||||
const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0];
|
||||
|
||||
prefillResponse[field.id] = {
|
||||
value: transformResponse({ field, value }),
|
||||
value: getFieldResponseForJsonLogic({ field, value }),
|
||||
label: field.label,
|
||||
};
|
||||
});
|
||||
const [response, setResponse] = useState<Response>(prefillResponse);
|
||||
const [response, setResponse] = useState<FormResponse>(prefillResponse);
|
||||
return [response, setResponse] as const;
|
||||
};
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
||||
|
||||
function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
|
||||
if (entries === null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Consider setting atleast ES2015 as target
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
for (const [key, value] of entries) {
|
||||
if (result.hasOwnProperty(key)) {
|
||||
let currentValue = result[key];
|
||||
if (!Array.isArray(currentValue)) {
|
||||
currentValue = [currentValue];
|
||||
}
|
||||
currentValue.push(value);
|
||||
result[key] = currentValue;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("getUrlSearchParamsToForward", () => {
|
||||
it("should build query params from response correctly when identifier is present in fields", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "value1", label: "Field 1" },
|
||||
[field2Id]: { value: ["option1", "option2"], label: "Field 2" },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, identifier: "f1", type: "text", label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
identifier: "f2",
|
||||
label: "Field 2",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
const expectedParams = {
|
||||
f1: "value1",
|
||||
f2: ["Option 1", "Option 2"],
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should build query params from response correctly when identifier is not present in fields. Should fallback to label", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: "value2" },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1", type: "text" },
|
||||
{ id: field2Id, label: "Field 2", type: "text" },
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
|
||||
const expectedParams = {
|
||||
"Field 1": "value1",
|
||||
"Field 2": "value2",
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should handle select fields correctly when options have id set", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "Option 1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field1Id,
|
||||
label: "Field 1",
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
{ id: "option3", label: "Option 3" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
|
||||
const expectedParams = {
|
||||
"Field 1": "Option 1",
|
||||
"Field 2": ["Option 1", "Option 2"],
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should handle select fields correctly when options have no id set(Legacy options)", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const formResponse = {
|
||||
[field1Id]: { value: "Option 1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field1Id,
|
||||
label: "Field 1",
|
||||
type: "select",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
{ id: "option3", label: "Option 3" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
|
||||
const expectedParams = {
|
||||
"Field 1": "Option 1",
|
||||
"Field 2": ["Option 1", "Option 2"],
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("should handle number values correctly", () => {
|
||||
const field1Id = uuidv4();
|
||||
const response = {
|
||||
[field1Id]: { value: 123 },
|
||||
};
|
||||
|
||||
const fields = [{ id: field1Id, label: "Field 1", type: "number" }];
|
||||
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
const expectedParams = {
|
||||
"Field 1": "123",
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse: response,
|
||||
fields,
|
||||
searchParams,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import type { getServerSideProps } from "./getServerSideProps";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
export function getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
fields,
|
||||
searchParams,
|
||||
}: {
|
||||
formResponse: Record<
|
||||
string,
|
||||
{
|
||||
value: number | string | string[];
|
||||
}
|
||||
>;
|
||||
fields: Pick<
|
||||
NonNullable<Props["form"]["fields"]>[number],
|
||||
"id" | "type" | "options" | "identifier" | "label"
|
||||
>[];
|
||||
searchParams: URLSearchParams;
|
||||
}) {
|
||||
type Params = Record<string, string | string[]>;
|
||||
const paramsFromResponse: Params = {};
|
||||
const paramsFromCurrentUrl: Params = {};
|
||||
|
||||
// Build query params from response
|
||||
Object.entries(formResponse).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;
|
||||
}
|
||||
let valueAsStringOrStringArray =
|
||||
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
|
||||
if (foundField.type === "select" || foundField.type === "multiselect") {
|
||||
const options = foundField.options || [];
|
||||
let arr =
|
||||
valueAsStringOrStringArray instanceof Array
|
||||
? valueAsStringOrStringArray
|
||||
: [valueAsStringOrStringArray];
|
||||
arr = arr.map((idOrLabel) => {
|
||||
const foundOptionById = options.find((option) => {
|
||||
return option.id === idOrLabel;
|
||||
});
|
||||
if (foundOptionById) {
|
||||
return foundOptionById.label;
|
||||
}
|
||||
return idOrLabel;
|
||||
});
|
||||
valueAsStringOrStringArray = foundField.type === "select" ? arr[0] : arr;
|
||||
}
|
||||
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
|
||||
valueAsStringOrStringArray;
|
||||
});
|
||||
|
||||
// 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 searchParams.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;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ function todo(title: string) {
|
||||
test.skip(title, () => {});
|
||||
}
|
||||
|
||||
const Identifiers = {
|
||||
multi: "multi",
|
||||
multiNewFormat: "multi-new-format",
|
||||
};
|
||||
|
||||
test.describe("Routing Forms", () => {
|
||||
test.describe("Zero State Routing Forms", () => {
|
||||
test("should be able to add a new form and view it", async ({ page }) => {
|
||||
@@ -254,11 +259,11 @@ test.describe("Routing Forms", () => {
|
||||
responses.push(rowResponses);
|
||||
}
|
||||
|
||||
expect(headers).toEqual(["Test field", "Multi Select"]);
|
||||
expect(headers).toEqual(["Test field", "Multi Select(with Legacy `selectText`)", "Multi Select"]);
|
||||
expect(responses).toEqual([
|
||||
["event-routing", ""],
|
||||
["external-redirect", ""],
|
||||
["custom-page", ""],
|
||||
["event-routing", "", ""],
|
||||
["external-redirect", "", ""],
|
||||
["custom-page", "", ""],
|
||||
]);
|
||||
|
||||
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
|
||||
@@ -281,19 +286,21 @@ test.describe("Routing Forms", () => {
|
||||
});
|
||||
const csvRows = csv.trim().split("\n");
|
||||
const csvHeaderRow = csvRows[0];
|
||||
expect(csvHeaderRow).toEqual("Test field,Multi Select,Submission Time");
|
||||
expect(csvHeaderRow).toEqual(
|
||||
"Test field,Multi Select(with Legacy `selectText`),Multi Select,Submission Time"
|
||||
);
|
||||
|
||||
const firstResponseCells = csvRows[1].split(",");
|
||||
const secondResponseCells = csvRows[2].split(",");
|
||||
const thirdResponseCells = csvRows[3].split(",");
|
||||
|
||||
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,");
|
||||
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,,");
|
||||
expect(new Date(firstResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
|
||||
|
||||
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,");
|
||||
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,,");
|
||||
expect(new Date(secondResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
|
||||
|
||||
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,");
|
||||
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,,");
|
||||
expect(new Date(thirdResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
|
||||
});
|
||||
|
||||
@@ -318,8 +325,13 @@ test.describe("Routing Forms", () => {
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
||||
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
||||
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
|
||||
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multi}=Option-2`);
|
||||
await expect(page.locator("text=Multiselect(Legacy) chosen")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.goto(
|
||||
`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multiNewFormat}=d1302635-9f12-17b1-9153-c3a854649182`
|
||||
);
|
||||
await expect(page.locator("text=Multiselect chosen")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("Routing Link should validate fields", async ({ page, users }) => {
|
||||
@@ -364,8 +376,34 @@ test.describe("Routing Forms", () => {
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("External Redirect");
|
||||
expect(route).toBe("https://google.com");
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
|
||||
// Multiselect(Legacy)
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
|
||||
await page.click(`[data-testid="form-field-${Identifiers.multi}"]`); // Open dropdown
|
||||
await page.click("text=Option-2"); // Select option
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("Custom Page");
|
||||
expect(route).toBe("Multiselect(Legacy) chosen");
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
|
||||
// Multiselect
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
|
||||
await page.click(`[data-testid="form-field-${Identifiers.multiNewFormat}"]`); // Open dropdown
|
||||
await page.click("text=Option-2"); // Select option
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("Custom Page");
|
||||
expect(route).toBe("Multiselect chosen");
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
|
||||
//fallback route
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
await page.fill('[data-testid="form-field-Test field"]', "fallback");
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
@@ -452,8 +490,11 @@ async function addAllTypesOfFieldsAndSaveForm(
|
||||
identifier = "firstField";
|
||||
}
|
||||
|
||||
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
|
||||
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
|
||||
if (fieldTypeLabel === "Multiple Selection" || fieldTypeLabel === "Single Selection") {
|
||||
await page.fill(`[data-testid="fields.${nth}.options.0-input"]`, "123");
|
||||
await page.fill(`[data-testid="fields.${nth}.options.1-input"]`, "456");
|
||||
await page.fill(`[data-testid="fields.${nth}.options.2-input"]`, "789");
|
||||
await page.fill(`[data-testid="fields.${nth}.options.3-input"]`, "10-11-12");
|
||||
}
|
||||
|
||||
await page.fill(`[name="fields.${nth}.label"]`, label);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildResponsesForReporting } from "./report.handler";
|
||||
|
||||
describe("buildResponsesForReporting", () => {
|
||||
it("for fields with options, it should return the labels of the options", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["option1", "option2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with no options but having array value, it should return the value as is", () => {
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 2"]);
|
||||
});
|
||||
|
||||
it("should correctly handle numeric responses converting them to strings", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: 1 },
|
||||
[field2Id]: { value: [1, 2] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const expectedResponses = [["1", "1, 2"]];
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("should handle empty responses", () => {
|
||||
const field1Id = uuidv4();
|
||||
const responsesFromDb = [{}];
|
||||
|
||||
const fields = [{ id: field1Id, label: "Field 1" }];
|
||||
|
||||
const expectedResponses = [[""]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1"]);
|
||||
});
|
||||
|
||||
it("should show correct header for deleted fields", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
deleted: true,
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(expectedHeaders);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { Response } from "../types/types";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { zodNonRouterField, zodFieldView } from "../zod";
|
||||
import type { TReportInputSchema } from "./report.schema";
|
||||
|
||||
interface ReportHandlerOptions {
|
||||
@@ -13,6 +17,45 @@ interface ReportHandlerOptions {
|
||||
};
|
||||
input: TReportInputSchema;
|
||||
}
|
||||
|
||||
type Field = z.infer<typeof zodNonRouterField>;
|
||||
|
||||
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()];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
|
||||
// Can be any prisma `where` clause
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -49,27 +92,13 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
|
||||
const fields = serializedForm?.fields || [];
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: (string | number)[][] = [];
|
||||
rows.forEach((r) => {
|
||||
const rowResponses: (string | number)[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r.response) {
|
||||
return;
|
||||
}
|
||||
const response = r.response as Response;
|
||||
const value = response[field.id]?.value || "";
|
||||
let transformedValue;
|
||||
if (value instanceof Array) {
|
||||
transformedValue = value.join(", ");
|
||||
} else {
|
||||
transformedValue = value;
|
||||
}
|
||||
rowResponses.push(transformedValue);
|
||||
});
|
||||
const { responses, headers } = buildResponsesForReporting({
|
||||
responsesFromDb: rows.map((r) => r.response),
|
||||
fields,
|
||||
});
|
||||
|
||||
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
|
||||
return {
|
||||
headers,
|
||||
@@ -79,3 +108,36 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
|
||||
};
|
||||
|
||||
export default reportHandler;
|
||||
|
||||
export function buildResponsesForReporting({
|
||||
responsesFromDb,
|
||||
fields,
|
||||
}: {
|
||||
responsesFromDb: App_RoutingForms_FormResponse["response"][];
|
||||
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
|
||||
}) {
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: string[][] = [];
|
||||
responsesFromDb.forEach((r) => {
|
||||
const rowResponses: string[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
const response = r as FormResponse;
|
||||
const value = response[field.id]?.value || "";
|
||||
if (field.options) {
|
||||
const optionIds = ensureStringOrStringArray(value);
|
||||
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
|
||||
rowResponses.push(labels.join(", "));
|
||||
} else {
|
||||
const arrayOfValues = value instanceof Array ? value : [value];
|
||||
const transformedValue = arrayOfValues.join(", ");
|
||||
rowResponses.push(transformedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { responses, headers };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { Response } from "../types/types";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { TResponseInputSchema } from "./response.schema";
|
||||
import { onFormSubmission } from "./utils";
|
||||
|
||||
@@ -117,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
|
||||
await onFormSubmission(
|
||||
{ ...serializableFormWithFields, userWithEmails },
|
||||
dbFormResponse.response as Response
|
||||
dbFormResponse.response as FormResponse
|
||||
);
|
||||
return dbFormResponse;
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,30 +8,97 @@ import { WebhookTriggerEvents } from "@calcom/prisma/client";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import type { OrderedResponses } from "../types/types";
|
||||
import type { Response, SerializableForm } from "../types/types";
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
|
||||
type Field = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
|
||||
|
||||
function isOptionsField(field: Pick<Field, "type" | "options">) {
|
||||
return (field.type === "select" || field.type === "multiselect") && field.options;
|
||||
}
|
||||
|
||||
function getFieldResponse({
|
||||
field,
|
||||
fieldResponseValue,
|
||||
}: {
|
||||
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
|
||||
field: Pick<Field, "type" | "options">;
|
||||
}) {
|
||||
if (!isOptionsField(field)) {
|
||||
return {
|
||||
value: fieldResponseValue,
|
||||
response: fieldResponseValue,
|
||||
};
|
||||
}
|
||||
|
||||
if (!field.options) {
|
||||
return {
|
||||
value: fieldResponseValue,
|
||||
response: fieldResponseValue,
|
||||
};
|
||||
}
|
||||
|
||||
const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue];
|
||||
const chosenOptions = valueArray.map((idOrLabel) => {
|
||||
const foundOptionById = field.options?.find((option) => {
|
||||
return option.id === idOrLabel;
|
||||
});
|
||||
if (foundOptionById) {
|
||||
return {
|
||||
label: foundOptionById.label,
|
||||
id: foundOptionById.id,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: idOrLabel.toString(),
|
||||
id: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
return {
|
||||
// value is a legacy prop that is just sending the labels which can change
|
||||
value: chosenOptions.map((option) => option.label),
|
||||
// response is new prop that is sending the label along with id(which doesn't change)
|
||||
response: chosenOptions,
|
||||
};
|
||||
}
|
||||
|
||||
type SelectFieldWebhookResponse = string | number | string[] | { label: string; id: string | null };
|
||||
type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record<
|
||||
string,
|
||||
{
|
||||
/**
|
||||
* Deprecates `value` prop as it now has both the id(that doesn't change) and the label(that can change but is human friendly)
|
||||
*/
|
||||
response: number | string | string[] | SelectFieldWebhookResponse | SelectFieldWebhookResponse[];
|
||||
/**
|
||||
* @deprecated Use `response` instead
|
||||
*/
|
||||
value: FormResponse[keyof FormResponse]["value"];
|
||||
}
|
||||
>;
|
||||
|
||||
export async function onFormSubmission(
|
||||
form: Ensure<
|
||||
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
|
||||
"fields"
|
||||
>,
|
||||
response: Response
|
||||
response: FormResponse
|
||||
) {
|
||||
const fieldResponsesByName: Record<
|
||||
string,
|
||||
{
|
||||
value: Response[keyof Response]["value"];
|
||||
}
|
||||
> = {};
|
||||
const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
const field = form.fields.find((f) => f.id === fieldId);
|
||||
if (!field) {
|
||||
throw new Error(`Field with id ${fieldId} not found`);
|
||||
}
|
||||
// Use the label lowercased as the key to identify a field.
|
||||
const key =
|
||||
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||
fieldResponsesByName[key] = {
|
||||
value: fieldResponse.value,
|
||||
};
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByIdentifier);
|
||||
fieldResponsesByIdentifier[key] = getFieldResponse({
|
||||
fieldResponseValue: fieldResponse.value,
|
||||
field,
|
||||
});
|
||||
}
|
||||
|
||||
const { userId, teamId } = getWebhookTargetEntity(form);
|
||||
@@ -57,14 +124,14 @@ export async function onFormSubmission(
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId: form.teamId,
|
||||
responses: fieldResponsesByName,
|
||||
responses: fieldResponsesByIdentifier,
|
||||
},
|
||||
rootData: {
|
||||
// Send responses unwrapped at root level for backwards compatibility
|
||||
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
|
||||
...Object.entries(fieldResponsesByIdentifier).reduce((acc, [key, value]) => {
|
||||
acc[key] = value.value;
|
||||
return acc;
|
||||
}, {} as Record<string, Response[keyof Response]["value"]>),
|
||||
}, {} as Record<string, FormResponse[keyof FormResponse]["value"]>),
|
||||
},
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ export type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
export type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
|
||||
|
||||
export type Response = Record<
|
||||
export type FormResponse = Record<
|
||||
// Field ID
|
||||
string,
|
||||
{
|
||||
@@ -20,7 +20,7 @@ export type Response = Record<
|
||||
>;
|
||||
|
||||
export type Fields = z.infer<typeof zodFieldsView>;
|
||||
export type Field = Fields[number];
|
||||
export type Field = NonNullable<Fields>[number];
|
||||
export type Routes = z.infer<typeof zodRoutesView>;
|
||||
export type Route = Routes[0];
|
||||
export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>;
|
||||
@@ -55,4 +55,4 @@ export type SerializableRoute =
|
||||
})
|
||||
| GlobalRoute;
|
||||
|
||||
export type OrderedResponses = Response[string][];
|
||||
export type OrderedResponses = FormResponse[string][];
|
||||
|
||||
@@ -6,9 +6,23 @@ export const zodNonRouterField = z.object({
|
||||
identifier: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
type: z.string(),
|
||||
/**
|
||||
* @deprecated in favour of `options`
|
||||
*/
|
||||
selectText: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
// To keep backwards compatibility with the options generated from legacy selectText, we allow saving null as id
|
||||
// It helps in differentiating whether the routing logic should consider the option.label as value or option.id as value.
|
||||
// This is important for legacy routes which has option.label saved in conditions and it must keep matching with the value of the option
|
||||
id: z.string().or(z.null()),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const zodRouterField = zodNonRouterField.extend({
|
||||
|
||||
@@ -214,7 +214,8 @@ const useBanners = () => {
|
||||
|
||||
const Layout = (props: LayoutProps) => {
|
||||
const banners = useBanners();
|
||||
|
||||
const pathname = usePathname();
|
||||
const isFullPageWithoutSidebar = pathname.startsWith("/apps/routing-forms/reporting/");
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const { boot } = useIntercom();
|
||||
const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title;
|
||||
@@ -253,7 +254,7 @@ const Layout = (props: LayoutProps) => {
|
||||
<TimezoneChangeDialog />
|
||||
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{banners && !props.isPlatformUser && (
|
||||
{banners && !props.isPlatformUser && !isFullPageWithoutSidebar && (
|
||||
<div className="sticky top-0 z-10 w-full divide-y divide-black">
|
||||
{Object.keys(banners).map((key) => {
|
||||
if (key === "teamUpgradeBanner") {
|
||||
|
||||
@@ -135,11 +135,28 @@ async function seedAppData() {
|
||||
{
|
||||
id: "d4292635-9f12-17b1-9153-c3a854649182",
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
label: "Multi Select(with legacy `selectText`)",
|
||||
identifier: "multi",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "d4292635-9f12-17b1-9153-c3a854649182",
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi",
|
||||
options: [
|
||||
{
|
||||
id: "d1234635-9f12-17b1-9153-c3a854649182",
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: "d1235635-9f12-17b1-9153-c3a854649182",
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
|
||||
Reference in New Issue
Block a user