From 2da54a50d1c0da921e3ab60492de3da4efd825d1 Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Mon, 13 Apr 2026 16:07:12 -0400 Subject: [PATCH 1/2] fix(ui): show moved flow in destination project without page refresh (#12670) * fix move flows folders * fix folder spec * fix folder spec test * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../hooks/__tests__/use-on-file-drop.test.ts | 116 ++++++++++++++++ .../__tests__/use-patch-update-flow.test.ts | 126 ++++++++++++++++++ .../queries/flows/use-patch-update-flow.ts | 15 ++- .../flows/__tests__/use-save-flow.test.ts | 69 ++++++++++ .../pages/MainPage/pages/homePage/index.tsx | 18 +-- .../utils/__tests__/isFolderEmpty.test.ts | 107 +++++++++++++++ .../pages/homePage/utils/isFolderEmpty.ts | 34 +++++ .../tests/core/features/folders.spec.ts | 64 ++++++--- ...general-bugs-move-flow-from-folder.spec.ts | 89 +++++++++++++ 9 files changed, 606 insertions(+), 32 deletions(-) create mode 100644 src/frontend/src/components/core/folderSidebarComponent/hooks/__tests__/use-on-file-drop.test.ts create mode 100644 src/frontend/src/controllers/API/queries/flows/__tests__/use-patch-update-flow.test.ts create mode 100644 src/frontend/src/pages/MainPage/pages/homePage/utils/__tests__/isFolderEmpty.test.ts create mode 100644 src/frontend/src/pages/MainPage/pages/homePage/utils/isFolderEmpty.ts diff --git a/src/frontend/src/components/core/folderSidebarComponent/hooks/__tests__/use-on-file-drop.test.ts b/src/frontend/src/components/core/folderSidebarComponent/hooks/__tests__/use-on-file-drop.test.ts new file mode 100644 index 0000000000..471f857bc0 --- /dev/null +++ b/src/frontend/src/components/core/folderSidebarComponent/hooks/__tests__/use-on-file-drop.test.ts @@ -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(""); + }); +}); diff --git a/src/frontend/src/controllers/API/queries/flows/__tests__/use-patch-update-flow.test.ts b/src/frontend/src/controllers/API/queries/flows/__tests__/use-patch-update-flow.test.ts new file mode 100644 index 0000000000..0f5ac484ed --- /dev/null +++ b/src/frontend/src/controllers/API/queries/flows/__tests__/use-patch-update-flow.test.ts @@ -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", ] 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); + }); +}); diff --git a/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts b/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts index 4d6ec1918f..75dfdf44ff 100644 --- a/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts +++ b/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts @@ -33,13 +33,14 @@ export const usePatchUpdateFlow: useMutationFunctionType< const mutation: UseMutationResult = 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"], }); }, diff --git a/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts b/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts index 18b080ea2c..2f4dff08a2 100644 --- a/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts +++ b/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts @@ -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" }), + ); + }); }); diff --git a/src/frontend/src/pages/MainPage/pages/homePage/index.tsx b/src/frontend/src/pages/MainPage/pages/homePage/index.tsx index 41c7e3c4f7..a682c30ee7 100644 --- a/src/frontend/src/pages/MainPage/pages/homePage/index.tsx +++ b/src/frontend/src/pages/MainPage/pages/homePage/index.tsx @@ -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); diff --git a/src/frontend/src/pages/MainPage/pages/homePage/utils/__tests__/isFolderEmpty.test.ts b/src/frontend/src/pages/MainPage/pages/homePage/utils/__tests__/isFolderEmpty.test.ts new file mode 100644 index 0000000000..cb623b169b --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/homePage/utils/__tests__/isFolderEmpty.test.ts @@ -0,0 +1,107 @@ +import type { FlowType } from "@/types/flow"; +import { isFolderEmpty } from "../isFolderEmpty"; + +const buildFlow = (overrides: Partial = {}): 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); + }); +}); diff --git a/src/frontend/src/pages/MainPage/pages/homePage/utils/isFolderEmpty.ts b/src/frontend/src/pages/MainPage/pages/homePage/utils/isFolderEmpty.ts new file mode 100644 index 0000000000..623d042af7 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/homePage/utils/isFolderEmpty.ts @@ -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; +} diff --git a/src/frontend/tests/core/features/folders.spec.ts b/src/frontend/tests/core/features/folders.spec.ts index 749ca084b1..ef4d1d173e 100644 --- a/src/frontend/tests/core/features/folders.spec.ts +++ b/src/frontend/tests/core/features/folders.spec.ts @@ -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 }); }); diff --git a/src/frontend/tests/extended/regression/general-bugs-move-flow-from-folder.spec.ts b/src/frontend/tests/extended/regression/general-bugs-move-flow-from-folder.spec.ts index 618a807541..1fad98f333 100644 --- a/src/frontend/tests/extended/regression/general-bugs-move-flow-from-folder.spec.ts +++ b/src/frontend/tests/extended/regression/general-bugs-move-flow-from-folder.spec.ts @@ -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); +}); From 4be5f7f52f27875fc03aa7331afcf7c4596c7fef Mon Sep 17 00:00:00 2001 From: Ram Gopal Srikar Katakam <44802869+RamGopalSrikar@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:26:42 -0400 Subject: [PATCH 2/2] chore(i18n): disable automatic browser language detection (#12671) * chore(i18n): disable automatic browser language detection, hardcode English * chore(i18n): remove language selector from Settings page --- src/frontend/src/i18n.ts | 7 +------ .../src/pages/SettingsPage/pages/GeneralPage/index.tsx | 3 --- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/frontend/src/i18n.ts b/src/frontend/src/i18n.ts index 46866f3b1e..ff4682f60f 100644 --- a/src/frontend/src/i18n.ts +++ b/src/frontend/src/i18n.ts @@ -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, diff --git a/src/frontend/src/pages/SettingsPage/pages/GeneralPage/index.tsx b/src/frontend/src/pages/SettingsPage/pages/GeneralPage/index.tsx index 3091439637..73ea73c483 100644 --- a/src/frontend/src/pages/SettingsPage/pages/GeneralPage/index.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/GeneralPage/index.tsx @@ -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 = () => {
- - {ENABLE_PROFILE_ICONS && (