BrowseDashboardsPage: added new pull request banner on new folder creation (#107596)

* BrowseDashboardsPage: added ProvisionedFolderPreviewBanner to display new provisioned folder on branch alert

* add comment

* fix test

* i18n

* Added test for PreviewBannerViewPR

* fix test, i18n fix
This commit is contained in:
Yunwen Zheng
2025-07-08 07:24:16 -04:00
committed by GitHub
parent 365234c2fe
commit 2de7f424f5
10 changed files with 266 additions and 88 deletions

View File

@ -163,17 +163,17 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
describe('at the root level', () => {
it('displays "Dashboards" as the page title', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
});
it('displays a search input', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument();
});
it('shows the "New" button', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('button', { name: 'New' })).toBeInTheDocument();
});
@ -185,25 +185,25 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canCreateFolders: false,
};
});
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New' })).not.toBeInTheDocument();
});
it('does not show "Folder actions"', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('does not show an "Edit title" button', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit title' })).not.toBeInTheDocument();
});
it('does not show any tabs', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Dashboards' })).not.toBeInTheDocument();
@ -212,7 +212,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('displays the filters and hides the actions initially', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
await screen.findByPlaceholderText('Search for dashboards and folders');
expect(await screen.findByText('Sort')).toBeInTheDocument();
@ -223,7 +223,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('selecting an item hides the filters and shows the actions instead', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashboards.table.checkbox(dashbdD.item.uid));
await userEvent.click(checkbox);
@ -238,7 +238,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('navigating into a child item resets the selected state', async () => {
const { rerender } = render(<BrowseDashboardsPage />);
const { rerender } = render(<BrowseDashboardsPage queryParams={{}} />);
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashboards.table.checkbox(folderA.item.uid));
await userEvent.click(checkbox);
@ -248,7 +248,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
(useParams as jest.Mock).mockReturnValue({ uid: folderA.item.uid });
rerender(<BrowseDashboardsPage />);
rerender(<BrowseDashboardsPage queryParams={{}} />);
// Check the filters are now visible again
expect(await screen.findByText('Filter by tag')).toBeInTheDocument();
@ -266,17 +266,17 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('shows the folder name as the page title', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
});
it('displays a search input', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument();
});
it('shows the "New" button', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('button', { name: 'New' })).toBeInTheDocument();
});
@ -288,13 +288,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canCreateFolders: false,
};
});
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New' })).not.toBeInTheDocument();
});
it('shows the "Folder actions" button', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
@ -308,13 +308,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canViewPermissions: false,
};
});
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('shows an "Edit title" button', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('button', { name: 'Edit title' })).toBeInTheDocument();
});
@ -325,13 +325,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canEditFolders: false,
};
});
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit title' })).not.toBeInTheDocument();
});
it('displays all the folder tabs and shows the "Dashboards" tab as selected', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toHaveAttribute('aria-selected', 'true');
@ -343,7 +343,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('displays the filters and hides the actions initially', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
await screen.findByPlaceholderText('Search for dashboards and folders');
expect(await screen.findByText('Sort')).toBeInTheDocument();
@ -354,7 +354,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('selecting an item hides the filters and shows the actions instead', async () => {
render(<BrowseDashboardsPage />);
render(<BrowseDashboardsPage queryParams={{}} />);
const checkbox = await screen.findByTestId(
selectors.pages.BrowseDashboards.table.checkbox(folderA_folderA.item.uid)

View File

@ -25,13 +25,14 @@ import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView';
import CreateNewButton from './components/CreateNewButton';
import { FolderActionsButton } from './components/FolderActionsButton';
import { ProvisionedFolderPreviewBanner } from './components/ProvisionedFolderPreviewBanner';
import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions';
import { useHasSelection } from './state/hooks';
import { setAllSelection } from './state/slice';
// New Browse/Manage/Search Dashboards views for nested folders
const BrowseDashboardsPage = memo(() => {
const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string, string> }) => {
const { uid: folderUID } = useParams();
const dispatch = useDispatch();
@ -159,6 +160,7 @@ const BrowseDashboardsPage = memo(() => {
}
>
<Page.Contents className={styles.pageContents}>
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
<div>
<FilterInput
placeholder={getSearchPlaceholder(searchState.includePanels)}

View File

@ -161,7 +161,7 @@ describe('NewProvisionedFolderForm', () => {
(getAppEvents as jest.Mock).mockReturnValue(mockAppEvents);
// Mock usePullRequestParam
(usePullRequestParam as jest.Mock).mockReturnValue(null);
(usePullRequestParam as jest.Mock).mockReturnValue({});
// Mock useCreateRepositoryFilesWithPathMutation
const mockCreate = jest.fn();
@ -409,7 +409,7 @@ describe('NewProvisionedFolderForm', () => {
});
it('should show PR link when PR URL is available', () => {
(usePullRequestParam as jest.Mock).mockReturnValue('https://github.com/grafana/grafana/pull/1234');
(usePullRequestParam as jest.Mock).mockReturnValue({ prURL: 'https://github.com/grafana/grafana/pull/1234' });
setup();

View File

@ -30,7 +30,7 @@ interface Props {
}
function FormContent({ initialValues, repository, workflowOptions, folder, isGitHub, onDismiss }: FormProps) {
const prURL = usePullRequestParam();
const { prURL } = usePullRequestParam();
const navigate = useNavigate();
const [create, request] = useCreateRepositoryFilesWithPathMutation();

View File

@ -0,0 +1,23 @@
import { config } from '@grafana/runtime';
import { CommonBannerProps } from 'app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner';
import { PreviewBannerViewPR } from 'app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
export function ProvisionedFolderPreviewBanner({ queryParams }: CommonBannerProps) {
const provisioningEnabled = config.featureToggles.provisioning;
const { prURL, newPrURL } = usePullRequestParam();
if (!provisioningEnabled || 'kiosk' in queryParams) {
return null;
}
if (prURL) {
return <PreviewBannerViewPR prParam={prURL} isFolder />;
}
if (newPrURL) {
return <PreviewBannerViewPR prParam={newPrURL} isFolder isNewPr />;
}
return null;
}

View File

@ -1,13 +1,14 @@
import { textUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Alert, Icon, Stack } from '@grafana/ui';
import { Alert } from '@grafana/ui';
import { useGetRepositoryFilesWithPathQuery } from 'app/api/clients/provisioning/v0alpha1';
import { DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
import { DashboardRoutes } from 'app/types';
interface CommonBannerProps {
import { PreviewBannerViewPR } from './PreviewBannerViewPR';
export interface CommonBannerProps {
queryParams: DashboardPageRouteSearchParams;
path?: string;
slug?: string;
@ -19,13 +20,13 @@ interface DashboardPreviewBannerProps extends CommonBannerProps {
interface DashboardPreviewBannerContentProps extends Required<Omit<CommonBannerProps, 'route'>> {}
const commonAlertProps = {
export const commonAlertProps = {
severity: 'info' as const,
style: { flex: 0 } as const,
};
function DashboardPreviewBannerContent({ queryParams, slug, path }: DashboardPreviewBannerContentProps) {
const prParam = usePullRequestParam();
const { prURL } = usePullRequestParam();
const file = useGetRepositoryFilesWithPathQuery({ name: slug, path, ref: queryParams.ref });
if (file.data?.errors) {
@ -43,56 +44,14 @@ function DashboardPreviewBannerContent({ queryParams, slug, path }: DashboardPre
}
// This page was loaded with a `pull_request_url` in the URL
if (prParam?.length) {
return (
<Alert
{...commonAlertProps}
title={t(
'dashboard-scene.dashboard-preview-banner.title-dashboard-loaded-request-git-hub',
'This dashboard is loaded from a pull request in GitHub.'
)}
buttonContent={
<Stack alignItems="center">
<Trans i18nKey="dashboard-scene.dashboard-preview-banner.view-pull-request-in-git-hub">
View pull request in GitHub
</Trans>
<Icon name="external-link-alt" />
</Stack>
}
onRemove={() => window.open(textUtil.sanitizeUrl(prParam), '_blank')}
>
<Trans i18nKey="dashboard-scene.dashboard-preview-banner.value-not-saved">
The value is not yet saved in the Grafana database
</Trans>
</Alert>
);
if (prURL?.length) {
return <PreviewBannerViewPR prParam={prURL} />;
}
// Check if this is a GitHub link
const githubURL = file.data?.urls?.newPullRequestURL ?? file.data?.urls?.compareURL;
if (githubURL) {
return (
<Alert
{...commonAlertProps}
title={t(
'dashboard-scene.dashboard-preview-banner.title-dashboard-loaded-branch-git-hub',
'This dashboard is loaded from a branch in GitHub.'
)}
buttonContent={
<Stack alignItems="center">
<Trans i18nKey="dashboard-scene.dashboard-preview-banner.open-pull-request-in-git-hub">
Open pull request in GitHub
</Trans>
<Icon name="external-link-alt" />
</Stack>
}
onRemove={() => window.open(textUtil.sanitizeUrl(githubURL), '_blank')}
>
<Trans i18nKey="dashboard-scene.dashboard-preview-banner.not-saved">
The value is not yet saved in the Grafana database
</Trans>
</Alert>
);
return <PreviewBannerViewPR prParam={githubURL} isNewPr />;
}
return (

View File

@ -0,0 +1,125 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { textUtil } from '@grafana/data';
import { PreviewBannerViewPR } from './PreviewBannerViewPR';
jest.mock('@grafana/data', () => ({
...jest.requireActual('@grafana/data'),
textUtil: {
sanitizeUrl: jest.fn(),
},
}));
jest.mock('@grafana/i18n', () => ({
t: jest.fn((key: string, defaultValue: string) => defaultValue),
Trans: ({ children }: { children: React.ReactNode }) => children,
}));
const mockTextUtil = jest.mocked(textUtil);
function setup(props: { prParam: string; isFolder?: boolean; isNewPr?: boolean } = { prParam: 'test-url' }) {
const defaultProps = {
isFolder: false,
isNewPr: false,
...props,
};
const renderResult = render(<PreviewBannerViewPR {...defaultProps} />);
return { renderResult, props: defaultProps };
}
describe('PreviewBannerViewPR', () => {
let windowOpenSpy: jest.SpyInstance;
beforeAll(() => {
Object.defineProperty(window, 'open', {
writable: true,
value: jest.fn(),
});
windowOpenSpy = jest.spyOn(window, 'open');
});
beforeEach(() => {
jest.clearAllMocks();
mockTextUtil.sanitizeUrl.mockImplementation((url) => url);
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(() => {
windowOpenSpy.mockRestore();
});
describe('Dashboard scenarios', () => {
it('should render correct text for new PR dashboard', () => {
setup({ prParam: 'test-url', isFolder: false, isNewPr: true });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('This dashboard is loaded from a branch in GitHub.')).toBeInTheDocument();
});
it('should render correct text for existing PR dashboard', () => {
setup({ prParam: 'test-url', isFolder: false, isNewPr: false });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('This dashboard is loaded from a pull request in GitHub.')).toBeInTheDocument();
});
it('should render correct button text for new PR dashboard', () => {
setup({ prParam: 'test-url', isFolder: false, isNewPr: true });
expect(screen.getByText('Open pull request in GitHub')).toBeInTheDocument();
});
it('should render correct button text for existing PR dashboard', () => {
setup({ prParam: 'test-url', isFolder: false, isNewPr: false });
expect(screen.getByText('View pull request in GitHub')).toBeInTheDocument();
});
});
describe('Folder scenarios', () => {
it('should render correct text for new PR folder', () => {
setup({ prParam: 'test-url', isFolder: true, isNewPr: true });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('A new folder has been created in a branch in GitHub.')).toBeInTheDocument();
});
it('should render correct text for existing PR folder', () => {
setup({ prParam: 'test-url', isFolder: true, isNewPr: false });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('A new folder has been created in a pull request in GitHub.')).toBeInTheDocument();
});
it('should render correct button text for new PR folder', () => {
setup({ prParam: 'test-url', isFolder: true, isNewPr: true });
expect(screen.getByText('Open pull request in GitHub')).toBeInTheDocument();
});
it('should render correct button text for existing PR folder', () => {
setup({ prParam: 'test-url', isFolder: true, isNewPr: false });
expect(screen.getByText('View pull request in GitHub')).toBeInTheDocument();
});
});
describe('Button functionality', () => {
it('should open URL in new tab when button is clicked', async () => {
const testUrl = 'https://github.com/test/repo/pull/123';
setup({ prParam: testUrl });
const button = screen.getByRole('button', { name: /close alert/i });
await userEvent.click(button);
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank');
});
});
});

View File

@ -0,0 +1,64 @@
import { textUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Icon, Stack } from '@grafana/ui';
import { commonAlertProps } from './DashboardPreviewBanner';
// TODO: We have this https://github.com/grafana/git-ui-sync-project/issues/166 to add more details about the PR.
interface Props {
prParam: string;
isFolder?: boolean;
isNewPr?: boolean;
}
/**
* @description This component is used to display a banner when a provisioned dashboard/folder is created or loaded from a new branch in Github.
*/
export function PreviewBannerViewPR({ prParam, isFolder = false, isNewPr }: Props) {
const titleText = isFolder
? isNewPr
? t(
'provisioned-resource-preview-banner.title-folder-created-branch-git-hub',
'A new folder has been created in a branch in GitHub.'
)
: t(
'provisioned-resource-preview-banner.title-folder-created-pull-request-git-hub',
'A new folder has been created in a pull request in GitHub.'
)
: isNewPr
? t(
'provisioned-resource-preview-banner.title-dashboard-loaded-branch-git-hub',
'This dashboard is loaded from a branch in GitHub.'
)
: t(
'provisioned-resource-preview-banner.title-dashboard-loaded-pull-request-git-hub',
'This dashboard is loaded from a pull request in GitHub.'
);
return (
<Alert
{...commonAlertProps}
title={titleText}
buttonContent={
<Stack alignItems="center">
{isNewPr
? t(
'provisioned-resource-preview-banner.preview-banner.open-pull-request-in-git-hub',
'Open pull request in GitHub'
)
: t(
'provisioned-resource-preview-banner.preview-banner.view-pull-request-in-git-hub',
'View pull request in GitHub'
)}
<Icon name="external-link-alt" />
</Stack>
}
onRemove={() => window.open(textUtil.sanitizeUrl(prParam), '_blank')}
>
<Trans i18nKey="provisioned-resource-preview-banner.preview-banner.not-saved">
The value is not yet saved in the Grafana database
</Trans>
</Alert>
);
}

View File

@ -4,10 +4,10 @@ import { useUrlParams } from 'app/core/navigation/hooks';
export const usePullRequestParam = () => {
const [params] = useUrlParams();
const prParam = params.get('pull_request_url');
const newPrParam = params.get('new_pull_request_url');
if (!prParam) {
return undefined;
}
return textUtil.sanitizeUrl(decodeURIComponent(prParam));
return {
prURL: prParam ? textUtil.sanitizeUrl(prParam) : undefined,
newPrURL: newPrParam ? textUtil.sanitizeUrl(newPrParam) : undefined,
};
};

View File

@ -5472,15 +5472,9 @@
"type": "Type"
},
"dashboard-preview-banner": {
"not-saved": "The value is not yet saved in the Grafana database",
"not-yet-saved": "The value is not saved in the Grafana database",
"open-pull-request-in-git-hub": "Open pull request in GitHub",
"title-dashboard-loaded-branch-git-hub": "This dashboard is loaded from a branch in GitHub.",
"title-dashboard-loaded-external-repository": "This dashboard is loaded from an external repository",
"title-dashboard-loaded-request-git-hub": "This dashboard is loaded from a pull request in GitHub.",
"title-error-loading-dashboard": "Error loading dashboard",
"value-not-saved": "The value is not yet saved in the Grafana database",
"view-pull-request-in-git-hub": "View pull request in GitHub"
"title-error-loading-dashboard": "Error loading dashboard"
},
"dashboard-scene": {
"text": {
@ -10312,6 +10306,17 @@
"label-workflow": "Workflow"
}
},
"provisioned-resource-preview-banner": {
"preview-banner": {
"not-saved": "The value is not yet saved in the Grafana database",
"open-pull-request-in-git-hub": "Open pull request in GitHub",
"view-pull-request-in-git-hub": "View pull request in GitHub"
},
"title-dashboard-loaded-branch-git-hub": "This dashboard is loaded from a branch in GitHub.",
"title-dashboard-loaded-pull-request-git-hub": "This dashboard is loaded from a pull request in GitHub.",
"title-folder-created-branch-git-hub": "A new folder has been created in a branch in GitHub.",
"title-folder-created-pull-request-git-hub": "A new folder has been created in a pull request in GitHub."
},
"provisioning": {
"banner": {
"message": "This feature is currently under active development. For the best experience and latest improvements, we recommend using the <2>nightly build</2> of Grafana."