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