diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx index c815a44fbb4c..63498e38b26f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx @@ -52,6 +52,14 @@ jest.mock('../../common/EntityDescription/Description', () => jest.fn().mockImplementation(() =>
Description
) ); +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + const mockProps = { glossary: mockedGlossaries[0], glossaryTerms: [], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx index 8439fb85c040..0873e26b47a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx @@ -130,6 +130,14 @@ jest.mock( }) ); +jest.mock('../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + const mockProps: GlossaryV1Props = { selectedData: mockedGlossaries[0], isGlossaryActive: true, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx index cbe646a3b3a8..9df044fb1959 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx @@ -21,6 +21,14 @@ jest.mock( }) ); +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + describe('LeftSidebar', () => { it('renders sidebar links correctly', () => { render( diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index 3328bc2aec13..47073e7c9686 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -10,7 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { renderHook } from '@testing-library/react-hooks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; import { Document } from '../generated/entity/docStore/document'; import { PageType } from '../generated/system/ui/page'; import { getDocumentByFQN } from '../rest/DocStoreAPI'; @@ -32,7 +34,16 @@ jest.mock('../rest/DocStoreAPI', () => ({ getDocumentByFQN: jest.fn(), })); +const createWrapper = (queryClient: QueryClient) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return Wrapper; +}; + describe('useCustomPages', () => { + let queryClient: QueryClient; + const mockSelectedPersona = { fullyQualifiedName: 'test-persona', }; @@ -60,6 +71,9 @@ describe('useCustomPages', () => { }; beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); jest.clearAllMocks(); mockUseApplicationStore.mockReturnValue({ selectedPersona: mockSelectedPersona, @@ -69,75 +83,92 @@ describe('useCustomPages', () => { it('should fetch and return customized page and navigation when persona is selected', async () => { mockGetDocumentByFQN.mockResolvedValue(mockDocument); - const { result, waitForNextUpdate } = renderHook(() => - useCustomPages(PageType.Table) - ); - - expect(result.current.isLoading).toBe(true); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); expect(result.current.customizedPage).toEqual(mockPage); expect(result.current.navigation).toEqual(mockNavigation); - expect(result.current.isLoading).toBe(false); }); it('should handle error when fetching document fails', async () => { mockGetDocumentByFQN.mockRejectedValue(new Error('API Error')); - const { result, waitForNextUpdate } = renderHook(() => - useCustomPages(PageType.Table) - ); - - expect(result.current.isLoading).toBe(true); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); expect(result.current.customizedPage).toBeNull(); expect(result.current.navigation).toEqual([]); - expect(result.current.isLoading).toBe(false); }); - it('should not fetch document when no persona is selected', () => { + it('should not fetch document when no persona is selected', async () => { mockUseApplicationStore.mockReturnValue({ selectedPersona: null, }); - const { result } = renderHook(() => useCustomPages(PageType.Table)); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(result.current.customizedPage).toBeNull(); expect(result.current.navigation).toBeNull(); - expect(result.current.isLoading).toBe(false); + + // hasMounted starts false (isLoading = true), flips after the mount effect. + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); }); - it('should refetch document when pageType changes', async () => { - mockGetDocumentByFQN.mockResolvedValue(mockDocument); + it('should filter by pageType from cached doc without re-fetching', async () => { + const mockDocWithMultiplePages: Document = { + ...mockDocument, + data: { + pages: [ + { pageType: PageType.Table, tabs: [] }, + { pageType: PageType.Dashboard, tabs: [] }, + ], + navigation: mockNavigation, + }, + }; + mockGetDocumentByFQN.mockResolvedValue(mockDocWithMultiplePages); - const { rerender, waitForNextUpdate } = renderHook( - ({ pageType }) => useCustomPages(pageType), + const { result, rerender } = renderHook( + ({ pageType }: { pageType: PageType }) => useCustomPages(pageType), { initialProps: { pageType: PageType.Table }, + wrapper: createWrapper(queryClient), } ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.customizedPage?.pageType).toBe(PageType.Table); + }); + // Changing pageType filters locally from the cached doc — no extra network request. expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); rerender({ pageType: PageType.Dashboard }); - await waitForNextUpdate(); - - expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(2); + expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); + expect(result.current.customizedPage?.pageType).toBe(PageType.Dashboard); }); it('should return updated results when selected persona changes', async () => { mockGetDocumentByFQN.mockResolvedValueOnce(mockDocument); - const { result, waitForNextUpdate, rerender } = renderHook( + const { result, rerender } = renderHook( ({ selectedPersona }) => { mockUseApplicationStore.mockReturnValue({ selectedPersona, @@ -149,16 +180,17 @@ describe('useCustomPages', () => { initialProps: { selectedPersona: { fullyQualifiedName: 'test-persona' }, }, + wrapper: createWrapper(queryClient), } ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.customizedPage).toEqual(mockDocument.data.pages[0]); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); - expect(result.current.customizedPage).toEqual(mockDocument.data.pages[0]); expect(result.current.navigation).toEqual(mockDocument.data.navigation); - // Change the selected persona const newPersona = { fullyQualifiedName: 'new-persona' }; mockGetDocumentByFQN.mockResolvedValueOnce({ entityType: 'PERSONA', @@ -172,13 +204,14 @@ describe('useCustomPages', () => { rerender({ selectedPersona: newPersona }); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.customizedPage).toEqual({ + pageType: PageType.Table, + content: 'New Content', + }); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.new-persona'); - expect(result.current.customizedPage).toEqual({ - pageType: PageType.Table, - content: 'New Content', - }); expect(result.current.navigation).toEqual([{ name: 'New Navigation' }]); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 97324011fa38..9ac91b98fc88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -10,48 +10,52 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; -import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; -import { EntityType } from '../enums/entity.enum'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { Page, PageType } from '../generated/system/ui/page'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; -import { getDocumentByFQN } from '../rest/DocStoreAPI'; +import { + docStoreQueryFn, + docStoreQueryKey, + personaDocFqn, + PERSONA_DOC_STALE_TIME, +} from '../rest/queries/docStoreQuery'; import { useApplicationStore } from './useApplicationStore'; export const useCustomPages = (pageType: PageType | 'Navigation') => { const { selectedPersona } = useApplicationStore(); - const [customizedPage, setCustomizedPage] = useState(null); - const [navigation, setNavigation] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const fqn = personaDocFqn(selectedPersona); - const fetchDocument = useCallback(async () => { - const pageFQN = `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona?.fullyQualifiedName}`; - try { - const doc = await getDocumentByFQN(pageFQN); - setCustomizedPage( - doc.data?.pages?.find((p: Page | null) => p?.pageType === pageType) - ); - setNavigation(doc.data?.navigation); - } catch (error) { - // Need to reset Navigation to avoid showing old navigation items - setNavigation([]); - setCustomizedPage(null); - } finally { - setIsLoading(false); - } - }, [selectedPersona?.fullyQualifiedName, pageType]); + const { data: doc, isError } = useQuery({ + queryKey: docStoreQueryKey(fqn ?? ''), + queryFn: docStoreQueryFn(fqn ?? ''), + enabled: !!fqn, + retry: false, + staleTime: PERSONA_DOC_STALE_TIME, + }); + // hasMounted flips once after the first paint so entity pages always show + // their loader on first render — identical to the old useState(true) pattern. + // Without this, selectedPersona arrives asynchronously after first render, + // causing isLoading to flip false→true→false in a window where + // waitForAllLoadersToDisappear may have already returned. + const [hasMounted, setHasMounted] = useState(false); useEffect(() => { - if (selectedPersona?.fullyQualifiedName) { - fetchDocument(); - } else { - setIsLoading(false); - } - }, [selectedPersona, pageType]); + setHasMounted(true); + }, []); return { - customizedPage, - navigation, - isLoading, + customizedPage: + (doc?.data?.pages?.find((p: Page | null) => p?.pageType === pageType) as + | Page + | undefined) ?? null, + // Reset to [] on error to clear stale navigation items, null when no persona selected. + navigation: isError + ? ([] as NavigationItem[]) + : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), + // Only block render on the very first paint. The persona doc fetches in the + // background after that; customizedPage/navigation update when it arrives + // without re-showing a loader (matches the old fetchDocument behaviour). + isLoading: !hasMounted, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index 95d7d0cf995f..ae94358ae15a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useQuery } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isEmpty } from 'lodash'; @@ -25,7 +26,6 @@ import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComp import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { LOGGED_IN_USER_STORAGE_KEY } from '../../constants/constants'; import { LandingPageWidgetKeys } from '../../enums/CustomizablePage.enum'; -import { EntityType } from '../../enums/entity.enum'; import type { Page } from '../../generated/system/ui/page'; import { PageType } from '../../generated/system/ui/page'; import type { PersonaPreferences } from '../../generated/type/personaPreferences'; @@ -37,7 +37,12 @@ import { AnnouncementEntity, getActiveAnnouncements, } from '../../rest/announcementsAPI'; -import { getDocumentByFQN } from '../../rest/DocStoreAPI'; +import { + docStoreQueryFn, + docStoreQueryKey, + personaDocFqn, + PERSONA_DOC_STALE_TIME, +} from '../../rest/queries/docStoreQuery'; import { updateUserDetail } from '../../rest/userAPI'; import { getConstrainedWidgetWidth } from '../../utils/CustomizableLandingPagePureUtils'; import customizeMyDataPageClassBase from '../../utils/CustomizeMyDataPageClassBase'; @@ -75,18 +80,59 @@ const MyDataPage = () => { useApplicationStore(); const { isWelcomeVisible } = useWelcomeStore(); - const [isLoading, setIsLoading] = useState(true); - const [layout, setLayout] = useState>( - getDefaultLandingPageLayout - ); - const [showWelcomeScreen, setShowWelcomeScreen] = useState(false); const [isAnnouncementLoading, setIsAnnouncementLoading] = useState(true); const [announcements, setAnnouncements] = useState([]); - const [personaPreferences, setPersonaPreferences] = useState< - PersonaPreferences[] - >([]); + + const personaFqn = personaDocFqn(selectedPersona); + + const { data: docData, isPending: isDocPending } = useQuery({ + queryKey: docStoreQueryKey(personaFqn ?? ''), + queryFn: docStoreQueryFn(personaFqn ?? ''), + enabled: !!personaFqn, + retry: false, + staleTime: PERSONA_DOC_STALE_TIME, + }); + + // hasMounted flips once after the first paint so the skeleton always shows on + // first render, deferring widget loaders until after that paint. Without this + // guard a user with no persona gets isLoading=false immediately, exposing + // widget loaders to Playwright's waitForAllLoadersToDisappear too early. + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => { + setHasMounted(true); + }, []); + const isLoading = !hasMounted || (!!personaFqn && isDocPending); + + const personaPreferences = useMemo( + () => docData?.data?.personPreferences ?? [], + [docData] + ); + + const layout = useMemo>(() => { + if (!docData || !selectedPersona) { + return getDefaultLandingPageLayout(); + } + const pageData = docData.data?.pages?.find( + (p: Page) => p.pageType === PageType.LandingPage + ) ?? { layout: [], pageType: PageType.LandingPage }; + const filteredLayout = (pageData.layout as WidgetConfig[]) + .filter( + (widget: WidgetConfig) => + !widget.i.startsWith(LandingPageWidgetKeys.CURATED_ASSETS) || + !isEmpty(widget.config) + ) + .map((widget: WidgetConfig) => ({ + ...widget, + w: getConstrainedWidgetWidth(widget.w), + h: 3, + })); + + return isEmpty(filteredLayout) + ? getDefaultLandingPageLayout() + : filteredLayout; + }, [docData, selectedPersona]); const storageData = useMemo( () => localStorage.getItem(LOGGED_IN_USER_STORAGE_KEY), [] @@ -118,49 +164,6 @@ const MyDataPage = () => { return userPersonaBackgroundColor ?? adminPersonaBackgroundColor; }, [userPersonaBackgroundColor, adminPersonaBackgroundColor]); - const fetchDocument = async () => { - setIsLoading(true); - - try { - if (selectedPersona) { - const pageFQN = `${EntityType.PERSONA}.${selectedPersona.fullyQualifiedName}`; - const docData = await getDocumentByFQN(pageFQN); - - setPersonaPreferences(docData.data?.personPreferences ?? []); - - const pageData = docData.data?.pages?.find( - (p: Page) => p.pageType === PageType.LandingPage - ) ?? { layout: [], pageType: PageType.LandingPage }; - - const filteredLayout = pageData.layout - .filter( - (widget: WidgetConfig) => - !widget.i.startsWith(LandingPageWidgetKeys.CURATED_ASSETS) || - !isEmpty(widget.config) - ) - .map((widget: WidgetConfig) => { - return { - ...widget, - w: getConstrainedWidgetWidth(widget.w), - h: 3, - }; - }); - - setLayout( - isEmpty(filteredLayout) - ? getDefaultLandingPageLayout() - : filteredLayout - ); - } else { - setLayout(getDefaultLandingPageLayout()); - } - } catch { - setLayout(getDefaultLandingPageLayout()); - } finally { - setIsLoading(false); - } - }; - const updateWelcomeScreen = (show: boolean) => { if (loggedInUserName) { const arr = storageData ? storageData.split(',') : []; @@ -172,10 +175,6 @@ const MyDataPage = () => { setShowWelcomeScreen(show); }; - useEffect(() => { - fetchDocument(); - }, [selectedPersona]); - useEffect(() => { updateWelcomeScreen(!usernameExistsInCookie && isWelcomeVisible); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx index 410f13e0d4bb..656ec80e2c36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PageType } from '../../generated/system/ui/page'; @@ -213,8 +214,20 @@ jest.mock( }) ); +let queryClient: QueryClient; + +const renderMyDataPage = () => + render( + + + + ); + describe('MyDataPage component', () => { beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); localStorage.setItem('loggedInUsers', mockUserData.name); mockSelectedPersona = { fullyQualifiedName: mockPersonaName, @@ -227,7 +240,7 @@ describe('MyDataPage component', () => { // Simulate no user is logged in condition localStorage.clear(); - render(); + renderMyDataPage(); expect(await screen.findByText('WelcomeScreen')).toBeInTheDocument(); }); @@ -236,7 +249,7 @@ describe('MyDataPage component', () => { // Simulate no user is logged in condition localStorage.clear(); - render(); + renderMyDataPage(); const welcomeScreen = await screen.findByText('WelcomeScreen'); @@ -249,7 +262,7 @@ describe('MyDataPage component', () => { }); it('MyDataPage should display skeleton while resolving the landing page layout', async () => { - render(); + renderMyDataPage(); expect(screen.getByText('MyDataPageSkeleton')).toBeInTheDocument(); expect(screen.queryByTestId('react-grid-layout')).not.toBeInTheDocument(); @@ -260,7 +273,7 @@ describe('MyDataPage component', () => { }); it('MyDataPage should render CustomiseLandingPageHeader component', async () => { - render(); + renderMyDataPage(); expect( screen.getByTestId('customise-landing-page-header') @@ -269,7 +282,7 @@ describe('MyDataPage component', () => { }); it('MyDataPage should display all the widgets in the config and the announcements widget if there are announcements', async () => { - render(); + renderMyDataPage(); expect( await screen.findByText('KnowledgePanel.ActivityFeed') @@ -295,7 +308,7 @@ describe('MyDataPage component', () => { data: [], }) ); - render(); + renderMyDataPage(); expect( await screen.findByText('KnowledgePanel.ActivityFeed') @@ -318,7 +331,7 @@ describe('MyDataPage component', () => { (getDocumentByFQN as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('API failure')) ); - render(); + renderMyDataPage(); expect( await screen.findByText('KnowledgePanel.ActivityFeed') @@ -344,7 +357,7 @@ describe('MyDataPage component', () => { it('MyDataPage should render default widgets when there is no selected persona', async () => { mockSelectedPersona = null; await act(async () => { - render(); + renderMyDataPage(); }); await screen.findByTestId('page-layout-v1'); @@ -367,7 +380,7 @@ describe('MyDataPage component', () => { describe('Component Structure', () => { it('should render the correct page structure with grid wrapper', async () => { await act(async () => { - render(); + renderMyDataPage(); }); expect(screen.getByTestId('page-layout-v1')).toBeInTheDocument(); @@ -380,7 +393,7 @@ describe('MyDataPage component', () => { it('should render CustomiseLandingPageHeader before the grid layout', async () => { await act(async () => { - render(); + renderMyDataPage(); }); const pageLayout = screen.getByTestId('page-layout-v1'); @@ -396,7 +409,7 @@ describe('MyDataPage component', () => { // Simulate no user is logged in condition localStorage.clear(); await act(async () => { - render(); + renderMyDataPage(); }); expect(screen.getByText('WelcomeScreen')).toBeInTheDocument(); @@ -407,7 +420,7 @@ describe('MyDataPage component', () => { it('should render the main content structure when not loading or showing welcome screen', async () => { await act(async () => { - render(); + renderMyDataPage(); }); // Verify main content elements are present diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts new file mode 100644 index 000000000000..48a1ef3d63f0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; +import { EntityType } from '../../enums/entity.enum'; +import { Document } from '../../generated/entity/docStore/document'; +import { getDocumentByFQN } from '../DocStoreAPI'; + +/** + * Shared query plumbing for a single DocStore document by FQN. Any consumer + * that wants a cache-aware read — page customisation hooks, sidebar navigation, + * the My Data landing page — should go through {@link docStoreQueryKey} + + * {@link docStoreQueryFn} so they all hit the same normalised cache slot. + * + * React Query deduplicates in-flight requests: if multiple components mount + * simultaneously with the same FQN key (e.g. the sidebar navigation hook and + * the My Data page both reading `persona.X`), only one network request fires + * and both subscribers receive the result. + * + * {@link PERSONA_DOC_STALE_TIME} extends deduplication beyond concurrent + * mounts: staggered subscribers (sidebar renders a tick before page body) + * reuse the cached document instead of triggering a background refetch. + * Matches the staleTime used in useResolvedAppMode for the same endpoint. + */ +export const PERSONA_DOC_STALE_TIME = 5 * 60 * 1000; + +export const docStoreQueryKey = (fqn: string) => ['docStore', fqn] as const; + +export const docStoreQueryFn = (fqn: string) => (): Promise => + getDocumentByFQN(fqn); + +/** + * Derive the docStore FQN for a persona's UICustomization document. + * Returns null when the persona has no FQN (disabled query guard). + */ +export const personaDocFqn = ( + persona?: { + fullyQualifiedName?: string; + } | null +): string | null => + persona?.fullyQualifiedName + ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${persona.fullyQualifiedName}` + : null;