diff --git a/.changeset/wide-cups-beam.md b/.changeset/wide-cups-beam.md new file mode 100644 index 0000000000..8b336d4f63 --- /dev/null +++ b/.changeset/wide-cups-beam.md @@ -0,0 +1,6 @@ +--- +"@cloudoperators/juno-app-greenhouse": minor +--- + +- Add a list of exposed services for all plugins +- Updated the plugin instance detail page to display a list of exposed services under `Details`, instead of a separate section. diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx new file mode 100644 index 0000000000..f6bf5bd82f --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { useSuspenseQuery } from "@tanstack/react-query" +import { useRouteContext, useSearch } from "@tanstack/react-router" +import { DataGridRow, DataGridCell } from "@cloudoperators/juno-ui-components" + +import { NO_VALUE_DEFAULT } from "../../../constants" +import { + fetchExposedServices, + FETCH_EXPOSED_SERVICES_CACHE_KEY, +} from "../../../api/exposed-services/fetchExposedServices" +import { EmptyDataGridRow } from "../../../common/EmptyDataGridRow" +import { extractFilterSettingsFromSearchParams } from "../../../utils" +import { ExternalLink } from "../../../common/ExternalLink" + +interface DataRowsProps { + colSpan: number +} + +export const DataRows = ({ colSpan }: DataRowsProps) => { + const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" }) + const search = useSearch({ from: "/admin/exposed-services" }) + const filterSettings = extractFilterSettingsFromSearchParams(search) + + const { data: flattenedExposedServices } = useSuspenseQuery({ + queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY, user.organization, filterSettings], + queryFn: () => + fetchExposedServices({ + apiClient, + namespace: user.organization, + filterSettings, + }), + }) + + if (!flattenedExposedServices || flattenedExposedServices.length === 0) { + return No exposed services found. + } + + return ( + <> + {flattenedExposedServices.map((service, index) => ( + + {} + {service.clusterName || NO_VALUE_DEFAULT} + {service.pluginName || NO_VALUE_DEFAULT} + {service.supportGroup || NO_VALUE_DEFAULT} + + ))} + + ) +} diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx new file mode 100644 index 0000000000..3d7a0cd79c --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { act } from "react" +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router" +import { render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ExposedServicesDataGrid } from "./index" +import { mockExposedServices, MockPluginsWithExposedServicesResponse } from "../../__mocks__/exposedServices" + +const renderComponent = async (mockPromise: Promise) => { + const rootRoute = createRootRoute({ + component: () => , + }) + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin/exposed-services", + component: () => ( + + + + ), + loader: () => ({ + filterSettings: { + selectedFilters: [], + searchTerm: "", + }, + }), + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = createRouter({ + routeTree: routeTree, + defaultPendingMinMs: 0, + context: { + apiClient: { + get() { + return mockPromise + }, + }, + user: { + organization: "test-org", + supportGroups: [], + }, + }, + history: createMemoryHistory({ + initialEntries: ["/admin/exposed-services"], + }), + }) + return await act(async () => render()) +} + +describe("ExposedServicesDataGrid", () => { + it("should render exposed services", async () => { + await renderComponent( + new Promise((resolve) => resolve(mockExposedServices)) + ) + + // Check for column headers + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Cluster")).toBeInTheDocument() + expect(screen.getByText("Plugin")).toBeInTheDocument() + expect(screen.getByText("Support Group")).toBeInTheDocument() + + // Verify service names are in the document + expect(screen.getByText("service1")).toBeInTheDocument() + expect(screen.getByText("service2")).toBeInTheDocument() + expect(screen.getByText("service3")).toBeInTheDocument() + + // Verify service links with correct URLs + const service1Link = screen.getByRole("link", { name: /service1/i }) + const service2Link = screen.getByRole("link", { name: /service2/i }) + + expect(service1Link).toHaveAttribute("href", "https://demo-service1.sci.greenhouse-qa.eu-nl-1.cloud.sap/") + expect(service2Link).toHaveAttribute("href", "https://demo-service2.sci.greenhouse-qa.eu-nl-1.cloud.sap/") + + // Ensure no link rendered without URL + const service3Link = screen.queryByRole("link", { name: /service3/i }) + expect(service3Link).toBeNull() + }) +}) diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx new file mode 100644 index 0000000000..5397cbf152 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Suspense } from "react" +import { useLoaderData } from "@tanstack/react-router" +import { DataGrid, DataGridRow, DataGridHeadCell } from "@cloudoperators/juno-ui-components" + +import { DataRows } from "./DataRows" +import { ErrorBoundary } from "../../common/ErrorBoundary" +import { LoadingDataRow } from "../../common/LoadingDataRow" +import { getErrorDataRowComponent } from "../../common/getErrorDataRow" + +const COLUMN_SPAN = 4 + +export const ExposedServicesDataGrid = () => { + const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" }) + return ( + + + Name + Cluster + Plugin + Support Group + + + + }> + + + + + ) +} diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx new file mode 100644 index 0000000000..93e003a21e --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from "react" +import { useLoaderData, useNavigate, useRouteContext } from "@tanstack/react-router" +import { Stack, InputGroup, Button, SearchInput } from "@cloudoperators/juno-ui-components/index" + +import { getFiltersForUrl } from "../utils" +import { useQuery } from "@tanstack/react-query" +import { SELECTED_FILTER_PREFIX } from "../constants" +import { FilterSelect } from "../common/FilterSelect" +import { SelectedFilters } from "../common/SelectedFilters" +import { + FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY, + fetchExposedServicesFilters, +} from "../api/exposed-services/fetchExposedServicesFilters" +import { FilterSettings, SelectedFilter } from "../common/types" + +export const ExposedServicesFilters = () => { + const navigate = useNavigate() + const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" }) + const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" }) + + const { + data: filters, + isLoading, + error, + } = useQuery({ + queryKey: [FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY, user.organization], + queryFn: () => + fetchExposedServicesFilters({ + apiClient, + namespace: user.organization, + }), + }) + + const updateFilters = useCallback( + (updatedFilterSettings: FilterSettings) => { + navigate({ + to: "/admin/exposed-services", + search: (prev) => { + const newFilterParams = getFiltersForUrl(updatedFilterSettings) + const cleanedPrev = Object.fromEntries( + Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX)) + ) + return { + ...cleanedPrev, + ...newFilterParams, + } + }, + }) + }, + [navigate] + ) + + const handleFilterDelete = useCallback( + (filterToRemove: SelectedFilter) => { + updateFilters({ + ...filterSettings, + selectedFilters: filterSettings.selectedFilters?.filter( + (filter) => !(filter.id === filterToRemove.id && filter.value === filterToRemove.value) + ), + }) + }, + [filterSettings, updateFilters] + ) + + return ( + + + + { + const filterExists = filterSettings.selectedFilters?.some( + (filter) => filter.id === selectedFilter.id && filter.value === selectedFilter.value + ) + // Only add filter if it does not already exist + if (!filterExists) { + updateFilters({ + ...filterSettings, + selectedFilters: [...(filterSettings.selectedFilters || []), selectedFilter], + }) + } + }} + /> + +