From ee8d84177a6fb5adb6f03f4a4c5804559660ae3d Mon Sep 17 00:00:00 2001 From: Dion Low Date: Wed, 24 Jun 2026 15:28:43 -0700 Subject: [PATCH 1/2] feat(InstallWizard): support fieldMapping prop (dynamic field + value mappings) Bring the wizard variant to parity with the classic Configure flow for the developer-supplied `fieldMapping` prop, which the wizard previously ignored. - headless: add getValueMapping/setValueMapping to ReadObjectHandlers - derive merged mapping units from manifest map fields + fieldMapping prop (prop dynamic field mappings take precedence on mapToName collisions) - render value-mapping blocks per entry on the Mappings sub-page; field-mapped entries resolve their field from the user's selection, fixed-field entries (fieldName) map values directly - make the Mappings sub-page appear when mappings come only from the prop - prune stale value mappings via a derived projection at submit time Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InstallWizard/steps/ReviewStep.tsx | 29 ++++- .../ConfigureObjectsStep.tsx | 15 +-- .../steps/configure-objects/ObjectTabs.tsx | 12 +- .../buildSubmissionConfig.ts | 53 +++++++++ .../mappings/MappingsContent.tsx | 96 ++++++++++------ .../steps/configure-objects/subPageUtils.ts | 44 +++++-- .../configure-objects/useObjectMappings.ts | 107 ++++++++++++++++++ .../configure-objects/useSubPageNavigation.ts | 54 ++++++--- .../value-mappings/ValueMappingBlock.tsx | 88 ++++++++++++++ .../value-mappings/ValueMappingRow.tsx | 46 ++++++++ .../configure-objects/value-mappings/utils.ts | 73 ++++++++++++ .../value-mappings/valueMappings.module.css | 36 ++++++ src/headless/config/useConfigHelper.tsx | 34 ++++++ 13 files changed, 616 insertions(+), 71 deletions(-) create mode 100644 src/components/InstallWizard/steps/configure-objects/buildSubmissionConfig.ts create mode 100644 src/components/InstallWizard/steps/configure-objects/useObjectMappings.ts create mode 100644 src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx create mode 100644 src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingRow.tsx create mode 100644 src/components/InstallWizard/steps/configure-objects/value-mappings/utils.ts create mode 100644 src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css 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/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..ddb6bb2e --- /dev/null +++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx @@ -0,0 +1,88 @@ +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], + ); + + // Field-mapped units have no field selected yet — nothing to map. + if (!resolvedField) return null; + + const validation = validateValueMapping( + resolvedField, + unit.mappedValues.length, + fieldMetadata ?? undefined, + ); + if (!validation.isValid) { + console.error(validation.errorMessage, unit); + return null; + } + + 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..eabcf2a3 --- /dev/null +++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css @@ -0,0 +1,36 @@ +.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); +} 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], ); From dc4d1eff77e83d01406dd6115d87c8bd8a24ac48 Mon Sep 17 00:00:00 2001 From: Dion Low Date: Wed, 24 Jun 2026 16:31:17 -0700 Subject: [PATCH 2/2] feat(InstallWizard): prevent duplicate field mappings + hint for unmappable values - filter provider fields already mapped to another destination out of each field-mapping row's options (wizard-idiomatic equivalent of classic checkDuplicateFieldError; the current row keeps its own selection) - replace the silent hide of a value-mapping block with a contextual hint when no field is selected yet or the chosen field isn't a mappable picklist Co-Authored-By: Claude Opus 4.8 (1M context) --- .../configure-objects/mappings/MappingRow.tsx | 24 ++++++++++++++--- .../value-mappings/ValueMappingBlock.tsx | 27 ++++++++++++++++--- .../value-mappings/valueMappings.module.css | 7 +++++ 3 files changed, 51 insertions(+), 7 deletions(-) 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/value-mappings/ValueMappingBlock.tsx b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx index ddb6bb2e..b40515be 100644 --- a/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx +++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/ValueMappingBlock.tsx @@ -42,8 +42,21 @@ export function ValueMappingBlock({ [manifest, objectName, resolvedField], ); - // Field-mapped units have no field selected yet — nothing to map. - if (!resolvedField) return null; + 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, @@ -51,8 +64,14 @@ export function ValueMappingBlock({ fieldMetadata ?? undefined, ); if (!validation.isValid) { - console.error(validation.errorMessage, unit); - return null; + 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 ?? []; 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 index eabcf2a3..c3088136 100644 --- a/src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css +++ b/src/components/InstallWizard/steps/configure-objects/value-mappings/valueMappings.module.css @@ -34,3 +34,10 @@ 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; +}