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 (
- <>
-
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