diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 8b2b3333e25..e177fcf9fa2 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -17,7 +17,6 @@ import { contextSrv } from '../../core/services/context_srv'; import { ManagerKind } from '../apiserver/types'; import { TemplateDashboardModal } from '../dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal'; import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; -import { FolderReadme } from '../provisioning/components/Folders/FolderReadme'; import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner'; import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; import { useSearchStateManager } from '../search/state/SearchStateManager'; @@ -180,7 +179,6 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record - {folderUID && } {/* only show recently viewed dashboards when in root */} {!folderUID && }
diff --git a/public/app/features/browse-dashboards/BrowseFolderReadmePage.tsx b/public/app/features/browse-dashboards/BrowseFolderReadmePage.tsx new file mode 100644 index 00000000000..77ede138f1e --- /dev/null +++ b/public/app/features/browse-dashboards/BrowseFolderReadmePage.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { useParams } from 'react-router-dom-v5-compat'; + +import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks'; +import { Page } from 'app/core/components/Page/Page'; + +import { GrafanaRouteComponentProps } from '../../core/navigation/types'; +import { ManagerKind } from '../apiserver/types'; +import { FolderActionsButton } from '../browse-dashboards/components/FolderActionsButton'; +import { buildNavModel, getReadmeTabID } from '../folders/state/navModel'; +import { FolderReadmeContent } from '../provisioning/components/Folders/FolderReadmeContent'; +import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; + +export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} + +export function BrowseFolderReadmePage() { + const { uid: folderUID = '' } = useParams(); + const { data: folderDTO } = useGetFolderQueryFacade(folderUID); + const [saveFolder] = useUpdateFolder(); + const { repoType, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUID }); + + const navModel = useMemo(() => { + if (!folderDTO) { + return undefined; + } + const model = buildNavModel(folderDTO); + + // Set the "README" tab to active + const readmeTabID = getReadmeTabID(folderDTO.uid); + const readmeTab = model.children?.find((child) => child.id === readmeTabID); + if (readmeTab) { + readmeTab.active = true; + } + return model; + }, [folderDTO]); + + const isProvisionedFolder = folderDTO?.managedBy === ManagerKind.Repo; + + const onEditTitle = + folderUID && !isProvisionedFolder + ? async (newValue: string) => { + if (folderDTO) { + const result = await saveFolder({ + ...folderDTO, + title: newValue, + }); + if ('error' in result) { + throw result.error; + } + } + } + : undefined; + + return ( + {folderDTO && }} + > + + + + + ); +} + +export default BrowseFolderReadmePage; diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 1a4dffef993..85988a8c992 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -9,6 +9,7 @@ import { FolderDTO, FolderParent } from 'app/types/folders'; export const FOLDER_ID = 'manage-folder'; +export const getReadmeTabID = (folderUID: string) => `folder-readme-${folderUID}`; export const getDashboardsTabID = (folderUID: string) => `folder-dashboards-${folderUID}`; export const getLibraryPanelsTabID = (folderUID: string) => `folder-library-panels-${folderUID}`; export const getAlertingTabID = (folderUID: string) => `folder-alerting-${folderUID}`; @@ -19,21 +20,35 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol const parents = parentsArg ?? ('parents' in folder ? folder.parents : undefined); const isProvisioned = 'managedBy' in folder ? folder.managedBy === ManagerKind.Repo : false; + const children: NavModelItem[] = []; + + // Add README tab first for provisioned folders + if (isProvisioned && config.featureToggles.provisioning) { + children.push({ + active: false, + icon: 'document-info', + id: getReadmeTabID(folder.uid), + text: t('browse-dashboards.manage-folder-nav.readme', 'README'), + url: `${folder.url}/readme`, + }); + } + + // Dashboards tab + children.push({ + active: false, + icon: 'apps', + id: getDashboardsTabID(folder.uid), + text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'), + url: folder.url, + }); + const model: NavModelItem = { icon: 'folder', id: FOLDER_ID, subTitle: getNavSubTitle('manage-folder'), url: folder.url, text: folder.title, - children: [ - { - active: false, - icon: 'apps', - id: getDashboardsTabID(folder.uid), - text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'), - url: folder.url, - }, - ], + children, }; if (parents && parents.length > 0) { diff --git a/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx b/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx index 7847aaf06a4..8971381642b 100644 --- a/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx +++ b/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx @@ -1,22 +1,12 @@ import { render, screen } from '@testing-library/react'; -import { config } from '@grafana/runtime'; import { useGetRepositoryFilesWithPathQuery, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView'; -import { FolderReadme } from './FolderReadme'; +import { FolderReadmeContent } from './FolderReadme'; // Mock dependencies -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - config: { - featureToggles: { - provisioning: true, - }, - }, -})); - jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useGetRepositoryFilesWithPathQuery: jest.fn(), })); @@ -52,31 +42,13 @@ const mockFolder = { status: {}, }; -describe('FolderReadme', () => { +describe('FolderReadmeContent', () => { beforeEach(() => { jest.clearAllMocks(); - (config.featureToggles as Record).provisioning = true; - }); - - describe('when provisioning is disabled', () => { - it('should not render anything', () => { - (config.featureToggles as Record).provisioning = false; - - mockUseGetResourceRepositoryView.mockReturnValue({ - repository: mockRepository, - folder: mockFolder, - isLoading: false, - isInstanceManaged: false, - isReadOnlyRepo: false, - }); - - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); }); describe('when folder is not provisioned', () => { - it('should not render anything when repository is undefined', () => { + it('should show not provisioned message when repository is undefined', () => { mockUseGetResourceRepositoryView.mockReturnValue({ repository: undefined, folder: undefined, @@ -85,26 +57,21 @@ describe('FolderReadme', () => { isReadOnlyRepo: false, }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('should not render anything when folderUID is undefined', () => { - mockUseGetResourceRepositoryView.mockReturnValue({ - repository: mockRepository, - folder: mockFolder, + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: undefined, isLoading: false, - isInstanceManaged: false, - isReadOnlyRepo: false, + isError: false, + error: undefined, + refetch: jest.fn(), }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByText(/not managed by a Git repository/i)).toBeInTheDocument(); }); }); describe('when loading', () => { - it('should not render anything while loading repository info', () => { + it('should show loading spinner while loading repository info', () => { mockUseGetResourceRepositoryView.mockReturnValue({ repository: mockRepository, folder: mockFolder, @@ -113,8 +80,8 @@ describe('FolderReadme', () => { isReadOnlyRepo: false, }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByTestId('Spinner')).toBeInTheDocument(); }); it('should show loading spinner while fetching README', () => { @@ -134,14 +101,13 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - render(); - // Check for spinner by looking for the role or a common class + render(); expect(screen.getByTestId('Spinner')).toBeInTheDocument(); }); }); describe('when README fetch fails', () => { - it('should not render anything on error', () => { + it('should show not found message on error', () => { mockUseGetResourceRepositoryView.mockReturnValue({ repository: mockRepository, folder: mockFolder, @@ -158,11 +124,11 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument(); }); - it('should not render anything when file data is empty', () => { + it('should show not found message when file data is empty', () => { mockUseGetResourceRepositoryView.mockReturnValue({ repository: mockRepository, folder: mockFolder, @@ -179,8 +145,8 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument(); }); }); @@ -208,7 +174,7 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - render(); + render(); // The markdown should be rendered as HTML expect(screen.getByText('Hello World')).toBeInTheDocument(); @@ -236,11 +202,11 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - render(); + render(); expect(screen.getByText('Direct String Content')).toBeInTheDocument(); }); - it('should not render when file content cannot be extracted', () => { + it('should show parse error when file content cannot be extracted', () => { mockUseGetResourceRepositoryView.mockReturnValue({ repository: mockRepository, folder: mockFolder, @@ -261,8 +227,8 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByText(/Unable to display README content/i)).toBeInTheDocument(); }); }); @@ -284,7 +250,7 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - render(); + render(); // Verify the query was called with the correct path expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({ @@ -319,7 +285,7 @@ describe('FolderReadme', () => { refetch: jest.fn(), }); - render(); + render(); expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({ name: 'test-repo', diff --git a/public/app/features/provisioning/components/Folders/FolderReadme.tsx b/public/app/features/provisioning/components/Folders/FolderReadme.tsx index e4d3ebe3e29..5ccc8b5f32a 100644 --- a/public/app/features/provisioning/components/Folders/FolderReadme.tsx +++ b/public/app/features/provisioning/components/Folders/FolderReadme.tsx @@ -2,27 +2,24 @@ import { css } from '@emotion/css'; import { skipToken } from '@reduxjs/toolkit/query/react'; import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { Card, Spinner, useStyles2 } from '@grafana/ui'; +import { Box, Spinner, Stack, Text, TextLink, useStyles2 } from '@grafana/ui'; import { useGetRepositoryFilesWithPathQuery } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView'; -interface FolderReadmeProps { - folderUID: string | undefined; +interface FolderReadmeContentProps { + folderUID: string; } /** - * FolderReadme fetches and renders a README.md file from a Git Sync provisioned folder. - * It only renders when: - * - The provisioning feature is enabled - * - The folder is managed by a repository - * - The README.md file exists and can be fetched + * FolderReadmeContent fetches and renders a README.md file from a Git Sync provisioned folder. + * This is the main content component used in the README tab. */ -export function FolderReadme({ folderUID }: FolderReadmeProps) { +export function FolderReadmeContent({ folderUID }: FolderReadmeContentProps) { const styles = useStyles2(getStyles); - const provisioningEnabled = config.featureToggles.provisioning; // Get repository info for the folder const { repository, folder, isLoading: isRepoLoading } = useGetResourceRepositoryView({ @@ -34,7 +31,7 @@ export function FolderReadme({ folderUID }: FolderReadmeProps) { const readmePath = sourcePath ? `${sourcePath}/README.md` : 'README.md'; // Determine if we should fetch the README - const shouldFetch = provisioningEnabled && !!repository && !!folderUID && !isRepoLoading; + const shouldFetch = !!repository && !!folderUID && !isRepoLoading; // Fetch the README.md file from the repository const { @@ -50,82 +47,101 @@ export function FolderReadme({ folderUID }: FolderReadmeProps) { : skipToken ); - // Don't render if provisioning is disabled or folder is not managed - if (!provisioningEnabled || !folderUID || !repository) { - return null; - } - - // Show loading spinner while fetching repository info - if (isRepoLoading) { - return null; - } - - // Show loading spinner while fetching README - if (isFileLoading) { + // Show loading spinner while fetching repository info or README + if (isRepoLoading || isFileLoading) { return ( - -
- -
-
+ + + ); } - // Don't render if there was an error (README doesn't exist or unsupported) + // Show empty state if folder is not managed by a repository + if (!repository) { + return ( + + + + + This folder is not managed by a Git repository. + + + + + ); + } + + // Show empty state if there was an error or no README exists if (isError || !fileData) { - return null; + return ( + + + + + No README.md file found in this folder. + + + + + Add a README.md file to your repository to display documentation here. + + + + + ); } // Extract the raw content from the file data - // The API returns a ResourceWrapper with resource.file containing the file data const fileContent = fileData.resource?.file; if (!fileContent) { return null; } - // For markdown files, the content might be in different formats depending on how the API returns it // Try to get the content as a string let markdownContent: string | undefined; if (typeof fileContent === 'string') { markdownContent = fileContent; } else if (typeof fileContent === 'object') { - // If it's an object, try common property names markdownContent = (fileContent as Record).content as string | undefined || (fileContent as Record).data as string | undefined || (fileContent as Record).spec as string | undefined; - // If still not found, try to get raw text if available if (!markdownContent && (fileContent as Record).raw) { markdownContent = (fileContent as Record).raw as string; } } - // Don't render if we couldn't extract the content if (!markdownContent || typeof markdownContent !== 'string') { - return null; + return ( + + + + Unable to display README content. + + + + ); } // Render the markdown content const renderedHtml = renderMarkdown(markdownContent); return ( - +
- +
); } const getStyles = (theme: GrafanaTheme2) => ({ - card: css({ - marginBottom: theme.spacing(2), - padding: theme.spacing(2), - }), - loadingContainer: css({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', + container: css({ padding: theme.spacing(2), + backgroundColor: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, }), }); + +// Keep the old export for backwards compatibility during transition +export { FolderReadmeContent as FolderReadme }; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 8b8b3213004..bf1fb1332c2 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -484,6 +484,15 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "TestStuffPage"*/ 'app/features/sandbox/TestStuffPage') ), }, + { + path: '/dashboards/f/:uid/:slug/readme', + component: SafeDynamicImport( + () => + import( + /* webpackChunkName: "FolderReadmePage"*/ 'app/features/browse-dashboards/BrowseFolderReadmePage' + ) + ), + }, { path: '/dashboards/f/:uid/:slug/library-panels', component: SafeDynamicImport(