diff --git a/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx b/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx index 99f976e2..ff61c380 100644 --- a/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx +++ b/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react"; import { HydratedIntegrationFieldExistent } from "services/api"; -import { isIntegrationFieldMapping } from "src/utils/manifest"; +import { + getFieldDisplayName, + isIntegrationFieldMapping, +} from "src/utils/manifest"; import { CheckboxItem, @@ -36,7 +39,7 @@ export function OptionalFieldsV2() { ) .map((field) => ({ id: field.fieldName, - label: field.displayName, + label: getFieldDisplayName(field), isChecked: !!selectedOptionalFields?.[field.fieldName], })) .sort((a, b) => a.label.localeCompare(b.label)) || [], diff --git a/src/components/Configure/content/fields/RequiredFields.tsx b/src/components/Configure/content/fields/RequiredFields.tsx index 03cae04d..ed81b78d 100644 --- a/src/components/Configure/content/fields/RequiredFields.tsx +++ b/src/components/Configure/content/fields/RequiredFields.tsx @@ -1,6 +1,9 @@ import { useManifest } from "src/headless"; import { useProjectQuery } from "src/hooks/query"; -import { isIntegrationFieldMapping } from "src/utils/manifest"; +import { + getFieldDisplayName, + isIntegrationFieldMapping, +} from "src/utils/manifest"; import { Tag } from "components/ui-base/Tag"; @@ -39,7 +42,9 @@ export function RequiredFields() { {requiredFields?.length ? requiredFields.map((field) => { if (isIntegrationFieldMapping(field)) return null; - return {field.displayName}; + return ( + {getFieldDisplayName(field)} + ); }) : "There are no required fields."} diff --git a/src/components/InstallWizard/steps/ReviewStep.tsx b/src/components/InstallWizard/steps/ReviewStep.tsx index a4e72f71..f113ed49 100644 --- a/src/components/InstallWizard/steps/ReviewStep.tsx +++ b/src/components/InstallWizard/steps/ReviewStep.tsx @@ -9,6 +9,7 @@ import { useManifest, } from "src/headless"; import { handleServerError } from "src/utils/handleServerError"; +import { getFieldDisplayName } from "src/utils/manifest"; import { StepHeader } from "../components/StepHeader"; import { useWizard } from "../wizard/WizardContext"; @@ -34,7 +35,7 @@ export function ReviewStep() { manifestObj?.getRequiredFields("no-mappings") ?? [] ) .filter((f) => "fieldName" in f) - .map((f) => ("fieldName" in f ? f.displayName || f.fieldName : "")); + .map(getFieldDisplayName); // Selected optional fields const selectedOptionalFields = ( @@ -44,7 +45,7 @@ export function ReviewStep() { const fieldName = "fieldName" in f ? f.fieldName : ""; return fieldName && configObj?.getSelectedField(fieldName); }) - .map((f) => ("fieldName" in f ? f.displayName || f.fieldName : "")); + .map(getFieldDisplayName); // Configured field mappings (source → destination) const allMapFields = [ diff --git a/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx b/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx index 03863dac..77174cba 100644 --- a/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx +++ b/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx @@ -3,6 +3,7 @@ import { useInstallIntegrationProps } from "context/InstallIIntegrationContextPr import { useLocalConfig, useManifest } from "src/headless"; import { useProjectQuery } from "src/hooks/query/useProjectQuery"; import { useProvider } from "src/hooks/useProvider"; +import { getFieldDisplayName } from "src/utils/manifest"; import type { CheckboxItem } from "components/ui-base/Checkbox/CheckboxPagination"; @@ -13,7 +14,7 @@ import { AdditionalFieldsContent } from "./additional/AdditionalFieldsContent"; import { FieldsContent } from "./fields/FieldsContent"; import { MappingsContent } from "./mappings/MappingsContent"; import { ObjectTabs } from "./ObjectTabs"; -import { getFieldDisplayName, getFieldName } from "./subPageUtils"; +import { getFieldName } from "./subPageUtils"; import { useSubPageNavigation } from "./useSubPageNavigation"; import styles from "./configureObjectsStep.module.css"; diff --git a/src/components/InstallWizard/steps/configure-objects/fields/FieldsContent.tsx b/src/components/InstallWizard/steps/configure-objects/fields/FieldsContent.tsx index 88f2250d..ae071a65 100644 --- a/src/components/InstallWizard/steps/configure-objects/fields/FieldsContent.tsx +++ b/src/components/InstallWizard/steps/configure-objects/fields/FieldsContent.tsx @@ -1,8 +1,9 @@ import type { HydratedIntegrationField } from "@generated/api/src"; import { ArrowRightIcon, WidthIcon } from "@radix-ui/react-icons"; +import { getFieldDisplayName } from "src/utils/manifest"; import { SectionHeader } from "../../../components/SectionHeader"; -import { getFieldDisplayName, getFieldName } from "../subPageUtils"; +import { getFieldName } from "../subPageUtils"; import sharedStyles from "../configureObjectsStep.module.css"; import styles from "./fieldsContent.module.css"; diff --git a/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts index cd3e19d0..b2c768e7 100644 --- a/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts +++ b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts @@ -14,11 +14,6 @@ export function getFieldName(field: HydratedIntegrationField): string { return isExistentField(field) ? field.fieldName : ""; } -export function getFieldDisplayName(field: HydratedIntegrationField): string { - if (isExistentField(field)) return field.displayName || field.fieldName; - return ""; -} - /** * Determine the initial sub-page for an object based on what data it has. */ diff --git a/src/utils/manifest.test.ts b/src/utils/manifest.test.ts new file mode 100644 index 00000000..d56750fc --- /dev/null +++ b/src/utils/manifest.test.ts @@ -0,0 +1,104 @@ +import type { + HydratedIntegrationFieldExistent, + IntegrationFieldMapping, +} from "@generated/api/src"; +import { describe, expect, it } from "@jest/globals"; + +import { getFieldDisplayName, isIntegrationFieldMapping } from "./manifest"; + +describe("isIntegrationFieldMapping", () => { + it("treats a field without fieldName as a mapping", () => { + expect(isIntegrationFieldMapping({ mapToName: "priority" })).toBe(true); + }); + + it("treats a field with fieldName as an existent field", () => { + expect( + isIntegrationFieldMapping({ fieldName: "email", displayName: "Email" }), + ).toBe(false); + }); +}); + +describe("getFieldDisplayName", () => { + describe("existent fields", () => { + it("uses the provider displayName when the builder defined no mapping", () => { + const field: HydratedIntegrationFieldExistent = { + fieldName: "email", + displayName: "Email", + }; + + expect(getFieldDisplayName(field)).toBe("Email"); + }); + + it("falls back to fieldName when there is no displayName", () => { + const field = { + fieldName: "estimate_number", + displayName: "", + } as HydratedIntegrationFieldExistent; + + expect(getFieldDisplayName(field)).toBe("estimate_number"); + }); + + // The reported bug. A nested field has no provider metadata, so the server + // hydrates displayName as TitleCase(fieldName) and the raw path renders. + it("prefers mapToDisplayName over a JSONPath displayName", () => { + const field: HydratedIntegrationFieldExistent = { + fieldName: "$['Customer']['Email']", + displayName: "$['Customer']['Email']", + mapToName: "customer_email", + mapToDisplayName: "Customer Email", + }; + + expect(getFieldDisplayName(field)).toBe("Customer Email"); + }); + + // displayName outranks mapToName, and the server always populates it, so a + // nested field needs mapToDisplayName to render a readable label. + it("falls back to displayName when mapToDisplayName is absent", () => { + const field: HydratedIntegrationFieldExistent = { + fieldName: "$['Customer']['First_name']", + displayName: "$['Customer']['First_name']", + mapToName: "customer_first_name", + }; + + expect(getFieldDisplayName(field)).toBe("$['Customer']['First_name']"); + }); + + it("prefers mapToDisplayName over a usable provider displayName", () => { + const field: HydratedIntegrationFieldExistent = { + fieldName: "firstname", + displayName: "First Name", + mapToName: "customer_first_name", + mapToDisplayName: "Customer First Name", + }; + + expect(getFieldDisplayName(field)).toBe("Customer First Name"); + }); + + it("prefers the provider displayName over mapToName", () => { + const field: HydratedIntegrationFieldExistent = { + fieldName: "phone", + displayName: "Business Phone", + mapToName: "customer_phone", + }; + + expect(getFieldDisplayName(field)).toBe("Business Phone"); + }); + }); + + describe("field mappings", () => { + it("uses mapToDisplayName when present", () => { + const field: IntegrationFieldMapping = { + mapToName: "priority", + mapToDisplayName: "Priority", + }; + + expect(getFieldDisplayName(field)).toBe("Priority"); + }); + + it("falls back to mapToName", () => { + const field: IntegrationFieldMapping = { mapToName: "priority" }; + + expect(getFieldDisplayName(field)).toBe("priority"); + }); + }); +}); diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index 5409baed..330e4b8a 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -15,6 +15,36 @@ export function isIntegrationFieldMapping( return !(field as HydratedIntegrationFieldExistent).fieldName; } +/** + * Returns the label to show for a field in the read UI. + * + * Precedence: mapToDisplayName > displayName > mapToName > fieldName. + * + * mapToDisplayName wins so a builder can give a nested field a readable label. + * The provider has no metadata for a nested path, so the server falls back to + * TitleCase(fieldName) and the raw JSONPath would otherwise show, e.g. + * "$['Customer']['Email']". + * + * displayName sits above mapToName so a provider's own label is preferred over a + * builder's machine name. Note the server always populates displayName, so + * mapToName is only reached for an IntegrationFieldMapping, which has none. + * + * @param field HydratedIntegrationField + * @returns string + */ +export function getFieldDisplayName(field: HydratedIntegrationField): string { + // displayName and fieldName only exist on HydratedIntegrationFieldExistent. + // On an IntegrationFieldMapping they are undefined and get skipped. + const existent = field as HydratedIntegrationFieldExistent; + + return ( + field.mapToDisplayName || + existent.displayName || + field.mapToName || + existent.fieldName + ); +} + /** * Returns the required existent fields from an object (mappings excluded). * For required mapping fields use getRequiredMapFieldsFromObject.