diff --git a/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.jsx b/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.jsx deleted file mode 100644 index 94585af525..0000000000 --- a/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.jsx +++ /dev/null @@ -1,169 +0,0 @@ -import { Graph } from "./graph" -import { JsonViewer } from "@cloudoperators/juno-ui-components" -import React from "react" - -export default class App extends React.Component { - state = { - filterCollapsed: true, - selectedTypes: {}, - } - - static defaultProps = { - showFilter: true, - } - - componentDidMount() { - this.props.loadRelatedObjects(this.props.objectId) - if (this.props.objects) { - this.setInitialSelectedTypes(this.props.objects) - } - } - - componentWillUnmount() { - this.props.resetState() - this.setState({ filterCollapsed: true, selectedTypes: {} }) - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (Object.keys(this.state.selectedTypes).length == 0 && nextProps.objects) { - this.setInitialSelectedTypes(nextProps.objects) - } - } - - showDetails = (event, node) => { - this.setState({ details: { x: event.offsetX, y: event.offsetY, node } }) - } - - setInitialSelectedTypes = (objects) => { - const availableObjectTypes = {} - for (let obj of Object.values(objects)) { - if (obj.cached_object_type) { - availableObjectTypes[obj.cached_object_type] = true - } - } - this.setState({ selectedTypes: availableObjectTypes }) - } - - convertObjectToNodes = () => { - let nodes = {} - let links = [] - - if (this.props.objects) { - for (let node of Object.values(this.props.objects)) { - let newNode = { - ...node, - label: Graph.nodeLabel(node), - isFetching: node.isFetching, - } - if (this.state.selectedTypes[newNode.cached_object_type]) { - nodes[node.id] = newNode - } - } - - for (let node of Object.values(this.props.objects)) { - for (let childId of node.children) { - if (nodes[node.id] && nodes[childId]) { - links.push({ source: node.id, target: childId }) - } - } - } - } - - return [Object.values(nodes), links] - } - - toggleFilter = () => { - this.setState({ filterCollapsed: !this.state.filterCollapsed }) - } - - updateSelectedTypes = (type) => { - const selectedTypes = { ...this.state.selectedTypes } - selectedTypes[type] = !selectedTypes[type] - this.setState({ selectedTypes }) - } - - availableObjectTypes = () => { - if (!this.props.objects) return [] - return Object.values(this.props.objects) - .map((obj) => obj.cached_object_type) - .filter((elem, pos, arr) => arr.indexOf(elem) == pos) - } - - render() { - const options = this.availableObjectTypes() - const graphData = this.convertObjectToNodes() - - return ( - <> -
- - -
console.log("filter onBlur")} - > - - -
-
- - - - {this.state.details && ( -
-

- {`Details for ${this.state.details.node.cached_object_type} ${this.state.details.node.name}`} - -

-
- -
-
- )} - - ) - } -} diff --git a/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.test.tsx b/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.test.tsx new file mode 100644 index 0000000000..d5385ef86f --- /dev/null +++ b/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import "@testing-library/jest-dom/vitest" +import ObjectTopology from "./object_topology" + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("./graph", () => ({ + Graph: ({ nodes, links, showDetails }: any) => ( +
showDetails({ offsetX: 10, offsetY: 20 }, nodes[0])} + /> + ), +})) + +vi.mock("@cloudoperators/juno-ui-components", () => ({ + JsonViewer: ({ data }: any) =>
{JSON.stringify(data)}
, +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockObjects = { + "obj-1": { + id: "obj-1", + name: "server-1", + cached_object_type: "server", + children: ["obj-2"], + isFetching: false, + payload: { foo: "bar" }, + }, + "obj-2": { + id: "obj-2", + name: "network-1", + cached_object_type: "network", + children: [], + isFetching: false, + payload: { baz: "qux" }, + }, +} + +const defaultProps = { + objectId: "root-1", + objects: mockObjects, + loadRelatedObjects: vi.fn(), + removeRelatedObjects: vi.fn(), + resetState: vi.fn(), + showFilter: true, +} + +const renderComponent = (props = {}) => + render() + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ObjectTopology", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── Mount / unmount ──────────────────────────────────────────────────────── + + describe("Mount / unmount", () => { + it("calls loadRelatedObjects with objectId on mount", () => { + renderComponent() + expect(defaultProps.loadRelatedObjects).toHaveBeenCalledWith("root-1") + }) + + it("calls resetState on unmount", () => { + const { unmount } = renderComponent() + unmount() + expect(defaultProps.resetState).toHaveBeenCalled() + }) + + it("re-calls loadRelatedObjects when objectId changes", () => { + const { rerender } = renderComponent() + rerender() + expect(defaultProps.loadRelatedObjects).toHaveBeenCalledWith("root-2") + }) + }) + + // ── Initial selected types ───────────────────────────────────────────────── + + describe("Initial selected types", () => { + it("initialises selectedTypes from objects when objects are present on mount", () => { + renderComponent() + // Both object types should appear as filter options that are checked + const links = screen.getAllByRole("link") + const labels = links.map((l) => l.textContent) + expect(labels).toContain("server") + expect(labels).toContain("network") + }) + + it("initialises selectedTypes when objects arrive via props after mount", () => { + const { rerender } = renderComponent({ objects: undefined }) + // no types yet + expect(screen.queryByRole("link")).not.toBeInTheDocument() + + rerender() + const links = screen.getAllByRole("link") + const labels = links.map((l) => l.textContent) + expect(labels).toContain("server") + expect(labels).toContain("network") + }) + + it("does not re-initialise selectedTypes once they are already set", () => { + const { rerender } = renderComponent() + // Open dropdown and uncheck 'server' + fireEvent.click(screen.getByText("Select ...")) + const serverLink = screen.getAllByRole("link").find((l) => l.textContent === "server")! + fireEvent.click(serverLink) + + // Re-render with new objects — selectedTypes should NOT be reset + const newObjects = { + ...mockObjects, + "obj-3": { id: "obj-3", name: "router-1", cached_object_type: "router", children: [], isFetching: false, payload: {} }, + } + rerender() + + // 'server' was unchecked and should stay unchecked (fa-square-o icon) + fireEvent.click(screen.getByText("Select ...")) + const serverIcon = screen + .getAllByRole("link") + .find((l) => l.textContent === "server")! + .querySelector("i") + expect(serverIcon).toHaveClass("fa-square-o") + }) + }) + + // ── Filter dropdown ──────────────────────────────────────────────────────── + + describe("Filter dropdown", () => { + it("renders the filter toolbar when showFilter is true", () => { + renderComponent() + expect(screen.getByText("Select ...")).toBeInTheDocument() + }) + + it("dropdown is collapsed by default", () => { + renderComponent() + const dropdown = document.querySelector(".dropdown") + expect(dropdown).not.toHaveClass("open") + }) + + it("opens dropdown when Select button is clicked", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + expect(document.querySelector(".dropdown")).toHaveClass("open") + }) + + it("closes dropdown when Select button is clicked again", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + fireEvent.click(screen.getByText("Select ...")) + expect(document.querySelector(".dropdown")).not.toHaveClass("open") + }) + + it("renders one option per unique object type", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + const links = screen.getAllByRole("link") + expect(links).toHaveLength(2) // server + network + }) + + it("toggles a type off when its option is clicked", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + const serverLink = screen.getAllByRole("link").find((l) => l.textContent === "server")! + fireEvent.click(serverLink) + const icon = serverLink.querySelector("i") + expect(icon).toHaveClass("fa-square-o") + expect(icon).not.toHaveClass("fa-check-square-o") + }) + + it("toggles a type back on when its option is clicked again", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + const serverLink = screen.getAllByRole("link").find((l) => l.textContent === "server")! + fireEvent.click(serverLink) // off + fireEvent.click(serverLink) // on + const icon = serverLink.querySelector("i") + expect(icon).toHaveClass("fa-check-square-o") + }) + }) + + // ── Graph rendering ──────────────────────────────────────────────────────── + + describe("Graph rendering", () => { + it("renders the Graph component", () => { + renderComponent() + expect(screen.getByTestId("graph")).toBeInTheDocument() + }) + + it("passes only nodes of selected types to Graph", () => { + renderComponent() + const graphEl = screen.getByTestId("graph") + const nodes = JSON.parse(graphEl.getAttribute("data-nodes") || "[]") + // Both types selected by default — both nodes visible + expect(nodes).toHaveLength(2) + }) + + it("excludes nodes of deselected types from Graph", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + const serverLink = screen.getAllByRole("link").find((l) => l.textContent === "server")! + fireEvent.click(serverLink) // deselect server + + const nodes = JSON.parse(screen.getByTestId("graph").getAttribute("data-nodes") || "[]") + expect(nodes.every((n: any) => n.cached_object_type !== "server")).toBe(true) + }) + + it("passes links only between visible nodes to Graph", () => { + renderComponent() + fireEvent.click(screen.getByText("Select ...")) + const serverLink = screen.getAllByRole("link").find((l) => l.textContent === "server")! + fireEvent.click(serverLink) // deselect server — link between obj-1 and obj-2 should disappear + + const links = JSON.parse(screen.getByTestId("graph").getAttribute("data-links") || "[]") + expect(links).toHaveLength(0) + }) + + it("renders no graph when objects is undefined", () => { + renderComponent({ objects: undefined }) + const nodes = JSON.parse(screen.getByTestId("graph").getAttribute("data-nodes") || "[]") + expect(nodes).toHaveLength(0) + }) + }) + + // ── Details popover ──────────────────────────────────────────────────────── + + describe("Details popover", () => { + it("shows details popover when showDetails is called", () => { + renderComponent() + fireEvent.click(screen.getByTestId("graph")) + expect(screen.getByText(/Details for server server-1/)).toBeInTheDocument() + }) + + it("renders JsonViewer with node payload in popover", () => { + renderComponent() + fireEvent.click(screen.getByTestId("graph")) + expect(screen.getByTestId("json-viewer")).toBeInTheDocument() + }) + + it("closes the popover when the close button is clicked", () => { + renderComponent() + fireEvent.click(screen.getByTestId("graph")) + expect(screen.getByText(/Details for server server-1/)).toBeInTheDocument() + fireEvent.click(screen.getByLabelText("Close")) + expect(screen.queryByText(/Details for server/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.tsx b/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.tsx new file mode 100644 index 0000000000..e157cb5d8c --- /dev/null +++ b/plugins/tools/app/javascript/widgets/universal_search/topology/components/object_topology.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect, useCallback } from "react" +import { Graph } from "./graph" +import { JsonViewer } from "@cloudoperators/juno-ui-components" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface TopologyObject { + id: string + name: string + cached_object_type: string + children: string[] + isFetching: boolean + payload: Record +} + +interface Details { + x: number + y: number + node: TopologyObject +} + +interface ObjectTopologyProps { + objectId: string + objects?: Record + loadRelatedObjects: (id: string) => void + removeRelatedObjects: (id: string) => void + resetState: () => void + showFilter?: boolean +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const ObjectTopology: React.FC = ({ + objectId, + objects, + loadRelatedObjects, + removeRelatedObjects, + resetState, + showFilter = true, +}) => { + const [filterCollapsed, setFilterCollapsed] = useState(true) + const [selectedTypes, setSelectedTypes] = useState>({}) + const [details, setDetails] = useState
(null) + + // Mount / unmount + useEffect(() => { + loadRelatedObjects(objectId) + return () => { + resetState() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objectId]) + + // Initialise selectedTypes when objects first arrive + useEffect(() => { + if (Object.keys(selectedTypes).length === 0 && objects) { + const availableObjectTypes: Record = {} + for (const obj of Object.values(objects)) { + if (obj.cached_object_type) { + availableObjectTypes[obj.cached_object_type] = true + } + } + setSelectedTypes(availableObjectTypes) + } + }, [objects]) // eslint-disable-line react-hooks/exhaustive-deps + + // ── Helpers ───────────────────────────────────────────────────────────────── + + const availableObjectTypes = (): string[] => { + if (!objects) return [] + return Object.values(objects) + .map((obj) => obj.cached_object_type) + .filter((elem, pos, arr) => arr.indexOf(elem) === pos) + } + + const convertObjectToNodes = (): [TopologyObject[], Array<{ source: string; target: string }>] => { + const nodes: Record = {} + const links: Array<{ source: string; target: string }> = [] + + if (objects) { + for (const node of Object.values(objects)) { + if (selectedTypes[node.cached_object_type]) { + nodes[node.id] = node + } + } + + for (const node of Object.values(objects)) { + for (const childId of node.children) { + if (nodes[node.id] && nodes[childId]) { + links.push({ source: node.id, target: childId }) + } + } + } + } + + return [Object.values(nodes), links] + } + + const showDetails = useCallback((event: { offsetX: number; offsetY: number }, node: TopologyObject) => { + setDetails({ x: event.offsetX, y: event.offsetY, node }) + }, []) + + const toggleFilter = () => setFilterCollapsed((prev) => !prev) + + const updateSelectedTypes = (type: string) => { + setSelectedTypes((prev) => ({ ...prev, [type]: !prev[type] })) + } + + // ── Render ────────────────────────────────────────────────────────────────── + + const options = availableObjectTypes() + const [nodes, links] = convertObjectToNodes() + + return ( + <> + {showFilter && ( +
+ + +
setFilterCollapsed(true)} + > + + +
+
+ )} + + + + {details && ( +
+

+ {`Details for ${details.node.cached_object_type} ${details.node.name}`} + +

+
+ +
+
+ )} + + ) +} + +export default ObjectTopology