Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/stupid-doors-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudoperators/juno-app-greenhouse": patch
"@cloudoperators/juno-app-heureka": patch
---

Implements initial filter URL synchronization with removable filter pills and adds context store to prevent re-application during tab navigation.
5 changes: 4 additions & 1 deletion apps/heureka/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import styles from "./styles.css?inline"
import { ErrorBoundary } from "./components/common/ErrorBoundary"
import { getClient } from "./apollo-client"
import { routeTree } from "./routeTree.gen"
import { StoreProvider } from "./store/StoreProvider"

export type InitialFilters = {
support_group?: string[]
Expand Down Expand Up @@ -91,7 +92,9 @@ const App = (props: AppProps) => {
<style>{styles.toString()}</style>
<ErrorBoundary>
<StrictMode>
<RouterProvider basepath={props.basePath || "/"} router={router} />
<StoreProvider>
<RouterProvider basepath={props.basePath || "/"} router={router} />
</StoreProvider>
</StrictMode>
</ErrorBoundary>
</AppShellProvider>
Expand Down
5 changes: 4 additions & 1 deletion apps/heureka/src/components/Services/Services.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Services } from "./index"
import { Filter, FilterSettings } from "../common/Filters/types"
import { getTestRouter } from "../../mocks/getTestRouter"
import { mockServicesPromise } from "../../mocks/promises"
import { StoreProvider } from "../../store/StoreProvider"

const mockFilters: Filter[] = [
{
Expand Down Expand Up @@ -44,7 +45,9 @@ const renderComponent = () => {
}),
component: () => (
<PortalProvider>
<Services />
<StoreProvider>
<Services />
</StoreProvider>
</PortalProvider>
),
})
Expand Down
14 changes: 11 additions & 3 deletions apps/heureka/src/components/Services/ServicesFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useLoaderData, useNavigate } from "@tanstack/react-router"
import { Filters } from "../common/Filters"
import { FilterSettings } from "../common/Filters/types"
import { getFiltersForUrl } from "./utils"
import { SELECTED_FILTER_PREFIX } from "../../constants"

export const ServicesFilters = () => {
const navigate = useNavigate()
Expand All @@ -17,12 +18,19 @@ export const ServicesFilters = () => {
(updatedFilterSettings: FilterSettings) => {
navigate({
to: "/services",
search: {
...getFiltersForUrl(updatedFilterSettings),
search: (prev) => {
const newFilterParams = getFiltersForUrl(updatedFilterSettings)
const cleanedPrev = Object.fromEntries(
Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX))
)
return {
...cleanedPrev,
...newFilterParams,
}
},
})
},
[filterSettings, navigate]
[navigate]
)

