diff --git a/src/components/InstallWizard/steps/ReviewStep.tsx b/src/components/InstallWizard/steps/ReviewStep.tsx
index a4e72f71..a392482f 100644
--- a/src/components/InstallWizard/steps/ReviewStep.tsx
+++ b/src/components/InstallWizard/steps/ReviewStep.tsx
@@ -13,6 +13,8 @@ import { handleServerError } from "src/utils/handleServerError";
import { StepHeader } from "../components/StepHeader";
import { useWizard } from "../wizard/WizardContext";
+import { buildSubmissionConfig } from "./configure-objects/buildSubmissionConfig";
+
import styles from "./reviewStep.module.css";
export function ReviewStep() {
@@ -21,7 +23,8 @@ export function ReviewStep() {
const manifest = useManifest();
const localConfig = useLocalConfig();
const { createInstallation, isPending } = useCreateInstallation();
- const { onInstallSuccess, setInstallation } = useInstallIntegrationProps();
+ const { onInstallSuccess, setInstallation, fieldMapping } =
+ useInstallIntegrationProps();
// Build summary data for each selected object
const objectSummaries = useMemo(() => {
@@ -46,11 +49,26 @@ export function ReviewStep() {
})
.map((f) => ("fieldName" in f ? f.displayName || f.fieldName : ""));
- // Configured field mappings (source → destination)
- const allMapFields = [
+ // Configured field mappings (source → destination). Includes prop dynamic
+ // field mappings (entries with a mapToName and no fixed fieldName), deduped
+ // so a mapToName isn't listed twice.
+ const propDynamicMapFields = (fieldMapping?.[objectName] ?? [])
+ .filter((entry) => entry.mapToName && !entry.fieldName)
+ .map((entry) => ({
+ mapToName: entry.mapToName as string,
+ mapToDisplayName: entry.mapToDisplayName,
+ }));
+ const manifestMapFields = [
...(manifestObj?.getRequiredMapFields() ?? []),
...(manifestObj?.getOptionalMapFields() ?? []),
];
+ const propMapToNames = new Set(
+ propDynamicMapFields.map((f) => f.mapToName),
+ );
+ const allMapFields = [
+ ...manifestMapFields.filter((f) => !propMapToNames.has(f.mapToName)),
+ ...propDynamicMapFields,
+ ];
const customerFields =
manifest.getCustomerFieldsForObject(objectName).allFields ?? {};
const configuredMappings = allMapFields
@@ -90,13 +108,13 @@ export function ReviewStep() {
bidirectional,
};
});
- }, [selectedObjects, manifest, localConfig]);
+ }, [selectedObjects, manifest, localConfig, fieldMapping]);
const handleCreate = useCallback(() => {
setSubmissionError(null);
createInstallation({
- config: localConfig.draft,
+ config: buildSubmissionConfig(localConfig.draft, fieldMapping),
onSuccess: (installation) => {
setInstallation(installation);
onInstallSuccess?.(installation.id, installation.config as Config);
@@ -109,6 +127,7 @@ export function ReviewStep() {
}, [
createInstallation,
localConfig.draft,
+ fieldMapping,
setSubmissionError,
setInstallation,
onInstallSuccess,
diff --git a/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx b/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx
index 03863dac..c8a62bf5 100644
--- a/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx
+++ b/src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx
@@ -14,6 +14,7 @@ import { FieldsContent } from "./fields/FieldsContent";
import { MappingsContent } from "./mappings/MappingsContent";
import { ObjectTabs } from "./ObjectTabs";
import { getFieldDisplayName, getFieldName } from "./subPageUtils";
+import { useObjectMappings } from "./useObjectMappings";
import { useSubPageNavigation } from "./useSubPageNavigation";
import styles from "./configureObjectsStep.module.css";
@@ -46,15 +47,10 @@ export function ConfigureObjectsStep() {
[currentManifestObject],
);
- const requiredMapFields = useMemo(
- () => currentManifestObject?.getRequiredMapFields() ?? [],
- [currentManifestObject],
- );
-
- const optionalMapFields = useMemo(
- () => currentManifestObject?.getOptionalMapFields() ?? [],
- [currentManifestObject],
- );
+ // Field mappings (manifest + fieldMapping prop dynamic mappings) and value
+ // mappings (fieldMapping prop entries with mappedValues) for this object.
+ const { requiredMapFields, optionalMapFields, valueMappingUnits } =
+ useObjectMappings(currentObjectName);
const customerFields = useMemo(() => {
if (!currentObjectName) return {};
@@ -162,6 +158,7 @@ export function ConfigureObjectsStep() {
isMappingBidirectional={isMappingBidirectional}
requiredMapFields={requiredMapFields}
optionalMapFields={optionalMapFields}
+ valueMappingUnits={valueMappingUnits}
customerFieldOptions={customerFieldOptions}
configHandlers={configHandlers}
/>
diff --git a/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx b/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx
index 06a8281e..1aaba955 100644
--- a/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx
+++ b/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx
@@ -1,4 +1,5 @@
import { useMemo } from "react";
+import { useInstallIntegrationProps } from "context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider";
import { useManifest } from "src/headless";
import { useWizard } from "../../wizard/WizardContext";
@@ -14,12 +15,13 @@ interface ObjectTabsProps {
export function ObjectTabs({ currentPageIndex, onTabClick }: ObjectTabsProps) {
const manifest = useManifest();
+ const { fieldMapping } = useInstallIntegrationProps();
const { state } = useWizard();
const { selectedObjects, currentObjectIndex } = state;
const objectTabs = useMemo(() => {
return selectedObjects.map((objName, objIndex) => {
- const pages = getSubPages(manifest, objName);
+ const pages = getSubPages(manifest, objName, fieldMapping);
const displayName =
manifest.getReadObject(objName)?.object?.displayName || objName;
@@ -35,7 +37,13 @@ export function ObjectTabs({ currentPageIndex, onTabClick }: ObjectTabsProps) {
return { objName, displayName, dots, objIndex };
});
- }, [selectedObjects, manifest, currentObjectIndex, currentPageIndex]);
+ }, [
+ selectedObjects,
+ manifest,
+ fieldMapping,
+ currentObjectIndex,
+ currentPageIndex,
+ ]);
return (
diff --git a/src/components/InstallWizard/steps/configure-objects/buildSubmissionConfig.ts b/src/components/InstallWizard/steps/configure-objects/buildSubmissionConfig.ts
new file mode 100644
index 00000000..bad2cc7b
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/buildSubmissionConfig.ts
@@ -0,0 +1,53 @@
+import { produce } from "immer";
+import type { FieldMapping } from "src/components/Configure/InstallIntegration";
+import type { InstallationConfigContent } from "src/headless/config/types";
+
+/**
+ * Returns a copy of the config draft with stale value mappings pruned.
+ *
+ * Value mappings are stored keyed by the resolved provider field name. When the
+ * user re-maps a field (or the field becomes unresolved), the picks under the
+ * old field name become orphans. Rather than eagerly clearing them on every
+ * field-mapping edit (which would lose the user's work if they toggle back), we
+ * keep them in the draft and derive the effective config here, at submit time:
+ * only `selectedValueMappings[field]` entries whose field is currently resolved
+ * by a value-mapping unit (a `fieldMapping` prop entry with `mappedValues`) are
+ * kept.
+ */
+export function buildSubmissionConfig(
+ config: InstallationConfigContent,
+ fieldMapping?: FieldMapping,
+): InstallationConfigContent {
+ const objects = config.read?.objects;
+ if (!objects) return config;
+
+ return produce(config, (draft) => {
+ const draftObjects = draft.read?.objects;
+ if (!draftObjects) return;
+
+ Object.entries(draftObjects).forEach(([objectName, obj]) => {
+ const selectedValueMappings = obj.selectedValueMappings;
+ if (!selectedValueMappings) return;
+
+ // Provider fields currently targeted by a value-mapping unit.
+ const resolvedFields = new Set
();
+ (fieldMapping?.[objectName] ?? []).forEach((entry) => {
+ if (!entry.mappedValues?.length) return;
+ const field = entry.mapToName
+ ? obj.selectedFieldMappings?.[entry.mapToName]
+ : entry.fieldName;
+ if (field) resolvedFields.add(field);
+ });
+
+ Object.keys(selectedValueMappings).forEach((field) => {
+ if (!resolvedFields.has(field)) {
+ delete selectedValueMappings[field];
+ }
+ });
+
+ if (Object.keys(selectedValueMappings).length === 0) {
+ delete obj.selectedValueMappings;
+ }
+ });
+ });
+}
diff --git a/src/components/InstallWizard/steps/configure-objects/mappings/MappingRow.tsx b/src/components/InstallWizard/steps/configure-objects/mappings/MappingRow.tsx
index 1ab946c8..50dcee91 100644
--- a/src/components/InstallWizard/steps/configure-objects/mappings/MappingRow.tsx
+++ b/src/components/InstallWizard/steps/configure-objects/mappings/MappingRow.tsx
@@ -27,15 +27,33 @@ export function MappingRow({
}: MappingRowProps) {
const selectedValue = configHandlers.getFieldMapping(mapping.mapToName) ?? "";
+ // Fields already mapped to a different destination — filtered out so the same
+ // provider field can't be mapped twice within an object (mirrors classic
+ // checkDuplicateFieldError, but prevents the duplicate rather than erroring).
+ const selectedFieldMappings = configHandlers.object?.selectedFieldMappings;
+ const usedByOtherMappings = useMemo(
+ () =>
+ new Set(
+ Object.entries(selectedFieldMappings ?? {})
+ .filter(([mapToName]) => mapToName !== mapping.mapToName)
+ .map(([, fieldName]) => fieldName),
+ ),
+ [selectedFieldMappings, mapping.mapToName],
+ );
+
const items = useMemo(() => {
+ const availableOptions = customerFieldOptions.filter(
+ (f) =>
+ f.fieldName === selectedValue || !usedByOtherMappings.has(f.fieldName),
+ );
const displayNameCounts = new Map();
- customerFieldOptions.forEach((f) => {
+ availableOptions.forEach((f) => {
displayNameCounts.set(
f.displayName,
(displayNameCounts.get(f.displayName) ?? 0) + 1,
);
});
- return customerFieldOptions
+ return availableOptions
.map((f) => ({
id: f.fieldName,
label: f.displayName,
@@ -46,7 +64,7 @@ export function MappingRow({
: undefined,
}))
.sort((a, b) => a.label.localeCompare(b.label));
- }, [customerFieldOptions]);
+ }, [customerFieldOptions, usedByOtherMappings, selectedValue]);
return (
diff --git a/src/components/InstallWizard/steps/configure-objects/mappings/MappingsContent.tsx b/src/components/InstallWizard/steps/configure-objects/mappings/MappingsContent.tsx
index da11e464..ed7885ff 100644
--- a/src/components/InstallWizard/steps/configure-objects/mappings/MappingsContent.tsx
+++ b/src/components/InstallWizard/steps/configure-objects/mappings/MappingsContent.tsx
@@ -2,9 +2,12 @@ import type { IntegrationFieldMapping } from "@generated/api/src";
import type { ReadObjectHandlers } from "src/headless/config/useConfigHelper";
import { SectionHeader } from "../../../components/SectionHeader";
+import type { ValueMappingUnit } from "../useObjectMappings";
+import { ValueMappingBlock } from "../value-mappings/ValueMappingBlock";
import { MappingRow } from "./MappingRow";
+import valueMappingStyles from "../value-mappings/valueMappings.module.css";
import styles from "./mappingsContent.module.css";
interface MappingsContentProps {
@@ -14,6 +17,7 @@ interface MappingsContentProps {
isMappingBidirectional: boolean;
requiredMapFields: IntegrationFieldMapping[];
optionalMapFields: IntegrationFieldMapping[];
+ valueMappingUnits: ValueMappingUnit[];
customerFieldOptions: Array<{ fieldName: string; displayName: string }>;
configHandlers: ReadObjectHandlers;
}
@@ -25,47 +29,71 @@ export function MappingsContent({
isMappingBidirectional,
requiredMapFields,
optionalMapFields,
+ valueMappingUnits,
customerFieldOptions,
configHandlers,
}: MappingsContentProps) {
+ const hasFieldMappings =
+ requiredMapFields.length > 0 || optionalMapFields.length > 0;
return (
<>
-
-
-
-
- {providerDisplayName} Field
-
-
- {appName} Field
-
-
- {requiredMapFields.map((mapping) => (
-
+
- ))}
- {optionalMapFields.map((mapping) => (
-
+
+
+ {providerDisplayName} Field
+
+
+ {appName} Field
+
+
+ {requiredMapFields.map((mapping) => (
+
+ ))}
+ {optionalMapFields.map((mapping) => (
+
+ ))}
+
+ >
+ )}
+
+ {valueMappingUnits.length > 0 && (
+
+
- ))}
-
+ {valueMappingUnits.map((unit) => (
+
+ ))}
+
+ )}
>
);
}
diff --git a/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts
index cd3e19d0..51cebbf5 100644
--- a/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts
+++ b/src/components/InstallWizard/steps/configure-objects/subPageUtils.ts
@@ -1,9 +1,34 @@
import type { HydratedIntegrationField } from "@generated/api/src";
import type { HydratedIntegrationFieldExistent } from "@generated/api/src";
+import type { FieldMapping } from "src/components/Configure/InstallIntegration";
import type { Manifest } from "src/headless/types";
export type SubPage = "fields" | "mappings" | "additional";
+/**
+ * True when an object has any mappings to show on the Mappings sub-page:
+ * manifest map fields, prop dynamic field mappings (a `mapToName` with no fixed
+ * `fieldName`), or prop value mappings (`mappedValues`).
+ */
+export function hasObjectMappings(
+ manifest: Manifest,
+ objectName: string,
+ fieldMapping?: FieldMapping,
+): boolean {
+ const obj = manifest.getReadObject(objectName);
+ const manifestHasMappings =
+ (obj?.getRequiredMapFields()?.length ?? 0) > 0 ||
+ (obj?.getOptionalMapFields()?.length ?? 0) > 0;
+
+ const propHasMappings = (fieldMapping?.[objectName] ?? []).some(
+ (entry) =>
+ (entry.mapToName && !entry.fieldName) ||
+ (entry.mappedValues?.length ?? 0) > 0,
+ );
+
+ return manifestHasMappings || propHasMappings;
+}
+
export function isExistentField(
field: HydratedIntegrationField,
): field is HydratedIntegrationFieldExistent {
@@ -25,8 +50,9 @@ export function getFieldDisplayName(field: HydratedIntegrationField): string {
export function getInitialSubPage(
manifest: Manifest,
objectName: string,
+ fieldMapping?: FieldMapping,
): SubPage {
- const pages = getSubPages(manifest, objectName);
+ const pages = getSubPages(manifest, objectName, fieldMapping);
return pages[0];
}
@@ -36,15 +62,20 @@ export function getInitialSubPage(
export function getLastSubPage(
manifest: Manifest,
objectName: string,
+ fieldMapping?: FieldMapping,
): SubPage {
- const pages = getSubPages(manifest, objectName);
+ const pages = getSubPages(manifest, objectName, fieldMapping);
return pages[pages.length - 1];
}
/**
* Return the ordered list of sub-pages an object will visit.
*/
-export function getSubPages(manifest: Manifest, objectName: string): SubPage[] {
+export function getSubPages(
+ manifest: Manifest,
+ objectName: string,
+ fieldMapping?: FieldMapping,
+): SubPage[] {
const obj = manifest.getReadObject(objectName);
if (!obj) return ["fields"];
@@ -55,10 +86,9 @@ export function getSubPages(manifest: Manifest, objectName: string): SubPage[] {
const hasObjectMapping = !!obj.object?.mapToName;
if (hasRequiredFields || hasObjectMapping) pages.push("fields");
- const hasMappings =
- (obj.getRequiredMapFields()?.length ?? 0) > 0 ||
- (obj.getOptionalMapFields()?.length ?? 0) > 0;
- if (hasMappings) pages.push("mappings");
+ if (hasObjectMappings(manifest, objectName, fieldMapping)) {
+ pages.push("mappings");
+ }
const hasOptionalFields =
(obj.getOptionalFields("no-mappings")?.length ?? 0) > 0;
diff --git a/src/components/InstallWizard/steps/configure-objects/useObjectMappings.ts b/src/components/InstallWizard/steps/configure-objects/useObjectMappings.ts
new file mode 100644
index 00000000..2fe094a9
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/useObjectMappings.ts
@@ -0,0 +1,107 @@
+import { useMemo } from "react";
+import type {
+ DynamicMappingsInputMappedValue,
+ IntegrationFieldMapping,
+} from "@generated/api/src";
+import { useInstallIntegrationProps } from "context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider";
+import { useManifest } from "src/headless";
+
+/**
+ * A value-mapping unit derived from the `fieldMapping` prop. Either:
+ * - field-mapped: `mapToName` set, the resolved provider field comes from the
+ * user's field-mapping selection; or
+ * - fixed-field: `fieldName` set, the provider field is pre-known and the user
+ * only maps its values.
+ */
+export interface ValueMappingUnit {
+ /** Stable key for rendering (mapToName or fieldName). */
+ key: string;
+ /** Header label for the value block. */
+ displayName: string;
+ /** Present when the field must be picked by the user first. */
+ mapToName?: string;
+ /** Present when the provider field is fixed (no field-mapping step). */
+ fieldName?: string;
+ mappedValues: DynamicMappingsInputMappedValue[];
+}
+
+export interface ObjectMappings {
+ /** Manifest-declared required field mappings (gate Next). */
+ requiredMapFields: IntegrationFieldMapping[];
+ /**
+ * Optional field mappings = manifest optional map fields merged with prop
+ * dynamic field mappings (entries with a `mapToName` and no fixed
+ * `fieldName`). The prop wins on `mapToName` collisions.
+ */
+ optionalMapFields: IntegrationFieldMapping[];
+ /** Value-mapping blocks derived from prop entries with `mappedValues`. */
+ valueMappingUnits: ValueMappingUnit[];
+}
+
+const EMPTY: ObjectMappings = {
+ requiredMapFields: [],
+ optionalMapFields: [],
+ valueMappingUnits: [],
+};
+
+/**
+ * Merges amp.yaml manifest map fields with the developer-supplied `fieldMapping`
+ * prop into the field rows and value blocks rendered on the Mappings sub-page.
+ *
+ * Mirrors the classic Configure variant, where dynamic field mappings come from
+ * the prop (`OptionalFieldMappings`/`DynamicFieldMappings`) and value mappings
+ * come from prop entries carrying `mappedValues` (`ValuesMapping`).
+ */
+export function useObjectMappings(
+ objectName: string | undefined,
+): ObjectMappings {
+ const manifest = useManifest();
+ const { fieldMapping } = useInstallIntegrationProps();
+
+ return useMemo(() => {
+ if (!objectName) return EMPTY;
+
+ const obj = manifest.getReadObject(objectName);
+ const requiredMapFields = obj?.getRequiredMapFields() ?? [];
+ const manifestOptional = obj?.getOptionalMapFields() ?? [];
+
+ const propEntries = fieldMapping?.[objectName] ?? [];
+
+ // Prop dynamic field mappings: the user must pick a field for these
+ // (they carry a mapToName and have no fixed fieldName).
+ const dynamicFieldMappings = propEntries.filter(
+ (entry) => entry.mapToName && !entry.fieldName,
+ );
+
+ // Prop takes precedence over manifest optional on mapToName collisions.
+ const dynamicMapToNames = new Set(
+ dynamicFieldMappings.map((entry) => entry.mapToName),
+ );
+ const dedupedManifestOptional = manifestOptional.filter(
+ (field) => !dynamicMapToNames.has(field.mapToName),
+ );
+
+ const optionalMapFields: IntegrationFieldMapping[] = [
+ ...dedupedManifestOptional,
+ ...dynamicFieldMappings.map((entry) => ({
+ mapToName: entry.mapToName as string,
+ mapToDisplayName: entry.mapToDisplayName,
+ prompt: entry.prompt,
+ })),
+ ];
+
+ // Value mappings: any prop entry declaring mappedValues.
+ const valueMappingUnits: ValueMappingUnit[] = propEntries
+ .filter((entry) => (entry.mappedValues?.length ?? 0) > 0)
+ .map((entry) => ({
+ key: entry.mapToName ?? entry.fieldName ?? "",
+ displayName:
+ entry.mapToDisplayName ?? entry.mapToName ?? entry.fieldName ?? "",
+ mapToName: entry.mapToName,
+ fieldName: entry.fieldName,
+ mappedValues: entry.mappedValues ?? [],
+ }));
+
+ return { requiredMapFields, optionalMapFields, valueMappingUnits };
+ }, [manifest, fieldMapping, objectName]);
+}
diff --git a/src/components/InstallWizard/steps/configure-objects/useSubPageNavigation.ts b/src/components/InstallWizard/steps/configure-objects/useSubPageNavigation.ts
index d8edaf0f..fcd52f24 100644
--- a/src/components/InstallWizard/steps/configure-objects/useSubPageNavigation.ts
+++ b/src/components/InstallWizard/steps/configure-objects/useSubPageNavigation.ts
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useInstallIntegrationProps } from "context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider";
import { useManifest } from "src/headless";
import { useWizard } from "../../wizard/WizardContext";
import type { SubPage } from "./subPageUtils";
-import { getInitialSubPage, getLastSubPage, getSubPages } from "./subPageUtils";
+import {
+ getInitialSubPage,
+ getLastSubPage,
+ getSubPages,
+ hasObjectMappings,
+} from "./subPageUtils";
export function useSubPageNavigation() {
const manifest = useManifest();
+ const { fieldMapping } = useInstallIntegrationProps();
const {
state,
isFirstObject,
@@ -27,7 +34,7 @@ export function useSubPageNavigation() {
// has mappings).
const [subPage, setSubPage] = useState(() =>
currentObjectName
- ? getInitialSubPage(manifest, currentObjectName)
+ ? getInitialSubPage(manifest, currentObjectName, fieldMapping)
: "fields",
);
const pendingSubPageRef = useRef(null);
@@ -43,9 +50,9 @@ export function useSubPageNavigation() {
setSubPage(pendingSubPageRef.current);
pendingSubPageRef.current = null;
} else if (currentObjectName) {
- setSubPage(getInitialSubPage(manifest, currentObjectName));
+ setSubPage(getInitialSubPage(manifest, currentObjectName, fieldMapping));
}
- }, [currentObjectName, manifest]);
+ }, [currentObjectName, manifest, fieldMapping]);
// Derived booleans for current object
const currentManifestObject = useMemo(() => {
@@ -53,11 +60,12 @@ export function useSubPageNavigation() {
return manifest.getReadObject(currentObjectName);
}, [manifest, currentObjectName]);
- const hasMappings = useMemo(() => {
- const required = currentManifestObject?.getRequiredMapFields()?.length ?? 0;
- const optional = currentManifestObject?.getOptionalMapFields()?.length ?? 0;
- return required > 0 || optional > 0;
- }, [currentManifestObject]);
+ const hasMappings = useMemo(
+ () =>
+ !!currentObjectName &&
+ hasObjectMappings(manifest, currentObjectName, fieldMapping),
+ [manifest, currentObjectName, fieldMapping],
+ );
const hasOptionalFields = useMemo(() => {
return (
@@ -129,7 +137,11 @@ export function useSubPageNavigation() {
const prevIndex = currentObjectIndex - 1;
const prevObjName = selectedObjects[prevIndex];
if (prevObjName) {
- pendingSubPageRef.current = getLastSubPage(manifest, prevObjName);
+ pendingSubPageRef.current = getLastSubPage(
+ manifest,
+ prevObjName,
+ fieldMapping,
+ );
}
prevObject();
}
@@ -142,13 +154,17 @@ export function useSubPageNavigation() {
currentObjectIndex,
selectedObjects,
manifest,
+ fieldMapping,
prevObject,
]);
// Object tabs — page tracking
const currentObjectPages = useMemo(
- () => (currentObjectName ? getSubPages(manifest, currentObjectName) : []),
- [manifest, currentObjectName],
+ () =>
+ currentObjectName
+ ? getSubPages(manifest, currentObjectName, fieldMapping)
+ : [],
+ [manifest, currentObjectName, fieldMapping],
);
const currentPageIndex = currentObjectPages.indexOf(subPage);
@@ -158,11 +174,21 @@ export function useSubPageNavigation() {
// Navigate to a completed object — land on its last sub-page
const objName = selectedObjects[objIndex];
if (objName) {
- pendingSubPageRef.current = getLastSubPage(manifest, objName);
+ pendingSubPageRef.current = getLastSubPage(
+ manifest,
+ objName,
+ fieldMapping,
+ );
}
setCurrentObjectIndex(objIndex);
},
- [currentObjectIndex, selectedObjects, manifest, setCurrentObjectIndex],
+ [
+ currentObjectIndex,
+ selectedObjects,
+ manifest,
+ fieldMapping,
+ setCurrentObjectIndex,
+ ],
);
return {
diff --git a/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx
new file mode 100644
index 00000000..b40515be
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx
@@ -0,0 +1,107 @@
+import { useMemo } from "react";
+import { useManifest } from "src/headless";
+import type { ReadObjectHandlers } from "src/headless/config/useConfigHelper";
+
+import type { ValueMappingUnit } from "../useObjectMappings";
+
+import { getAvailableOptions, validateValueMapping } from "./utils";
+import { ValueMappingRow } from "./ValueMappingRow";
+
+import styles from "./valueMappings.module.css";
+
+interface ValueMappingBlockProps {
+ objectName: string;
+ unit: ValueMappingUnit;
+ configHandlers: ReadObjectHandlers;
+}
+
+/**
+ * Renders the value-mapping rows for a single prop entry. The provider field is
+ * resolved from the user's field-mapping selection (for `mapToName` units) or
+ * the entry's fixed `fieldName`. Renders nothing until a field is resolved or if
+ * the field can't have its values mapped (mirrors classic validation).
+ */
+export function ValueMappingBlock({
+ objectName,
+ unit,
+ configHandlers,
+}: ValueMappingBlockProps) {
+ const manifest = useManifest();
+
+ const resolvedField = unit.mapToName
+ ? configHandlers.getFieldMapping(unit.mapToName)
+ : unit.fieldName;
+
+ const fieldMetadata = useMemo(
+ () =>
+ resolvedField
+ ? manifest
+ .getCustomerFieldsForObject(objectName)
+ .getField(resolvedField)
+ : null,
+ [manifest, objectName, resolvedField],
+ );
+
+ const hintBlock = (message: string) => (
+
+
+ Map the values for {unit.displayName}
+
+
{message}
+
+ );
+
+ // Field-mapped units have no field selected yet — prompt the user instead of
+ // hiding the section, so it's clear values will need mapping once a field is
+ // chosen.
+ if (!resolvedField) {
+ return hintBlock("Select a field above to map its values.");
+ }
+
+ const validation = validateValueMapping(
+ resolvedField,
+ unit.mappedValues.length,
+ fieldMetadata ?? undefined,
+ );
+ if (!validation.isValid) {
+ const isSelectType = ["singleSelect", "multiSelect"].includes(
+ fieldMetadata?.valueType ?? "",
+ );
+ return hintBlock(
+ isSelectType
+ ? "The selected field's values can't be mapped to these options."
+ : "Select a picklist (single/multi-select) field to map its values.",
+ );
+ }
+
+ const allOptions = fieldMetadata?.values ?? [];
+ const mappingsForField =
+ configHandlers.object?.selectedValueMappings?.[resolvedField] ?? {};
+
+ return (
+
+
+ Map the values for {unit.displayName}
+
+ {unit.mappedValues.map((mappedValue) => (
+
+ configHandlers.setValueMapping({
+ fieldName: resolvedField,
+ sourceValue: mappedValue.mappedValue,
+ targetValue,
+ })
+ }
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingRow.tsx b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingRow.tsx
new file mode 100644
index 00000000..58281683
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingRow.tsx
@@ -0,0 +1,46 @@
+import { useMemo } from "react";
+import type { FieldValue } from "@generated/api/src";
+import { ComboBox } from "src/components/ui-base/ComboBox/ComboBox";
+
+import styles from "./valueMappings.module.css";
+
+interface ValueMappingRowProps {
+ /** The app value being mapped (its human-readable label). */
+ sourceDisplayValue: string;
+ /** Available provider values to map onto. */
+ options: FieldValue[];
+ /** Currently selected provider value, if any. */
+ selectedValue: string | undefined;
+ /** Called with the chosen provider value, or "" to clear. */
+ onChange: (targetValue: string) => void;
+}
+
+export function ValueMappingRow({
+ sourceDisplayValue,
+ options,
+ selectedValue,
+ onChange,
+}: ValueMappingRowProps) {
+ const items = useMemo(
+ () =>
+ options.map((option) => ({
+ id: option.value,
+ label: option.displayValue,
+ value: option.value,
+ })),
+ [options],
+ );
+
+ return (
+
+ {sourceDisplayValue}
+ onChange(item?.value ?? "")}
+ placeholder="Please select one"
+ clearable
+ />
+
+ );
+}
diff --git a/src/components/InstallWizard/steps/configure-objects/value-mappings/utils.ts b/src/components/InstallWizard/steps/configure-objects/value-mappings/utils.ts
new file mode 100644
index 00000000..5ca03d3d
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/utils.ts
@@ -0,0 +1,73 @@
+import type { FieldMetadata, FieldValue } from "@generated/api/src";
+
+/**
+ * Result of validating whether a value mapping can be rendered for a field.
+ */
+export interface ValueMappingValidation {
+ isValid: boolean;
+ errorMessage?: string;
+}
+
+/**
+ * Validates that the resolved provider field can have its values mapped:
+ * - a field must be resolved,
+ * - the field must be a singleSelect / multiSelect,
+ * - the field must expose a `values` array,
+ * - the provider value count must match the declared mappedValues count.
+ *
+ * Mirrors the classic `ValueMapping/utils.ts` behavior so the wizard produces
+ * the same config as the legacy flow.
+ */
+export function validateValueMapping(
+ fieldName: string | undefined,
+ mappedValuesCount: number,
+ fieldMetadata?: FieldMetadata,
+): ValueMappingValidation {
+ if (!fieldName) {
+ return { isValid: false, errorMessage: "Field name is missing" };
+ }
+
+ const valueType = fieldMetadata?.valueType;
+ if (!["singleSelect", "multiSelect"].includes(valueType || "")) {
+ return {
+ isValid: false,
+ errorMessage: "field is not a singleSelect or multiSelect",
+ };
+ }
+
+ const values = fieldMetadata?.values;
+ if (!values) {
+ return { isValid: false, errorMessage: "Field values array is missing" };
+ }
+
+ if (values.length !== mappedValuesCount) {
+ return {
+ isValid: false,
+ errorMessage:
+ "field values and the values to be mapped are not of the same length",
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Gets the available provider value options for a single mapped (app) value,
+ * filtering out provider values already selected for other source values so the
+ * same provider value can't be picked twice within one field.
+ */
+export function getAvailableOptions(
+ allOptions: FieldValue[],
+ selectedMappings: Record,
+ currentSourceValue: string,
+): FieldValue[] {
+ const currentlySelectedValues =
+ Object.values(selectedMappings).filter(Boolean);
+ const currentValueSelection = selectedMappings[currentSourceValue];
+
+ return allOptions.filter((option) => {
+ const isCurrentSelection = option.value === currentValueSelection;
+ const isAlreadySelected = currentlySelectedValues.includes(option.value);
+ return isCurrentSelection || !isAlreadySelected;
+ });
+}
diff --git a/src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css b/src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css
new file mode 100644
index 00000000..c3088136
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css
@@ -0,0 +1,43 @@
+.valueMappingSection {
+ margin-top: var(--amp-space-4);
+}
+
+.valueMappingBlock {
+ margin-bottom: var(--amp-space-4);
+}
+
+.valueMappingBlock:last-of-type {
+ margin-bottom: 0;
+}
+
+.valueMappingBlockTitle {
+ font-size: var(--amp-font-size-sm);
+ font-weight: var(--amp-font-semibold);
+ color: var(--amp-text-secondary);
+ margin-bottom: var(--amp-space-2);
+}
+
+.valueMappingRow {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ align-items: center;
+ gap: var(--amp-space-2);
+ padding: var(--amp-space-2) 0;
+ border-bottom: 1px solid var(--amp-border-default);
+}
+
+.valueMappingRow:last-of-type {
+ border-bottom: none;
+}
+
+.valueMappingLabel {
+ font-size: var(--amp-font-size-md);
+ color: var(--amp-text-secondary);
+}
+
+.valueMappingHint {
+ font-size: var(--amp-font-size-sm);
+ color: var(--amp-text-muted);
+ font-style: italic;
+ padding: var(--amp-space-1) 0;
+}
diff --git a/src/headless/config/useConfigHelper.tsx b/src/headless/config/useConfigHelper.tsx
index 7ef67f51..524b108b 100644
--- a/src/headless/config/useConfigHelper.tsx
+++ b/src/headless/config/useConfigHelper.tsx
@@ -26,6 +26,15 @@ export type ReadObjectHandlers = {
getFieldMapping: (fieldName: string) => string | undefined;
setFieldMapping: (params: { fieldName: string; mapToName: string }) => void;
deleteFieldMapping: (mapToName: string) => void;
+ getValueMapping: (
+ fieldName: string,
+ sourceValue: string,
+ ) => string | undefined;
+ setValueMapping: (params: {
+ fieldName: string;
+ sourceValue: string;
+ targetValue: string;
+ }) => void;
};
export type WriteObjectHandlers = {
@@ -216,6 +225,31 @@ export function useConfigHelper(initialConfig: InstallationConfigContent) {
}),
);
},
+
+ getValueMapping: (fieldName: string, sourceValue: string) =>
+ draft.read?.objects?.[objectName]?.selectedValueMappings?.[fieldName]?.[
+ sourceValue
+ ],
+
+ setValueMapping: ({ fieldName, sourceValue, targetValue }) => {
+ setDraft((prev) =>
+ produce(prev, (_draft) => {
+ const { obj } = initializeObjectWithDefaults(objectName, _draft);
+
+ // Initialize selectedValueMappings structure if it doesn't exist
+ obj.selectedValueMappings = obj.selectedValueMappings || {};
+ obj.selectedValueMappings[fieldName] =
+ obj.selectedValueMappings[fieldName] || {};
+
+ // An empty target clears the mapping for this source value
+ if (targetValue === "") {
+ delete obj.selectedValueMappings[fieldName][sourceValue];
+ } else {
+ obj.selectedValueMappings[fieldName][sourceValue] = targetValue;
+ }
+ }),
+ );
+ },
}),
[draft.read?.objects, initializeObjectWithDefaults],
);