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.