return (
Expand Down
20 changes: 10 additions & 10 deletions apps/heureka/src/components/Services/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,22 +279,22 @@ export const getNormalizedImageVersionIssuesResponse = (data: any): NormalizedIm
* }
*/
export const getFiltersForUrl = (filterSettings: FilterSettings): Record<string, string | string[]> => {
if (!filterSettings?.selectedFilters) {
return {}
const result: Record<string, string | string[]> = {
searchTerm: filterSettings.searchTerm || "",
}

return {
searchTerm: filterSettings.searchTerm || "",
...filterSettings.selectedFilters.reduce<Record<string, string | string[]>>((acc, filter) => {
if (filterSettings?.selectedFilters && filterSettings.selectedFilters.length > 0) {
filterSettings.selectedFilters.forEach((filter) => {
const key = `${SELECTED_FILTER_PREFIX}${filter.name}`
if (acc[key]) {
acc[key] = Array.isArray(acc[key]) ? [...acc[key], filter.value] : [acc[key], filter.value]
if (result[key]) {
result[key] = Array.isArray(result[key]) ? [...result[key], filter.value] : [result[key], filter.value]
} else {
acc[key] = filter.value
result[key] = filter.value
}
return acc
}, {}),
})
}

return result
}

export const getNormalizedFilters = (data: GetServiceFiltersQuery | undefined | null): Filter[] =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useLoaderData, useNavigate } from "@tanstack/react-router"
import { Filters } from "../common/Filters"
import { FilterSettings } from "../common/Filters/types"
import { getFiltersForUrl } from "./utils"
import { SELECTED_FILTER_PREFIX } from "../../constants"

export const VulnerabilitiesFilters = () => {
const navigate = useNavigate()
Expand All @@ -17,12 +18,24 @@ export const VulnerabilitiesFilters = () => {
(updatedFilterSettings: FilterSettings) => {
navigate({
to: "/vulnerabilities",
search: {
...getFiltersForUrl(updatedFilterSettings),
search: (prev) => {
// Get the new filter URL params
const newFilterParams = getFiltersForUrl(updatedFilterSettings)

// Remove all existing filter params from prev
const cleanedPrev = Object.fromEntries(
Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX))
)

// Merge with new filter params
return {
...cleanedPrev,
...newFilterParams,
}
},
})
},
[filterSettings, navigate]
[navigate]
)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const SelectedFilters = ({ selectedFilters, onDelete }: SelectedFiltersPr
<Stack gap="2" wrap={true}>
{selectedFilters?.map((filter) => (
<Pill
key={`${name}:${filter.value}`}
key={`${filter.name}:${filter.value}`}
closeable
pillKey={filter.name}
pillValue={filter.value}
Expand Down
52 changes: 40 additions & 12 deletions apps/heureka/src/routes/services/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useLayoutEffect } from "react"
import { useNavigate, useRouteContext, useSearch } from "@tanstack/react-router"
import { getFiltersForUrl, getInitialFilters } from "../../components/Services/utils"
import { useStore } from "../../store/StoreProvider"
import { createFileRoute } from "@tanstack/react-router"
import { z } from "zod"
import { Services } from "../../components/Services"
Expand All @@ -11,7 +14,6 @@ import { fetchServices } from "../../api/fetchServices"
import { fetchServicesFilters } from "../../api/fetchServicesFilters"
import {
extractFilterSettingsFromSearchParams,
getInitialFilters,
getNormalizedFilters,
sanitizeFilterSettings,
} from "../../components/Services/utils"
Expand Down Expand Up @@ -44,17 +46,10 @@ export const Route = createFileRoute("/services/")({
return rest
},
shouldReload: false, // Only reload the route when the user navigates to it or when deps change
beforeLoad: ({ context: { appProps }, search }) => {
beforeLoad: ({ search }) => {
const filterSettings = extractFilterSettingsFromSearchParams(search)
return {
filterSettings:
// Filters from the URL always have preference over initial filters
(filterSettings?.selectedFilters ?? []).length > 0
? filterSettings
: {
...filterSettings,
selectedFilters: getInitialFilters(appProps?.initialFilters),
},
filterSettings,
}
},
loader: async ({ context }) => {
Expand All @@ -79,5 +74,38 @@ export const Route = createFileRoute("/services/")({
filterSettings: sanitizeFilterSettings(filters, filterSettings), // we need to only apply filters that backend supports hence this sanitization
}
},
component: Services,
component: RouteComponent,
})

function RouteComponent() {
const navigate = useNavigate()
const { appProps } = useRouteContext({ from: "/services/" })
const search = useSearch({ from: "/services/" })
const { hasAppliedInitialFilters, markInitialFiltersApplied } = useStore()

// Use store to track initial filters across tab navigation - prevents re-application when switching between services/vulnerabilities tabs
useLayoutEffect(() => {
if (hasAppliedInitialFilters) return

// Use parsed search params from TanStack Router
const hasUrlFilters = Object.keys(search).some((key) => key.startsWith(SELECTED_FILTER_PREFIX))

if (!hasUrlFilters && appProps?.initialFilters?.support_group?.length) {
const initialFilters = getInitialFilters(appProps.initialFilters)

if (initialFilters.length > 0) {
navigate({
to: "/services",
search: getFiltersForUrl({
searchTerm: "",
selectedFilters: initialFilters,
}),
replace: true,
})
markInitialFiltersApplied()
}
}
}, [navigate, appProps, hasAppliedInitialFilters, markInitialFiltersApplied, search])

return <Services />
}
54 changes: 54 additions & 0 deletions apps/heureka/src/store/StoreProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { createContext, useContext, useState, ReactNode } from "react"

// Store state interface - easily extensible for future global state
interface StoreState {
hasAppliedInitialFilters: boolean
}

// Store actions interface - easily extensible for future actions
interface StoreActions {
markInitialFiltersApplied: () => void
}

// Combined store context type
interface StoreContextType extends StoreState, StoreActions {}

const StoreContext = createContext<StoreContextType | undefined>(undefined)

interface StoreProviderProps {
children: ReactNode
}

export const StoreProvider = ({ children }: StoreProviderProps) => {
// State management
const [hasAppliedInitialFilters, setHasAppliedInitialFilters] = useState(false)

// Actions
const markInitialFiltersApplied = () => {
setHasAppliedInitialFilters(true)
}
// Future actions can be added here

const storeValue: StoreContextType = {
// State
hasAppliedInitialFilters,

// Actions
markInitialFiltersApplied,
}

return <StoreContext.Provider value={storeValue}>{children}</StoreContext.Provider>
}

export const useStore = () => {
const context = useContext(StoreContext)
if (context === undefined) {
throw new Error("useStore must be used within a StoreProvider")
}
return context
}
Loading