From 2686befcc195d7ac0422d45ead5bc15467859a6f Mon Sep 17 00:00:00 2001 From: Dion Low Date: Mon, 13 Apr 2026 16:43:00 -0700 Subject: [PATCH 1/3] feat(install-wizard): support unresolved mapped objects in amp.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds runtime support for amp.yaml read objects declared with only a mapToName (no concrete objectName). When the InstallWizard encounters one, it prompts the user for the provider-side object name, fetches its field metadata via getObjectMetadataForConnection, and hydrates the manifest with the resolved object so downstream configuration continues unchanged. - New useObjectMetadataForConnectionQuery hook (ObjectsFieldsApi). - New ResolvedMappedObjectsProvider holds mapToName → resolution map and seeds from existing Installation.config on re-entry. - useManifest merges resolutions into the hydrated revision so the rest of the wizard (tabs, fields, mappings, draft, review) stays oblivious to the pre-resolution state. - New ResolveMappedObjectSubPage renders the input, handles API errors, and swaps the selectedObjects entry from mapToName → resolvedObjectName on success. - SelectObjectsStep and ObjectTabs show a "Needs setup" badge for unresolved objects; ConfigureObjectsStep adds an Edit affordance for previously resolved objects. - WizardContext gains a REPLACE_SELECTED_OBJECT action that preserves currentObjectIndex. Co-Authored-By: Claude Opus 4.6 (1M context) --- MAPPED_OBJECT_RESOLUTION_PLAN.md | 142 +++++++++++++++++ .../InstallWizard/InstallWizard.tsx | 57 +++---- .../state/ResolvedMappedObjectsProvider.tsx | 150 ++++++++++++++++++ .../InstallWizard/steps/SelectObjectsStep.tsx | 56 +++++-- .../ConfigureObjectsStep.tsx | 48 +++++- .../steps/configure-objects/ObjectTabs.tsx | 17 +- .../configureObjectsStep.module.css | 18 +++ .../configure-objects/objectTabs.module.css | 12 ++ .../resolve/ResolveMappedObjectSubPage.tsx | 124 +++++++++++++++ .../resolveMappedObjectSubPage.module.css | 32 ++++ .../steps/selectObjectsStep.module.css | 11 ++ .../InstallWizard/wizard/WizardContext.tsx | 17 ++ src/headless/manifest/useManifest.ts | 36 +++-- .../useObjectMetadataForConnectionQuery.ts | 53 +++++++ src/services/ApiService.ts | 4 + src/utils/manifest.ts | 71 +++++++++ 16 files changed, 791 insertions(+), 57 deletions(-) create mode 100644 MAPPED_OBJECT_RESOLUTION_PLAN.md create mode 100644 src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx create mode 100644 src/components/InstallWizard/steps/configure-objects/resolve/ResolveMappedObjectSubPage.tsx create mode 100644 src/components/InstallWizard/steps/configure-objects/resolve/resolveMappedObjectSubPage.module.css create mode 100644 src/hooks/query/useObjectMetadataForConnectionQuery.ts diff --git a/MAPPED_OBJECT_RESOLUTION_PLAN.md b/MAPPED_OBJECT_RESOLUTION_PLAN.md new file mode 100644 index 000000000..866ab415a --- /dev/null +++ b/MAPPED_OBJECT_RESOLUTION_PLAN.md @@ -0,0 +1,142 @@ +# Plan: Mapped-Object Resolution in InstallWizard + +## Context + +Today, every `read.objects[]` entry in `amp.yaml` ships with a concrete `objectName`. We are adding support for a new authoring pattern where an object is declared with only `mapToName` / `mapToDisplayName` and **no `objectName`**. In that case, the integrating customer must tell the UI which provider-side object should fulfill that mapping. + +When the wizard encounters such an "unresolved mapped object": + +1. UI prompts: *"Which object represents `{mapToDisplayName}`?"* +2. User types e.g. `contacts` and submits. +3. UI calls `ObjectsFieldsApi.getObjectMetadataForConnection` to fetch field metadata for that provider object. +4. API error → inline error + retry. API success → the object is treated as fully resolved and the normal field-configuration flow continues. + +Decisions already made: +- Resolution happens **per-object inside `ConfigureObjectsStep`** (not up-front). +- Users can **edit** the resolved name before install. +- Re-entering an existing Installation whose `Installation.config` already has an `objectName` for this manifest entry **skips** the resolve sub-page. + +## Design principle: resolve-then-hydrate + +Resolution is a **one-way adapter at the manifest boundary**, not a status flag threaded through state. The moment the user resolves an object: + +1. The draft config stores the entry keyed by the user-chosen `objectName`, with `objectName` set on the object — exactly like any normal object. `createInstallation` then persists `objectName` for free (`ReviewStep.tsx:97`, `useConfigHelper.tsx:102`). +2. A `useManifest` merge step substitutes the unresolved `HydratedIntegrationObject` with a fully-formed one (real `objectName`, `requiredFields`/`optionalFields`/`allFieldsMetadata` from the API response). + +Every downstream consumer — object tabs, field pickers, mapping UI, install mutation, `ReviewStep` — keeps seeing a normal object shape. No keying dance, no payload swap, no reducer plumbing. + +## Approach + +### 1. New query hook +New file `src/hooks/query/useObjectMetadataForConnectionQuery.ts`: +- Wraps `ObjectsFieldsApi.getObjectMetadataForConnection(projectIdOrName, provider, providerObjectName, groupRef?, excludeReadOnly?)` → `ObjectMetadata`. +- Query key: `["amp", "objectMetadataForConnection", projectIdOrName, provider, providerObjectName, groupRef]`. +- `enabled`: requires all of `projectIdOrName`, `provider`, `providerObjectName` truthy; caller passes an additional `enabled` flag so fetch only fires after submit. +- `staleTime: Infinity`; expose `refetch` for retry. + +### 2. Resolutions store +New small context `src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx`: +- Holds a `Map`. +- Exposes `getResolution(mapToName)`, `setResolution(mapToName, payload)`, `clearResolution(mapToName)`. +- Mounted inside the wizard tree near `ConfigurationStateProvider`. +- Seeded once on mount from an existing `Installation.config` (see §6). + +### 3. Manifest merge +In `src/headless/manifest/useManifest.ts` and `src/utils/manifest.ts`: +- Add predicate `isUnresolvedReadObject(obj)` → `!obj.objectName && !!obj.mapToName`. +- In `useManifest`, pull resolutions from `ResolvedMappedObjectsProvider`. Build a derived revision where each unresolved read object is replaced (if a resolution exists) with: + ```ts + { + ...obj, + objectName: resolution.resolvedObjectName, + requiredFields: toRequiredFields(resolution.metadata), + optionalFields: toOptionalFields(resolution.metadata), + allFieldsMetadata: resolution.metadata.fields, + } + ``` + Helpers (`toRequiredFields` / `toOptionalFields`) respect the manifest's declared required/optional field names if present; otherwise everything returned by `getObjectMetadataForConnection` is treated as optional. +- `getReadObjects()` returns resolved + still-unresolved objects (stable order preserved). +- `getReadObject(name)` matches on the now-resolved `objectName`; for still-unresolved objects it matches on `mapToName` (the only stable key available). +- `getCustomerFieldsForObject(name)` returns `{}` for still-unresolved objects. +- **No changes** to how resolved objects behave downstream. + +### 4. Resolve sub-page component +New file `src/components/InstallWizard/steps/configure-objects/resolve/ResolveMappedObjectSubPage.tsx`: +- Props: `mapToName`, `mapToDisplayName`, `initialValue?`, `onResolved()`. +- Local `draftName` state; separate `submittedName` state feeds `useObjectMetadataForConnectionQuery({ enabled: !!submittedName })`. +- Uses `FormControl` + `Input` from `src/components/form/`; inline error via `isInvalid` + `errorMessage: error?.message`. +- Submit button disabled while `draftName` empty or `isFetching`. +- On `query.data` success: call `setResolution(mapToName, { resolvedObjectName: submittedName, metadata: data })`, then `onResolved()`. + +### 5. Wizard wiring +`src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx` — gate before line 143: +```ts +const currentObject = currentManifestObject?.object; +const needsResolution = currentObject && isUnresolvedReadObject(currentObject); +if (needsResolution) { + return ( + {/* no-op; manifest merge triggers rerender */}} + /> + ); +} +``` +Because the manifest merge rewrites the object as soon as `setResolution` runs, the next render of `ConfigureObjectsStep` sees a resolved object and falls through into `FieldsContent` / `MappingsContent` / `AdditionalFieldsContent` naturally. `useSubPageNavigation` needs no changes. + +`src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx`: +- For currently-unresolved objects, render the tab label using `mapToDisplayName` and append a subtle "Needs setup" badge. +- For resolved objects, add a small **Edit** affordance in the sub-page header (inside `ResolveMappedObjectSubPage` render path) that calls `clearResolution(mapToName)` to return to the resolve UI. Keyed off the presence of a resolution in the store. + +### 6. SelectObjectsStep +`src/components/InstallWizard/steps/SelectObjectsStep.tsx`: +- Include unresolved objects in the list, labelled by `mapToDisplayName`, with the same "Needs setup" badge. +- Selection works normally; resolution is deferred to `ConfigureObjectsStep`. +- `selectedObjects` in wizard state must key off a stable identifier — since resolved objects key by `objectName` and unresolved by `mapToName`, use `objectName ?? mapToName` as the `selectedObjects` value. After resolution the same entry still appears in `selectedObjects` (via the resolved `objectName`) because the resolved object adopts the user-chosen name; confirm this holds in `SelectObjectsStep` by seeding selection with the resolved name once resolution lands. + +### 7. Rehydration from existing Installation +Inside `ResolvedMappedObjectsProvider` on mount: +- Read the raw (unmerged) manifest via a thin selector. +- For each unresolved manifest object, scan `installation?.config.content.read.objects` for an entry whose `objectName` is present but has no matching manifest `objectName`. If found, seed a resolution entry `{ mapToName, resolvedObjectName: objectName, metadata: }`. +- Metadata for rehydrated entries is fetched on-demand the first time the user lands on that object's tab (same hook, triggered with `enabled: true` and the stored `resolvedObjectName`). Until metadata arrives, show a lightweight loader in the tab; the resolve sub-page does not reappear. + +### 8. Install-mutation payload +**No changes required.** The draft is already keyed and populated by `objectName` (`src/headless/config/useConfigHelper.tsx:94-128`), and `ReviewStep` submits `localConfig.draft` (`src/components/InstallWizard/steps/ReviewStep.tsx:97-98`). Since resolved objects appear in the manifest with a real `objectName`, the existing `initializeObjectWithDefaults` path fills in the draft correctly. + +## Critical files + +- `src/hooks/query/useObjectMetadataForConnectionQuery.ts` *(new)* +- `src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx` *(new)* +- `src/utils/manifest.ts` +- `src/headless/manifest/useManifest.ts` +- `src/components/InstallWizard/steps/configure-objects/ConfigureObjectsStep.tsx` +- `src/components/InstallWizard/steps/configure-objects/ObjectTabs.tsx` +- `src/components/InstallWizard/steps/configure-objects/resolve/ResolveMappedObjectSubPage.tsx` *(new)* +- `src/components/InstallWizard/steps/SelectObjectsStep.tsx` + +## Reused primitives + +- `ObjectsFieldsApi.getObjectMetadataForConnection` (`generated-sources/api/src/apis/ObjectsFieldsApi.ts:87`) +- `FormControl` (`src/components/form/FormControl/index.tsx:56`) + `Input` (`src/components/form/Input/index.tsx`) +- `useProjectQuery`, `useProvider`, `useInstallIntegrationProps` for query hook inputs +- Existing `useSubPageNavigation`, `FieldsContent`, `MappingsContent`, `AdditionalFieldsContent` — **no changes** +- Existing `useConfigHelper` draft plumbing — **no changes** + +## Backend dependencies (confirm before implementation) + +- `HydratedIntegrationObject` may return with missing/empty `objectName` and present `mapToName`. The TypeScript model currently marks `objectName` as required; either relax to `objectName?: string` in the generated model (after backend OpenAPI regen) or handle runtime-optional via a narrow cast. +- `Installation.config.read.objects[]` with a user-chosen `objectName` not present in the manifest must be accepted on create and echoed back on read. + +## Verification + +- **Unit**: `useObjectMetadataForConnectionQuery` gating + error propagation (React Query test harness). Manifest helpers: `isUnresolvedReadObject`, `getReadObjects` returns merged view, `getReadObject` matches by `mapToName` pre-resolution and by `objectName` post-resolution. +- **Component**: RTL on `ResolveMappedObjectSubPage` — loading, API error, success → `setResolution` called and `FieldsContent` renders on next tick. RTL on `ConfigureObjectsStep` gate with a fixture manifest containing a `mapToName`-only object. +- **Manual / dev harness**: amp.yaml fixture with a `mapToName`-only read object. Scenarios: + 1. Happy path: submit name → fields appear. + 2. API 4xx: error message, retry succeeds. + 3. Empty input: submit disabled. + 4. Edit: click Edit → resolve sub-page reappears → change name → fields refresh with new metadata. + 5. Rehydration: install once, close wizard, reopen → resolved object's tab opens directly to `FieldsContent` (no resolve sub-page). + 6. Install payload: `Installation.config.read.objects` contains the resolved `objectName`, not the `mapToName`. +- Run `yarn lint` and `yarn test`. diff --git a/src/components/InstallWizard/InstallWizard.tsx b/src/components/InstallWizard/InstallWizard.tsx index d27acfe8a..5faf55046 100644 --- a/src/components/InstallWizard/InstallWizard.tsx +++ b/src/components/InstallWizard/InstallWizard.tsx @@ -12,6 +12,7 @@ import { } from "../Configure/ComponentContainer"; import { AmpersandErrorBoundary } from "../Configure/ErrorBoundary"; +import { ResolvedMappedObjectsProvider } from "./state/ResolvedMappedObjectsProvider"; import { ConfigureObjectsGate } from "./steps/configure-objects"; import { ConnectStep } from "./steps/ConnectStep"; import { ReviewStep } from "./steps/ReviewStep"; @@ -98,33 +99,35 @@ const InstallWizardContent = ({ resetComponent={reset} > - -
- - - - - - - - - - - - - - - - - -
-
+ + +
+ + + + + + + + + + + + + + + + + +
+
+
diff --git a/src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx b/src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx new file mode 100644 index 000000000..feee5ea57 --- /dev/null +++ b/src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx @@ -0,0 +1,150 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ObjectMetadata } from "@generated/api/src"; +import { useInstallIntegrationProps } from "context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider"; +import { useHydratedRevisionQuery } from "src/headless/manifest/useHydratedRevisionQuery"; + +export type ResolvedMappedObject = { + resolvedObjectName: string; + metadata?: ObjectMetadata; +}; + +type ResolvedMappedObjectsContextValue = { + getResolution: (mapToName: string) => ResolvedMappedObject | undefined; + setResolution: (mapToName: string, payload: ResolvedMappedObject) => void; + clearResolution: (mapToName: string) => void; + /** mapToName → resolvedObjectName, for components that need to enumerate resolutions. */ + resolutions: Record; +}; + +const ResolvedMappedObjectsContext = createContext< + ResolvedMappedObjectsContextValue | undefined +>(undefined); + +export function ResolvedMappedObjectsProvider({ + children, +}: { + children: ReactNode; +}) { + const [resolutions, setResolutions] = useState< + Record + >({}); + + const { installation } = useInstallIntegrationProps(); + const { data: hydratedRevision } = useHydratedRevisionQuery(); + const seededRef = useRef(false); + + // Seed once from an existing Installation's config: if the config contains an + // objectName that has no matching manifest object but corresponds to a manifest + // object's mapToName, treat it as previously resolved. + useEffect(() => { + if (seededRef.current) return; + if (!installation || !hydratedRevision) return; + + const readObjects = hydratedRevision.content?.read?.objects ?? []; + const unresolvedMapToNames = readObjects + .filter((obj) => !obj.objectName && !!obj.mapToName) + .map((obj) => obj.mapToName as string); + + if (unresolvedMapToNames.length === 0) { + seededRef.current = true; + return; + } + + const configReadObjects = installation.config?.content?.read?.objects ?? {}; + const manifestObjectNames = new Set( + readObjects.map((o) => o.objectName).filter(Boolean) as string[], + ); + + // Config entries that don't correspond to a concrete manifest objectName + // are candidates for a previously resolved mapped object. + const leftoverConfigKeys = Object.keys(configReadObjects).filter( + (key) => !manifestObjectNames.has(key), + ); + + // Heuristic: if counts match, pair them in manifest-declaration order. + // This works for the common case (1 unresolved mapped object) without + // depending on the backend echoing mapToName on the config entry. + if (leftoverConfigKeys.length !== unresolvedMapToNames.length) { + seededRef.current = true; + return; + } + + const seeded: Record = {}; + unresolvedMapToNames.forEach((mapToName, idx) => { + seeded[mapToName] = { + resolvedObjectName: leftoverConfigKeys[idx], + }; + }); + + setResolutions((prev) => ({ ...seeded, ...prev })); + seededRef.current = true; + }, [installation, hydratedRevision]); + + const getResolution = useCallback( + (mapToName: string) => resolutions[mapToName], + [resolutions], + ); + + const setResolution = useCallback( + (mapToName: string, payload: ResolvedMappedObject) => { + setResolutions((prev) => ({ ...prev, [mapToName]: payload })); + }, + [], + ); + + const clearResolution = useCallback((mapToName: string) => { + setResolutions((prev) => { + const next = { ...prev }; + delete next[mapToName]; + return next; + }); + }, []); + + const value = useMemo( + () => ({ getResolution, setResolution, clearResolution, resolutions }), + [getResolution, setResolution, clearResolution, resolutions], + ); + + return ( + + {children} + + ); +} + +export function useResolvedMappedObjects(): ResolvedMappedObjectsContextValue { + const ctx = useContext(ResolvedMappedObjectsContext); + if (!ctx) { + throw new Error( + "useResolvedMappedObjects must be used inside ResolvedMappedObjectsProvider", + ); + } + return ctx; +} + +const emptyResolutions: Record = {}; +const noopSet = () => {}; +const noopClear = () => {}; +const emptyValue: ResolvedMappedObjectsContextValue = { + getResolution: () => undefined, + setResolution: noopSet, + clearResolution: noopClear, + resolutions: emptyResolutions, +}; + +/** + * Variant that does NOT throw when the provider is absent. Use from hooks/components + * that may render outside the InstallWizard tree (e.g. the standalone Configure flow). + */ +export function useOptionalResolvedMappedObjects(): ResolvedMappedObjectsContextValue { + return useContext(ResolvedMappedObjectsContext) ?? emptyValue; +} diff --git a/src/components/InstallWizard/steps/SelectObjectsStep.tsx b/src/components/InstallWizard/steps/SelectObjectsStep.tsx index 105e06475..1dded767c 100644 --- a/src/components/InstallWizard/steps/SelectObjectsStep.tsx +++ b/src/components/InstallWizard/steps/SelectObjectsStep.tsx @@ -3,6 +3,7 @@ import { useLocalConfig } from "src/headless"; import { useManifest } from "src/headless"; import { useProjectQuery } from "src/hooks/query/useProjectQuery"; import { useProvider } from "src/hooks/useProvider"; +import { isUnresolvedReadObject } from "src/utils/manifest"; import { InfoTooltip } from "../components/InfoTooltip"; import { StepHeader } from "../components/StepHeader"; @@ -38,16 +39,23 @@ export function SelectObjectsStep() { // Get all available read objects from manifest const readObjects = manifest.getReadObjects(); - // Build a set of object names that have write support in the manifest + // Build a set of object names that have write support in the manifest. + // Unresolved mapped objects have no objectName yet, so skip write detection. const writeSupported = useMemo(() => { const set = new Set(); readObjects.forEach((obj) => { + if (!obj.objectName) return; const writeObj = manifest.getWriteObject(obj.objectName); if (writeObj?.object) set.add(obj.objectName); }); return set; }, [readObjects, manifest]); + // Stable identifier: real objectName if present, else mapToName. + // Unresolved objects are keyed by mapToName until the user resolves them. + const getStableKey = (obj: { objectName?: string; mapToName?: string }) => + obj.objectName || obj.mapToName || ""; + const toggleObject = useCallback((objectName: string) => { setSelected((prev) => { const next = new Set(prev); @@ -78,8 +86,16 @@ export function SelectObjectsStep() { setSelectedObjects(selectedArray); - // Initialize newly selected objects in the config draft + const unresolvedKeys = new Set( + readObjects + .filter(isUnresolvedReadObject) + .map((obj) => obj.mapToName as string), + ); + + // Initialize newly selected objects in the config draft. Skip unresolved + // mapped objects — their draft entry is created once the user resolves them. selectedArray.forEach((objectName) => { + if (unresolvedKeys.has(objectName)) return; localConfig.readObject(objectName).setEnableRead(); }); @@ -111,6 +127,7 @@ export function SelectObjectsStep() { writeSupported, writeEnabled, nextStep, + readObjects, ]); const isValid = selected.size > 0; @@ -144,13 +161,21 @@ export function SelectObjectsStep() {
{readObjects.map((obj) => { - const isSelected = selected.has(obj.objectName); - const hasWrite = writeSupported.has(obj.objectName); - const isWriteOn = writeEnabled.has(obj.objectName); + const key = getStableKey(obj); + const isUnresolved = isUnresolvedReadObject(obj); + const isSelected = selected.has(key); + const hasWrite = !isUnresolved && writeSupported.has(key); + const isWriteOn = writeEnabled.has(key); + const label = + obj.displayName || + obj.mapToDisplayName || + obj.objectName || + obj.mapToName || + ""; return (
{ const isWriteToggle = (e.target as HTMLElement).closest( @@ -169,7 +194,7 @@ export function SelectObjectsStep() { if (isWriteToggle) return; if (e.key === " " || e.key === "Enter") { e.preventDefault(); - toggleObject(obj.objectName); + toggleObject(key); } }} > @@ -178,26 +203,27 @@ export function SelectObjectsStep() { className={styles.checkbox} checked={isSelected} tabIndex={-1} - onChange={() => toggleObject(obj.objectName)} + onChange={() => toggleObject(key)} onClick={(e) => e.stopPropagation()} />
- - {obj.displayName || obj.objectName} - + {label} + {isUnresolved && ( + Needs setup + )}
{hasWrite && isSelected && (