Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5546b5f
feat: enhance accessibility and internationalization for AlertDisplay…
deon-sanchez Jul 9, 2026
5232c27
Merge branch 'release-1.11.0' into db-provider-a11y
deon-sanchez Jul 10, 2026
b06b578
Merge branch 'release-1.11.0' into db-provider-a11y
deon-sanchez Jul 13, 2026
9aee8ce
Merge branch 'release-1.11.0' into db-provider-a11y
deon-sanchez Jul 13, 2026
9a61719
Merge branch 'release-1.11.0' into db-provider-a11y
deon-sanchez Jul 13, 2026
fb34708
Merge branch 'release-1.11.0' into db-provider-a11y
deon-sanchez Jul 13, 2026
9b8f912
Merge branch 'release-1.11.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 13, 2026
c8a0e86
Enhance Button Component Accessibility and Behavior
deon-sanchez Jul 13, 2026
82a79aa
fix(mcp): stabilize Tool Mode activation (#14188)
erichare Jul 21, 2026
034969b
Merge branch 'release-1.11.0' into db-provider-a11y
deon-sanchez Jul 21, 2026
9c55f8b
Merge branch 'release-1.12.0' into db-provider-a11y
deon-sanchez Jul 21, 2026
ba51545
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 21, 2026
3863c1a
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 21, 2026
88638a2
Merge branch 'release-1.12.0' into db-provider-a11y
deon-sanchez Jul 21, 2026
b2e85a7
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 22, 2026
e4a75c6
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 22, 2026
e6a6c42
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
from lfx.components.models_and_agents.mcp_component import MCPToolsComponent


def test_tool_mode_capability_is_available_before_server_discovery() -> None:
"""The toolbar must not wait for an MCP connection to discover Tool Mode."""
tool_placeholder = next(input_ for input_ in MCPToolsComponent.inputs if input_.name == "tool_placeholder")

assert tool_placeholder.tool_mode is True


def _make_tool(name: str) -> MagicMock:
tool = MagicMock()
tool.name = name
Expand Down Expand Up @@ -324,6 +331,14 @@ def _build_config(**overrides):
config.update(overrides)
return config

@pytest.mark.asyncio
async def test_clearing_server_keeps_tool_mode_capability(self) -> None:
component = MCPToolsComponent()

build_config = await component.update_build_config(self._build_config(), {}, "mcp_server")

assert build_config["tool_placeholder"]["tool_mode"] is True

@pytest.mark.asyncio
async def test_refresh_bypasses_existing_options(self) -> None:
component = MCPToolsComponent()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { APIClassType } from "@/types/api";
import { mutateTemplate } from "../mutate-template";

describe("mutateTemplate", () => {
afterEach(() => {
jest.useRealTimers();
});

it("sends Tool Mode changes immediately", async () => {
const node = {
template: {
code: { value: "component source" },
},
outputs: [],
tool_mode: false,
} as unknown as APIClassType;
const updatedNode = {
template: node.template,
outputs: [],
last_updated: "2026-07-21T16:30:00.000Z",
} as unknown as APIClassType;
const mutateAsync = jest.fn().mockResolvedValue(updatedNode);
const setNodeClass = jest.fn();

await mutateTemplate(
true,
"mcp-tools-node",
node,
setNodeClass,
{ mutateAsync } as never,
jest.fn(),
"tool_mode",
jest.fn(),
true,
);

expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ value: true, tool_mode: true }),
);
expect(setNodeClass).toHaveBeenCalledWith(
expect.objectContaining({ tool_mode: true }),
);
});

it("cancels a stale Toolset metadata refresh when Tool Mode changes", async () => {
jest.useFakeTimers();
const node = {
template: {
code: { value: "component source" },
tools_metadata: { value: [{ name: "fetch_content" }] },
},
outputs: [{ name: "component_as_tool" }],
tool_mode: true,
} as unknown as APIClassType;
const metadataMutateAsync = jest.fn();
const toolModeMutateAsync = jest.fn().mockResolvedValue({
template: node.template,
outputs: [],
last_updated: "2026-07-21T16:31:00.000Z",
});

await mutateTemplate(
node.template.tools_metadata.value,
"url-node",
node,
jest.fn(),
{ mutateAsync: metadataMutateAsync } as never,
jest.fn(),
"tools_metadata",
jest.fn(),
true,
);

await mutateTemplate(
false,
"url-node",
node,
jest.fn(),
{ mutateAsync: toolModeMutateAsync } as never,
jest.fn(),
"tool_mode",
jest.fn(),
false,
);
await jest.runAllTimersAsync();

expect(toolModeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ value: false, tool_mode: false }),
);
expect(metadataMutateAsync).not.toHaveBeenCalled();
});
});
23 changes: 21 additions & 2 deletions src/frontend/src/CustomNodes/helpers/mutate-template.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { UseMutationResult } from "@tanstack/react-query";
import { cloneDeep, debounce } from "lodash";
import { SAVE_DEBOUNCE_TIME } from "@/constants/constants";
import i18n from "../../i18n";
import type { APIClassType, ResponseErrorDetailAPI } from "@/types/api";
import i18n from "../../i18n";
import { updateHiddenOutputs } from "./update-hidden-outputs";

