diff --git a/app/javascript/react-bootstrap.d.ts b/app/javascript/react-bootstrap.d.ts
index f9e4444ebc..4cddec1249 100644
--- a/app/javascript/react-bootstrap.d.ts
+++ b/app/javascript/react-bootstrap.d.ts
@@ -4,6 +4,8 @@ declare module "react-bootstrap" {
export const Form: any
export const Nav: any
export const Navbar: any
+ export const Tabs: any
+ export const Tab: any
// Add other components you use
}
diff --git a/plugins/tools/app/javascript/widgets/universal_search/search/components/show.jsx b/plugins/tools/app/javascript/widgets/universal_search/search/components/show.jsx
deleted file mode 100644
index 96b109eb9f..0000000000
--- a/plugins/tools/app/javascript/widgets/universal_search/search/components/show.jsx
+++ /dev/null
@@ -1,158 +0,0 @@
-/* eslint-disable no-undef */
-import { Modal, Button, Tabs, Tab } from "react-bootstrap"
-import { JsonViewer } from "@cloudoperators/juno-ui-components"
-import { projectUrl, objectUrl, vCenterUrl } from "../../shared/object_link_helper"
-import React from "react"
-
-import ProjectRoleAssignments from "plugins/identity/app/javascript/widgets/role_assignments/containers/project_role_assignments"
-import UserRoleAssignments from "plugins/identity/app/javascript/widgets/role_assignments/containers/user_role_assignments"
-import NetworkUsageStats from "plugins/networking/app/javascript/widgets/network_usage_stats/containers/application"
-import Asr from "plugins/networking/app/javascript/widgets/asr/application"
-
-import ObjectTopology from "../../topology/containers/object_topology"
-
-export default class ShowSearchObjectModal extends React.Component {
- state = {
- show: true,
- isFetching: false,
- error: null,
- }
-
- componentDidMount = () => {
- // load object if it does not exist
- if (!this.props.item && this.props.match.params.id) {
- this.setState({ isFetching: true }, () =>
- this.props.load(this.props.match.params.id).catch((error) => this.setState({ isFetching: false, error }))
- )
- }
- }
-
- UNSAFE_componentWillReceiveProps = (props) => {
- if (props.item) {
- this.setState({ show: true, isFetching: false, error: null })
- }
- }
-
- restoreUrl = (e) => {
- if (this.state.show) return
-
- if (this.props.match && this.props.match.path) {
- const found = this.props.match.path.match(/(\/[^/]+)\/:id\/show/)
- if (found) {
- this.props.history.replace(found[1])
- return
- }
- }
-
- this.props.history.goBack()
- }
-
- hide = (e) => {
- if (e) e.stopPropagation()
- this.setState({ show: false })
- }
-
- render() {
- const { item, project, aggregates } = this.props
- const vcAggregates =
- aggregates && aggregates.items ? aggregates.items.filter((a) => a.name.indexOf("vc-") === 0) : []
- const projectLink = projectUrl(item)
- const objectLink = objectUrl(item)
- const vCenterLink = vCenterUrl(item, vcAggregates)
- const found = this.props.location.search.match(/\?tab=([^&]+)/)
- let activeTab = found ? found[1] : null
- const isProject = item && item.cached_object_type == "project"
- const isUser = item && item.cached_object_type == "user"
- const isDomain = item && item.cached_object_type == "domain"
- const isRouter = item && item.cached_object_type == "router"
- if (activeTab == "userRoles" && isDomain) activeTab = "data"
-
- return (
-
-
-
- Show{" "}
- {item && (
- <>
- {item.cached_object_type} {item.name} ({item.id})
- >
- )}
-
-
-
- {this.state.isFetching && (
- <>
-
- Loading...
- >
- )}
- {this.state.error && {this.state.error}}
- {item && (
-
-
-
-
- {isProject && policy.isAllowed("tools:universal_search_role_assignments") && (
-
-
-
- )}
- {isProject && policy.isAllowed("tools:universal_search_role_assignments") && (
-
-
-
- )}
- {isUser && policy.isAllowed("tools:universal_search_user_role_assignments", { user: item }) && (
-
-
-
- )}
- {(isProject || isDomain) && policy.isAllowed("tools:universal_search_netstats") && (
-
-
-
- )}
- {isRouter && policy.isAllowed("tools:universal_search_asr") && (
-
-
-
- )}
-
- {policy.isAllowed("tools:universal_search_asr") && (
-
-
-
- )}
-
- )}
-
-
- {vCenterLink && (
-
- Switch to VCenter
-
- )}
-
- {objectLink && (
-
- Show in Elektra
-
- )}
-
- {projectLink && policy.isAllowed("tools:switch_to_project", { project: item }) && (
-
- Switch to Project
-
- )}
-
-
-
- )
- }
-}
diff --git a/plugins/tools/app/javascript/widgets/universal_search/search/components/show.test.tsx b/plugins/tools/app/javascript/widgets/universal_search/search/components/show.test.tsx
new file mode 100644
index 0000000000..8aee6066cc
--- /dev/null
+++ b/plugins/tools/app/javascript/widgets/universal_search/search/components/show.test.tsx
@@ -0,0 +1,365 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { render, screen, fireEvent } from "@testing-library/react"
+import "@testing-library/jest-dom/vitest"
+import ShowSearchObjectModal from "./show"
+import { projectUrl, objectUrl, vCenterUrl } from "../../shared/object_link_helper"
+
+// ─── Global mocks ────────────────────────────────────────────────────────────
+
+// `policy` is a Rails-injected global — not imported. We simulate it here.
+;(global as any).policy = {
+ isAllowed: vi.fn().mockReturnValue(true),
+}
+
+// ─── Module mocks ─────────────────────────────────────────────────────────────
+
+vi.mock("react-bootstrap", () => ({
+ Modal: Object.assign(
+ ({ show, onHide, children }: any) => (
+
+ {children}
+
+ ),
+ {
+ Header: ({ children }: any) => {children}
,
+ Title: ({ children }: any) => {children}
,
+ Body: ({ children }: any) => {children}
,
+ Footer: ({ children }: any) => {children}
,
+ }
+ ),
+ Button: ({ children, onClick }: any) => (
+
+ ),
+ Tabs: ({ children, defaultActiveKey }: any) => (
+
+ {children}
+
+ ),
+ Tab: ({ children, title, eventKey }: any) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock("@cloudoperators/juno-ui-components", () => ({
+ Spinner: () => ,
+ JsonViewer: ({ data }: any) => {JSON.stringify(data)}
,
+}))
+
+vi.mock("../../shared/object_link_helper", () => ({
+ projectUrl: vi.fn().mockReturnValue(null),
+ objectUrl: vi.fn().mockReturnValue(null),
+ vCenterUrl: vi.fn().mockReturnValue(null),
+}))
+
+vi.mock(
+ "plugins/identity/app/javascript/widgets/role_assignments/containers/project_role_assignments",
+ () => ({ default: () => })
+)
+
+vi.mock(
+ "plugins/identity/app/javascript/widgets/role_assignments/containers/user_role_assignments",
+ () => ({ default: () => })
+)
+
+vi.mock(
+ "plugins/networking/app/javascript/widgets/network_usage_stats/containers/application",
+ () => ({ default: () => })
+)
+
+vi.mock("plugins/networking/app/javascript/widgets/asr/application", () => ({
+ default: () => ,
+}))
+
+vi.mock("../../topology/containers/object_topology", () => ({
+ default: () => ,
+}))
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+interface Item {
+ id: string
+ name: string
+ cached_object_type: string
+ domain_id?: string
+ project_id?: string
+ payload: Record
+}
+
+interface Props {
+ item?: Item
+ project?: object
+ aggregates?: { items: any[] }
+ match: {
+ params: { id?: string }
+ path: string
+ }
+ location: { search: string }
+ history: {
+ replace: (path: string) => void
+ goBack: () => void
+ }
+ load: (id: string) => Promise
+}
+
+// ─── Fixtures ────────────────────────────────────────────────────────────────
+
+const mockItem: Item = {
+ id: "abc-123",
+ name: "test-object",
+ cached_object_type: "server",
+ domain_id: "domain-1",
+ project_id: "project-1",
+ payload: { foo: "bar" },
+}
+
+const mockHistory = {
+ replace: vi.fn(),
+ goBack: vi.fn(),
+}
+
+const baseProps: Props = {
+ item: mockItem,
+ project: {},
+ aggregates: { items: [] },
+ match: { params: { id: "abc-123" }, path: "/universal-search/:id/show" },
+ location: { search: "" },
+ history: mockHistory,
+ load: vi.fn().mockResolvedValue(undefined),
+}
+
+const renderComponent = (props: Partial = {}) =>
+ render()
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe("ShowSearchObjectModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ ;(global as any).policy.isAllowed.mockReturnValue(true)
+ vi.mocked(vCenterUrl).mockReturnValue(null)
+ vi.mocked(objectUrl).mockReturnValue(null)
+ vi.mocked(projectUrl).mockReturnValue(null)
+ })
+
+ afterEach(() => {
+ vi.clearAllTimers()
+ })
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe("Initial rendering", () => {
+ it("renders the modal as visible", () => {
+ renderComponent()
+ expect(screen.getByTestId("modal")).toHaveAttribute("data-show", "true")
+ })
+
+ it("renders modal title with object type, name and id when item is loaded", () => {
+ renderComponent()
+ const title = screen.getByTestId("modal-title")
+ expect(title).toHaveTextContent("Show server test-object (abc-123)")
+ })
+
+ it("renders modal title without item info when item is not loaded", () => {
+ renderComponent({ item: undefined })
+ expect(screen.getByTestId("modal-title")).toHaveTextContent("Show")
+ })
+
+ it("renders Close button", () => {
+ renderComponent()
+ expect(screen.getByTestId("close-btn")).toHaveTextContent("Close")
+ })
+ })
+
+ // ── Loading state ──────────────────────────────────────────────────────────
+
+ describe("Loading state", () => {
+ it("shows spinner and loading text when item is missing and id is present", () => {
+ const load = vi.fn().mockReturnValue(new Promise(() => {})) // never resolves
+ renderComponent({ item: undefined, load })
+ expect(screen.getByTestId("spinner")).toBeInTheDocument()
+ })
+
+ it("calls load with the id from match.params on mount when item is missing", () => {
+ const load = vi.fn().mockResolvedValue(undefined)
+ renderComponent({ item: undefined, load })
+ expect(load).toHaveBeenCalledWith("abc-123")
+ })
+
+ it("does not call load when item is already present", () => {
+ const load = vi.fn().mockResolvedValue(undefined)
+ renderComponent({ load })
+ expect(load).not.toHaveBeenCalled()
+ })
+
+ it("does not call load when match has no id", () => {
+ const load = vi.fn().mockResolvedValue(undefined)
+ renderComponent({
+ item: undefined,
+ match: { params: {}, path: "/universal-search" },
+ load,
+ })
+ expect(load).not.toHaveBeenCalled()
+ })
+
+ it("hides spinner and shows data tabs once item arrives via props update", () => {
+ const { rerender } = renderComponent({ item: undefined })
+ expect(screen.getByTestId("spinner")).toBeInTheDocument()
+
+ rerender()
+ expect(screen.queryByTestId("spinner")).not.toBeInTheDocument()
+ expect(screen.getByTestId("tabs")).toBeInTheDocument()
+ })
+ })
+
+ // ── Error state ────────────────────────────────────────────────────────────
+
+ describe("Error state", () => {
+ it("shows an error message when load rejects", async () => {
+ const load = vi.fn().mockRejectedValue("Something went wrong")
+ renderComponent({ item: undefined, load })
+ // wait for the promise rejection to be handled
+ await vi.waitFor(() => {
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument()
+ })
+ })
+ })
+
+ // ── Tabs ───────────────────────────────────────────────────────────────────
+
+ describe("Tabs", () => {
+ it("always renders the Data tab when item is present", () => {
+ renderComponent()
+ expect(screen.getByTestId("tab-data")).toBeInTheDocument()
+ expect(screen.getByTestId("json-viewer")).toBeInTheDocument()
+ })
+
+ it("renders Topology tab when policy allows", () => {
+ renderComponent()
+ expect(screen.getByTestId("tab-objectTopology")).toBeInTheDocument()
+ })
+
+ it("does not render Topology tab when policy denies", () => {
+ ;(global as any).policy.isAllowed.mockReturnValue(false)
+ renderComponent()
+ expect(screen.queryByTestId("tab-objectTopology")).not.toBeInTheDocument()
+ })
+
+ it("renders User Role Assignments and Group Role Assignments tabs for a project item", () => {
+ const projectItem = { ...mockItem, cached_object_type: "project" }
+ renderComponent({ item: projectItem })
+ expect(screen.getByTestId("tab-userRoles")).toBeInTheDocument()
+ expect(screen.getByTestId("tab-groupRoles")).toBeInTheDocument()
+ })
+
+ it("does not render project role assignment tabs when policy denies", () => {
+ ;(global as any).policy.isAllowed.mockReturnValue(false)
+ const projectItem = { ...mockItem, cached_object_type: "project" }
+ renderComponent({ item: projectItem })
+ expect(screen.queryByTestId("tab-groupRoles")).not.toBeInTheDocument()
+ })
+
+ it("renders User Role Assignments tab for a user item", () => {
+ const userItem = { ...mockItem, cached_object_type: "user" }
+ renderComponent({ item: userItem })
+ expect(screen.getByTestId("tab-userRoles")).toBeInTheDocument()
+ })
+
+ it("renders Network Statistics tab for a project item", () => {
+ const projectItem = { ...mockItem, cached_object_type: "project" }
+ renderComponent({ item: projectItem })
+ expect(screen.getByTestId("tab-networkStats")).toBeInTheDocument()
+ })
+
+ it("renders Network Statistics tab for a domain item", () => {
+ const domainItem = { ...mockItem, cached_object_type: "domain" }
+ renderComponent({ item: domainItem })
+ expect(screen.getByTestId("tab-networkStats")).toBeInTheDocument()
+ })
+
+ it("renders ASR Info tab for a router item", () => {
+ const routerItem = { ...mockItem, cached_object_type: "router" }
+ renderComponent({ item: routerItem })
+ expect(screen.getByTestId("tab-asr")).toBeInTheDocument()
+ })
+
+ it("does not render ASR Info tab for non-router items", () => {
+ renderComponent() // default is 'server'
+ expect(screen.queryByTestId("tab-asr")).not.toBeInTheDocument()
+ })
+
+ it("uses the tab query param as the default active tab", () => {
+ renderComponent({ location: { search: "?tab=objectTopology" } })
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-default-key", "objectTopology")
+ })
+
+ it("defaults to the data tab when no tab query param is present", () => {
+ renderComponent({ location: { search: "" } })
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-default-key", "data")
+ })
+
+ it("resets activeTab to data when item is a domain and tab=userRoles", () => {
+ const domainItem = { ...mockItem, cached_object_type: "domain" }
+ renderComponent({ item: domainItem, location: { search: "?tab=userRoles" } })
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-default-key", "data")
+ })
+ })
+
+ // ── Footer links ───────────────────────────────────────────────────────────
+
+ describe("Footer links", () => {
+ it("renders vCenter link when vCenterUrl returns a value", () => {
+ vi.mocked(vCenterUrl).mockReturnValue("https://vcenter.example.com")
+ renderComponent()
+ expect(screen.getByText("Switch to VCenter")).toHaveAttribute("href", "https://vcenter.example.com")
+ })
+
+ it("renders Show in Elektra link when objectUrl returns a value", () => {
+ vi.mocked(objectUrl).mockReturnValue("/domain-1/project-1/compute/instances?overlay=abc-123")
+ renderComponent()
+ expect(screen.getByText("Show in Elektra")).toBeInTheDocument()
+ })
+
+ it("renders Switch to Project link when projectUrl returns a value and policy allows", () => {
+ vi.mocked(projectUrl).mockReturnValue("/domain-1/abc-123/home")
+ renderComponent()
+ expect(screen.getByText("Switch to Project")).toBeInTheDocument()
+ })
+
+ it("does not render footer links when helpers return null", () => {
+ renderComponent() // all helpers mocked to return null by default
+ expect(screen.queryByText("Switch to VCenter")).not.toBeInTheDocument()
+ expect(screen.queryByText("Show in Elektra")).not.toBeInTheDocument()
+ expect(screen.queryByText("Switch to Project")).not.toBeInTheDocument()
+ })
+ })
+
+ // ── Hide / restoreUrl ──────────────────────────────────────────────────────
+
+ describe("Modal close behaviour", () => {
+ it("sets modal show to false when Close button is clicked", () => {
+ renderComponent()
+ fireEvent.click(screen.getByTestId("close-btn"))
+ expect(screen.getByTestId("modal")).toHaveAttribute("data-show", "false")
+ })
+
+ it("navigates to the list path via history.replace when path matches /:segment/:id/show", () => {
+ renderComponent({
+ match: { params: { id: "abc-123" }, path: "/universal-search/:id/show" },
+ })
+ fireEvent.click(screen.getByTestId("close-btn"))
+ expect(mockHistory.replace).toHaveBeenCalledWith("/universal-search")
+ })
+
+ it("calls history.goBack when path does not match the expected pattern", () => {
+ renderComponent({
+ match: { params: { id: "abc-123" }, path: "/some/other/path" },
+ })
+ fireEvent.click(screen.getByTestId("close-btn"))
+ expect(mockHistory.goBack).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/plugins/tools/app/javascript/widgets/universal_search/search/components/show.tsx b/plugins/tools/app/javascript/widgets/universal_search/search/components/show.tsx
new file mode 100644
index 0000000000..8f63b94efd
--- /dev/null
+++ b/plugins/tools/app/javascript/widgets/universal_search/search/components/show.tsx
@@ -0,0 +1,193 @@
+import React, { useState, useEffect } from "react"
+import { Spinner, JsonViewer } from "@cloudoperators/juno-ui-components"
+import { Tabs, Tab, Modal, Button } from "react-bootstrap"
+import { projectUrl, objectUrl, vCenterUrl } from "../../shared/object_link_helper"
+
+import ProjectRoleAssignments from "plugins/identity/app/javascript/widgets/role_assignments/containers/project_role_assignments"
+import UserRoleAssignments from "plugins/identity/app/javascript/widgets/role_assignments/containers/user_role_assignments"
+import NetworkUsageStats from "plugins/networking/app/javascript/widgets/network_usage_stats/containers/application"
+import Asr from "plugins/networking/app/javascript/widgets/asr/application"
+
+import ObjectTopology from "../../topology/containers/object_topology"
+
+interface PolicyType {
+ isAllowed: (permission: string, options?: Record) => boolean
+}
+
+declare global {
+ var policy: PolicyType
+}
+
+interface Item {
+ id: string
+ name: string
+ cached_object_type: string
+ domain_id?: string
+ project_id?: string
+ payload: Record
+}
+
+interface AggregatesState {
+ items: Array<{ name: string; [key: string]: unknown }>
+}
+
+interface Match {
+ params: { id?: string }
+ path: string
+}
+
+interface History {
+ replace: (path: string) => void
+ goBack: () => void
+}
+
+interface ShowSearchObjectModalProps {
+ item?: Item
+ project?: object
+ aggregates?: AggregatesState
+ match: Match
+ location: { search: string }
+ history: History
+ load: (id: string) => Promise
+}
+
+const ShowSearchObjectModal: React.FC = ({
+ item,
+ aggregates,
+ match,
+ location,
+ history,
+ load,
+}) => {
+ const [show, setShow] = useState(true)
+ const [isFetching, setIsFetching] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Load the item on mount if it isn't already in the store
+ useEffect(() => {
+ if (!item && match.params.id) {
+ setIsFetching(true)
+ load(match.params.id).catch((err) => {
+ setIsFetching(false)
+ setError(err)
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // When the item arrives via props, clear the loading state
+ useEffect(() => {
+ if (item) {
+ setShow(true)
+ setIsFetching(false)
+ setError(null)
+ }
+ }, [item])
+
+ // Combines hide + restoreUrl: closes the modal and navigates back
+ const close = () => {
+ setShow(false)
+ if (match?.path) {
+ const found = match.path.match(/(\/[^/]+)\/:id\/show/)
+ if (found) {
+ history.replace(found[1])
+ return
+ }
+ }
+ history.goBack()
+ }
+
+ const vcAggregates =
+ aggregates && aggregates.items ? aggregates.items.filter((a) => a.name.indexOf("vc-") === 0) : []
+ const projectLink = projectUrl(item)
+ const objectLink = objectUrl(item)
+ const vCenterLink = vCenterUrl(item, vcAggregates)
+
+ const tabMatch = location.search.match(/\?tab=([^&]+)/)
+ let activeTab = tabMatch ? tabMatch[1] : null
+
+ const isProject = item && item.cached_object_type === "project"
+ const isUser = item && item.cached_object_type === "user"
+ const isDomain = item && item.cached_object_type === "domain"
+ const isRouter = item && item.cached_object_type === "router"
+
+ if (activeTab === "userRoles" && isDomain) activeTab = "data"
+
+ const modalTitle = item
+ ? `Show ${item.cached_object_type} ${item.name} (${item.id})`
+ : "Show"
+
+ return (
+
+
+ {modalTitle}
+
+
+ {isFetching && }
+ {error && {error}}
+ {item && (
+
+
+
+
+ {isProject && policy.isAllowed("tools:universal_search_role_assignments") && (
+
+
+
+ )}
+ {isProject && policy.isAllowed("tools:universal_search_role_assignments") && (
+
+
+
+ )}
+ {isUser && policy.isAllowed("tools:universal_search_user_role_assignments", { user: item }) && (
+
+
+
+ )}
+ {(isProject || isDomain) && policy.isAllowed("tools:universal_search_netstats") && (
+
+
+
+ )}
+ {isRouter && policy.isAllowed("tools:universal_search_asr") && (
+
+
+
+ )}
+ {policy.isAllowed("tools:universal_search_asr") && (
+
+
+
+ )}
+
+ )}
+
+
+ {vCenterLink && (
+
+ Switch to VCenter
+
+ )}
+ {objectLink && (
+
+ Show in Elektra
+
+ )}
+ {projectLink && policy.isAllowed("tools:switch_to_project", { project: item }) && (
+
+ Switch to Project
+
+ )}
+
+
+
+ )
+}
+
+export default ShowSearchObjectModal