diff --git a/.changeset/stupid-doors-rescue.md b/.changeset/stupid-doors-rescue.md new file mode 100644 index 0000000000..4823846b6b --- /dev/null +++ b/.changeset/stupid-doors-rescue.md @@ -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. diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index 77ad832286..0093a126a5 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -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[] @@ -91,7 +92,9 @@ const App = (props: AppProps) => { - + + + diff --git a/apps/heureka/src/components/Services/Services.test.tsx b/apps/heureka/src/components/Services/Services.test.tsx index aea49fd9df..5a5cdcdf9f 100644 --- a/apps/heureka/src/components/Services/Services.test.tsx +++ b/apps/heureka/src/components/Services/Services.test.tsx @@ -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[] = [ { @@ -44,7 +45,9 @@ const renderComponent = () => { }), component: () => ( - + + + ), }) diff --git a/apps/heureka/src/components/Services/ServicesFilters.tsx b/apps/heureka/src/components/Services/ServicesFilters.tsx index bc8ea51dbd..77cb038a72 100644 --- a/apps/heureka/src/components/Services/ServicesFilters.tsx +++ b/apps/heureka/src/components/Services/ServicesFilters.tsx @@ -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() @@ -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 ( diff --git a/apps/heureka/src/components/Services/utils.ts b/apps/heureka/src/components/Services/utils.ts index a6cdf2bb5c..a93489f83d 100644 --- a/apps/heureka/src/components/Services/utils.ts +++ b/apps/heureka/src/components/Services/utils.ts @@ -279,22 +279,22 @@ export const getNormalizedImageVersionIssuesResponse = (data: any): NormalizedIm * } */ export const getFiltersForUrl = (filterSettings: FilterSettings): Record => { - if (!filterSettings?.selectedFilters) { - return {} + const result: Record = { + searchTerm: filterSettings.searchTerm || "", } - return { - searchTerm: filterSettings.searchTerm || "", - ...filterSettings.selectedFilters.reduce>((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[] => diff --git a/apps/heureka/src/components/Vulnerabilities/VulnerabilitiesFilters.tsx b/apps/heureka/src/components/Vulnerabilities/VulnerabilitiesFilters.tsx index 999cff6461..2359490888 100644 --- a/apps/heureka/src/components/Vulnerabilities/VulnerabilitiesFilters.tsx +++ b/apps/heureka/src/components/Vulnerabilities/VulnerabilitiesFilters.tsx @@ -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() @@ -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 ( diff --git a/apps/heureka/src/components/common/Filters/SelectedFilters.tsx b/apps/heureka/src/components/common/Filters/SelectedFilters.tsx index 6d66c1d72b..aea1267888 100644 --- a/apps/heureka/src/components/common/Filters/SelectedFilters.tsx +++ b/apps/heureka/src/components/common/Filters/SelectedFilters.tsx @@ -16,7 +16,7 @@ export const SelectedFilters = ({ selectedFilters, onDelete }: SelectedFiltersPr {selectedFilters?.map((filter) => ( { + 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 }) => { @@ -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 +} diff --git a/apps/heureka/src/store/StoreProvider.tsx b/apps/heureka/src/store/StoreProvider.tsx new file mode 100644 index 0000000000..4d3cbb1462 --- /dev/null +++ b/apps/heureka/src/store/StoreProvider.tsx @@ -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(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 {children} +} + +export const useStore = () => { + const context = useContext(StoreContext) + if (context === undefined) { + throw new Error("useStore must be used within a StoreProvider") + } + return context +}