Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/components/InstallWizard/steps/ReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -109,6 +127,7 @@ export function ReviewStep() {
}, [
createInstallation,
localConfig.draft,
fieldMapping,
setSubmissionError,
setInstallation,
onInstallSuccess,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {};
Expand Down Expand Up @@ -162,6 +158,7 @@ export function ConfigureObjectsStep() {
isMappingBidirectional={isMappingBidirectional}
requiredMapFields={requiredMapFields}
optionalMapFields={optionalMapFields}
valueMappingUnits={valueMappingUnits}
customerFieldOptions={customerFieldOptions}
configHandlers={configHandlers}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useInstallIntegrationProps } from "context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider";
import { useManifest } from "src/headless";

import { useWizard } from "../../wizard/WizardContext";
Expand All @@ -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;

Expand All @@ -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 (
<div className={styles.objectTabs}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();
(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;
}
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
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,
Expand All @@ -46,7 +64,7 @@ export function MappingRow({
: undefined,
}))
.sort((a, b) => a.label.localeCompare(b.label));
}, [customerFieldOptions]);
}, [customerFieldOptions, usedByOtherMappings, selectedValue]);

return (
<div className={styles.mappingRow}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +17,7 @@ interface MappingsContentProps {
isMappingBidirectional: boolean;
requiredMapFields: IntegrationFieldMapping[];
optionalMapFields: IntegrationFieldMapping[];
valueMappingUnits: ValueMappingUnit[];
customerFieldOptions: Array<{ fieldName: string; displayName: string }>;
configHandlers: ReadObjectHandlers;
}
Expand All @@ -25,47 +29,71 @@ export function MappingsContent({
isMappingBidirectional,
requiredMapFields,
optionalMapFields,
valueMappingUnits,
customerFieldOptions,
configHandlers,
}: MappingsContentProps) {
const hasFieldMappings =
requiredMapFields.length > 0 || optionalMapFields.length > 0;
return (
<>
<SectionHeader
title="Field Mappings"
description={`Map ${providerDisplayName} fields to the corresponding ${appName} fields for read${isMappingBidirectional ? " and write" : ""}.`}
/>
<div className={styles.mappingContent}>
<div className={styles.mappingColumnHeaders}>
<span className={styles.mappingColumnTitle}>
{providerDisplayName} Field
</span>
<span className={styles.mappingColumnArrow} />
<span className={styles.mappingColumnTitle}>{appName} Field</span>
</div>

{requiredMapFields.map((mapping) => (
<MappingRow
key={mapping.mapToName}
objectName={objectName}
mapping={mapping}
required
bidirectional={isMappingBidirectional}
customerFieldOptions={customerFieldOptions}
configHandlers={configHandlers}
{hasFieldMappings && (
<>
<SectionHeader
title="Field Mappings"
description={`Map ${providerDisplayName} fields to the corresponding ${appName} fields for read${isMappingBidirectional ? " and write" : ""}.`}
/>
))}
{optionalMapFields.map((mapping) => (
<MappingRow
key={mapping.mapToName}
objectName={objectName}
mapping={mapping}
required={false}
bidirectional={isMappingBidirectional}
customerFieldOptions={customerFieldOptions}
configHandlers={configHandlers}
<div className={styles.mappingContent}>
<div className={styles.mappingColumnHeaders}>
<span className={styles.mappingColumnTitle}>
{providerDisplayName} Field
</span>
<span className={styles.mappingColumnArrow} />
<span className={styles.mappingColumnTitle}>{appName} Field</span>
</div>

{requiredMapFields.map((mapping) => (
<MappingRow
key={mapping.mapToName}
objectName={objectName}
mapping={mapping}
required
bidirectional={isMappingBidirectional}
customerFieldOptions={customerFieldOptions}
configHandlers={configHandlers}
/>
))}
{optionalMapFields.map((mapping) => (
<MappingRow
key={mapping.mapToName}
objectName={objectName}
mapping={mapping}
required={false}
bidirectional={isMappingBidirectional}
customerFieldOptions={customerFieldOptions}
configHandlers={configHandlers}
/>
))}
</div>
</>
)}

{valueMappingUnits.length > 0 && (
<div className={valueMappingStyles.valueMappingSection}>
<SectionHeader
title="Value Mappings"
description={`Map your values to the corresponding ${providerDisplayName} values.`}
/>
))}
</div>
{valueMappingUnits.map((unit) => (
<ValueMappingBlock
key={unit.key}
objectName={objectName}
unit={unit}
configHandlers={configHandlers}
/>
))}
</div>
)}
</>
);
}
Loading
Loading