Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
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,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 (
<Stack direction="vertical" gap="4" className="bg-theme-background-lvl-1 py-2 px-4 mb-px">
<Stack alignment="start" gap="4">
<InputGroup>
<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],
})
}
}}
/>
</InputGroup>
<Button
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I commented this in the Clusters PR, here as well to make it consistent:
Please use the pattern of xs button in the pill row directly behind the pills, only visible if at least one filter is active

label="Clear all"
className="ml-4"
onClick={() =>
updateFilters({
...filterSettings,
selectedFilters: [],
})
}
variant="subdued"
/>
<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 && (
<SelectedFilters selectedFilters={filterSettings.selectedFilters} onDelete={handleFilterDelete} />
)}
</Stack>
)
}
Loading
Loading