diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index 36182de2d44..a2bfe9ca651 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -163,17 +163,17 @@ describe('browse-dashboards BrowseDashboardsPage', () => { describe('at the root level', () => { it('displays "Dashboards" as the page title', async () => { - render(); + render(); expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument(); }); it('displays a search input', async () => { - render(); + render(); expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument(); }); it('shows the "New" button', async () => { - render(); + render(); expect(await screen.findByRole('button', { name: 'New' })).toBeInTheDocument(); }); @@ -185,25 +185,25 @@ describe('browse-dashboards BrowseDashboardsPage', () => { canCreateFolders: false, }; }); - render(); + render(); expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New' })).not.toBeInTheDocument(); }); it('does not show "Folder actions"', async () => { - render(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + const { rerender } = render(); 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(); + rerender(); // 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(); + render(); expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument(); }); it('displays a search input', async () => { - render(); + render(); expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument(); }); it('shows the "New" button', async () => { - render(); + render(); expect(await screen.findByRole('button', { name: 'New' })).toBeInTheDocument(); }); @@ -288,13 +288,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => { canCreateFolders: false, }; }); - render(); + render(); 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(); + render(); expect(await screen.findByRole('button', { name: 'Folder actions' })).toBeInTheDocument(); }); @@ -308,13 +308,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => { canViewPermissions: false, }; }); - render(); + render(); 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(); + render(); expect(await screen.findByRole('button', { name: 'Edit title' })).toBeInTheDocument(); }); @@ -325,13 +325,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => { canEditFolders: false, }; }); - render(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); const checkbox = await screen.findByTestId( selectors.pages.BrowseDashboards.table.checkbox(folderA_folderA.item.uid) diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 76b7fe057b9..7a58ec579ae 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -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 }) => { const { uid: folderUID } = useParams(); const dispatch = useDispatch(); @@ -159,6 +160,7 @@ const BrowseDashboardsPage = memo(() => { } > +
{ (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(); diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx index ed03a23cb7a..10a9e429099 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -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(); diff --git a/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx b/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx new file mode 100644 index 00000000000..57443939fa6 --- /dev/null +++ b/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx @@ -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 ; + } + + if (newPrURL) { + return ; + } + + return null; +} diff --git a/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx b/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx index 1f7486a921f..ee306bad2f9 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx @@ -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> {} -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 ( - - - View pull request in GitHub - - - - } - onRemove={() => window.open(textUtil.sanitizeUrl(prParam), '_blank')} - > - - The value is not yet saved in the Grafana database - - - ); + if (prURL?.length) { + return ; } // Check if this is a GitHub link const githubURL = file.data?.urls?.newPullRequestURL ?? file.data?.urls?.compareURL; if (githubURL) { - return ( - - - Open pull request in GitHub - - - - } - onRemove={() => window.open(textUtil.sanitizeUrl(githubURL), '_blank')} - > - - The value is not yet saved in the Grafana database - - - ); + return ; } return ( diff --git a/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.test.tsx b/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.test.tsx new file mode 100644 index 00000000000..de716143ac0 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.test.tsx @@ -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(); + + 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'); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx b/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx new file mode 100644 index 00000000000..2188c4029a0 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx @@ -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 ( + + {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' + )} + + + } + onRemove={() => window.open(textUtil.sanitizeUrl(prParam), '_blank')} + > + + The value is not yet saved in the Grafana database + + + ); +} diff --git a/public/app/features/provisioning/hooks/usePullRequestParam.ts b/public/app/features/provisioning/hooks/usePullRequestParam.ts index 911a8ce988a..e4432232900 100644 --- a/public/app/features/provisioning/hooks/usePullRequestParam.ts +++ b/public/app/features/provisioning/hooks/usePullRequestParam.ts @@ -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, + }; }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ff317b5d624..4334abe4952 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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 of Grafana."