fix(cloud): Backfill cloud metadata for saved nodes

Older flows saved before cloud metadata existed could miss cloud warnings
and option filtering. Overlay only the safe cloud fields from the
current catalog when Cloud Mode is on so existing node state stays
unchanged.
This commit is contained in:
Jordan Frazier
2026-03-27 07:48:43 -04:00
parent 4775eb402e
commit fa9118742d
9 changed files with 273 additions and 39 deletions

View File

@ -264,8 +264,13 @@ export const NodeDialog: React.FC<NodeDialogProps> = ({
editNode={false}
handleNodeClass={() => {}}
nodeClass={dialogNodeData}
nodeType={
typeof dialogNodeData?.type === "string"
? dialogNodeData.type
: undefined
}
disabled={
(fieldValue as { disabled: boolean })?.disabled ?? false
(fieldValue as Partial<InputFieldType>)?.disabled ?? false
}
placeholder={
(fieldValue as { placeholder: string })?.placeholder ?? ""

View File

@ -208,6 +208,7 @@ export default function NodeInputField({
handleNodeClass={handleNodeClass}
showParameter={true}
nodeClass={data.node!}
nodeType={data.type}
placeholder={
isToolMode
? DEFAULT_TOOLSET_PLACEHOLDER

View File

@ -1,9 +1,9 @@
import { render, screen } from "@testing-library/react";
import type {
ButtonHTMLAttributes,
ComponentProps,
PropsWithChildren,
} from "react";
import { render, screen } from "@testing-library/react";
let mockCloudOnly = false;
type Selector<TState, TResult = unknown> = (state: TState) => TResult;
@ -26,6 +26,11 @@ type TypesStoreState = {
templates: Record<string, unknown>;
};
const typesStoreState: TypesStoreState = {
types: {},
templates: {},
};
const flowStoreState = {
deleteNode: jest.fn(),
setNode: jest.fn(),
@ -121,10 +126,7 @@ jest.mock("../../stores/shortcuts", () => ({
jest.mock("../../stores/typesStore", () => ({
useTypesStore: <T,>(selector: Selector<TypesStoreState, T>) =>
selector({
types: {},
templates: {},
}),
selector(typesStoreState),
}));
jest.mock("../helpers/process-node-advanced-fields", () => ({
@ -206,6 +208,8 @@ describe("GenericNode", () => {
flowStoreState.dismissedNodesLegacy = [];
flowStoreState.componentsToUpdate = [];
flowStoreState.nodes = [];
typesStoreState.types = {};
typesStoreState.templates = {};
});
it("renders legacy and cloud incompatibility warnings together", () => {
@ -231,4 +235,34 @@ describe("GenericNode", () => {
expect(screen.getByText("Legacy")).toBeInTheDocument();
expect(screen.getByTestId("cloud-incompatible-banner")).toBeInTheDocument();
});
it("backfills cloud incompatibility from the current catalog for older saved nodes", () => {
mockCloudOnly = true;
typesStoreState.templates = {
Directory: {
description: "Reads local directories",
display_name: "Directory",
documentation: "",
template: {},
cloud_compatible: false,
},
};
const data: ComponentProps<typeof GenericNode>["data"] = {
id: "legacy-directory-node",
type: "Directory",
showNode: true,
node: {
display_name: "Directory",
description: "Reads local directories",
documentation: "",
template: {},
outputs: [],
},
};
render(<GenericNode data={data} />);
expect(screen.getByTestId("cloud-incompatible-banner")).toBeInTheDocument();
});
});

View File

@ -14,19 +14,20 @@ import { ICON_STROKE_WIDTH } from "../../constants/constants";
import NodeToolbarComponent from "../../pages/FlowPage/components/nodeToolbarComponent";
import { useChangeOnUnfocus } from "../../shared/hooks/use-change-on-unfocus";
import useAlertStore from "../../stores/alertStore";
import { useCloudModeStore } from "../../stores/cloudModeStore";
import useFlowStore from "../../stores/flowStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
import { useShortcutsStore } from "../../stores/shortcuts";
import { useTypesStore } from "../../stores/typesStore";
import type { OutputFieldType, VertexBuildTypeAPI } from "../../types/api";
import type { NodeDataType } from "../../types/flow";
import { withCurrentCloudMetadata } from "../../utils/cloudMetadataUtils";
import { scapedJSONStringfy } from "../../utils/reactflowUtils";
import { classNames, cn } from "../../utils/utils";
import { processNodeAdvancedFields } from "../helpers/process-node-advanced-fields";
import useUpdateNodeCode from "../hooks/use-update-node-code";
import NodeDescription from "./components/NodeDescription";
import { useCloudModeStore } from "../../stores/cloudModeStore";
import NodeCloudIncompatibleComponent from "./components/NodeCloudIncompatibleComponent";
import NodeDescription from "./components/NodeDescription";
import NodeLegacyComponent from "./components/NodeLegacyComponent";
import NodeName from "./components/NodeName";
import NodeOutputs from "./components/NodeOutputParameter/NodeOutputs";
@ -374,9 +375,17 @@ function GenericNode({
);
const cloudOnly = useCloudModeStore((state) => state.cloudOnly);
const effectiveNodeClass = useMemo(
() =>
cloudOnly
? (withCurrentCloudMetadata(data.node, templates[data.type]) ??
data.node)
: data.node,
[cloudOnly, data.node, data.type, templates],
);
const shouldShowCloudIncompatible = useMemo(
() => cloudOnly && data.node?.cloud_compatible === false,
[cloudOnly, data.node?.cloud_compatible],
() => cloudOnly && effectiveNodeClass?.cloud_compatible === false,
[cloudOnly, effectiveNodeClass?.cloud_compatible],
);
const memoizedNodeToolbarComponent = useMemo(() => {

View File

@ -4,7 +4,6 @@ import useHandleOnNewValue from "@/CustomNodes/hooks/use-handle-new-value";
import useHandleNodeClass from "@/CustomNodes/hooks/use-handle-node-class";
import { ParameterRenderComponent } from "@/components/core/parameterRenderComponent";
import type { NodeInfoType } from "@/components/core/parameterRenderComponent/types";
import { IS_AUTO_LOGIN } from "@/constants/constants";
import { useIsAutoLogin } from "@/hooks/use-is-auto-login";
import useAuthStore from "@/stores/authStore";
import useFlowStore from "@/stores/flowStore";
@ -70,6 +69,7 @@ export default function TableNodeCellRender({
editNode={true}
handleNodeClass={handleNodeClass}
nodeClass={node?.data.node}
nodeType={node?.data?.type}
disabled={disabled}
nodeInformationMetadata={nodeInformationMetadata}
/>

View File

@ -1,12 +1,17 @@
import type { ComponentProps } from "react";
import { render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import type { APIClassType } from "@/types/api";
import { ParameterRenderComponent } from ".";
let mockCloudOnly = false;
let mockTemplates: Record<string, unknown> = {};
type CloudModeState = {
cloudOnly: boolean;
setCloudOnly: jest.Mock;
};
type TypesStoreState = {
templates: Record<string, unknown>;
};
type StrRenderProps = {
value?: string | number | readonly string[] | null;
placeholder?: string | null;
@ -22,6 +27,11 @@ jest.mock("@/stores/cloudModeStore", () => ({
selector({ cloudOnly: mockCloudOnly, setCloudOnly: jest.fn() }),
}));
jest.mock("@/stores/typesStore", () => ({
useTypesStore: <T,>(selector: (state: TypesStoreState) => T) =>
selector({ templates: mockTemplates }),
}));
jest.mock("./components/strRenderComponent", () => ({
StrRenderComponent: ({ value, placeholder }: StrRenderProps) => (
<div
@ -55,8 +65,20 @@ jest.mock("./components/sortableListComponent", () => ({
describe("ParameterRenderComponent", () => {
beforeEach(() => {
mockCloudOnly = false;
mockTemplates = {};
});
const createNodeClass = (
overrides: Record<string, unknown> = {},
): APIClassType =>
({
description: "Test component",
template: {},
display_name: "Test Component",
documentation: "Test component documentation",
...overrides,
}) as APIClassType;
const baseProps: ComponentProps<typeof ParameterRenderComponent> = {
handleOnNewValue: jest.fn(),
name: "url",
@ -65,11 +87,8 @@ describe("ParameterRenderComponent", () => {
showParameter: true,
inspectionPanel: false,
handleNodeClass: jest.fn(),
nodeClass: {
description: "Test component",
template: {},
display_name: "Test Component",
documentation: "Test component documentation",
templateValue: "",
nodeClass: createNodeClass({
metadata: {
cloud_default_overrides: {
url: {
@ -78,7 +97,7 @@ describe("ParameterRenderComponent", () => {
},
},
},
},
}),
disabled: false,
templateData: { type: "str", name: "url", placeholder: "Local URL" },
};
@ -114,6 +133,38 @@ describe("ParameterRenderComponent", () => {
);
});
it("backfills the cloud placeholder from the current catalog for older saved nodes", () => {
mockCloudOnly = true;
mockTemplates = {
File: createNodeClass({
display_name: "Read File",
documentation: "",
metadata: {
cloud_default_overrides: {
url: {
placeholder: "Enter your cloud URL",
},
},
},
}),
};
render(
<ParameterRenderComponent
{...baseProps}
nodeType="File"
nodeClass={createNodeClass()}
templateValue=""
/>,
);
const renderedProps = screen.getByTestId("str-render-props");
expect(renderedProps).toHaveAttribute(
"data-placeholder",
"Enter your cloud URL",
);
});
it("preserves incompatible sortable values while filtering them from the chooser in cloud mode", () => {
mockCloudOnly = true;
@ -128,14 +179,49 @@ describe("ParameterRenderComponent", () => {
options: [{ name: "Local" }, { name: "AWS" }],
limit: 1,
}}
nodeClass={{
...baseProps.nodeClass,
nodeClass={createNodeClass({
metadata: {
cloud_incompatible_options: {
storage_location: ["Local"],
},
},
})}
/>,
);
const renderedProps = screen.getByTestId("sortable-list-props");
expect(renderedProps).toHaveAttribute("data-value", "Local");
expect(renderedProps).toHaveAttribute("data-options", "AWS");
expect(renderedProps).toHaveAttribute("data-cloud-incompatible", "Local");
});
it("backfills cloud-incompatible sortable options from the current catalog for older saved nodes", () => {
mockCloudOnly = true;
mockTemplates = {
File: createNodeClass({
display_name: "Read File",
documentation: "",
metadata: {
cloud_incompatible_options: {
storage_location: ["Local"],
},
},
}),
};
render(
<ParameterRenderComponent
{...baseProps}
nodeType="File"
name="storage_location"
templateValue={[{ name: "Local" }]}
templateData={{
type: "sortableList",
name: "storage_location",
options: [{ name: "Local" }, { name: "AWS" }],
limit: 1,
}}
nodeClass={createNodeClass()}
/>,
);

View File

@ -5,12 +5,14 @@ import SliderComponent from "@/components/core/parameterRenderComponent/componen
import TableNodeComponent from "@/components/core/parameterRenderComponent/components/TableNodeComponent";
import TabComponent from "@/components/core/parameterRenderComponent/components/tabComponent";
import { TEXT_FIELD_TYPES } from "@/constants/constants";
import { useCloudModeStore } from "@/stores/cloudModeStore";
import CustomConnectionComponent from "@/customization/components/custom-connectionComponent";
import CustomInputFileComponent from "@/customization/components/custom-input-file";
import CustomLinkComponent from "@/customization/components/custom-linkComponent";
import { ENABLE_INSPECTION_PANEL } from "@/customization/feature-flags";
import { useCloudModeStore } from "@/stores/cloudModeStore";
import { useTypesStore } from "@/stores/typesStore";
import type { APIClassType, InputFieldType } from "@/types/api";
import { withCurrentCloudMetadata } from "@/utils/cloudMetadataUtils";
import AccordionPromptComponent from "./components/accordionPromptComponent";
import DictComponent from "./components/dictComponent";
import { EmptyParameterComponent } from "./components/emptyParameterComponent";
@ -36,10 +38,11 @@ export function ParameterRenderComponent({
templateData,
templateValue,
editNode,
showParameter,
showParameter = true,
inspectionPanel = false,
handleNodeClass,
nodeClass,
nodeType,
disabled,
placeholder,
isToolMode,
@ -53,16 +56,27 @@ export function ParameterRenderComponent({
templateData: Partial<InputFieldType>;
templateValue: unknown;
editNode: boolean;
showParameter: boolean;
inspectionPanel: boolean;
showParameter?: boolean;
inspectionPanel?: boolean;
handleNodeClass: (value: unknown, code?: string, type?: string) => void;
nodeClass: APIClassType;
nodeType?: string;
disabled: boolean;
placeholder?: string;
isToolMode?: boolean;
nodeInformationMetadata?: NodeInfoType;
}) {
const cloudOnly = useCloudModeStore((state) => state.cloudOnly);
const templates = useTypesStore((state) => state.templates);
const resolvedNodeType =
nodeType ??
(typeof nodeClass?.type === "string" ? nodeClass.type : undefined);
const effectiveNodeClass =
cloudOnly && resolvedNodeType
? (withCurrentCloudMetadata(nodeClass, templates[resolvedNodeType]) ??
nodeClass)
: nodeClass;
const id = (
templateData.type +
@ -71,7 +85,7 @@ export function ParameterRenderComponent({
templateData.name
).toLowerCase();
const nodeMetadata = nodeClass?.metadata as
const nodeMetadata = effectiveNodeClass?.metadata as
| {
cloud_default_overrides?: Record<
string,
@ -98,7 +112,7 @@ export function ParameterRenderComponent({
editNode,
handleOnNewValue: handleOnNewValue as handleOnNewValueType,
disabled,
nodeClass,
nodeClass: effectiveNodeClass,
handleNodeClass,
nodeId,
helperText: templateData?.helper_text,
@ -143,7 +157,7 @@ export function ParameterRenderComponent({
<StrRenderComponent
{...baseInputProps}
nodeId={nodeId}
nodeClass={nodeClass}
nodeClass={effectiveNodeClass}
handleNodeClass={handleNodeClass}
templateData={templateData}
name={name}
@ -218,14 +232,14 @@ export function ParameterRenderComponent({
return ENABLE_INSPECTION_PANEL && !baseInputProps.editNode ? (
<AccordionPromptComponent
{...baseInputProps}
readonly={!!nodeClass.flow}
readonly={!!effectiveNodeClass.flow}
field_name={name}
id={`promptarea_${id}`}
/>
) : (
<PromptAreaComponent
{...baseInputProps}
readonly={!!nodeClass.flow}
readonly={!!effectiveNodeClass.flow}
field_name={name}
id={`promptarea_${id}`}
/>
@ -234,7 +248,7 @@ export function ParameterRenderComponent({
return ENABLE_INSPECTION_PANEL && !baseInputProps.editNode ? (
<AccordionPromptComponent
{...baseInputProps}
readonly={!!nodeClass.flow}
readonly={!!effectiveNodeClass.flow}
field_name={name}
id={`mustachepromptarea_${id}`}
isDoubleBrackets={true}
@ -242,7 +256,7 @@ export function ParameterRenderComponent({
) : (
<MustachePromptAreaComponent
{...baseInputProps}
readonly={!!nodeClass.flow}
readonly={!!effectiveNodeClass.flow}
field_name={name}
id={`mustachepromptarea_${id}`}
/>
@ -269,9 +283,9 @@ export function ParameterRenderComponent({
<ToolsComponent
{...baseInputProps}
description={templateData.info || "Add or edit data"}
title={nodeClass?.display_name ?? "Tools"}
icon={nodeClass?.icon ?? ""}
template={nodeClass?.template}
title={effectiveNodeClass?.display_name ?? "Tools"}
icon={effectiveNodeClass?.icon ?? ""}
template={effectiveNodeClass?.template}
/>
);
case "slider": {
@ -338,7 +352,7 @@ export function ParameterRenderComponent({
{...baseInputProps}
name={name}
nodeId={nodeId}
nodeClass={nodeClass}
nodeClass={effectiveNodeClass}
helperText={templateData?.helper_text}
helperMetadata={templateData?.helper_text_metadata}
options={templateData?.options}

View File

@ -19,6 +19,7 @@ export function CustomParameterComponent({
editNode,
handleNodeClass,
nodeClass,
nodeType,
placeholder,
isToolMode = false,
nodeInformationMetadata,
@ -29,12 +30,13 @@ export function CustomParameterComponent({
nodeId: string;
inputId: targetHandleType;
templateData: Partial<InputFieldType>;
templateValue: any;
templateValue: unknown;
showParameter: boolean;
inspectionPanel: boolean;
inspectionPanel?: boolean;
editNode: boolean;
handleNodeClass: (value: any, code?: string, type?: string) => void;
handleNodeClass: (value: unknown, code?: string, type?: string) => void;
nodeClass: APIClassType;
nodeType?: string;
placeholder?: string;
isToolMode?: boolean;
nodeInformationMetadata?: NodeInfoType;
@ -61,6 +63,7 @@ export function CustomParameterComponent({
inspectionPanel={inspectionPanel}
handleNodeClass={handleNodeClass}
nodeClass={nodeClass}
nodeType={nodeType}
disabled={disabled}
placeholder={placeholder}
isToolMode={isToolMode}
@ -107,7 +110,7 @@ export function CustomParameterLabel({
}: {
name: string;
nodeId: string;
templateValue: any;
templateValue: unknown;
nodeClass: APIClassType;
}) {
return <></>;

View File

@ -0,0 +1,82 @@
import type { APIClassType } from "@/types/api";
type CloudFieldOverride = {
value?: unknown;
placeholder?: string;
};
type CloudUiMetadata = Record<string, unknown> & {
cloud_default_overrides?: Record<string, CloudFieldOverride>;
cloud_incompatible_options?: Record<string, unknown[]>;
};
function isCloudUiMetadata(value: unknown): value is CloudUiMetadata {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function withCurrentCloudMetadata(
savedNode: APIClassType | undefined,
currentCatalogNode: APIClassType | undefined,
): APIClassType | undefined {
if (!savedNode || !currentCatalogNode) {
return savedNode;
}
const savedMetadata = isCloudUiMetadata(savedNode.metadata)
? savedNode.metadata
: undefined;
const currentMetadata = isCloudUiMetadata(currentCatalogNode.metadata)
? currentCatalogNode.metadata
: undefined;
const shouldOverlayCloudCompatible =
savedNode.cloud_compatible === undefined &&
currentCatalogNode.cloud_compatible !== undefined;
const shouldOverlayCloudDefaultOverrides =
savedMetadata?.cloud_default_overrides === undefined &&
currentMetadata?.cloud_default_overrides !== undefined;
const shouldOverlayCloudIncompatibleOptions =
savedMetadata?.cloud_incompatible_options === undefined &&
currentMetadata?.cloud_incompatible_options !== undefined;
if (
!shouldOverlayCloudCompatible &&
!shouldOverlayCloudDefaultOverrides &&
!shouldOverlayCloudIncompatibleOptions
) {
return savedNode;
}
const nextMetadata =
shouldOverlayCloudDefaultOverrides || shouldOverlayCloudIncompatibleOptions
? {
...((savedMetadata ?? {}) as Record<string, unknown>),
...(shouldOverlayCloudDefaultOverrides
? {
cloud_default_overrides:
currentMetadata?.cloud_default_overrides,
}
: {}),
...(shouldOverlayCloudIncompatibleOptions
? {
cloud_incompatible_options:
currentMetadata?.cloud_incompatible_options,
}
: {}),
}
: savedMetadata;
return {
...savedNode,
...(shouldOverlayCloudCompatible
? {
cloud_compatible: currentCatalogNode.cloud_compatible,
}
: {}),
...(nextMetadata !== undefined
? {
metadata: nextMetadata,
}
: {}),
} as APIClassType;
}