Merge branch 'release-1.9.0' into LE-846

This commit is contained in:
olayinkaadelakun
2026-04-13 17:06:37 -04:00
committed by GitHub
11 changed files with 607 additions and 41 deletions

View File

@ -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("");
});
});

View File

@ -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);
});
});

View File

@ -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"],
});
},

View File

@ -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" }),
);
});
});

View File

@ -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,

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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}

View File

@ -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 });
});

View File

@ -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);
});