Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/wide-cups-beam.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"

Comment thread
guoda-puidokaite marked this conversation as resolved.
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)

Comment thread
guoda-puidokaite marked this conversation as resolved.
const { data: flattenedExposedServices } = useSuspenseQuery({
queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY, user.organization, filterSettings],
queryFn: () =>
fetchExposedServices({
apiClient,
namespace: user.organization,
filterSettings,
}),
})
Comment thread
guoda-puidokaite marked this conversation as resolved.

if (!flattenedExposedServices || flattenedExposedServices.length === 0) {
return <EmptyDataGridRow colSpan={colSpan}>No exposed services found.</EmptyDataGridRow>
}

return (
<>
{flattenedExposedServices.map((service, index) => (
<DataGridRow key={`${service.serviceName}-${index}`}>
Comment thread
guoda-puidokaite marked this conversation as resolved.
<DataGridCell>{<ExternalLink url={service.serviceUrl} label={service.serviceName} />}</DataGridCell>
<DataGridCell>{service.clusterName || NO_VALUE_DEFAULT}</DataGridCell>
<DataGridCell>{service.pluginName || NO_VALUE_DEFAULT}</DataGridCell>
<DataGridCell>{service.supportGroup || NO_VALUE_DEFAULT}</DataGridCell>
Comment thread
guoda-puidokaite marked this conversation as resolved.
</DataGridRow>
))}
</>
)
}
Original file line number Diff line number Diff line change
@@ -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<MockPluginsWithExposedServicesResponse | unknown>) => {
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/admin/exposed-services",
component: () => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
>
<ExposedServicesDataGrid />
</QueryClientProvider>
),
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(<RouterProvider router={router} />))
}

describe("ExposedServicesDataGrid", () => {
it("should render exposed services", async () => {
await renderComponent(
new Promise<MockPluginsWithExposedServicesResponse>((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()
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<DataGrid columns={COLUMN_SPAN}>
<DataGridRow>
<DataGridHeadCell>Name</DataGridHeadCell>
<DataGridHeadCell>Cluster</DataGridHeadCell>
<DataGridHeadCell>Plugin</DataGridHeadCell>
<DataGridHeadCell>Support Group</DataGridHeadCell>
</DataGridRow>

<ErrorBoundary
displayErrorMessage
fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })}
resetKeys={[filterSettings]} // Reset on filter changes
>
<Suspense fallback={<LoadingDataRow colSpan={COLUMN_SPAN} />}>
<DataRows colSpan={COLUMN_SPAN} />
</Suspense>
</ErrorBoundary>
</DataGrid>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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, 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 (
<Stack direction="vertical" gap="4" className="bg-theme-background-lvl-1 py-2 px-4 mb-px">
<Stack alignment="start" gap="4">
<FilterSelect
filters={filters}
isLoading={isLoading}
error={error}
onChange={(selectedFilter: SelectedFilter) => {
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],
})
}
}}
/>
<SearchInput
placeholder={`search term for exposed service name`}
className="w-96 ml-auto"
data-testid="searchbar"
value={filterSettings.searchTerm}
onSearch={(searchTerm) => {
updateFilters({
...filterSettings,
searchTerm,
Comment thread
guoda-puidokaite marked this conversation as resolved.
})
}}
onClear={() =>
updateFilters({
...filterSettings,
searchTerm: "",
})
}
/>
</Stack>
{filterSettings.selectedFilters && filterSettings.selectedFilters.length > 0 && (
<Stack>
<SelectedFilters selectedFilters={filterSettings.selectedFilters} onDelete={handleFilterDelete} />
<Button
size="xs"
label="Clear all"
className="ml-4"
onClick={() =>
updateFilters({
...filterSettings,
selectedFilters: [],
})
}
variant="subdued"
/>
</Stack>
)}
</Stack>
)
}
Loading
Loading