- {objectDisplayName}
+
+ {objectDisplayName}
+ {wasResolved && (
+
+ )}
+
Confirm which {providerDisplayName} fields {appName} can read
{isMappingBidirectional ? " and write" : ""}.
diff --git a/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx b/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx
index 06a8281e..5083e7a0 100644
--- a/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx
+++ b/src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useManifest } from "src/headless";
+import { isUnresolvedReadObject } from "src/utils/manifest";
import { useWizard } from "../../wizard/WizardContext";
@@ -20,8 +21,11 @@ export function ObjectTabs({ currentPageIndex, onTabClick }: ObjectTabsProps) {
const objectTabs = useMemo(() => {
return selectedObjects.map((objName, objIndex) => {
const pages = getSubPages(manifest, objName);
- const displayName =
- manifest.getReadObject(objName)?.object?.displayName || objName;
+ const object = manifest.getReadObject(objName)?.object ?? null;
+ const isUnresolved = !!object && isUnresolvedReadObject(object);
+ const displayName = isUnresolved
+ ? (object?.mapToDisplayName ?? object?.mapToName ?? objName)
+ : object?.displayName || objName;
const dots = pages.map((_, pageIdx) => {
if (objIndex < currentObjectIndex) return "complete" as const;
@@ -33,7 +37,7 @@ export function ObjectTabs({ currentPageIndex, onTabClick }: ObjectTabsProps) {
return "pending" as const;
});
- return { objName, displayName, dots, objIndex };
+ return { objName, displayName, dots, objIndex, isUnresolved };
});
}, [selectedObjects, manifest, currentObjectIndex, currentPageIndex]);
@@ -47,7 +51,12 @@ export function ObjectTabs({ currentPageIndex, onTabClick }: ObjectTabsProps) {
disabled={tab.objIndex > currentObjectIndex}
onClick={() => onTabClick(tab.objIndex)}
>
- {tab.displayName}
+
+ {tab.displayName}
+ {tab.isUnresolved && (
+ Needs setup
+ )}
+
{tab.dots.map((status, dotIdx) => (
(
+ undefined,
+ );
+
+ const { data, error, isFetching, isSuccess, refetch } =
+ useObjectMetadataForConnectionQuery({
+ provider,
+ providerObjectName: submittedName ?? "",
+ groupRef,
+ enabled: !!submittedName,
+ });
+
+ useEffect(() => {
+ if (!isSuccess || !data || !submittedName) return;
+
+ setResolution(mapToName, {
+ resolvedObjectName: submittedName,
+ metadata: data,
+ });
+
+ // Swap the current wizard selection entry from mapToName → resolvedObjectName
+ // so every downstream consumer (draft, tabs, review) keys off a real objectName.
+ if (mapToName !== submittedName) {
+ replaceSelectedObject(mapToName, submittedName);
+ localConfig.removeObject(mapToName);
+ }
+
+ // Initialize the draft under the resolved objectName so subsequent sub-pages
+ // see a valid handlers entry.
+ localConfig.readObject(submittedName).setEnableRead();
+ }, [
+ isSuccess,
+ data,
+ submittedName,
+ mapToName,
+ setResolution,
+ replaceSelectedObject,
+ localConfig,
+ ]);
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ const trimmed = draftName.trim();
+ if (!trimmed) return;
+ if (trimmed === submittedName) {
+ refetch();
+ return;
+ }
+ setSubmittedName(trimmed);
+ };
+
+ const inputId = `resolve-mapped-object-${mapToName}`;
+ const errorMessage = error instanceof Error ? error.message : undefined;
+ const isInvalid = !!error;
+
+ return (
+
+ );
+}
diff --git a/src/components/InstallWizard/steps/configure-objects/resolve/resolveMappedObjectSubPage.module.css b/src/components/InstallWizard/steps/configure-objects/resolve/resolveMappedObjectSubPage.module.css
new file mode 100644
index 00000000..6a190dcf
--- /dev/null
+++ b/src/components/InstallWizard/steps/configure-objects/resolve/resolveMappedObjectSubPage.module.css
@@ -0,0 +1,32 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ padding: 1rem 0;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--amp-text-color, #111827);
+}
+
+.description {
+ margin: 0;
+ color: var(--amp-text-muted-color, #4b5563);
+ font-size: 0.875rem;
+ line-height: 1.4;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
diff --git a/src/components/InstallWizard/steps/selectObjectsStep.module.css b/src/components/InstallWizard/steps/selectObjectsStep.module.css
index 1c44c578..015ee11b 100644
--- a/src/components/InstallWizard/steps/selectObjectsStep.module.css
+++ b/src/components/InstallWizard/steps/selectObjectsStep.module.css
@@ -65,6 +65,17 @@
border-radius: var(--amp-radius-sm);
}
+.needsSetupBadge {
+ display: inline-block;
+ width: fit-content;
+ font-size: var(--amp-font-size-sm);
+ padding: var(--amp-space-1) 0.625rem;
+ background: var(--amp-surface-secondary);
+ color: var(--amp-text-muted);
+ border-radius: var(--amp-radius-sm);
+ font-weight: var(--amp-font-medium);
+}
+
/* Write Toggle */
.writeToggle {
display: flex;
diff --git a/src/components/InstallWizard/wizard/WizardContext.tsx b/src/components/InstallWizard/wizard/WizardContext.tsx
index e1467ad1..14940f39 100644
--- a/src/components/InstallWizard/wizard/WizardContext.tsx
+++ b/src/components/InstallWizard/wizard/WizardContext.tsx
@@ -38,6 +38,7 @@ type WizardAction =
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "SET_SELECTED_OBJECTS"; objects: string[] }
+ | { type: "REPLACE_SELECTED_OBJECT"; from: string; to: string }
| { type: "SET_CURRENT_OBJECT_INDEX"; index: number }
| { type: "NEXT_OBJECT" }
| { type: "PREV_OBJECT" }
@@ -64,6 +65,14 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState {
selectedObjects: action.objects,
currentObjectIndex: 0,
};
+ case "REPLACE_SELECTED_OBJECT": {
+ const idx = state.selectedObjects.indexOf(action.from);
+ if (idx === -1) return state;
+ if (state.selectedObjects.includes(action.to)) return state;
+ const next = [...state.selectedObjects];
+ next[idx] = action.to;
+ return { ...state, selectedObjects: next };
+ }
case "SET_CURRENT_OBJECT_INDEX":
return { ...state, currentObjectIndex: action.index };
case "NEXT_OBJECT":
@@ -102,6 +111,7 @@ interface WizardContextValue {
nextStep: () => void;
prevStep: () => void;
setSelectedObjects: (objects: string[]) => void;
+ replaceSelectedObject: (from: string, to: string) => void;
nextObject: () => void;
prevObject: () => void;
setCurrentObjectIndex: (index: number) => void;
@@ -127,6 +137,11 @@ export function WizardProvider({ children }: { children: React.ReactNode }) {
(objects: string[]) => dispatch({ type: "SET_SELECTED_OBJECTS", objects }),
[],
);
+ const replaceSelectedObject = useCallback(
+ (from: string, to: string) =>
+ dispatch({ type: "REPLACE_SELECTED_OBJECT", from, to }),
+ [],
+ );
const nextObject = useCallback(() => dispatch({ type: "NEXT_OBJECT" }), []);
const prevObject = useCallback(() => dispatch({ type: "PREV_OBJECT" }), []);
const setCurrentObjectIndex = useCallback(
@@ -155,6 +170,7 @@ export function WizardProvider({ children }: { children: React.ReactNode }) {
nextStep,
prevStep,
setSelectedObjects,
+ replaceSelectedObject,
nextObject,
prevObject,
setCurrentObjectIndex,
@@ -170,6 +186,7 @@ export function WizardProvider({ children }: { children: React.ReactNode }) {
nextStep,
prevStep,
setSelectedObjects,
+ replaceSelectedObject,
nextObject,
prevObject,
setCurrentObjectIndex,
diff --git a/src/headless/manifest/useManifest.ts b/src/headless/manifest/useManifest.ts
index 0921e786..7cea75df 100644
--- a/src/headless/manifest/useManifest.ts
+++ b/src/headless/manifest/useManifest.ts
@@ -22,11 +22,14 @@ import {
HydratedIntegrationWriteObject,
IntegrationFieldMapping,
} from "@generated/api/src";
+import { useOptionalResolvedMappedObjects } from "src/components/InstallWizard/state/ResolvedMappedObjectsProvider";
import {
getOptionalFieldsFromObject,
getOptionalMapFieldsFromObject,
getRequiredFieldsFromObject,
getRequiredMapFieldsFromObject,
+ hydrateResolvedMappedObject,
+ isUnresolvedReadObject,
} from "src/utils/manifest";
import { useHydratedRevisionQuery } from "./useHydratedRevisionQuery";
@@ -85,15 +88,30 @@ export function useManifest() {
} = hydratedRevisionQuery;
const content = hydratedRevision?.content;
+ const { resolutions } = useOptionalResolvedMappedObjects();
+
+ const mergedReadObjects = useMemo(() => {
+ const readObjects = content?.read?.objects ?? [];
+ if (Object.keys(resolutions).length === 0) return readObjects;
+ return readObjects.map((obj) => {
+ if (!isUnresolvedReadObject(obj)) return obj;
+ const resolution = obj.mapToName ? resolutions[obj.mapToName] : undefined;
+ if (!resolution) return obj;
+ return hydrateResolvedMappedObject(
+ obj,
+ resolution.resolvedObjectName,
+ resolution.metadata,
+ );
+ });
+ }, [content?.read?.objects, resolutions]);
const manifest: Manifest = useMemo(
() => ({
- getReadObjects: (): HydratedIntegrationObject[] =>
- content?.read?.objects ?? [],
+ getReadObjects: (): HydratedIntegrationObject[] => mergedReadObjects,
getReadObject: (objectName: string) => {
- const object = content?.read?.objects?.find(
- (obj) => obj.objectName === objectName,
- );
+ const object =
+ mergedReadObjects.find((obj) => obj.objectName === objectName) ??
+ mergedReadObjects.find((obj) => obj.mapToName === objectName);
if (!object) {
console.error(`Object ${objectName} not found`);
return {
@@ -145,9 +163,9 @@ export function useManifest() {
return { object };
},
getCustomerFieldsForObject: (objectName: string) => {
- const object = content?.read?.objects?.find(
- (obj) => obj.objectName === objectName,
- );
+ const object =
+ mergedReadObjects.find((obj) => obj.objectName === objectName) ??
+ mergedReadObjects.find((obj) => obj.mapToName === objectName);
if (!object) {
console.error(`Object ${objectName} not found`);
return { allFields: null, getField: () => null };
@@ -165,7 +183,7 @@ export function useManifest() {
};
},
}),
- [content?.read?.objects, content?.write?.objects],
+ [mergedReadObjects, content?.write?.objects],
);
return {
diff --git a/src/hooks/query/useObjectMetadataForConnectionQuery.ts b/src/hooks/query/useObjectMetadataForConnectionQuery.ts
new file mode 100644
index 00000000..1177dbb2
--- /dev/null
+++ b/src/hooks/query/useObjectMetadataForConnectionQuery.ts
@@ -0,0 +1,54 @@
+import type { ObjectMetadata } from "@generated/api/src";
+import { useQuery } from "@tanstack/react-query";
+import { useAmpersandProviderProps } from "src/context/AmpersandContextProvider";
+import { useAPI } from "src/services/api";
+
+type UseObjectMetadataForConnectionQueryProps = {
+ provider: string;
+ providerObjectName: string;
+ groupRef: string;
+ excludeReadOnly?: boolean;
+ enabled?: boolean;
+};
+
+export const useObjectMetadataForConnectionQuery = ({
+ provider,
+ providerObjectName,
+ groupRef,
+ excludeReadOnly,
+ enabled = true,
+}: UseObjectMetadataForConnectionQueryProps) => {
+ const getAPI = useAPI();
+ const { projectIdOrName } = useAmpersandProviderProps();
+
+ return useQuery({
+ queryKey: [
+ "amp",
+ "objectMetadataForConnection",
+ projectIdOrName,
+ provider,
+ providerObjectName,
+ groupRef,
+ excludeReadOnly,
+ ],
+ queryFn: async () => {
+ if (!projectIdOrName) {
+ throw new Error(
+ "Project ID or name not found. Please wrap this component inside of AmpersandProvider",
+ );
+ }
+ const api = await getAPI();
+ return api.objectsFieldsApi.getObjectMetadataForConnection({
+ projectIdOrName,
+ provider,
+ objectName: providerObjectName,
+ groupRef,
+ excludeReadOnly,
+ });
+ },
+ enabled:
+ enabled && !!projectIdOrName && !!provider && !!providerObjectName && !!groupRef,
+ staleTime: Infinity,
+ retry: false,
+ });
+};
diff --git a/src/services/ApiService.ts b/src/services/ApiService.ts
index d4650605..00531a9c 100644
--- a/src/services/ApiService.ts
+++ b/src/services/ApiService.ts
@@ -5,6 +5,7 @@ import {
InstallationApi,
IntegrationApi,
OAuthApi,
+ ObjectsFieldsApi,
ProjectApi,
ProviderApi,
ProviderAppApi,
@@ -28,6 +29,8 @@ export class ApiService {
public oAuthApi: OAuthApi;
+ public objectsFieldsApi: ObjectsFieldsApi;
+
public projectApi: ProjectApi;
public providerApi: ProviderApi;
@@ -41,6 +44,7 @@ export class ApiService {
this.installationApi = new InstallationApi(config);
this.integrationApi = new IntegrationApi(config);
this.oAuthApi = new OAuthApi(config);
+ this.objectsFieldsApi = new ObjectsFieldsApi(config);
this.projectApi = new ProjectApi(config);
this.providerApi = new ProviderApi(config);
this.providerAppApi = new ProviderAppApi(config);
diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts
index 5409baed..ba3ef0a8 100644
--- a/src/utils/manifest.ts
+++ b/src/utils/manifest.ts
@@ -3,6 +3,7 @@ import {
HydratedIntegrationFieldExistent,
HydratedIntegrationObject,
IntegrationFieldMapping,
+ ObjectMetadata,
} from "@generated/api/src";
/**
@@ -86,3 +87,73 @@ export function getOptionalMapFieldsFromObject(
) || [];
return optionalMapFields as IntegrationFieldMapping[];
}
+
+/**
+ * True when a read object is an "unresolved mapped object": declared in amp.yaml
+ * with a mapToName but no concrete objectName. The integrating customer must
+ * choose which provider-side object fulfills the mapping.
+ */
+export function isUnresolvedReadObject(
+ object: HydratedIntegrationObject,
+): boolean {
+ return !object.objectName && !!object.mapToName;
+}
+
+/**
+ * Produce a fully-formed HydratedIntegrationObject by combining an unresolved
+ * manifest object with the user's chosen provider-side objectName and the
+ * ObjectMetadata returned by getObjectMetadataForConnection.
+ *
+ * The amp.yaml can't declare required/existent fields on an unresolved object
+ * (it doesn't know the provider-side fieldNames), so every field from the API
+ * is surfaced as optional. Any IntegrationFieldMapping entries declared on the
+ * original object are preserved.
+ */
+export function hydrateResolvedMappedObject(
+ object: HydratedIntegrationObject,
+ resolvedObjectName: string,
+ metadata: ObjectMetadata | undefined,
+): HydratedIntegrationObject {
+ const existingRequired = object.requiredFields ?? [];
+ const existingOptional = object.optionalFields ?? [];
+
+ if (!metadata) {
+ return { ...object, objectName: resolvedObjectName };
+ }
+
+ const existingOptionalFieldNames = new Set(
+ existingOptional
+ .filter((f) => !isIntegrationFieldMapping(f))
+ .map((f) => (f as HydratedIntegrationFieldExistent).fieldName),
+ );
+ const existingRequiredFieldNames = new Set(
+ existingRequired
+ .filter((f) => !isIntegrationFieldMapping(f))
+ .map((f) => (f as HydratedIntegrationFieldExistent).fieldName),
+ );
+
+ const extraOptional: HydratedIntegrationFieldExistent[] = Object.entries(
+ metadata.fields ?? {},
+ )
+ .filter(
+ ([fieldName]) =>
+ !existingOptionalFieldNames.has(fieldName) &&
+ !existingRequiredFieldNames.has(fieldName),
+ )
+ .map(([fieldName, meta]) => ({
+ fieldName,
+ displayName: meta.displayName || fieldName,
+ }));
+
+ return {
+ ...object,
+ objectName: resolvedObjectName,
+ displayName:
+ object.displayName || metadata.displayName || resolvedObjectName,
+ optionalFields: [...existingOptional, ...extraOptional],
+ allFieldsMetadata: {
+ ...(object.allFieldsMetadata ?? {}),
+ ...(metadata.fields ?? {}),
+ },
+ };
+}