Provisioning: Add README as a dedicated tab for provisioned folders

Instead of showing README inline on the Dashboards tab, this adds a
dedicated README tab as the first tab for Git Sync provisioned folders.

Changes:
- Add getReadmeTabID and README tab to folder nav model
- Create BrowseFolderReadmePage component
- Add /dashboards/f/:uid/:slug/readme route
- Update FolderReadmeContent to show appropriate empty states
- Remove inline FolderReadme from BrowseDashboardsPage
This commit is contained in:
Roberto Jimenez Sanchez
2026-01-13 13:18:32 +01:00
parent dca2fd6b8d
commit 907bea63f2
6 changed files with 190 additions and 118 deletions
@@ -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<string
>
<Page.Contents className={styles.pageContents}>
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
{folderUID && <FolderReadme folderUID={folderUID} />}
{/* only show recently viewed dashboards when in root */}
{!folderUID && <RecentlyViewedDashboards />}
<div>
@@ -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 (
<Page
navId="dashboards/browse"
pageNav={navModel}
onEditTitle={onEditTitle}
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} repoType={repoType} isReadOnlyRepo={isReadOnlyRepo} />}</>}
>
<Page.Contents>
<FolderReadmeContent folderUID={folderUID} />
</Page.Contents>
</Page>
);
}
export default BrowseFolderReadmePage;
+24 -9
View File
@@ -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) {
@@ -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<string, boolean>).provisioning = true;
});
describe('when provisioning is disabled', () => {
it('should not render anything', () => {
(config.featureToggles as Record<string, boolean>).provisioning = false;
mockUseGetResourceRepositoryView.mockReturnValue({
repository: mockRepository,
folder: mockFolder,
isLoading: false,
isInstanceManaged: false,
isReadOnlyRepo: false,
});
const { container } = render(<FolderReadme folderUID="test-folder" />);
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(<FolderReadme folderUID="test-folder" />);
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(<FolderReadme folderUID={undefined} />);
expect(container).toBeEmptyDOMElement();
render(<FolderReadmeContent folderUID="test-folder" />);
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(<FolderReadme folderUID="test-folder" />);
expect(container).toBeEmptyDOMElement();
render(<FolderReadmeContent folderUID="test-folder" />);
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
});
it('should show loading spinner while fetching README', () => {
@@ -134,14 +101,13 @@ describe('FolderReadme', () => {
refetch: jest.fn(),
});
render(<FolderReadme folderUID="test-folder" />);
// Check for spinner by looking for the role or a common class
render(<FolderReadmeContent folderUID="test-folder" />);
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(<FolderReadme folderUID="test-folder" />);
expect(container).toBeEmptyDOMElement();
render(<FolderReadmeContent folderUID="test-folder" />);
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(<FolderReadme folderUID="test-folder" />);
expect(container).toBeEmptyDOMElement();
render(<FolderReadmeContent folderUID="test-folder" />);
expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument();
});
});
@@ -208,7 +174,7 @@ describe('FolderReadme', () => {
refetch: jest.fn(),
});
render(<FolderReadme folderUID="test-folder" />);
render(<FolderReadmeContent folderUID="test-folder" />);
// The markdown should be rendered as HTML
expect(screen.getByText('Hello World')).toBeInTheDocument();
@@ -236,11 +202,11 @@ describe('FolderReadme', () => {
refetch: jest.fn(),
});
render(<FolderReadme folderUID="test-folder" />);
render(<FolderReadmeContent folderUID="test-folder" />);
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(<FolderReadme folderUID="test-folder" />);
expect(container).toBeEmptyDOMElement();
render(<FolderReadmeContent folderUID="test-folder" />);
expect(screen.getByText(/Unable to display README content/i)).toBeInTheDocument();
});
});
@@ -284,7 +250,7 @@ describe('FolderReadme', () => {
refetch: jest.fn(),
});
render(<FolderReadme folderUID="test-folder" />);
render(<FolderReadmeContent folderUID="test-folder" />);
// Verify the query was called with the correct path
expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({
@@ -319,7 +285,7 @@ describe('FolderReadme', () => {
refetch: jest.fn(),
});
render(<FolderReadme folderUID="test-folder" />);
render(<FolderReadmeContent folderUID="test-folder" />);
expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({
name: 'test-repo',
@@ -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 (
<Card className={styles.card}>
<div className={styles.loadingContainer}>
<Spinner size="sm" />
</div>
</Card>
<Box display="flex" justifyContent="center" alignItems="center" paddingY={4}>
<Spinner size="lg" />
</Box>
);
}
// 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 (
<Box paddingY={4}>
<Stack direction="column" alignItems="center" gap={2}>
<Text color="secondary">
<Trans i18nKey="browse-dashboards.readme.not-provisioned">
This folder is not managed by a Git repository.
</Trans>
</Text>
</Stack>
</Box>
);
}
// Show empty state if there was an error or no README exists
if (isError || !fileData) {
return null;
return (
<Box paddingY={4}>
<Stack direction="column" alignItems="center" gap={2}>
<Text color="secondary">
<Trans i18nKey="browse-dashboards.readme.not-found">
No README.md file found in this folder.
</Trans>
</Text>
<Text color="secondary" variant="bodySmall">
<Trans i18nKey="browse-dashboards.readme.add-hint">
Add a README.md file to your repository to display documentation here.
</Trans>
</Text>
</Stack>
</Box>
);
}
// 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<string, unknown>).content as string | undefined ||
(fileContent as Record<string, unknown>).data as string | undefined ||
(fileContent as Record<string, unknown>).spec as string | undefined;
// If still not found, try to get raw text if available
if (!markdownContent && (fileContent as Record<string, unknown>).raw) {
markdownContent = (fileContent as Record<string, unknown>).raw as string;
}
}
// Don't render if we couldn't extract the content
if (!markdownContent || typeof markdownContent !== 'string') {
return null;
return (
<Box paddingY={4}>
<Text color="secondary">
<Trans i18nKey="browse-dashboards.readme.parse-error">
Unable to display README content.
</Trans>
</Text>
</Box>
);
}
// Render the markdown content
const renderedHtml = renderMarkdown(markdownContent);
return (
<Card className={styles.card}>
<div className={styles.container}>
<div className="markdown-html" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
</Card>
</div>
);
}
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 };
+9
View File
@@ -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(