From 13d0495ac49f57fe37cf131ec8591e1167cc4f4f Mon Sep 17 00:00:00 2001 From: Anusha Talasila Date: Thu, 30 Jul 2026 16:41:39 -0700 Subject: [PATCH 1/4] feat: show displayName for nested objects --- .../OptionalFields/OptionalFieldsV2.tsx | 8 +- .../content/fields/RequiredFields.tsx | 23 +++- .../InstallWizard/steps/ReviewStep.tsx | 5 +- .../steps/configure-objects/subPageUtils.ts | 7 +- src/utils/manifest.test.ts | 104 ++++++++++++++++++ src/utils/manifest.ts | 29 +++++ 6 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 src/utils/manifest.test.ts diff --git a/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx b/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx index 99f976e2..71557f09 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, @@ -35,8 +38,9 @@ export function OptionalFieldsV2() { "displayName" in field, ) .map((field) => ({ + // id stays the provider field name: it is the key used in the config 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..83402d98 100644 --- a/src/components/Configure/content/fields/RequiredFields.tsx +++ b/src/components/Configure/content/fields/RequiredFields.tsx @@ -1,6 +1,10 @@ +import { HydratedIntegrationFieldExistent } from "services/api"; 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"; @@ -37,10 +41,19 @@ export function RequiredFields() { }} > {requiredFields?.length - ? requiredFields.map((field) => { - if (isIntegrationFieldMapping(field)) return null; - return {field.displayName}; - }) + ? requiredFields + .filter( + (field): field is HydratedIntegrationFieldExistent => + !isIntegrationFieldMapping(field), + ) + // the server sorts by its own displayName, which is not what we + // render, so re-sort by the label the user actually sees + .sort((a, b) => + getFieldDisplayName(a).localeCompare(getFieldDisplayName(b)), + ) + .map((field) => ( + {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/subPageUtils.ts b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts index cd3e19d0..926143bb 100644 --- a/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts +++ b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts @@ -14,10 +14,9 @@ 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 ""; -} +// Label resolution is shared with the non-wizard Configure view, so that a +// builder's mapToName/mapToDisplayName wins in both UIs. +export { getFieldDisplayName } from "src/utils/manifest"; /** * 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..6689776b --- /dev/null +++ b/src/utils/manifest.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@jest/globals"; +import type { + HydratedIntegrationFieldExistent, + IntegrationFieldMapping, +} from "@generated/api/src"; + +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"); + }); + + it("uses mapToName for a nested field 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"); + }); + + // Per the feature request, builder-defined names win even when the field is + // not nested and the provider supplied a perfectly good display 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 mapToName over a usable provider displayName", () => { + const field: HydratedIntegrationFieldExistent = { + fieldName: "phone", + displayName: "Business Phone", + mapToName: "customer_phone", + }; + + expect(getFieldDisplayName(field)).toBe("customer_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..13580c59 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -15,6 +15,35 @@ export function isIntegrationFieldMapping( return !(field as HydratedIntegrationFieldExistent).fieldName; } +/** + * Returns the label to show for a field in the read UI. + * + * Names the builder defined in amp.yaml (mapToDisplayName, then mapToName) take + * precedence over the provider's own displayName, for two reasons: + * + * 1. A nested field has no provider metadata for its path, so the server falls + * back to TitleCase(fieldName) and the raw JSONPath leaks into the UI + * (e.g. "$['Customer']['Email']"). + * 2. Even when the provider's name is good, the end user is configuring what + * lands in the builder's product, so the builder's naming is what they + * recognize. + * + * @param field HydratedIntegrationField + * @returns string + */ +export function getFieldDisplayName(field: HydratedIntegrationField): string { + if (isIntegrationFieldMapping(field)) { + return field.mapToDisplayName || field.mapToName; + } + + return ( + field.mapToDisplayName || + field.mapToName || + field.displayName || + field.fieldName + ); +} + /** * Returns the required existent fields from an object (mappings excluded). * For required mapping fields use getRequiredMapFieldsFromObject. From 7c1642aa7f68fa634c2888a97c25f005b3976d5a Mon Sep 17 00:00:00 2001 From: Anusha Talasila Date: Thu, 30 Jul 2026 16:56:39 -0700 Subject: [PATCH 2/4] fix linter error --- src/utils/manifest.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/manifest.test.ts b/src/utils/manifest.test.ts index 6689776b..441e6c3c 100644 --- a/src/utils/manifest.test.ts +++ b/src/utils/manifest.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from "@jest/globals"; import type { HydratedIntegrationFieldExistent, IntegrationFieldMapping, } from "@generated/api/src"; +import { describe, expect, it } from "@jest/globals"; import { getFieldDisplayName, isIntegrationFieldMapping } from "./manifest"; From 492f7a9000cba21a720181a8c2795552c2138627 Mon Sep 17 00:00:00 2001 From: Anusha Talasila Date: Fri, 31 Jul 2026 10:43:52 -0700 Subject: [PATCH 3/4] addressing review comments --- .../fields/OptionalFields/OptionalFieldsV2.tsx | 1 - .../content/fields/RequiredFields.tsx | 18 +++++------------- .../configure-objects/ConfigureObjectsStep.tsx | 3 ++- .../configure-objects/fields/FieldsContent.tsx | 3 ++- .../steps/configure-objects/subPageUtils.ts | 4 ---- src/utils/manifest.ts | 10 +++++----- 6 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx b/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx index 71557f09..ff61c380 100644 --- a/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx +++ b/src/components/Configure/content/fields/OptionalFields/OptionalFieldsV2.tsx @@ -38,7 +38,6 @@ export function OptionalFieldsV2() { "displayName" in field, ) .map((field) => ({ - // id stays the provider field name: it is the key used in the config id: field.fieldName, label: getFieldDisplayName(field), isChecked: !!selectedOptionalFields?.[field.fieldName], diff --git a/src/components/Configure/content/fields/RequiredFields.tsx b/src/components/Configure/content/fields/RequiredFields.tsx index 83402d98..ed81b78d 100644 --- a/src/components/Configure/content/fields/RequiredFields.tsx +++ b/src/components/Configure/content/fields/RequiredFields.tsx @@ -1,4 +1,3 @@ -import { HydratedIntegrationFieldExistent } from "services/api"; import { useManifest } from "src/headless"; import { useProjectQuery } from "src/hooks/query"; import { @@ -41,19 +40,12 @@ export function RequiredFields() { }} > {requiredFields?.length - ? requiredFields - .filter( - (field): field is HydratedIntegrationFieldExistent => - !isIntegrationFieldMapping(field), - ) - // the server sorts by its own displayName, which is not what we - // render, so re-sort by the label the user actually sees - .sort((a, b) => - getFieldDisplayName(a).localeCompare(getFieldDisplayName(b)), - ) - .map((field) => ( + ? requiredFields.map((field) => { + if (isIntegrationFieldMapping(field)) return null; + return ( {getFieldDisplayName(field)} - )) + ); + }) : "There are no required fields."} 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 926143bb..b2c768e7 100644 --- a/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts +++ b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts @@ -14,10 +14,6 @@ export function getFieldName(field: HydratedIntegrationField): string { return isExistentField(field) ? field.fieldName : ""; } -// Label resolution is shared with the non-wizard Configure view, so that a -// builder's mapToName/mapToDisplayName wins in both UIs. -export { getFieldDisplayName } from "src/utils/manifest"; - /** * Determine the initial sub-page for an object based on what data it has. */ diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index 13580c59..e4611764 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -32,15 +32,15 @@ export function isIntegrationFieldMapping( * @returns string */ export function getFieldDisplayName(field: HydratedIntegrationField): string { - if (isIntegrationFieldMapping(field)) { - return field.mapToDisplayName || field.mapToName; - } + // 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 || - field.displayName || - field.fieldName + existent.fieldName ); } From cd76dd54a1b753a8cec5e4819a5a768d4b2cfb36 Mon Sep 17 00:00:00 2001 From: Anusha Talasila Date: Fri, 31 Jul 2026 14:42:24 -0700 Subject: [PATCH 4/4] Address review comment. --- src/utils/manifest.test.ts | 12 ++++++------ src/utils/manifest.ts | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/utils/manifest.test.ts b/src/utils/manifest.test.ts index 441e6c3c..d56750fc 100644 --- a/src/utils/manifest.test.ts +++ b/src/utils/manifest.test.ts @@ -51,18 +51,18 @@ describe("getFieldDisplayName", () => { expect(getFieldDisplayName(field)).toBe("Customer Email"); }); - it("uses mapToName for a nested field when mapToDisplayName is absent", () => { + // 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"); + expect(getFieldDisplayName(field)).toBe("$['Customer']['First_name']"); }); - // Per the feature request, builder-defined names win even when the field is - // not nested and the provider supplied a perfectly good display name. it("prefers mapToDisplayName over a usable provider displayName", () => { const field: HydratedIntegrationFieldExistent = { fieldName: "firstname", @@ -74,14 +74,14 @@ describe("getFieldDisplayName", () => { expect(getFieldDisplayName(field)).toBe("Customer First Name"); }); - it("prefers mapToName over a usable provider displayName", () => { + it("prefers the provider displayName over mapToName", () => { const field: HydratedIntegrationFieldExistent = { fieldName: "phone", displayName: "Business Phone", mapToName: "customer_phone", }; - expect(getFieldDisplayName(field)).toBe("customer_phone"); + expect(getFieldDisplayName(field)).toBe("Business Phone"); }); }); diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index e4611764..330e4b8a 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -18,15 +18,16 @@ export function isIntegrationFieldMapping( /** * Returns the label to show for a field in the read UI. * - * Names the builder defined in amp.yaml (mapToDisplayName, then mapToName) take - * precedence over the provider's own displayName, for two reasons: + * Precedence: mapToDisplayName > displayName > mapToName > fieldName. * - * 1. A nested field has no provider metadata for its path, so the server falls - * back to TitleCase(fieldName) and the raw JSONPath leaks into the UI - * (e.g. "$['Customer']['Email']"). - * 2. Even when the provider's name is good, the end user is configuring what - * lands in the builder's product, so the builder's naming is what they - * recognize. + * 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