mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 10:06:43 +08:00
Merge branch 'release-1.9.0' into LE-846
This commit is contained in:
@ -0,0 +1,116 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import useFileDrop from "../use-on-file-drop";
|
||||
|
||||
const mockSaveFlow = jest.fn();
|
||||
const mockSetFolderDragging = jest.fn();
|
||||
const mockSetFolderIdDragging = jest.fn();
|
||||
const mockUploadFlowToFolder = jest.fn();
|
||||
const mockSetErrorData = jest.fn();
|
||||
|
||||
let flowsManagerState: any;
|
||||
let folderState: any;
|
||||
|
||||
jest.mock("@/hooks/flows/use-save-flow", () => ({
|
||||
__esModule: true,
|
||||
default: () => mockSaveFlow,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
"@/controllers/API/queries/folders/use-post-upload-to-folder",
|
||||
() => ({
|
||||
usePostUploadFlowToFolder: () => ({ mutate: mockUploadFlowToFolder }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock("react-i18next", () => ({
|
||||
__esModule: true,
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: {
|
||||
type: "3rdParty",
|
||||
init: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../stores/alertStore", () => ({
|
||||
__esModule: true,
|
||||
default: (selector: any) =>
|
||||
selector({
|
||||
setErrorData: mockSetErrorData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../stores/flowsManagerStore", () => {
|
||||
const useFlowsManagerStore = (selector: any) =>
|
||||
selector ? selector(flowsManagerState) : flowsManagerState;
|
||||
useFlowsManagerStore.getState = () => flowsManagerState;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: useFlowsManagerStore,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../../../../stores/foldersStore", () => {
|
||||
const useFolderStore = (selector: any) =>
|
||||
selector ? selector(folderState) : folderState;
|
||||
useFolderStore.getState = () => folderState;
|
||||
return {
|
||||
useFolderStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("useFileDrop.onDrop (drag-and-drop flow between projects)", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const flowInProjectA = {
|
||||
id: "flow-1",
|
||||
name: "Flow One",
|
||||
data: null,
|
||||
folder_id: "project-A",
|
||||
is_component: false,
|
||||
} as any;
|
||||
|
||||
flowsManagerState = {
|
||||
flows: [flowInProjectA],
|
||||
};
|
||||
|
||||
folderState = {
|
||||
setFolderDragging: mockSetFolderDragging,
|
||||
setFolderIdDragging: mockSetFolderIdDragging,
|
||||
myCollectionId: "my-collection",
|
||||
};
|
||||
});
|
||||
|
||||
it("should_call_saveFlow_with_new_folder_id_when_flow_is_dropped_into_empty_project", () => {
|
||||
const { result } = renderHook(() => useFileDrop("project-B"));
|
||||
|
||||
const event = {
|
||||
dataTransfer: {
|
||||
getData: jest.fn((type: string) =>
|
||||
type === "flow"
|
||||
? JSON.stringify({
|
||||
id: "flow-1",
|
||||
name: "Flow One",
|
||||
folder_id: "project-A",
|
||||
})
|
||||
: "",
|
||||
),
|
||||
types: ["flow"],
|
||||
},
|
||||
preventDefault: jest.fn(),
|
||||
} as any;
|
||||
|
||||
result.current.onDrop(event, "project-B");
|
||||
|
||||
expect(mockSaveFlow).toHaveBeenCalledTimes(1);
|
||||
const savedFlow = mockSaveFlow.mock.calls[0][0];
|
||||
expect(savedFlow).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "flow-1",
|
||||
folder_id: "project-B",
|
||||
}),
|
||||
);
|
||||
expect(mockSetFolderDragging).toHaveBeenCalledWith(false);
|
||||
expect(mockSetFolderIdDragging).toHaveBeenCalledWith("");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,126 @@
|
||||
// usePatchUpdateFlow hook tests
|
||||
|
||||
const mockApiPatch = jest.fn();
|
||||
|
||||
const mockQueryClient = {
|
||||
refetchQueries: jest.fn(),
|
||||
invalidateQueries: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock("@/controllers/API/api", () => ({
|
||||
api: {
|
||||
patch: mockApiPatch,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/controllers/API/helpers/constants", () => ({
|
||||
getURL: jest.fn((key: string) => `/api/v1/${key.toLowerCase()}`),
|
||||
}));
|
||||
|
||||
jest.mock("@/controllers/API/services/request-processor", () => ({
|
||||
UseRequestProcessor: jest.fn(() => ({
|
||||
mutate: jest.fn((_key: any, fn: any, options: any) => ({
|
||||
mutate: async (payload: any) => {
|
||||
const result = await fn(payload);
|
||||
options?.onSettled?.(result);
|
||||
return result;
|
||||
},
|
||||
})),
|
||||
queryClient: mockQueryClient,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { usePatchUpdateFlow } from "../use-patch-update-flow";
|
||||
|
||||
describe("usePatchUpdateFlow", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should_refresh_global_flows_cache_when_flow_is_moved_to_new_folder", async () => {
|
||||
// Arrange — backend responds with the updated flow (FlowRead)
|
||||
// carrying the new folder_id.
|
||||
mockApiPatch.mockResolvedValue({
|
||||
data: { id: "flow-1", folder_id: "folder-B" },
|
||||
});
|
||||
|
||||
const mutation = usePatchUpdateFlow();
|
||||
|
||||
// Act — simulate the drag-drop PATCH request.
|
||||
await mutation.mutate({
|
||||
id: "flow-1",
|
||||
folder_id: "folder-B",
|
||||
});
|
||||
|
||||
// Assert — the global flows cache (useGetRefreshFlowsQuery) that
|
||||
// HomePage's `isEmptyFolder` check depends on must be invalidated
|
||||
// so stale entries in other folders are refreshed without a manual
|
||||
// page reload.
|
||||
const allInvalidateCalls = [
|
||||
...mockQueryClient.invalidateQueries.mock.calls,
|
||||
...mockQueryClient.refetchQueries.mock.calls,
|
||||
];
|
||||
const invalidatesRefreshFlows = allInvalidateCalls.some((call) => {
|
||||
const queryKey = call[0]?.queryKey;
|
||||
return (
|
||||
Array.isArray(queryKey) && queryKey[0] === "useGetRefreshFlowsQuery"
|
||||
);
|
||||
});
|
||||
expect(invalidatesRefreshFlows).toBe(true);
|
||||
});
|
||||
|
||||
it("should_invalidate_folders_list_query_with_correct_key_when_flow_is_patched", async () => {
|
||||
// Arrange
|
||||
mockApiPatch.mockResolvedValue({
|
||||
data: { id: "flow-1", folder_id: "folder-B" },
|
||||
});
|
||||
|
||||
const mutation = usePatchUpdateFlow();
|
||||
|
||||
// Act
|
||||
await mutation.mutate({
|
||||
id: "flow-1",
|
||||
folder_id: "folder-B",
|
||||
});
|
||||
|
||||
// Assert — the folders list query key is ["useGetFolders"], so any
|
||||
// refetch must use that exact prefix. A composite key like
|
||||
// ["useGetFolders", <folder_id>] never matches the real cache entry.
|
||||
const allCalls = [
|
||||
...mockQueryClient.invalidateQueries.mock.calls,
|
||||
...mockQueryClient.refetchQueries.mock.calls,
|
||||
];
|
||||
const matchesFoldersList = allCalls.some((call) => {
|
||||
const queryKey = call[0]?.queryKey;
|
||||
return (
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "useGetFolders" &&
|
||||
queryKey.length === 1
|
||||
);
|
||||
});
|
||||
expect(matchesFoldersList).toBe(true);
|
||||
});
|
||||
|
||||
it("should_invalidate_individual_folder_queries_when_flow_is_patched", async () => {
|
||||
mockApiPatch.mockResolvedValue({
|
||||
data: { id: "flow-1", folder_id: "folder-B" },
|
||||
});
|
||||
|
||||
const mutation = usePatchUpdateFlow();
|
||||
|
||||
await mutation.mutate({
|
||||
id: "flow-1",
|
||||
folder_id: "folder-B",
|
||||
});
|
||||
|
||||
const allCalls = [
|
||||
...mockQueryClient.invalidateQueries.mock.calls,
|
||||
...mockQueryClient.refetchQueries.mock.calls,
|
||||
];
|
||||
const matchesIndividualFolder = allCalls.some((call) => {
|
||||
const queryKey = call[0]?.queryKey;
|
||||
return Array.isArray(queryKey) && queryKey[0] === "useGetFolder";
|
||||
});
|
||||
expect(matchesIndividualFolder).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -33,13 +33,14 @@ export const usePatchUpdateFlow: useMutationFunctionType<
|
||||
|
||||
const mutation: UseMutationResult<IPatchUpdateFlow, any, IPatchUpdateFlow> =
|
||||
mutate(["usePatchUpdateFlow"], PatchUpdateFlowFn, {
|
||||
onSettled: (res) => {
|
||||
if (res) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["useGetFolders", res.folder_id],
|
||||
});
|
||||
}
|
||||
queryClient.refetchQueries({
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetRefreshFlowsQuery"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetFolders"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetFolder"],
|
||||
});
|
||||
},
|
||||
|
||||
@ -128,4 +128,73 @@ describe("useSaveFlow", () => {
|
||||
expect(mockSetSaveLoading).toHaveBeenCalledWith(false);
|
||||
expect(mockSetCurrentFlow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should_update_store_flow_folder_id_when_moved_via_drag_drop_from_dashboard", async () => {
|
||||
// Arrange — dashboard scenario: no flow open in the editor, the
|
||||
// global flows store is populated from `header_flows=true`, so the
|
||||
// flow being moved has no `data` field.
|
||||
const headerFlow = {
|
||||
id: "flow-1",
|
||||
name: "Saved Flow",
|
||||
data: null,
|
||||
description: "desc",
|
||||
folder_id: "folder-A",
|
||||
endpoint_name: "saved-flow",
|
||||
locked: false,
|
||||
is_component: false,
|
||||
};
|
||||
|
||||
flowStoreState = {
|
||||
currentFlow: null,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
reactFlowInstance: null,
|
||||
onFlowPage: false,
|
||||
setCurrentFlow: mockSetCurrentFlow,
|
||||
};
|
||||
|
||||
flowsManagerState = {
|
||||
currentFlow: null,
|
||||
flows: [headerFlow],
|
||||
setFlows: mockSetFlows,
|
||||
setSaveLoading: mockSetSaveLoading,
|
||||
};
|
||||
|
||||
mockMutate.mockImplementation((payload, options) => {
|
||||
// Backend responds with the patched flow (FlowRead), echoing the
|
||||
// new folder_id the client just sent.
|
||||
options.onSuccess({
|
||||
...headerFlow,
|
||||
folder_id: payload.folder_id,
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSaveFlow());
|
||||
|
||||
// Act — the drag-and-drop handler spreads the existing flow and sets
|
||||
// the new folder_id, then calls saveFlow(updatedFlow).
|
||||
const updatedFlow = { ...headerFlow, folder_id: "folder-B" };
|
||||
await expect(result.current(updatedFlow)).resolves.toBeUndefined();
|
||||
|
||||
// Assert — the global flows store must be updated with the new
|
||||
// folder_id so the HomePage's `isEmptyFolder` effect reflects the
|
||||
// move without requiring a full page refresh.
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "flow-1",
|
||||
folder_id: "folder-B",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mockSetFlows).toHaveBeenCalledTimes(1);
|
||||
const nextFlows = mockSetFlows.mock.calls[0][0];
|
||||
expect(nextFlows).toHaveLength(1);
|
||||
expect(nextFlows[0]).toEqual(
|
||||
expect.objectContaining({ id: "flow-1", folder_id: "folder-B" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,16 +2,11 @@ import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import en from "./locales/en.json";
|
||||
|
||||
const detectedLang =
|
||||
localStorage.getItem("languagePreference") ||
|
||||
navigator.language.split("-")[0] ||
|
||||
"en";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: detectedLang,
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
|
||||
@ -22,6 +22,7 @@ import useFileDrop from "../../hooks/use-on-file-drop";
|
||||
import type { FlowTabType } from "../../types";
|
||||
import DeploymentsPage from "../deploymentsPage/deployments-page";
|
||||
import EmptyFolder from "../emptyFolder";
|
||||
import { isFolderEmpty } from "./utils/isFolderEmpty";
|
||||
|
||||
const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
|
||||
const { t } = useTranslation();
|
||||
@ -101,14 +102,15 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isEmpty =
|
||||
flows?.find(
|
||||
(flow) =>
|
||||
flow.folder_id === (folderId ?? myCollectionId) &&
|
||||
(ENABLE_MCP ? flow.is_component === false : true),
|
||||
) === undefined;
|
||||
setIsEmptyFolder(isEmpty);
|
||||
}, [flows, folderId, myCollectionId]);
|
||||
setIsEmptyFolder(
|
||||
isFolderEmpty({
|
||||
flows,
|
||||
folderId: folderId ?? myCollectionId ?? "",
|
||||
folderTotal: folderData?.flows?.total,
|
||||
enableMcp: ENABLE_MCP,
|
||||
}),
|
||||
);
|
||||
}, [flows, folderId, myCollectionId, folderData]);
|
||||
|
||||
const handleFileDrop = useFileDrop(isEmptyFolder ? undefined : flowType);
|
||||
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
import type { FlowType } from "@/types/flow";
|
||||
import { isFolderEmpty } from "../isFolderEmpty";
|
||||
|
||||
const buildFlow = (overrides: Partial<FlowType> = {}): FlowType =>
|
||||
({
|
||||
id: overrides.id ?? "flow-1",
|
||||
name: overrides.name ?? "Flow",
|
||||
folder_id: overrides.folder_id ?? "folder-A",
|
||||
is_component: overrides.is_component ?? false,
|
||||
data: null,
|
||||
description: "",
|
||||
endpoint_name: "",
|
||||
locked: false,
|
||||
}) as FlowType;
|
||||
|
||||
describe("isFolderEmpty", () => {
|
||||
it("should_return_true_when_both_store_and_query_report_no_content", () => {
|
||||
expect(
|
||||
isFolderEmpty({
|
||||
flows: [],
|
||||
folderId: "folder-B",
|
||||
folderTotal: 0,
|
||||
enableMcp: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should_return_false_when_store_has_flow_in_folder_but_query_is_still_stale", () => {
|
||||
// Arrange — simulates the drag-drop scenario where saveFlow.onSuccess
|
||||
// has already updated the global store but the folder query cache for
|
||||
// the destination hasn't been refetched yet.
|
||||
const flows = [buildFlow({ id: "flow-1", folder_id: "folder-B" })];
|
||||
|
||||
// Act
|
||||
const result = isFolderEmpty({
|
||||
flows,
|
||||
folderId: "folder-B",
|
||||
folderTotal: 0,
|
||||
enableMcp: false,
|
||||
});
|
||||
|
||||
// Assert — if the store knows about the move, we must not render the
|
||||
// empty state, even when the query cache is still reporting zero.
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should_return_false_when_query_has_content_but_store_is_still_stale", () => {
|
||||
// Arrange — opposite stale direction: the folder query already has
|
||||
// the fresh total from the server (e.g. first navigation to a
|
||||
// previously-empty destination), but the global store hasn't been
|
||||
// invalidated yet.
|
||||
const flows = [buildFlow({ id: "flow-1", folder_id: "folder-A" })];
|
||||
|
||||
// Act
|
||||
const result = isFolderEmpty({
|
||||
flows,
|
||||
folderId: "folder-B",
|
||||
folderTotal: 1,
|
||||
enableMcp: false,
|
||||
});
|
||||
|
||||
// Assert — a non-zero folder total must also prevent the empty state.
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should_return_true_when_store_only_has_components_and_mcp_mode_excludes_them", () => {
|
||||
// In ENABLE_MCP mode, only non-component flows count as content.
|
||||
const flows = [
|
||||
buildFlow({ id: "comp-1", folder_id: "folder-A", is_component: true }),
|
||||
];
|
||||
|
||||
const result = isFolderEmpty({
|
||||
flows,
|
||||
folderId: "folder-A",
|
||||
folderTotal: 0,
|
||||
enableMcp: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should_ignore_component_flag_when_mcp_disabled", () => {
|
||||
const flows = [
|
||||
buildFlow({ id: "comp-1", folder_id: "folder-A", is_component: true }),
|
||||
];
|
||||
|
||||
const result = isFolderEmpty({
|
||||
flows,
|
||||
folderId: "folder-A",
|
||||
folderTotal: 0,
|
||||
enableMcp: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should_return_true_when_flows_store_is_still_loading", () => {
|
||||
expect(
|
||||
isFolderEmpty({
|
||||
flows: undefined,
|
||||
folderId: "folder-A",
|
||||
folderTotal: undefined,
|
||||
enableMcp: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
import type { FlowType } from "@/types/flow";
|
||||
|
||||
interface IsFolderEmptyInput {
|
||||
flows: FlowType[] | undefined;
|
||||
folderId: string;
|
||||
folderTotal: number | undefined;
|
||||
enableMcp: boolean;
|
||||
}
|
||||
|
||||
export function isFolderEmpty({
|
||||
flows,
|
||||
folderId,
|
||||
folderTotal,
|
||||
enableMcp,
|
||||
}: IsFolderEmptyInput): boolean {
|
||||
// Two independent sources of truth for folder emptiness:
|
||||
// - the global flows store (tab-agnostic, but can go stale after
|
||||
// mutations if query invalidations miss it)
|
||||
// - the current folder query total (fresh from the server, but
|
||||
// filtered by the active tab)
|
||||
// Treat the folder as empty only when BOTH agree — this keeps the
|
||||
// tab-agnostic semantics while preventing a stale store from masking
|
||||
// a freshly moved flow in the destination project.
|
||||
const storeHasContent =
|
||||
flows?.some(
|
||||
(flow) =>
|
||||
flow.folder_id === folderId &&
|
||||
(enableMcp ? flow.is_component === false : true),
|
||||
) ?? false;
|
||||
|
||||
const queryHasContent = (folderTotal ?? 0) > 0;
|
||||
|
||||
return !storeHasContent && !queryHasContent;
|
||||
}
|
||||
@ -21,7 +21,6 @@ import type {
|
||||
} from "../../../../types/components";
|
||||
import useScrollToElement from "../hooks/use-scroll-to-element";
|
||||
import GeneralPageHeaderComponent from "./components/GeneralPageHeader";
|
||||
import LanguageFormComponent from "./components/LanguageForm";
|
||||
import PasswordFormComponent from "./components/PasswordForm";
|
||||
import ProfilePictureFormComponent from "./components/ProfilePictureForm";
|
||||
|
||||
@ -139,8 +138,6 @@ export const GeneralPage = () => {
|
||||
<GeneralPageHeaderComponent />
|
||||
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<LanguageFormComponent />
|
||||
|
||||
{ENABLE_PROFILE_ICONS && (
|
||||
<ProfilePictureFormComponent
|
||||
profilePicture={profilePicture}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { expect, test } from "../../fixtures";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
import { renameFlow } from "../../utils/rename-flow";
|
||||
|
||||
test(
|
||||
"CRUD folders",
|
||||
@ -128,44 +129,73 @@ test("add a flow into a folder by drag and drop", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("change flow folder", async ({ page }) => {
|
||||
const uniqueFlowName = `move-${Math.random().toString(36).substring(2, 10)}`;
|
||||
const destinationProjectName = `dest-${Math.random().toString(36).substring(2, 10)}`;
|
||||
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Create a flow in the Starter Project and rename it to something
|
||||
// unique so our assertions can't collide with any template that
|
||||
// Starter ships with by default.
|
||||
await page.getByTestId("side_nav_options_all-templates").click();
|
||||
await page.getByRole("heading", { name: "Basic Prompting" }).click();
|
||||
|
||||
await page.waitForSelector('[data-testid="sidebar-search-input"]', {
|
||||
timeout: 100000,
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await renameFlow(page, { flowName: uniqueFlowName });
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByTestId("icon-ChevronLeft").first().click();
|
||||
|
||||
await page.getByPlaceholder("Search flows").isVisible();
|
||||
await page.getByText("Flows").first().isVisible();
|
||||
if (await page.getByText("Components").first().isVisible()) {
|
||||
await page.getByText("Components").first().isVisible();
|
||||
} else {
|
||||
await page.getByText("MCP Server").first().isVisible();
|
||||
}
|
||||
await expect(page.getByPlaceholder("Search flows")).toBeVisible();
|
||||
|
||||
await page.getByTestId("add-project-button").click();
|
||||
await page
|
||||
.locator("[data-testid='project-sidebar']")
|
||||
.getByText("New Project")
|
||||
.last()
|
||||
.isVisible();
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page
|
||||
.locator("[data-testid='project-sidebar']")
|
||||
.getByText("New Project")
|
||||
.last()
|
||||
.dblclick();
|
||||
await page.getByTestId("input-project").fill("new project test name");
|
||||
await page.getByTestId("input-project").fill(destinationProjectName);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByText("new project test name").last().isVisible();
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-nav-${destinationProjectName}`),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByText("Starter Project").last().click();
|
||||
await page.getByText("Basic Prompting").first().hover();
|
||||
await page.mouse.down();
|
||||
await page.getByText("test").first().hover();
|
||||
await page.mouse.up();
|
||||
await page.getByText("Basic Prompting").first().isVisible();
|
||||
// Go back to the source project where the flow currently lives.
|
||||
await page.getByTestId("sidebar-nav-Starter Project").click();
|
||||
await expect(
|
||||
page.getByTestId("list-card").filter({ hasText: uniqueFlowName }),
|
||||
).toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
// Real HTML5 drag-and-drop: `dragTo()` populates `DataTransfer` so
|
||||
// the `use-on-file-drop.ts` handler reads `getData("flow")` and
|
||||
// triggers the folder-change mutation. `mouse.down/up` would NOT.
|
||||
await page
|
||||
.getByTestId("list-card")
|
||||
.filter({ hasText: uniqueFlowName })
|
||||
.first()
|
||||
.dragTo(page.getByTestId(`sidebar-nav-${destinationProjectName}`));
|
||||
|
||||
// Click the destination folder and verify the moved flow is visible
|
||||
// WITHOUT a manual page refresh. This is the behavior that regresses
|
||||
// when the patch-flow cache invalidation is incomplete.
|
||||
await page.getByTestId(`sidebar-nav-${destinationProjectName}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("list-card").filter({ hasText: uniqueFlowName }),
|
||||
).toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
// And the flow must NOT remain in the source project.
|
||||
await page.getByTestId("sidebar-nav-Starter Project").click();
|
||||
await expect(
|
||||
page.getByTestId("list-card").filter({ hasText: uniqueFlowName }),
|
||||
).toHaveCount(0, { timeout: 10000 });
|
||||
});
|
||||
|
||||
@ -3,6 +3,11 @@ import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
import { renameFlow } from "../../utils/rename-flow";
|
||||
|
||||
test("user must be able to move flow from folder", async ({ page }) => {
|
||||
/* This is the original, happy-path regression: the destination
|
||||
* project is BRAND NEW and has never been observed by React Query,
|
||||
* so its `useGetFolder` cache doesn't exist and the first visit
|
||||
* after the drop triggers a fresh fetch. It exercises drag-and-drop
|
||||
* plumbing but does not exercise stale cache invalidation. */
|
||||
const randomName = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
await awaitBootstrapTest(page);
|
||||
@ -47,3 +52,87 @@ test("user must be able to move flow from folder", async ({ page }) => {
|
||||
const flowNameCount = await page.getByText(randomName).count();
|
||||
expect(flowNameCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("moved flow must appear when destination project was visited while still empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
/* Reproduces the real bug scenario:
|
||||
*
|
||||
* 1. Create an empty destination project and OBSERVE it (visit it
|
||||
* once while still empty). This populates the `useGetFolder`
|
||||
* cache with `total: 0`.
|
||||
* 2. Navigate away to the source project.
|
||||
* 3. Drag a flow from the source to the now-stale destination.
|
||||
* 4. Click the destination and verify the moved flow appears.
|
||||
*
|
||||
* Before the `usePatchUpdateFlow` invalidation fix + `isEmptyFolder`
|
||||
* dual-source fix, the destination kept showing the "Empty project"
|
||||
* / "flows not supported" state until a manual page refresh, because
|
||||
* BOTH the folder query cache and the global flows store were
|
||||
* stale and nothing re-triggered them. */
|
||||
const flowName = `stale-${Math.random().toString(36).substring(2, 10)}`;
|
||||
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Create a flow and rename it so we can find it later by a unique name.
|
||||
await page.getByTestId("side_nav_options_all-templates").click();
|
||||
await page.getByRole("heading", { name: "Basic Prompting" }).click();
|
||||
await page.waitForTimeout(2000);
|
||||
await renameFlow(page, { flowName });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.getByTestId("icon-ChevronLeft").click();
|
||||
|
||||
// Step 1: create a destination project. `add-project-button`
|
||||
// navigates the UI INTO the new project automatically — we rely on
|
||||
// that because it populates the React Query cache with the empty
|
||||
// folder state (`total: 0`), which is exactly what we want to
|
||||
// defeat.
|
||||
await page.waitForSelector('[data-testid="add-project-button"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByTestId("add-project-button").click();
|
||||
|
||||
// Confirm we landed on the new (empty) project and the empty-state
|
||||
// UI rendered — this guarantees `useGetFolder` has actually fired
|
||||
// and cached `total: 0` for the destination.
|
||||
await expect(page.getByTestId("sidebar-nav-New Project")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Step 2: go back to the source project. The destination's query
|
||||
// becomes inactive but stays in the cache with the stale empty
|
||||
// payload.
|
||||
await page.getByTestId("sidebar-nav-Starter Project").click();
|
||||
await expect(page.getByText(flowName).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Step 3: real HTML5 drag-and-drop — `dragTo()` populates
|
||||
// `DataTransfer` so `use-on-file-drop.ts` actually triggers
|
||||
// `saveFlow`. `page.mouse.down/up` would NOT.
|
||||
await page
|
||||
.getByTestId("list-card")
|
||||
.filter({ hasText: flowName })
|
||||
.first()
|
||||
.dragTo(page.getByTestId("sidebar-nav-New Project"));
|
||||
|
||||
// Give the PATCH request time to complete but NOT enough time for a
|
||||
// full page refresh to matter. If the fix works, the query cache
|
||||
// and the global store will both be refreshed by the mutation's
|
||||
// invalidation, and the empty state will never render.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 4: click the destination. The flow must be visible. Use a
|
||||
// short polling timeout — a long `waitForSelector` would mask the
|
||||
// bug by giving React Query's implicit `refetchOnMount` enough
|
||||
// runway to recover behind the scenes.
|
||||
await page.getByTestId("sidebar-nav-New Project").click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("list-card").filter({ hasText: flowName }).first(),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// And the empty-state placeholder must NOT be rendered.
|
||||
await expect(page.getByText("Begin with a template")).toHaveCount(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user