diff --git a/src/components/HomepageRedirect.tsx b/src/components/HomepageRedirect.tsx index 83310120e..66df46578 100644 --- a/src/components/HomepageRedirect.tsx +++ b/src/components/HomepageRedirect.tsx @@ -17,8 +17,8 @@ import { UUID_REGEX } from "./dashboardViewConstants"; -const SingleView = React.lazy( - () => import("../pages/views/components/SingleView") +const ViewContainer = React.lazy( + () => import("../pages/views/components/ViewContainer") ); async function resolveViewId(value: string): Promise { @@ -59,7 +59,7 @@ export function HomepageRedirect() { if (resolvedViewId) { return ( }> - + ); } diff --git a/src/components/__tests__/HomepageRedirect.unit.test.tsx b/src/components/__tests__/HomepageRedirect.unit.test.tsx index d8edd3282..e6006faff 100644 --- a/src/components/__tests__/HomepageRedirect.unit.test.tsx +++ b/src/components/__tests__/HomepageRedirect.unit.test.tsx @@ -20,7 +20,7 @@ jest.mock("../../api/services/views", () => ({ getViewIdByNamespaceAndName: jest.fn() })); -jest.mock("../../pages/views/components/SingleView", () => ({ +jest.mock("../../pages/views/components/ViewContainer", () => ({ __esModule: true, default: ({ id }: { id: string }) => (
diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index 26bded675..5a11ecf91 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -486,8 +486,23 @@ export interface ViewResult { panels?: PanelResult[]; columnOptions?: Record; variables?: ViewVariable[]; + + /** + * Card display configuration for tabular data. + * + * This controls presentation (e.g. card layout/default mode) and does not + * contain primary result data by itself. + */ card?: DisplayCard; + + /** + * Table display configuration (e.g. default sort and page size). + * + * This controls presentation/query defaults and does not contain primary + * result data by itself. + */ table?: DisplayTable; + requestFingerprint: string; sections?: ViewSection[]; } diff --git a/src/pages/config/details/ConfigDetailsViewPage.tsx b/src/pages/config/details/ConfigDetailsViewPage.tsx index 141708f45..ca5231710 100644 --- a/src/pages/config/details/ConfigDetailsViewPage.tsx +++ b/src/pages/config/details/ConfigDetailsViewPage.tsx @@ -2,7 +2,7 @@ import { ConfigDetailsTabs } from "@flanksource-ui/components/Configs/ConfigDeta import { useParams } from "react-router-dom"; import { Loading } from "@flanksource-ui/ui/Loading"; import { useViewData } from "@flanksource-ui/pages/views/hooks/useViewData"; -import ViewWithSections from "@flanksource-ui/pages/views/components/ViewWithSections"; +import ViewContent from "@flanksource-ui/pages/views/components/ViewContent"; import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; export function ConfigDetailsViewPage() { @@ -17,6 +17,7 @@ export function ConfigDetailsViewPage() { error, aggregatedVariables, currentVariables, + sectionData, handleForceRefresh } = useViewData({ viewId: viewId!, @@ -42,8 +43,9 @@ export function ConfigDetailsViewPage() {
) : viewResult ? ( - import("./components/SingleView")); +const ViewContainer = React.lazy(() => import("./components/ViewContainer")); /** * ViewPage supports the following routes: @@ -102,7 +102,7 @@ export function ViewPage() { } > - + ); } diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/ViewContainer.tsx similarity index 93% rename from src/pages/views/components/SingleView.tsx rename to src/pages/views/components/ViewContainer.tsx index a9a122655..54b987618 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/ViewContainer.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import Age from "../../../ui/Age/Age"; import ViewLayout from "./ViewLayout"; -import ViewWithSections from "./ViewWithSections"; +import ViewContent from "./ViewContent"; import { useViewData } from "../hooks/useViewData"; import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; import { @@ -12,11 +12,11 @@ import { DialogTitle } from "@flanksource-ui/components/ui/dialog"; -interface SingleViewProps { +interface ViewContainerProps { id: string; } -const SingleView: React.FC = ({ id }) => { +const ViewContainer: React.FC = ({ id }) => { const { viewResult, isLoading, @@ -24,6 +24,7 @@ const SingleView: React.FC = ({ id }) => { error, aggregatedVariables, currentVariables, + sectionData, handleForceRefresh } = useViewData({ viewId: id }); const [refreshErrorOpen, setRefreshErrorOpen] = useState(false); @@ -115,9 +116,10 @@ const SingleView: React.FC = ({ id }) => { ) } > - @@ -126,4 +128,4 @@ const SingleView: React.FC = ({ id }) => { ); }; -export default SingleView; +export default ViewContainer; diff --git a/src/pages/views/components/ViewWithSections.tsx b/src/pages/views/components/ViewContent.tsx similarity index 59% rename from src/pages/views/components/ViewWithSections.tsx rename to src/pages/views/components/ViewContent.tsx index 05caee808..54408e89a 100644 --- a/src/pages/views/components/ViewWithSections.tsx +++ b/src/pages/views/components/ViewContent.tsx @@ -2,40 +2,37 @@ import React from "react"; import ViewSection from "./ViewSection"; import GlobalFiltersForm from "../../audit-report/components/View/GlobalFiltersForm"; import GlobalFilters from "../../audit-report/components/View/GlobalFilters"; +import View from "../../audit-report/components/View/View"; import { VIEW_VAR_PREFIX } from "../constants"; import type { ViewResult, ViewVariable } from "../../audit-report/types"; +import type { SectionDataEntry } from "../hooks/useAggregatedViewVariables"; -interface ViewWithSectionsProps { +interface ViewContentProps { className?: string; viewResult: ViewResult; + sectionData?: Map; aggregatedVariables?: ViewVariable[]; currentVariables?: Record; hideVariables?: boolean; } -const ViewWithSections: React.FC = React.memo( +const ViewContent: React.FC = React.memo( ({ viewResult, className, + sectionData, aggregatedVariables, currentVariables, hideVariables }) => { - const { namespace, name, panels, columns } = viewResult; + const { panels, columns } = viewResult; - const isAggregatorView = - viewResult.sections && - viewResult.sections.length > 0 && - !panels && - !columns; + const hasPrimaryContent = + (Array.isArray(panels) && panels.length > 0) || + (Array.isArray(columns) && columns.length > 0); - const primaryViewSection = { - title: viewResult.title || name, - viewRef: { - namespace: namespace || "", - name: name - } - }; + const isAggregatorView = + Boolean(viewResult.sections?.length) && !hasPrimaryContent; const showVariables = !hideVariables && aggregatedVariables && aggregatedVariables.length > 0; @@ -56,12 +53,19 @@ const ViewWithSections: React.FC = React.memo( {!isAggregatorView && (
-
)} @@ -69,7 +73,6 @@ const ViewWithSections: React.FC = React.memo( {viewResult?.sections && viewResult.sections.length > 0 && ( <> {viewResult.sections.map((section, index) => { - // Generate a unique key based on section type const baseKey = section.viewRef ? `${section.viewRef.namespace || "default"}:${section.viewRef.name}` : section.uiRef?.changes @@ -78,14 +81,21 @@ const ViewWithSections: React.FC = React.memo( ? `configs:${section.title}` : `section:${section.title}`; const sectionKey = `${baseKey}:${index}`; + const viewRefKey = section.viewRef + ? `${section.viewRef.namespace || ""}:${section.viewRef.name}` + : undefined; + const sectionEntry = viewRefKey + ? sectionData?.get(viewRefKey) + : undefined; return (
); @@ -97,4 +107,4 @@ const ViewWithSections: React.FC = React.memo( } ); -export default ViewWithSections; +export default ViewContent; diff --git a/src/pages/views/components/ViewSection.tsx b/src/pages/views/components/ViewSection.tsx index c98d55c2d..28478d603 100644 --- a/src/pages/views/components/ViewSection.tsx +++ b/src/pages/views/components/ViewSection.tsx @@ -1,12 +1,8 @@ import React, { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; import { IoChevronDownOutline } from "react-icons/io5"; -import { getViewDataByNamespace } from "../../../api/services/views"; import View from "../../audit-report/components/View/View"; import { Icon } from "../../../ui/Icons/Icon"; -import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; -import { VIEW_VAR_PREFIX } from "../constants"; -import { ViewSection as Section } from "../../audit-report/types"; +import { ViewSection as Section, ViewResult } from "../../audit-report/types"; import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; import ChangesUISection from "./ChangesUISection"; import ConfigsUISection from "./ConfigsUISection"; @@ -15,52 +11,19 @@ const toSafeId = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "-"); interface ViewSectionProps { section: Section; - hideVariables?: boolean; + viewData?: ViewResult; + isLoading?: boolean; + error?: unknown; variables?: Record; - sectionKeySuffix?: string; } const ViewSection: React.FC = React.memo( - ({ - section, - hideVariables, - variables: defaultVariables, - sectionKeySuffix - }) => { + ({ section, viewData, isLoading, error, variables }) => { const [isExpanded, setIsExpanded] = useState(true); - // Determine if this is a native UI section or a view reference section const isUIRefSection = !!section.uiRef; const isViewRefSection = !!section.viewRef; - // Use prefixed search params for view variables - const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); - const paramVariables = useMemo( - () => Object.fromEntries(viewVarParams.entries()), - [viewVarParams] - ); - const currentViewVariables = useMemo( - () => ({ ...(defaultVariables ?? {}), ...paramVariables }), - [defaultVariables, paramVariables] - ); - - // Extract namespace and name for view reference sections - const namespace = section.viewRef?.namespace || "default"; - const name = section.viewRef?.name ?? ""; - - // Fetch section view data - only enabled for viewRef sections - const { - data: sectionViewResult, - isLoading, - error - } = useQuery({ - queryKey: ["view-result", namespace, name, currentViewVariables], - queryFn: () => - getViewDataByNamespace(namespace, name, currentViewVariables), - enabled: isViewRefSection && !!name, - staleTime: 5 * 60 * 1000 - }); - const handleHeaderKeyDown = ( event: React.KeyboardEvent ) => { @@ -70,30 +33,21 @@ const ViewSection: React.FC = React.memo( } }; - // TODO: see if safeTitle is only needed for the prefix. - // If yes then remove this and use something simpler for the prefix. const safeTitle = useMemo(() => toSafeId(section.title), [section.title]); - const sectionIdSuffix = useMemo( - () => (sectionKeySuffix ? `-${toSafeId(sectionKeySuffix)}` : ""), - [sectionKeySuffix] - ); - - // Determine the section ID for accessibility const sectionId = useMemo(() => { if (section.viewRef) { - return `section-${namespace}-${section.viewRef.name}${sectionIdSuffix}`; + return `section-${section.viewRef.namespace || "default"}-${section.viewRef.name}`; } if (section.uiRef?.changes) { - return `section-changes-${safeTitle}${sectionIdSuffix}`; + return `section-changes-${safeTitle}`; } if (section.uiRef?.configs) { - return `section-configs-${safeTitle}${sectionIdSuffix}`; + return `section-configs-${safeTitle}`; } - return `section-${safeTitle}${sectionIdSuffix}`; - }, [namespace, safeTitle, section, sectionIdSuffix]); + return `section-${safeTitle}`; + }, [safeTitle, section.uiRef, section.viewRef]); - // Render section header const renderHeader = () => (
= React.memo(
); - // Render native UI section (Changes or Configs) if (isUIRefSection) { return ( <> @@ -142,7 +95,6 @@ const ViewSection: React.FC = React.memo( ); } - // Render invalid section error if (!isViewRefSection) { return ( = React.memo( ); } - // Render view reference section - error state - if (!sectionViewResult && (error || !isLoading)) { - return ( - <> - {renderHeader()} - {isExpanded && ( -
- -
- )} - - ); - } - - // Render view reference section - success state return ( <> {renderHeader()} {isExpanded && (
- {isLoading ? ( + {isLoading && !viewData ? (
+ ) : error ? ( + + ) : !viewData ? ( + ) : ( )}
diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts index b5fa3dd5b..fe4cd651c 100644 --- a/src/pages/views/hooks/useAggregatedViewVariables.ts +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -4,9 +4,18 @@ import { getViewDataByNamespace } from "../../../api/services/views"; import { aggregateVariables } from "../utils/aggregateVariables"; import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; import { VIEW_VAR_PREFIX } from "../constants"; -import type { ViewRef } from "../../audit-report/types"; +import type { ViewRef, ViewResult } from "../../audit-report/types"; -export function useAggregatedViewVariables(sections: ViewRef[]) { +export interface SectionDataEntry { + data?: ViewResult; + isLoading: boolean; + error?: unknown; +} + +export function useAggregatedViewVariables( + sections: ViewRef[], + baseVariables?: Record +) { const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); const viewVarParamsString = useMemo( () => viewVarParams.toString(), @@ -21,25 +30,39 @@ export function useAggregatedViewVariables(sections: ViewRef[]) { [viewVarParamsString] ); - // Use a stable key for the current variables to avoid needless refetches - const currentVariablesKey = viewVarParamsString; + const effectiveVariables = useMemo( + () => ({ ...(baseVariables ?? {}), ...currentVariables }), + [baseVariables, currentVariables] + ); + + const effectiveVariablesKey = useMemo( + () => + Object.entries(effectiveVariables) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join("&"), + [effectiveVariables] + ); // Fetch all sections in parallel const queries = useQueries({ queries: sections.map((section) => ({ queryKey: [ - "view-variables", + "view-section-result", section.namespace, section.name, - currentVariablesKey + effectiveVariablesKey ], queryFn: () => getViewDataByNamespace( - section.namespace || "", + section.namespace || "default", section.name, - currentVariables + effectiveVariables ), - enabled: !!section.namespace && !!section.name, + enabled: !!section.name, staleTime: 5 * 60 * 1000, keepPreviousData: true })) @@ -49,14 +72,37 @@ export function useAggregatedViewVariables(sections: ViewRef[]) { // eslint-disable-next-line react-hooks/exhaustive-deps const dataKey = queries.map((q) => q.dataUpdatedAt).join(","); const aggregatedVariables = useMemo(() => { - const variableArrays = queries.map((q) => q.data?.variables); + const variableArrays = queries.map( + (q) => (q.data as ViewResult | undefined)?.variables + ); return aggregateVariables(variableArrays); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataKey]); + const sectionData = useMemo(() => { + const sectionDataMap = new Map(); + + sections.forEach((section, index) => { + const query = queries[index]; + + if (!query) { + return; + } + + sectionDataMap.set(`${section.namespace ?? ""}:${section.name}`, { + data: query.data as ViewResult | undefined, + isLoading: query.isLoading, + error: query.error ?? undefined + }); + }); + + return sectionDataMap; + }, [queries, sections]); + return { variables: aggregatedVariables, isLoading, - currentVariables + currentVariables, + sectionData }; } diff --git a/src/pages/views/hooks/useViewData.ts b/src/pages/views/hooks/useViewData.ts index ee74f649a..c79aa223e 100644 --- a/src/pages/views/hooks/useViewData.ts +++ b/src/pages/views/hooks/useViewData.ts @@ -4,7 +4,10 @@ import { getViewDataById, getViewDisplayPluginVariables } from "../../../api/services/views"; -import { useAggregatedViewVariables } from "./useAggregatedViewVariables"; +import { + useAggregatedViewVariables, + type SectionDataEntry +} from "./useAggregatedViewVariables"; import { toastError } from "../../../components/Toast/toast"; import type { ViewRef, @@ -26,9 +29,40 @@ export interface UseViewDataResult { error: unknown; aggregatedVariables: ViewVariable[]; currentVariables: Record; + sectionData: Map; handleForceRefresh: () => Promise; } +/** + * Fetches and manages all data needed to render a view. + * + * Supports two modes: + * + * **Standard mode** (`viewId` only): Fetches the top-level view by ID, then + * fetches all of its sections (viewRef-based) in parallel via + * `useAggregatedViewVariables`. URL search params prefixed with the view + * variable prefix are read and forwarded to every request as query-time + * variable overrides. + * + * **Display-plugin mode** (`viewId` + `configId`): Used when a view is + * embedded inside a config detail tab. A preliminary request resolves the + * config-specific variables (e.g. `{{ .config.id }}`), which are then + * forwarded to the view fetch and to section fetches. URL search params are + * ignored in this mode — the config variables are the source of truth. + * `aggregatedVariables` is intentionally emptied in this mode because the + * global variable filter UI should not be shown inside an embedded tab. + * + * **Fetching strategy** — to avoid duplicate network requests, section data is + * fetched exactly once inside `useAggregatedViewVariables` (which needs the + * responses for variable aggregation anyway) and the results are surfaced here + * as `sectionData`. `ViewSection` components receive that data as props rather + * than issuing their own queries. + * + * **Force-refresh** — `handleForceRefresh` re-fetches the top-level view with + * a `cache-control: max-age=1` header to bypass server-side caching, then + * invalidates the React Query cache for all related section queries so they + * are re-fetched on the next render. + */ export function useViewData({ viewId, configId @@ -97,8 +131,12 @@ export function useViewData({ const { variables: aggregatedVariables, - currentVariables: aggregatedCurrentVariables - } = useAggregatedViewVariables(allSectionRefs); + currentVariables: aggregatedCurrentVariables, + sectionData + } = useAggregatedViewVariables( + allSectionRefs, + isDisplayPluginMode ? variables : undefined + ); const currentVariables = isDisplayPluginMode ? (variables ?? {}) @@ -177,6 +215,7 @@ export function useViewData({ ? EMPTY_VARIABLES : aggregatedVariables, currentVariables, + sectionData, handleForceRefresh }; }