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
57 changes: 30 additions & 27 deletions src/components/InstallWizard/InstallWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -98,33 +99,35 @@ const InstallWizardContent = ({
resetComponent={reset}
>
<ConnectionsProvider>
<WizardProvider>
<div className={styles.installWizard}>
<WizardLayout>
<WizardStepContainer step={WizardStep.Connect}>
<ConnectStep
consumerRef={consumerRef}
consumerName={consumerName}
groupRef={groupRef}
groupName={groupName}
resetComponent={reset}
/>
</WizardStepContainer>
<WizardStepContainer step={WizardStep.SelectObjects}>
<SelectObjectsStep />
</WizardStepContainer>
<WizardStepContainer step={WizardStep.ConfigureObjects}>
<ConfigureObjectsGate />
</WizardStepContainer>
<WizardStepContainer step={WizardStep.Review}>
<ReviewStep />
</WizardStepContainer>
<WizardStepContainer step={WizardStep.Success}>
<SuccessStep onEditConfiguration={onEditConfiguration} />
</WizardStepContainer>
</WizardLayout>
</div>
</WizardProvider>
<ResolvedMappedObjectsProvider>
<WizardProvider>
<div className={styles.installWizard}>
<WizardLayout>
<WizardStepContainer step={WizardStep.Connect}>
<ConnectStep
consumerRef={consumerRef}
consumerName={consumerName}
groupRef={groupRef}
groupName={groupName}
resetComponent={reset}
/>
</WizardStepContainer>
<WizardStepContainer step={WizardStep.SelectObjects}>
<SelectObjectsStep />
</WizardStepContainer>
<WizardStepContainer step={WizardStep.ConfigureObjects}>
<ConfigureObjectsGate />
</WizardStepContainer>
<WizardStepContainer step={WizardStep.Review}>
<ReviewStep />
</WizardStepContainer>
<WizardStepContainer step={WizardStep.Success}>
<SuccessStep onEditConfiguration={onEditConfiguration} />
</WizardStepContainer>
</WizardLayout>
</div>
</WizardProvider>
</ResolvedMappedObjectsProvider>
</ConnectionsProvider>
</InstallIntegrationProvider>
</div>
Expand Down
150 changes: 150 additions & 0 deletions src/components/InstallWizard/state/ResolvedMappedObjectsProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ResolvedMappedObject>;
};

const ResolvedMappedObjectsContext = createContext<
ResolvedMappedObjectsContextValue | undefined
>(undefined);

export function ResolvedMappedObjectsProvider({
children,
}: {
children: ReactNode;
}) {
const [resolutions, setResolutions] = useState<
Record<string, ResolvedMappedObject>
>({});

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<string, ResolvedMappedObject> = {};
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<ResolvedMappedObjectsContextValue>(
() => ({ getResolution, setResolution, clearResolution, resolutions }),
[getResolution, setResolution, clearResolution, resolutions],
);

return (
<ResolvedMappedObjectsContext.Provider value={value}>
{children}
</ResolvedMappedObjectsContext.Provider>
);
}

export function useResolvedMappedObjects(): ResolvedMappedObjectsContextValue {
const ctx = useContext(ResolvedMappedObjectsContext);
if (!ctx) {
throw new Error(
"useResolvedMappedObjects must be used inside ResolvedMappedObjectsProvider",
);
}
return ctx;
}

const emptyResolutions: Record<string, ResolvedMappedObject> = {};
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;
}
56 changes: 41 additions & 15 deletions src/components/InstallWizard/steps/SelectObjectsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();
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);
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -111,6 +127,7 @@ export function SelectObjectsStep() {
writeSupported,
writeEnabled,
nextStep,
readObjects,
]);

const isValid = selected.size > 0;
Expand Down Expand Up @@ -144,13 +161,21 @@ export function SelectObjectsStep() {

<div className={styles.objectList}>
{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 (
<div
key={obj.objectName}
key={key}
role="checkbox"
aria-checked={isSelected}
tabIndex={0}
Expand All @@ -160,7 +185,7 @@ export function SelectObjectsStep() {
`.${styles.writeToggle}`,
);
if (isWriteToggle) return;
toggleObject(obj.objectName);
toggleObject(key);
}}
onKeyDown={(e) => {
const isWriteToggle = (e.target as HTMLElement).closest(
Expand All @@ -169,7 +194,7 @@ export function SelectObjectsStep() {
if (isWriteToggle) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
toggleObject(obj.objectName);
toggleObject(key);
}
}}
>
Expand All @@ -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()}
/>
<div className={styles.objectInfo}>
<span className={styles.objectName}>
{obj.displayName || obj.objectName}
</span>
<span className={styles.objectName}>{label}</span>
{isUnresolved && (
<span className={styles.needsSetupBadge}>Needs setup</span>
)}
</div>
{hasWrite && isSelected && (
<label className={styles.writeToggle}>
<span className={styles.writeToggleLabel}>Write Enabled</span>
<InfoTooltip
text={`Allow ${appName} to write back to ${obj.displayName || obj.objectName}`}
text={`Allow ${appName} to write back to ${label}`}
/>
<span className={styles.toggleSwitch}>
<input
type="checkbox"
aria-label={`Enable write for ${obj.displayName || obj.objectName}`}
aria-label={`Enable write for ${label}`}
checked={isWriteOn}
onChange={() => toggleWrite(obj.objectName)}
onChange={() => toggleWrite(key)}
/>
<span className={styles.toggleSlider} />
</span>
Expand Down
Loading
Loading