Skip to content

Commit f8b9234

Browse files
authored
feat(heureka): implement initial filter URL sync and its filter pill deletion (#1196)
* feat(heureka): makes initial filter pill removable * feat(heureka): adds store to track initial filter state across tab navigation * feat(heureka): adds changeset * fix(heureka): pretifies the files * fix(heureka): pretifies the files * fix(heureka): pretifies the files * fix(heureka): fixes prettier issue * fix(heureka): adjust services test * chore(heureka): renames store to StoreProvider to be generic for future uses * chore(heureka): corrects the comment * chore(heureka): improve store prop handling * chore(heureka): handles initialFilter store status in services route component * chore(heureka): fixes some leftovers
1 parent b1e5a52 commit f8b9234

9 files changed

Lines changed: 146 additions & 31 deletions

File tree

.changeset/stupid-doors-rescue.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudoperators/juno-app-greenhouse": patch
3+
"@cloudoperators/juno-app-heureka": patch
4+
---
5+
6+
Implements initial filter URL synchronization with removable filter pills and adds context store to prevent re-application during tab navigation.

apps/heureka/src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import styles from "./styles.css?inline"
1313
import { ErrorBoundary } from "./components/common/ErrorBoundary"
1414
import { getClient } from "./apollo-client"
1515
import { routeTree } from "./routeTree.gen"
16+
import { StoreProvider } from "./store/StoreProvider"
1617

1718
export type InitialFilters = {
1819
support_group?: string[]
@@ -91,7 +92,9 @@ const App = (props: AppProps) => {
9192
<style>{styles.toString()}</style>
9293
<ErrorBoundary>
9394
<StrictMode>
94-
<RouterProvider basepath={props.basePath || "/"} router={router} />
95+
<StoreProvider>
96+
<RouterProvider basepath={props.basePath || "/"} router={router} />
97+
</StoreProvider>
9598
</StrictMode>
9699
</ErrorBoundary>
97100
</AppShellProvider>

apps/heureka/src/components/Services/Services.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Services } from "./index"
1111
import { Filter, FilterSettings } from "../common/Filters/types"
1212
import { getTestRouter } from "../../mocks/getTestRouter"
1313
import { mockServicesPromise } from "../../mocks/promises"
14+
import { StoreProvider } from "../../store/StoreProvider"
1415

1516
const mockFilters: Filter[] = [
1617
{
@@ -44,7 +45,9 @@ const renderComponent = () => {
4445
}),
4546
component: () => (
4647
<PortalProvider>
47-
<Services />
48+
<StoreProvider>
49+
<Services />
50+
</StoreProvider>
4851
</PortalProvider>
4952
),
5053
})

apps/heureka/src/components/Services/ServicesFilters.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useLoaderData, useNavigate } from "@tanstack/react-router"
88
import { Filters } from "../common/Filters"
99
import { FilterSettings } from "../common/Filters/types"
1010
import { getFiltersForUrl } from "./utils"
11+
import { SELECTED_FILTER_PREFIX } from "../../constants"
1112

1213
export const ServicesFilters = () => {
1314
const navigate = useNavigate()
@@ -17,12 +18,19 @@ export const ServicesFilters = () => {
1718
(updatedFilterSettings: FilterSettings) => {
1819
navigate({
1920
to: "/services",
20-
search: {
21-
...getFiltersForUrl(updatedFilterSettings),
21+
search: (prev) => {
22+
const newFilterParams = getFiltersForUrl(updatedFilterSettings)
23+
const cleanedPrev = Object.fromEntries(
24+
Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX))
25+
)
26+
return {
27+
...cleanedPrev,
28+
...newFilterParams,
29+
}
2230
},
2331
})
2432
},
25-
[filterSettings, navigate]
33+
[navigate]
2634
)
2735

