diff --git a/.changeset/ten-mammals-judge.md b/.changeset/ten-mammals-judge.md new file mode 100644 index 0000000000..8673270a55 --- /dev/null +++ b/.changeset/ten-mammals-judge.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-heureka": minor +--- + +Enables authentication support for Heureka and improves false positive remediation UX: inline spinner feedback during API operations, timed success messages with propagation delay notice, proper error display without unhandled exceptions, auth user ID scoped to embedded mode, and background polling to reflect backend status updates. diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index f83412a15f..0147fe0d4a 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { StrictMode } from "react" +import React, { createContext, StrictMode, useContext, useMemo } from "react" import { ApolloProvider } from "@apollo/client/react" import { createRouter, RouterProvider, createHashHistory, createBrowserHistory } from "@tanstack/react-router" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" @@ -14,14 +14,26 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary" import { getClient } from "./apollo-client" import { routeTree } from "./routeTree.gen" import { StoreProvider } from "./store/StoreProvider" -import { AuthProvider, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider" +import { AuthProvider, EmbeddedAuth, type AuthState } from "@cloudoperators/greenhouse-auth-provider" + +/** + * Auth user ID for the current user when embedded and authenticated; null otherwise. + * Derived from auth.getSnapshot() at App render time — intentionally NOT using useAuth() + * because the package may run on a different React instance (micro-frontend architecture). + * The shell remounts this plugin on auth change, so getSnapshot() is always fresh at mount. + */ +export const AuthUserIdContext = createContext(null) +export const useAuthUserId = () => useContext(AuthUserIdContext) export type InitialFilters = { support_group?: string[] } +export type { AuthState, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider" + const queryClient = new QueryClient() +/** Auth can be EmbeddedAuth (from shell) or a plain AuthState (e.g. from appProps.json, which cannot contain functions). */ export type AppProps = { theme?: "theme-dark" | "theme-light" apiEndpoint?: string @@ -29,7 +41,7 @@ export type AppProps = { initialFilters?: InitialFilters basePath?: string enableHashedRouting?: boolean - auth?: EmbeddedAuth + auth?: EmbeddedAuth | AuthState } const router = createRouter({ @@ -47,11 +59,31 @@ declare module "@tanstack/react-router" { } } +/** Normalize auth so AuthProvider always receives EmbeddedAuth (with getSnapshot). Plain objects from appProps.json are wrapped. */ +function toEmbeddedAuth(auth: AppProps["auth"]): EmbeddedAuth | undefined { + if (!auth) return undefined + if (typeof (auth as EmbeddedAuth).getSnapshot === "function") return auth as EmbeddedAuth + return { getSnapshot: () => auth as AuthState } +} + const App = (props: AppProps) => { const apiClient = getClient({ uri: props.apiEndpoint, }) + const authForProvider = useMemo(() => toEmbeddedAuth(props.auth), [props.auth]) + + const authUserId = useMemo(() => { + if (!props.embedded || !authForProvider) return null + const state = authForProvider.getSnapshot() + return state.status === "authenticated" ? state.userId : null + }, [props.embedded, authForProvider]) + + const authProviderProps = + props.embedded && authForProvider + ? ({ embedded: true as const, auth: authForProvider } as const) + : ({ embedded: false as const } as const) + /* * Dynamically change the type of history on the router * based on the enableHashedRouting prop. This ensures that @@ -96,10 +128,12 @@ const App = (props: AppProps) => { }> - - - - + + + + + + diff --git a/apps/heureka/src/api/fetchImages.tsx b/apps/heureka/src/api/fetchImages.tsx index a4216842b8..aed28b0ea3 100644 --- a/apps/heureka/src/api/fetchImages.tsx +++ b/apps/heureka/src/api/fetchImages.tsx @@ -41,9 +41,11 @@ export const fetchImages = ({ afterVersions, vulFilter, ], + staleTime: 2.5 * 60 * 1000, queryFn: () => apiClient.query({ query: GetImagesDocument, + fetchPolicy: "network-only", variables: { imgFilter: filter, vulFilter, diff --git a/apps/heureka/src/api/fetchRemediations.tsx b/apps/heureka/src/api/fetchRemediations.tsx index 234284547a..d0703dd4df 100644 --- a/apps/heureka/src/api/fetchRemediations.tsx +++ b/apps/heureka/src/api/fetchRemediations.tsx @@ -9,20 +9,24 @@ import { RouteContext } from "../routes/-types" type FetchRemediationsParams = Pick & { filter?: RemediationFilter + staleTime?: number } export const fetchRemediations = ({ queryClient, apiClient, filter, + staleTime = 2.5 * 60 * 1000, }: FetchRemediationsParams): Promise> => { const queryKey = ["remediations", filter] return queryClient.ensureQueryData({ queryKey, + staleTime, queryFn: () => apiClient.query({ query: GetRemediationsDocument, + fetchPolicy: "network-only", variables: { filter, }, diff --git a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx index 76221aeed9..a0371ff48f 100644 --- a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx @@ -10,15 +10,17 @@ import { Button, Stack, Textarea, + TextInput, DateTimePicker, Message, } from "@cloudoperators/juno-ui-components" import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../../../generated/graphql" +import { useAuthUserId } from "../../../../App" type FalsePositiveModalProps = { open: boolean onClose: () => void - onConfirm: (input: RemediationInput) => Promise + onConfirm: (input: RemediationInput) => Promise<{ error: string } | void> vulnerability: string severity?: string service: string @@ -50,12 +52,19 @@ export const FalsePositiveModal: React.FC = ({ errorMessage, onSetError, }) => { + const authUserId = useAuthUserId() const [description, setDescription] = useState("") + const [manualUserId, setManualUserId] = useState("") const [expirationDate, setExpirationDate] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [descriptionError, setDescriptionError] = useState("") + const [userIdError, setUserIdError] = useState("") const isMountedRef = useRef(true) + const manualUserIdTrimmed = manualUserId.trim() + const remediatedBy = authUserId ?? (manualUserIdTrimmed || undefined) + const isUserIdValid = !!remediatedBy + useEffect(() => { return () => { isMountedRef.current = false @@ -69,8 +78,13 @@ export const FalsePositiveModal: React.FC = ({ setDescriptionError("Description is required") return } + if (!remediatedBy) { + setUserIdError("User ID is required") + return + } setDescriptionError("") + setUserIdError("") setIsSubmitting(true) try { const input: RemediationInput = { @@ -79,14 +93,20 @@ export const FalsePositiveModal: React.FC = ({ service, image, description: descriptionTrimmed, + ...(remediatedBy && { remediatedBy }), ...(severity && { severity: toSeverityValue(severity) }), ...(expirationDate && { expirationDate: expirationDate.toISOString() }), } - await onConfirm(input) + const result = await onConfirm(input) if (isMountedRef.current) { - setDescription("") - setExpirationDate(null) - onClose() + if (result?.error) { + onSetError?.(result.error) + } else { + setDescription("") + setManualUserId("") + setExpirationDate(null) + onClose() + } } } catch (error) { const message = error instanceof Error ? error.message : "Failed to create remediation" @@ -102,8 +122,10 @@ export const FalsePositiveModal: React.FC = ({ const handleClose = () => { setDescription("") + setManualUserId("") setExpirationDate(null) setDescriptionError("") + setUserIdError("") onSetError?.(null) onClose() } @@ -129,7 +151,7 @@ export const FalsePositiveModal: React.FC = ({ onClick={handleConfirm} label={CONFIRM_LABEL} variant="primary" - disabled={isSubmitting || !descriptionTrimmed} + disabled={isSubmitting || !descriptionTrimmed || !isUserIdValid} /> @@ -146,6 +168,22 @@ export const FalsePositiveModal: React.FC = ({
Image: {image}
+
+ { + setManualUserId(e.target.value) + if (userIdError) setUserIdError("") + }} + disabled={!!authUserId} + required + invalid={!!userIdError} + errortext={userIdError} + placeholder={authUserId ? undefined : "Enter your user ID"} + helptext={authUserId ? "User ID from current session (read-only)." : "Enter your user ID."} + /> +
{ const [isExpanded, setIsExpanded] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const [createError, setCreateError] = useState(null) const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") const { apiClient } = useRouteContext({ from: "/services/$service" }) @@ -67,15 +69,20 @@ export const IssuesDataRow = ({ setIsModalOpen(true) } - const handleModalConfirm = async (input: RemediationInput) => { + const handleModalConfirm = async (input: RemediationInput): Promise<{ error: string } | void> => { setCreateError(null) + setIsModalOpen(false) + setIsSubmitting(true) try { await createRemediation({ apiClient, input }) const cveNumber = issue?.name || "unknown" - await onFalsePositiveSuccess?.(cveNumber) - setIsModalOpen(false) + // Fire refresh in the background so the spinner clears immediately after createRemediation. + Promise.resolve(onFalsePositiveSuccess?.(cveNumber)).catch(() => {}) } catch (error) { - setCreateError(error instanceof Error ? error.message : "Failed to create remediation") + setIsModalOpen(true) + return { error: error instanceof Error ? error.message : "Failed to create remediation" } + } finally { + setIsSubmitting(false) } } @@ -126,11 +133,15 @@ export const IssuesDataRow = ({ {showFalsePositiveAction && ( e.stopPropagation()}> - - - - - + {isSubmitting ? ( + + ) : ( + + + + + + )} )} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index ac67484c67..420dbd396e 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -17,6 +17,7 @@ import { PopupMenuItem, Message, Stack, + Spinner, } from "@cloudoperators/juno-ui-components" import { fetchRemediations } from "../../../../../api/fetchRemediations" import { deleteRemediation } from "../../../../../api/deleteRemediation" @@ -27,6 +28,7 @@ import { EmptyDataGridRow } from "../../../../common/EmptyDataGridRow" import { LoadingDataRow } from "../../../../common/LoadingDataRow" import { getErrorDataRowComponent } from "../../../../common/getErrorDataRow" import type { RemediatedVulnerability } from "../../../../Services/utils" +import { useTimedState } from "../../../../../utils" type RemediationHistoryPanelProps = { service: string @@ -35,6 +37,8 @@ type RemediationHistoryPanelProps = { onClose: () => void /** Called after a successful revert so the parent can refetch getRemediations and getImages. */ onRevertSuccess?: (vulnerability: string) => void | Promise + /** Increment to force a fresh fetch of remediations (e.g. after createRemediation or deleteRemediation). */ + refreshKey?: number } const COLUMN_SPAN = 6 @@ -83,21 +87,21 @@ const RemediationHistoryTable = ({ <> {remediatedVulnerabilities.map((r: RemediatedVulnerability) => ( + {r.type ?? "—"} {formatDateTime(r.expirationDate)} {formatDateTime(r.remediationDate)} {r.remediatedBy ?? "—"} - {r.type ?? "—"} {r.description ?? "—"} e.stopPropagation()}> - - - handleRevert(r)} - disabled={!!revertingId} - /> - - + {revertingId === r.remediationId ? ( + + ) : ( + + + handleRevert(r)} disabled={!!revertingId} /> + + + )} ))} @@ -113,40 +117,44 @@ export const RemediationHistoryPanel = ({ vulnerability, onClose, onRevertSuccess, + refreshKey, }: RemediationHistoryPanelProps) => { const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) - const [revertMessage, setRevertMessage] = useState(null) + const [revertMessage, setRevertMessage] = useTimedState(10000, (m) => m.variant === "success") const remediationsPromise = useMemo(() => { if (!vulnerability) return null - return fetchRemediations({ - apiClient, - queryClient, - filter: { - service: [service], - image: [image], - vulnerability: [vulnerability], - }, - }) - }, [service, image, vulnerability, apiClient, queryClient]) + const filter = { + service: [service], + image: [image], + vulnerability: [vulnerability], + } + + // Always fetch from scratch — the panel opens on user interaction and must show current state. + // staleTime: 0 makes ensureQueryData treat any cached entry as immediately stale, forcing a + // network request without cancelling in-flight queries (unlike removeQueries). + return fetchRemediations({ apiClient, queryClient, filter, staleTime: 0 }) + }, [service, image, vulnerability, apiClient, queryClient, refreshKey]) const handleRevert = async (remediationId: string) => { + // Clear any existing feedback when starting a new revert operation. + setRevertMessage(null) try { await deleteRemediation({ apiClient, remediationId }) - const text = `Vulnerability ${vulnerability ?? "unknown"} reverted from false positive successfully.` + const text = `The false positive for ${vulnerability ?? "unknown"} has been reverted. The status may take up to 5–6 minutes to update in the tables.` setRevertMessage({ variant: "success", text }) - // Refresh panel/list data after showing success feedback. - try { - if (vulnerability) { - await onRevertSuccess?.(vulnerability) - } - } catch (refreshError) { - const refreshMsg = refreshError instanceof Error ? refreshError.message : "Failed to refresh data after revert" - setRevertMessage({ - variant: "error", - text: `Revert succeeded, but ${refreshMsg.toLowerCase()}. You may need to refresh the page.`, + // Refresh panel/list data in the background — do not await so the + // spinner clears at the same time as the success message appears. + if (vulnerability) { + Promise.resolve(onRevertSuccess?.(vulnerability)).catch((refreshError) => { + const refreshMsg = + refreshError instanceof Error ? refreshError.message : "Failed to refresh data after revert" + setRevertMessage({ + variant: "error", + text: `Revert succeeded, but ${refreshMsg.toLowerCase()}. You may need to refresh the page.`, + }) }) } } catch (err) { @@ -177,18 +185,19 @@ export const RemediationHistoryPanel = ({ )} {remediationsPromise && ( - + + Type Expiration Date Remediation Date Remediated By - Type Description - Actions + }> diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 9546a32dfd..bca6f5cbf2 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Suspense, useState, useCallback, useEffect } from "react" +import React, { Suspense, useState, useCallback, useEffect, useRef } from "react" import { useNavigate, useRouteContext } from "@tanstack/react-router" import { DataGrid, @@ -19,6 +19,7 @@ import { Message, } from "@cloudoperators/juno-ui-components" import { getNormalizedImageVulnerabilitiesResponse, ServiceImage } from "../../../Services/utils" +import { useTimedState } from "../../../../utils" import type { VulnerabilityFilter } from "../../../../generated/graphql" import { fetchImages } from "../../../../api/fetchImages" import { fetchRemediations } from "../../../../api/fetchRemediations" @@ -81,7 +82,7 @@ const VulnerabilitiesTabContent = ({ Vulnerability Target Date Description - Actions + {issuesPromise && ( @@ -126,6 +127,7 @@ const RemediatedVulnerabilitiesTabContent = ({ onDataRefresh, selectedVulnerability, onSelectVulnerability, + refreshKey, }: { service: string image: string @@ -136,6 +138,7 @@ const RemediatedVulnerabilitiesTabContent = ({ onDataRefresh?: (vulnerability: string) => void | Promise selectedVulnerability: string | null onSelectVulnerability: (cve: string | null) => void + refreshKey: number }) => { return ( <> @@ -193,13 +196,12 @@ const RemediatedVulnerabilitiesTabContent = ({ vulnerability={selectedVulnerability} onClose={() => onSelectVulnerability(null)} onRevertSuccess={onDataRefresh} + refreshKey={refreshKey} /> ) } -const SUCCESS_MESSAGE_DURATION_MS = 5000 - export const ImageIssuesList = ({ service, image, @@ -236,11 +238,23 @@ export const ImageIssuesList = ({ }, [navigate, service, image.repository] ) - const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useState(null) - const [, setRefreshKey] = useState(0) + const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(10000) + const [pollErrorMessage, setPollErrorMessage] = useTimedState(10000) + const [refreshKey, setRefreshKey] = useState(0) + const pollTimeoutsRef = useRef[]>([]) + const isMountedRef = useRef(true) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + pollTimeoutsRef.current.forEach(clearTimeout) + } + }, []) const refreshIssuesData = useCallback( async (vulnerability: string) => { + // Helper: match only queries for the currently viewed service + image. const matchesCurrentServiceAndImage = ( filter: { service?: string[]; image?: string[]; repository?: string[]; vulnerability?: string[] } | undefined ) => @@ -249,6 +263,7 @@ export const ImageIssuesList = ({ ((Array.isArray(filter?.image) && filter.image.includes(image.repository)) || (Array.isArray(filter?.repository) && filter.repository.includes(image.repository))) + // 1. Immediately refetch remediations for the affected CVE so the panel shows fresh data. await queryClient.refetchQueries({ type: "all", predicate: (query) => { @@ -265,6 +280,7 @@ export const ImageIssuesList = ({ }, }) + // 2. Immediately refetch images so the active/remediated tabs reflect the change. await queryClient.refetchQueries({ type: "all", predicate: (query) => { @@ -276,9 +292,45 @@ export const ImageIssuesList = ({ }, }) + // 3. Bump refreshKey so memoized promises (e.g. RemediationHistoryPanel) re-read from cache. setRefreshKey((k) => k + 1) + + // 4. Schedule background re-polls at 2.5 min and 5 min. + // The backend takes ~5–6 min to propagate, so a second pass catches late updates. + // Note: we use setTimeout + invalidateQueries (not refetchInterval) because the data + // layer relies on ensureQueryData + React use() for Suspense, not useQuery hooks. + pollTimeoutsRef.current.forEach(clearTimeout) + pollTimeoutsRef.current = [] + ;[2.5 * 60 * 1000, 5 * 60 * 1000].forEach((delay) => { + const id = setTimeout(() => { + queryClient + .invalidateQueries({ + predicate: (query) => { + const [key, filter] = query.queryKey as [ + string, + { service?: string[]; image?: string[]; repository?: string[]; vulnerability?: string[] } | undefined, + ] + if (key === "images" || key === "remediations") { + return matchesCurrentServiceAndImage(filter) + } + return false + }, + }) + .then(() => { + if (!isMountedRef.current) return + setRefreshKey((k) => k + 1) + }) + .catch(() => { + if (!isMountedRef.current) return + setPollErrorMessage( + "Background data refresh failed. The table may not reflect the latest status, you can refresh the page to see the latest data." + ) + }) + }, delay) + pollTimeoutsRef.current.push(id) + }) }, - [queryClient, service, image.repository] + [queryClient, service, image.repository, isMountedRef, setPollErrorMessage] ) const openVulFilter = { @@ -290,7 +342,7 @@ export const ImageIssuesList = ({ ...(remediatedSearchTerm ? { search: [remediatedSearchTerm] } : {}), } - const issuesPromise = fetchImages({ + const activeIssuesPromise = fetchImages({ apiClient, queryClient, filter: { @@ -327,63 +379,51 @@ export const ImageIssuesList = ({ }, }) - useEffect(() => { - if (!vulnerabilitiesSuccessMessage) return - const timer = setTimeout(() => setVulnerabilitiesSuccessMessage(null), SUCCESS_MESSAGE_DURATION_MS) - return () => clearTimeout(timer) - }, [vulnerabilitiesSuccessMessage]) - - const VulnerabilitiesTab = () => { - const handleFalsePositiveSuccess = useCallback( - async (cveNumber: string) => { - await refreshIssuesData(cveNumber) - const text = `Vulnerability ${cveNumber} marked as false positive successfully.` - setVulnerabilitiesSuccessMessage(text) - }, - [refreshIssuesData] - ) - - return ( - - ) - } - - const RemediatedVulnerabilitiesTab = () => { - return ( - - ) - } + const handleFalsePositiveSuccess = useCallback( + async (cveNumber: string) => { + const text = `Vulnerability ${cveNumber} has been marked as a false positive. The status may take up to 5–6 minutes to update in the tables.` + setVulnerabilitiesSuccessMessage(text) + await refreshIssuesData(cveNumber) + }, + [refreshIssuesData] + ) return ( <> + {pollErrorMessage && ( +
+ +
+ )} - + - + diff --git a/apps/heureka/src/components/Service/ImageDetails/index.tsx b/apps/heureka/src/components/Service/ImageDetails/index.tsx index 006d33dae5..180cef74cd 100644 --- a/apps/heureka/src/components/Service/ImageDetails/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/index.tsx @@ -104,22 +104,17 @@ export const ImageDetails = ({ Versions ({versions.length}) -
+ {displayedVersions.map((version) => ( - { - e.preventDefault() - handleVersionClick(version.version) - }} - className="link-hover w-fit" - > - {getShortSha256(version.version)} - + onClick={() => handleVersionClick(version.version)} + /> ))} -
+ {hasMoreVersions && ( { expect(screen.getByText("Some Component")).toBeInTheDocument() }) + it("should wrap children in a div with className when className is provided", () => { + const { container } = render( + + + + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.tagName).toBe("DIV") + expect(wrapper).toHaveClass("my-class") + expect(screen.getByText("Some Component")).toBeInTheDocument() + }) + + it("should render children without a wrapper div when className is not provided", () => { + const { container } = render( + + + + ) + // Children are rendered directly — no extra wrapper div with a class attribute + expect(container.querySelector("div[class]")).toBeNull() + expect(screen.getByText("Some Component")).toBeInTheDocument() + }) + describe("when error occurs", () => { it("should render null when displayErrorMessage is false", () => { const { container } = render( diff --git a/apps/heureka/src/components/common/ErrorBoundary/index.tsx b/apps/heureka/src/components/common/ErrorBoundary/index.tsx index b7e91398e9..cf878860ac 100644 --- a/apps/heureka/src/components/common/ErrorBoundary/index.tsx +++ b/apps/heureka/src/components/common/ErrorBoundary/index.tsx @@ -12,16 +12,18 @@ export const ErrorBoundary = ({ displayErrorMessage, fallbackRender, resetKeys, + className, }: { children: ReactNode displayErrorMessage?: boolean fallbackRender?: (props: FallbackProps) => ReactNode resetKeys?: any + className?: string }) => ( null} > - {children} + {className ?
{children}
: children}
) diff --git a/apps/heureka/src/utils.ts b/apps/heureka/src/utils.ts index 90bc405e1f..6304e88e03 100644 --- a/apps/heureka/src/utils.ts +++ b/apps/heureka/src/utils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useRef, useEffect } from "react" +import { useState, useRef, useEffect, Dispatch, SetStateAction } from "react" import { KnownIcons } from "@cloudoperators/juno-ui-components" export const capitalizeFirstLetter = (str: string): string => { @@ -122,6 +122,34 @@ export function filterSearchParamsByPrefix( return result } +/** + * Like useState, but automatically clears the value to null after `duration` ms. + * An optional `shouldExpire` predicate controls which values auto-clear (defaults to all non-null values). + */ +export function useTimedState( + duration: number, + shouldExpire?: (value: T) => boolean +): [T | null, Dispatch>] { + const [value, setValue] = useState(null) + const shouldExpireRef = useRef(undefined) + + // Keep the ref updated with the latest predicate without tying the timer effect + // to the predicate's identity (which may change across renders). + useEffect(() => { + shouldExpireRef.current = shouldExpire + }, [shouldExpire]) + + useEffect(() => { + if (value === null) return + const predicate = shouldExpireRef.current + if (predicate && !predicate(value)) return + const timer = setTimeout(() => setValue(null), duration) + return () => clearTimeout(timer) + }, [value, duration]) + + return [value, setValue] +} + /** * Extracts the first 7 characters after "sha256:" from a version string. * If the version doesn't match the pattern, returns the original version.