diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx
new file mode 100644
index 0000000000..d5b8cc2e5b
--- /dev/null
+++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx
@@ -0,0 +1,50 @@
+import { FullStore, useStore } from "pages/FlowEditor/lib/store";
+import React from "react";
+import { act } from "react-dom/test-utils";
+import { setup } from "testUtils";
+import { axe } from "vitest-axe";
+
+import { ExternalPortalList } from "./ExternalPortalList";
+
+const { getState, setState } = useStore;
+
+let initialState: FullStore;
+
+beforeAll(() => (initialState = getState()));
+afterEach(() => act(() => setState(initialState)));
+
+const externalPortals: FullStore["externalPortals"] = {
+ abc: { name: "Portal 1", href: "myTeam/portalOne" },
+ def: { name: "Portal 2", href: "myTeam/portalTwo" },
+};
+
+it("does not display if there are no external portals in the flow", () => {
+ const { container } = setup();
+
+ expect(container).toBeEmptyDOMElement();
+});
+
+it("displays a list of external portals if present in the flow", () => {
+ act(() => setState({ externalPortals }));
+ const { container, getAllByRole } = setup();
+
+ expect(container).not.toBeEmptyDOMElement();
+ expect(getAllByRole("listitem")).toHaveLength(2);
+});
+
+it("allows users to navigate to the external portals", () => {
+ act(() => setState({ externalPortals }));
+ const { container, getAllByRole } = setup();
+
+ expect(container).not.toBeEmptyDOMElement();
+ const [first, second] = getAllByRole("link") as HTMLAnchorElement[];
+ expect(first).toHaveAttribute("href", "../myTeam/portalOne");
+ expect(second).toHaveAttribute("href", "../myTeam/portalTwo");
+});
+
+it("should not have any accessibility violations on initial load", async () => {
+ act(() => setState({ externalPortals }));
+ const { container } = setup();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+});
diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx
new file mode 100644
index 0000000000..b026dcad2d
--- /dev/null
+++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import { setup } from "testUtils";
+import { FONT_WEIGHT_BOLD } from "theme";
+import { axe } from "vitest-axe";
+
+import { Headline } from "./Headline";
+
+const sampleText = "The quick fox jumps...";
+const foxIndices: [number, number][] = [[10, 13]];
+
+const DEFAULT_FONT_WEIGHT = "400";
+
+it("displays matches from the headline in bold", () => {
+ const { getByText } = setup(
+ ,
+ );
+
+ // Input text is split into characters in order to highlight a substring
+ const tStyle = window.getComputedStyle(getByText("T"));
+ const hStyle = window.getComputedStyle(getByText("h"));
+ const eStyle = window.getComputedStyle(getByText("e"));
+
+ // Non matching text is not in bold
+ expect(tStyle.fontWeight).toEqual(DEFAULT_FONT_WEIGHT);
+ expect(hStyle.fontWeight).toEqual(DEFAULT_FONT_WEIGHT);
+ expect(eStyle.fontWeight).toEqual(DEFAULT_FONT_WEIGHT);
+
+ const fStyle = window.getComputedStyle(getByText("f"));
+ const oStyle = window.getComputedStyle(getByText("o"));
+ const xStyle = window.getComputedStyle(getByText("x"));
+
+ // Matching text is in bold
+ expect(fStyle.fontWeight).toEqual(FONT_WEIGHT_BOLD);
+ expect(oStyle.fontWeight).toEqual(FONT_WEIGHT_BOLD);
+ expect(xStyle.fontWeight).toEqual(FONT_WEIGHT_BOLD);
+});
+
+it("should not have any accessibility violations on initial load", async () => {
+ const { container } = setup(
+ ,
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+});
diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx
new file mode 100644
index 0000000000..042d9b4755
--- /dev/null
+++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx
@@ -0,0 +1,39 @@
+import { useStore } from "pages/FlowEditor/lib/store";
+import React from "react";
+import { setup } from "testUtils";
+import { axe } from "vitest-axe";
+
+import { flow, results } from "./mocks/simple";
+import { NodeSearchResults } from "./NodeSearchResults";
+
+beforeAll(() => useStore.setState({ flow }));
+
+it("Displays a warning if no results are returned", () => {
+ const { getByText, getByRole } = setup();
+ expect(getByText("No matches found")).toBeInTheDocument();
+ expect(getByRole("list")).toBeEmptyDOMElement();
+});
+
+it("Displays the count for a single result", () => {
+ const { getByText, getByRole, getAllByRole } = setup(
+ ,
+ );
+ expect(getByText("1 result:")).toBeInTheDocument();
+ expect(getByRole("list")).not.toBeEmptyDOMElement();
+ expect(getAllByRole("listitem")).toHaveLength(1);
+});
+
+it("Displays the count for multiple results", () => {
+ const { getByText, getByRole, getAllByRole } = setup(
+ ,
+ );
+ expect(getByText("2 results:")).toBeInTheDocument();
+ expect(getByRole("list")).not.toBeEmptyDOMElement();
+ expect(getAllByRole("listitem")).toHaveLength(2);
+});
+
+it("should not have any accessibility violations on initial load", async () => {
+ const { container } = setup();
+ const axeResults = await axe(container);
+ expect(axeResults).toHaveNoViolations();
+});
diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx
new file mode 100644
index 0000000000..a8b6e0bc74
--- /dev/null
+++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx
@@ -0,0 +1,94 @@
+import * as planxCore from "@opensystemslab/planx-core";
+import { waitFor } from "@testing-library/react";
+import { FullStore, useStore } from "pages/FlowEditor/lib/store";
+import React from "react";
+import { act } from "react-dom/test-utils";
+import { setup } from "testUtils";
+import { vi } from "vitest";
+import { axe } from "vitest-axe";
+
+import Search from ".";
+import { flow } from "./mocks/simple";
+
+const { setState, getState } = useStore;
+
+let initialState: FullStore;
+
+beforeAll(() => (initialState = getState()));
+
+beforeEach(() => setState({ flow }));
+afterEach(() => act(() => setState(initialState)));
+
+vi.mock("@opensystemslab/planx-core", async (originalModule) => {
+ const actualModule = await originalModule();
+ return {
+ ...actualModule,
+ // Spy on sortFlow while keeping its original implementation
+ sortFlow: vi.fn(actualModule.sortFlow),
+ };
+});
+
+test("data field checkbox is checked and disabled", () => {
+ const { getByLabelText } = setup();
+ const checkbox = getByLabelText("Search only data fields");
+
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).toBeChecked();
+ expect(checkbox).toBeDisabled();
+});
+
+test("entering a search term displays a series of cards", async () => {
+ const { user, queryByRole, getByRole, getAllByRole, getByLabelText } = setup(
+ ,
+ );
+
+ expect(queryByRole("list")).not.toBeInTheDocument();
+
+ const searchInput = getByLabelText("Search this flow and internal portals");
+ user.type(searchInput, "ind");
+
+ await waitFor(() => expect(getByRole("list")).toBeInTheDocument());
+ await waitFor(() => expect(getAllByRole("listitem")).toHaveLength(2));
+});
+
+test.todo("cards link to their associated nodes", async () => {
+ const { user, getAllByRole, getByLabelText } = setup();
+
+ const searchInput = getByLabelText("Search this flow and internal portals");
+ user.type(searchInput, "ind");
+
+ await waitFor(() => expect(getAllByRole("listitem")).toHaveLength(2));
+
+ const [first, second] = getAllByRole("listitem");
+ // TODO!
+ expect(first).toHaveAttribute("href", "link to tR9tdaWOvF (India)");
+ expect(second).toHaveAttribute("href", "link to tvUxd2IoPo (Indonesia)");
+});
+
+it("orderedFlow is set in the store on render of Search", async () => {
+ expect(getState().orderedFlow).toBeUndefined();
+
+ setup();
+
+ expect(getState().orderedFlow).toBeDefined();
+});
+
+test("setOrderedFlow is only called once on initial render", async () => {
+ const sortFlowSpy = vi.spyOn(planxCore, "sortFlow");
+ expect(sortFlowSpy).not.toHaveBeenCalled();
+
+ const { user, getAllByRole, getByLabelText } = setup();
+
+ const searchInput = getByLabelText("Search this flow and internal portals");
+ user.type(searchInput, "ind");
+
+ await waitFor(() => expect(getAllByRole("listitem")).toHaveLength(2));
+
+ expect(sortFlowSpy).toHaveBeenCalledTimes(1);
+});
+
+it("should not have any accessibility violations on initial load", async () => {
+ const { container } = setup();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+});
diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx
index a7f9a825f5..9aaecb84de 100644
--- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx
+++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx
@@ -51,6 +51,7 @@ const Search: React.FC = () => {
Search this flow and internal portals
{
diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts
new file mode 100644
index 0000000000..275c397f34
--- /dev/null
+++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts
@@ -0,0 +1,66 @@
+import { FlowGraph, IndexedNode } from "@opensystemslab/planx-core/types";
+import { SearchResults } from "hooks/useSearch";
+
+export const flow: FlowGraph = {
+ _root: {
+ edges: ["Ej0xpn4l8u"],
+ },
+ Ej0xpn4l8u: {
+ type: 100,
+ data: {
+ fn: "country",
+ text: "Pick a country",
+ },
+ edges: ["VhSydY2fTe", "tR9tdaWOvF", "tvUxd2IoPo"],
+ },
+ VhSydY2fTe: {
+ type: 200,
+ data: {
+ text: "Spain",
+ val: "spain",
+ },
+ },
+ tR9tdaWOvF: {
+ type: 200,
+ data: {
+ text: "India",
+ val: "india",
+ },
+ },
+ tvUxd2IoPo: {
+ type: 200,
+ data: {
+ text: "Indonesia",
+ val: "indonesia",
+ },
+ },
+};
+
+export const results: SearchResults = [
+ {
+ item: {
+ id: "tR9tdaWOvF",
+ parentId: "Ej0xpn4l8u",
+ type: 200,
+ data: {
+ text: "India",
+ val: "india",
+ },
+ },
+ key: "data.val",
+ matchIndices: [[0, 2]],
+ },
+ {
+ item: {
+ id: "tvUxd2IoPo",
+ parentId: "Ej0xpn4l8u",
+ type: 200,
+ data: {
+ text: "Indonesia",
+ val: "indonesia",
+ },
+ },
+ key: "data.val",
+ matchIndices: [[0, 2]],
+ },
+];