2836
return (

apps/heureka/src/components/Services/utils.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -279,22 +279,22 @@ export const getNormalizedImageVersionIssuesResponse = (data: any): NormalizedIm
279279
* }
280280
*/
281281
export const getFiltersForUrl = (filterSettings: FilterSettings): Record<string, string | string[]> => {
282-
if (!filterSettings?.selectedFilters) {
283-
return {}
282+
const result: Record<string, string | string[]> = {
283+
searchTerm: filterSettings.searchTerm || "",
284284
}
285285

286-
return {
287-
searchTerm: filterSettings.searchTerm || "",
288-
...filterSettings.selectedFilters.reduce<Record<string, string | string[]>>((acc, filter) => {
286+
if (filterSettings?.selectedFilters && filterSettings.selectedFilters.length > 0) {
287+
filterSettings.selectedFilters.forEach((filter) => {
289288
const key = `${SELECTED_FILTER_PREFIX}${filter.name}`
290-
if (acc[key]) {
291-
acc[key] = Array.isArray(acc[key]) ? [...acc[key], filter.value] : [acc[key], filter.value]
289+
if (result[key]) {
290+
result[key] = Array.isArray(result[key]) ? [...result[key], filter.value] : [result[key], filter.value]
292291
} else {
293-
acc[key] = filter.value
292+
result[key] = filter.value
294293
}
295-
return acc
296-
}, {}),
294+
})
297295
}
296+
297+
return result
298298
}
299299

300300
export const getNormalizedFilters = (data: GetServiceFiltersQuery | undefined | null): Filter[] =>

apps/heureka/src/components/Vulnerabilities/VulnerabilitiesFilters.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useLoaderData, useNavigate } from "@tanstack/react-router"
88
import { Filters } from "../common/Filters"
99
import { FilterSettings } from "../common/Filters/types"
1010
import { getFiltersForUrl } from "./utils"
11+
import { SELECTED_FILTER_PREFIX } from "../../constants"
1112

1213
export const VulnerabilitiesFilters = () => {
1314
const navigate = useNavigate()
@@ -17,12 +18,24 @@ export const VulnerabilitiesFilters = () => {
1718
(updatedFilterSettings: FilterSettings) => {
1819
navigate({
1920
to: "/vulnerabilities",
20-
search: {
21-
...getFiltersForUrl(updatedFilterSettings),
21+
search: (prev) => {
22+
// Get the new filter URL params
23+
const newFilterParams = getFiltersForUrl(updatedFilterSettings)
24+
25+
// Remove all existing filter params from prev
26+
const cleanedPrev = Object.fromEntries(
27+
Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX))
28+
)
29+
30+
// Merge with new filter params
31+
return {
32+
...cleanedPrev,
33+
...newFilterParams,
34+
}
2235
},
2336
})
2437
},
25-
[filterSettings, navigate]
38+
[navigate]
2639
)
2740

2841
return (

apps/heureka/src/components/common/Filters/SelectedFilters.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const SelectedFilters = ({ selectedFilters, onDelete }: SelectedFiltersPr
1616
<Stack gap="2" wrap={true}>
1717
{selectedFilters?.map((filter) => (
1818
<Pill
19-
key={`${name}:${filter.value}`}
19+
key={`${filter.name}:${filter.value}`}
2020
closeable
2121
pillKey={filter.name}
2222
pillValue={filter.value}

apps/heureka/src/routes/services/index.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
5+
import React, { useLayoutEffect } from "react"
6+
import { useNavigate, useRouteContext, useSearch } from "@tanstack/react-router"
7+
import { getFiltersForUrl, getInitialFilters } from "../../components/Services/utils"
8+
import { useStore } from "../../store/StoreProvider"
69
import { createFileRoute } from "@tanstack/react-router"
710
import { z } from "zod"
811
import { Services } from "../../components/Services"
@@ -11,7 +14,6 @@ import { fetchServices } from "../../api/fetchServices"
1114
import { fetchServicesFilters } from "../../api/fetchServicesFilters"
1215
import {
1316
extractFilterSettingsFromSearchParams,
14-
getInitialFilters,
1517
getNormalizedFilters,
1618
sanitizeFilterSettings,
1719
} from "../../components/Services/utils"
@@ -44,17 +46,10 @@ export const Route = createFileRoute("/services/")({
4446
return rest
4547
},
4648
shouldReload: false, // Only reload the route when the user navigates to it or when deps change
47-
beforeLoad: ({ context: { appProps }, search }) => {
49+
beforeLoad: ({ search }) => {
4850
const filterSettings = extractFilterSettingsFromSearchParams(search)
4951
return {
50-
filterSettings:
51-
// Filters from the URL always have preference over initial filters
52-
(filterSettings?.selectedFilters ?? []).length > 0
53-
? filterSettings
54-
: {
55-
...filterSettings,
56-
selectedFilters: getInitialFilters(appProps?.initialFilters),
57-
},
52+
filterSettings,
5853
}
5954
},
6055
loader: async ({ context }) => {
@@ -79,5 +74,38 @@ export const Route = createFileRoute("/services/")({
7974
filterSettings: sanitizeFilterSettings(filters, filterSettings), // we need to only apply filters that backend supports hence this sanitization
8075
}
8176
},
82-
component: Services,
77+
component: RouteComponent,
8378
})
79+
80+
function RouteComponent() {
81+
const navigate = useNavigate()
82+
const { appProps } = useRouteContext({ from: "/services/" })
83+
const search = useSearch({ from: "/services/" })
84+
const { hasAppliedInitialFilters, markInitialFiltersApplied } = useStore()
85+
86+
// Use store to track initial filters across tab navigation - prevents re-application when switching between services/vulnerabilities tabs
87+
useLayoutEffect(() => {
88+
if (hasAppliedInitialFilters) return
89+
90+
// Use parsed search params from TanStack Router
91+
const hasUrlFilters = Object.keys(search).some((key) => key.startsWith(SELECTED_FILTER_PREFIX))
92+
93+
if (!hasUrlFilters && appProps?.initialFilters?.support_group?.length) {
94+
const initialFilters = getInitialFilters(appProps.initialFilters)
95+
96+
if (initialFilters.length > 0) {
97+
navigate({
98+
to: "/services",
99+
search: getFiltersForUrl({
100+
searchTerm: "",
101+
selectedFilters: initialFilters,
102+
}),
103+
replace: true,
104+
})
105+
markInitialFiltersApplied()
106+
}
107+
}
108+
}, [navigate, appProps, hasAppliedInitialFilters, markInitialFiltersApplied, search])
109+
110+
return <Services />
111+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { createContext, useContext, useState, ReactNode } from "react"
7+
8+
// Store state interface - easily extensible for future global state
9+
interface StoreState {
10+
hasAppliedInitialFilters: boolean
11+
}
12+
13+
// Store actions interface - easily extensible for future actions
14+
interface StoreActions {
15+
markInitialFiltersApplied: () => void
16+
}
17+
18+
// Combined store context type
19+
interface StoreContextType extends StoreState, StoreActions {}
20+
21+
const StoreContext = createContext<StoreContextType | undefined>(undefined)
22+
23+
interface StoreProviderProps {
24+
children: ReactNode
25+
}
26+
27+
export const StoreProvider = ({ children }: StoreProviderProps) => {
28+
// State management
29+
const [hasAppliedInitialFilters, setHasAppliedInitialFilters] = useState(false)
30+
31+
// Actions
32+
const markInitialFiltersApplied = () => {
33+
setHasAppliedInitialFilters(true)
34+
}
35+
// Future actions can be added here
36+
37+
const storeValue: StoreContextType = {
38+
// State
39+
hasAppliedInitialFilters,
40+
41+
// Actions
42+
markInitialFiltersApplied,
43+
}
44+
45+
return <StoreContext.Provider value={storeValue}>{children}</StoreContext.Provider>
46+
}
47+
48+
export const useStore = () => {
49+
const context = useContext(StoreContext)
50+
if (context === undefined) {
51+
throw new Error("useStore must be used within a StoreProvider")
52+
}
53+
return context
54+
}

0 commit comments

Comments
 (0)