// Map to store debounced functions for each node ID + parameter combination
Expand All @@ -16,6 +16,7 @@ export const mutateTemplate = async (
postTemplateValue: UseMutationResult<
APIClassType | undefined,
ResponseErrorDetailAPI,
// biome-ignore lint/suspicious/noExplicitAny: legacy mutation payload
any
>,
setErrorData,
Expand All @@ -38,6 +39,7 @@ export const mutateTemplate = async (
postTemplateValue: UseMutationResult<
APIClassType | undefined,
ResponseErrorDetailAPI,
// biome-ignore lint/suspicious/noExplicitAny: legacy mutation payload
any
>,
setErrorData,
Expand Down Expand Up @@ -89,7 +91,17 @@ export const mutateTemplate = async (
);
}

debouncedFunctions.get(debounceKey)?.(
// Enabling Tool Mode mounts the tools_metadata field, which queues its own
// debounced refresh. If the user turns Tool Mode off before that refresh
// runs, the queued request still carries tool_mode=true and can restore the
// Toolset output after the off response. The explicit toggle supersedes that
// pending metadata refresh.
if (parameterName === "tool_mode") {
debouncedFunctions.get(`${nodeId}-tools_metadata`)?.cancel();
}

const debouncedFunction = debouncedFunctions.get(debounceKey);
debouncedFunction?.(
newValue,
node,
setNodeClass,
Expand All @@ -100,4 +112,11 @@ export const mutateTemplate = async (
toolMode,
isRefresh,
);

// Tool Mode is a discrete toggle, so delaying it like a text input leaves
// the node in its previous output shape and gives slower refresh responses
// a chance to repaint the toggle with stale state.
if (parameterName === "tool_mode") {
await debouncedFunction?.flush();
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ describe("AlertDisplayArea accessibility", () => {
it("keeps_live_regions_mounted_before_alerts_are_inserted", () => {
const { container } = render(<AlertDisplayArea />);

expect(
screen.getByRole("region", { name: "Notifications" }),
).toBeInTheDocument();
expect(
container.querySelector('[aria-live="assertive"]'),
).toBeInTheDocument();
Expand All @@ -48,7 +51,9 @@ describe("AlertDisplayArea accessibility", () => {
useAlertStore.getState().setSuccessData({ title: "Flow saved" });
});

const region = screen.getByRole("region", { name: "Notifications" });
const status = screen.getByRole("status");
expect(region).toContainElement(status);
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
expect(status).toHaveTextContent("Flow saved");
Expand Down
8 changes: 7 additions & 1 deletion src/frontend/src/alerts/displayArea/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useTranslation } from "react-i18next";
import useAlertStore from "../../stores/alertStore";
import ErrorAlert from "../error";
import NoticeAlert from "../notice";
import SuccessAlert from "../success";

export default function AlertDisplayArea() {
const { t } = useTranslation();
const removeFromTempNotificationList = useAlertStore(
(state) => state.removeFromTempNotificationList,
);
Expand All @@ -21,7 +23,11 @@ export default function AlertDisplayArea() {
);

return (
<div style={{ zIndex: 999 }}>
<div
role="region"
aria-label={t("alerts.notificationsTitle")}
style={{ zIndex: 999 }}
>
<div
aria-atomic="true"
aria-live="assertive"
Expand Down
65 changes: 60 additions & 5 deletions src/frontend/src/components/ui/__tests__/button.a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { axe } from "@/utils/a11y-test";
import { Button } from "../button";

Expand All @@ -23,13 +23,68 @@ describe("Button accessibility", () => {
).toBeInTheDocument();
});

it("should_keep_accessible_name_while_loading", () => {
// Loading visually hides the label ("invisible" class) but must keep
// it in the accessibility tree so the name does not disappear.
it("should_keep_accessible_name_and_focus_while_loading", () => {
// Loading keeps the label via sr-only and stays focusable via aria-disabled
// (native disabled would drop focus to <body>).
render(<Button loading>Save flow</Button>);

const button = screen.getByRole("button", { name: "Save Flow" });
expect(button).toBeDisabled();
expect(button).not.toHaveAttribute("disabled");
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAttribute("aria-busy", "true");

button.focus();
expect(button).toHaveFocus();
});

it("should_retain_focus_when_entering_and_leaving_loading", () => {
const { rerender } = render(<Button>Save flow</Button>);
const button = screen.getByRole("button", { name: "Save Flow" });
button.focus();
expect(button).toHaveFocus();

rerender(<Button loading>Save flow</Button>);
expect(button).toHaveFocus();
expect(button).toHaveAttribute("aria-busy", "true");
expect(button).toHaveAttribute("aria-disabled", "true");

rerender(<Button>Save flow</Button>);
expect(button).toHaveFocus();
expect(button).not.toHaveAttribute("aria-busy");
expect(button).not.toHaveAttribute("aria-disabled");
});

it("should_ignore_activation_while_loading", () => {
const onClick = jest.fn();
render(
<Button loading onClick={onClick}>
Save flow
</Button>,
);

const button = screen.getByRole("button", { name: "Save Flow" });
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });

expect(onClick).not.toHaveBeenCalled();
});

it("should_prefer_aria_disabled_over_overlapping_disabled_while_loading", () => {
// Call sites often pass disabled={isLoading} with loading={isLoading}.
render(
<Button loading disabled>
Save flow
</Button>,
);

const button = screen.getByRole("button", { name: "Save Flow" });
expect(button).not.toHaveAttribute("disabled");
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAttribute("aria-busy", "true");

button.focus();
expect(button).toHaveFocus();
});

it("should_be_disabled_when_disabled", () => {
Expand Down
Loading
Loading