diff --git a/src/components/InstallWizard/InstallWizard.tsx b/src/components/InstallWizard/InstallWizard.tsx index d27acfe8..5faf5504 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 00000000..feee5ea5 --- /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 105e0647..1dded767 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 && (