mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 01:59:06 +08:00
fix(cloud): Tighten cloud compatibility truthfulness
Keep preserved cloud-incompatible selections visible with inline warnings and make model empty states follow the filtered cloud-compatible set. Add a cloud default override for SearXNG and update the built component index so non-dev startup matches cloud mode behavior.
This commit is contained in:
@ -523,6 +523,43 @@ describe("ModelInputComponent", () => {
|
||||
mockCloudOnly = false;
|
||||
});
|
||||
|
||||
it("should show a cloud-specific empty state when every provider is filtered out", async () => {
|
||||
mockCloudOnly = true;
|
||||
const user = userEvent.setup();
|
||||
const handleOnNewValue = jest.fn();
|
||||
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent
|
||||
{...defaultProps}
|
||||
options={[
|
||||
{
|
||||
id: "llama3",
|
||||
name: "llama3",
|
||||
icon: "Ollama",
|
||||
provider: "Ollama",
|
||||
metadata: {},
|
||||
},
|
||||
]}
|
||||
value={[]}
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("No cloud-compatible models"),
|
||||
).toBeInTheDocument();
|
||||
expect(handleOnNewValue).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("llama3-option")).not.toBeInTheDocument();
|
||||
|
||||
mockCloudOnly = false;
|
||||
});
|
||||
|
||||
it("should show all providers when cloud mode is inactive", () => {
|
||||
mockCloudOnly = false;
|
||||
|
||||
|
||||
@ -4,12 +4,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { RECEIVING_INPUT_VALUE } from "@/constants/constants";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { ModelOption, SelectedModel } from "../types";
|
||||
import type { SelectedModel } from "../types";
|
||||
|
||||
interface ModelTriggerProps {
|
||||
open: boolean;
|
||||
disabled: boolean;
|
||||
options: ModelOption[];
|
||||
visibleOptionsCount: number;
|
||||
selectedModel: SelectedModel | null;
|
||||
showCloudIncompatibleWarning?: boolean;
|
||||
placeholder?: string;
|
||||
@ -18,12 +18,13 @@ interface ModelTriggerProps {
|
||||
id: string;
|
||||
refButton: RefObject<HTMLButtonElement | null>;
|
||||
showEmptyState?: boolean;
|
||||
emptyStateLabel?: string;
|
||||
}
|
||||
|
||||
const ModelTrigger = ({
|
||||
open,
|
||||
disabled,
|
||||
options,
|
||||
visibleOptionsCount,
|
||||
selectedModel,
|
||||
showCloudIncompatibleWarning = false,
|
||||
placeholder = "Setup Provider",
|
||||
@ -32,9 +33,12 @@ const ModelTrigger = ({
|
||||
id,
|
||||
refButton,
|
||||
showEmptyState = false,
|
||||
emptyStateLabel = "No models enabled",
|
||||
}: ModelTriggerProps) => {
|
||||
const hasVisibleOptions = visibleOptionsCount > 0;
|
||||
|
||||
const renderSelectedIcon = () => {
|
||||
if (disabled || options.length === 0) {
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -47,9 +51,9 @@ const ModelTrigger = ({
|
||||
};
|
||||
|
||||
// Check if we're in empty state mode (showEmptyState=true and no options)
|
||||
const isEmptyStateMode = showEmptyState && options.length === 0;
|
||||
const isEmptyStateMode = showEmptyState && !hasVisibleOptions;
|
||||
|
||||
if (!hasEnabledProviders && !showEmptyState && options.length === 0) {
|
||||
if (!hasEnabledProviders && !showEmptyState && !hasVisibleOptions) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
@ -67,7 +71,10 @@ const ModelTrigger = ({
|
||||
<div className="flex w-full flex-col">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabled={disabled || (options.length === 0 && !showEmptyState)}
|
||||
disabled={
|
||||
disabled ||
|
||||
(!hasVisibleOptions && !isEmptyStateMode && !selectedModel)
|
||||
}
|
||||
variant="primary"
|
||||
size="xs"
|
||||
role="combobox"
|
||||
@ -89,7 +96,7 @@ const ModelTrigger = ({
|
||||
RECEIVING_INPUT_VALUE
|
||||
) : isEmptyStateMode ? (
|
||||
<div className="truncate text-muted-foreground">
|
||||
No models enabled
|
||||
{emptyStateLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col items-start">
|
||||
|
||||
@ -162,6 +162,16 @@ export default function ModelInputComponent({
|
||||
[cloudOnly, selectedModel?.provider],
|
||||
);
|
||||
|
||||
const showNoCompatibleCloudModels = useMemo(
|
||||
() => cloudOnly && options.length > 0 && flatOptions.length === 0 && !selectedModel,
|
||||
[cloudOnly, options.length, flatOptions.length, selectedModel],
|
||||
);
|
||||
|
||||
const effectiveShowEmptyState = showEmptyState || showNoCompatibleCloudModels;
|
||||
const emptyStateLabel = showNoCompatibleCloudModels
|
||||
? "No cloud-compatible models"
|
||||
: "No models enabled";
|
||||
|
||||
useEffect(() => {
|
||||
// Only proceed if we have options and haven't selected a value
|
||||
if (flatOptions.length > 0 && (!value || value.length === 0)) {
|
||||
@ -333,7 +343,7 @@ export default function ModelInputComponent({
|
||||
|
||||
// Loading state (skip if showEmptyState is true - we want to show the empty dropdown instead)
|
||||
if (
|
||||
((!options || options.length === 0) && !showEmptyState) ||
|
||||
((!options || options.length === 0) && !effectiveShowEmptyState) ||
|
||||
isRefreshingAfterClose ||
|
||||
refreshOptions
|
||||
) {
|
||||
@ -348,7 +358,7 @@ export default function ModelInputComponent({
|
||||
<ModelTrigger
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
options={options}
|
||||
visibleOptionsCount={flatOptions.length}
|
||||
selectedModel={selectedModel}
|
||||
showCloudIncompatibleWarning={showCloudIncompatibleWarning}
|
||||
placeholder={placeholder}
|
||||
@ -356,7 +366,8 @@ export default function ModelInputComponent({
|
||||
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
|
||||
id={id}
|
||||
refButton={refButton}
|
||||
showEmptyState={showEmptyState}
|
||||
showEmptyState={effectiveShowEmptyState}
|
||||
emptyStateLabel={emptyStateLabel}
|
||||
/>
|
||||
</div>
|
||||
{renderPopoverContent()}
|
||||
|
||||
@ -14,6 +14,7 @@ type SortableListComponentProps = {
|
||||
helperText?: string;
|
||||
helperMetadata?: any;
|
||||
options?: any[];
|
||||
cloudIncompatibleOptions?: unknown[];
|
||||
searchCategory?: string[];
|
||||
icon?: string;
|
||||
limit?: number;
|
||||
@ -25,16 +26,23 @@ const SortableListItem = memo(
|
||||
index,
|
||||
onRemove,
|
||||
limit = 1,
|
||||
showCloudIncompatibleWarning = false,
|
||||
}: {
|
||||
data: any;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
limit?: number;
|
||||
showCloudIncompatibleWarning?: boolean;
|
||||
}) => (
|
||||
<li
|
||||
className={cn(
|
||||
"inline-flex h-12 w-full items-center gap-2 text-sm font-medium",
|
||||
limit === 1 ? "h-6 rounded-md bg-muted" : "group cursor-grab",
|
||||
"inline-flex w-full items-center gap-2 text-sm font-medium",
|
||||
limit === 1
|
||||
? cn(
|
||||
"rounded-md bg-muted",
|
||||
showCloudIncompatibleWarning ? "min-h-10 py-1" : "h-6",
|
||||
)
|
||||
: "group min-h-12 cursor-grab",
|
||||
)}
|
||||
>
|
||||
{limit !== 1 && (
|
||||
@ -51,14 +59,22 @@ const SortableListItem = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xxs font-medium text-muted-foreground",
|
||||
limit === 1 ? "max-w-56 pl-2" : "max-w-48",
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xxs font-medium text-muted-foreground",
|
||||
limit === 1 ? "max-w-56 pl-2" : "max-w-48",
|
||||
)}
|
||||
>
|
||||
{data.name}
|
||||
</span>
|
||||
{showCloudIncompatibleWarning && (
|
||||
<div className="flex items-center gap-1 pl-2 text-[11px] text-accent-emerald-foreground">
|
||||
<ForwardedIconComponent name="CloudOff" className="h-3 w-3" />
|
||||
<span>Not available in cloud</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
@ -84,6 +100,7 @@ const SortableListComponent = ({
|
||||
helperText = "",
|
||||
helperMetadata = { icon: undefined, variant: "muted-foreground" },
|
||||
options = [],
|
||||
cloudIncompatibleOptions = [],
|
||||
searchCategory = [],
|
||||
limit,
|
||||
id,
|
||||
@ -96,6 +113,18 @@ const SortableListComponent = ({
|
||||
// Convert value to an array if it exists, otherwise use empty array
|
||||
const listData = useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||
|
||||
const isCloudIncompatibleOption = useCallback(
|
||||
(item: unknown) => {
|
||||
const itemName =
|
||||
typeof item === "object" && item !== null && "name" in item
|
||||
? (item as { name?: unknown }).name ?? item
|
||||
: item;
|
||||
|
||||
return cloudIncompatibleOptions.some((option) => option === itemName);
|
||||
},
|
||||
[cloudIncompatibleOptions],
|
||||
);
|
||||
|
||||
const createRemoveHandler = useCallback(
|
||||
(index: number) => () => {
|
||||
const newList = listData.filter((_, i) => i !== index);
|
||||
@ -195,6 +224,7 @@ const SortableListComponent = ({
|
||||
index={index}
|
||||
onRemove={createRemoveHandler(index)}
|
||||
limit={limit}
|
||||
showCloudIncompatibleWarning={isCloudIncompatibleOption(data)}
|
||||
/>
|
||||
))}
|
||||
</ReactSortable>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import * as ReactSortableModule from "react-sortablejs";
|
||||
import SortableListComponent from "./index";
|
||||
@ -56,4 +56,27 @@ describe("SortableListComponent reproduction", () => {
|
||||
|
||||
expect(handleOnNewValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mark preserved cloud-incompatible selections", () => {
|
||||
render(
|
||||
<SortableListComponent
|
||||
tooltip=""
|
||||
name="storage_location"
|
||||
value={[{ name: "Local" }]}
|
||||
handleOnNewValue={jest.fn()}
|
||||
disabled={false}
|
||||
recommended={false}
|
||||
placeholder="Select items"
|
||||
isList={true}
|
||||
fileTypes={[]}
|
||||
onDelete={jest.fn()}
|
||||
id="test-id"
|
||||
limit={1}
|
||||
cloudIncompatibleOptions={["Local"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Local")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not available in cloud")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,6 +11,11 @@ type StrRenderProps = {
|
||||
value?: string | number | readonly string[] | null;
|
||||
placeholder?: string | null;
|
||||
};
|
||||
type SortableListRenderProps = {
|
||||
value?: Array<{ name?: string }>;
|
||||
options?: Array<{ name?: string }>;
|
||||
cloudIncompatibleOptions?: unknown[];
|
||||
};
|
||||
|
||||
jest.mock("@/stores/cloudModeStore", () => ({
|
||||
useCloudModeStore: <T,>(selector: (state: CloudModeState) => T) =>
|
||||
@ -31,6 +36,22 @@ jest.mock("./components/mcpComponent", () => () => (
|
||||
<div data-testid="mcp-component" />
|
||||
));
|
||||
|
||||
jest.mock("./components/sortableListComponent", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
value = [],
|
||||
options = [],
|
||||
cloudIncompatibleOptions = [],
|
||||
}: SortableListRenderProps) => (
|
||||
<div
|
||||
data-testid="sortable-list-props"
|
||||
data-value={value.map((option) => option.name ?? "").join(",")}
|
||||
data-options={options.map((option) => option.name ?? "").join(",")}
|
||||
data-cloud-incompatible={cloudIncompatibleOptions.join(",")}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ParameterRenderComponent", () => {
|
||||
beforeEach(() => {
|
||||
mockCloudOnly = false;
|
||||
@ -92,4 +113,35 @@ describe("ParameterRenderComponent", () => {
|
||||
"Enter your cloud URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves incompatible sortable values while filtering them from the chooser in cloud mode", () => {
|
||||
mockCloudOnly = true;
|
||||
|
||||
render(
|
||||
<ParameterRenderComponent
|
||||
{...baseProps}
|
||||
name="storage_location"
|
||||
templateValue={[{ name: "Local" }]}
|
||||
templateData={{
|
||||
type: "sortableList",
|
||||
name: "storage_location",
|
||||
options: [{ name: "Local" }, { name: "AWS" }],
|
||||
limit: 1,
|
||||
}}
|
||||
nodeClass={{
|
||||
...baseProps.nodeClass,
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -298,15 +298,16 @@ export function ParameterRenderComponent({
|
||||
case "sortableList": {
|
||||
// Filter out cloud-incompatible options when cloud mode is active
|
||||
let sortableOptions = templateData?.options;
|
||||
let cloudIncompatibleOptions: unknown[] = [];
|
||||
if (cloudOnly && nodeMetadata?.cloud_incompatible_options) {
|
||||
const incompatible = nodeMetadata.cloud_incompatible_options[name];
|
||||
if (incompatible && Array.isArray(incompatible)) {
|
||||
cloudIncompatibleOptions = incompatible;
|
||||
sortableOptions = sortableOptions?.filter((opt: unknown) => {
|
||||
const optionName =
|
||||
typeof opt === "object" && opt !== null && "name" in opt
|
||||
? ((opt as { name?: unknown }).name ?? opt)
|
||||
: opt;
|
||||
|
||||
return !incompatible.includes(optionName);
|
||||
});
|
||||
}
|
||||
@ -317,6 +318,7 @@ export function ParameterRenderComponent({
|
||||
helperText={templateData?.helper_text}
|
||||
helperMetadata={templateData?.helper_text_metadata}
|
||||
options={sortableOptions}
|
||||
cloudIncompatibleOptions={cloudIncompatibleOptions}
|
||||
searchCategory={templateData?.search_category}
|
||||
limit={templateData?.limit}
|
||||
id={`sortablelist_${id}`}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -20,6 +20,11 @@ class SearXNGToolComponent(LCToolComponent):
|
||||
description = "A component that searches for tools using SearXNG."
|
||||
name = "SearXNGTool"
|
||||
legacy: bool = True
|
||||
metadata = {
|
||||
"cloud_default_overrides": {
|
||||
"url": {"value": "", "placeholder": "Enter SearXNG URL"},
|
||||
},
|
||||
}
|
||||
|
||||
inputs = [
|
||||
MessageTextInput(
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
"""Tests for cloud-mode default overrides on mixed compatibility components."""
|
||||
|
||||
from lfx.components.tools.searxng import SearXNGToolComponent
|
||||
|
||||
|
||||
def test_searxng_component_clears_localhost_default_in_cloud_mode():
|
||||
assert SearXNGToolComponent.metadata["cloud_default_overrides"] == {
|
||||
"url": {"value": "", "placeholder": "Enter SearXNG URL"},
|
||||
}
|
||||
Reference in New Issue
Block a user