Code Diff
diff --git a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts
index 3c3b2605f0c21..e4c40d42b1e1b 100644
--- a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts
+++ b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts
@@ -386,10 +386,8 @@ function useColorFunction(
* triggers:
* - Moving the browser window between a HiDPI and a standard display (matchMedia)
* - Browser zoom changes (visualViewport resize)
- *
- * Exported for testing — do not use directly outside this module.
*/
-export function useDevicePixelRatio() {
+function useDevicePixelRatio() {
const [devicePixelRatio, setDevicePixelRatio] = useState(window.devicePixelRatio);
useEffect(() => {
diff --git a/public/app/features/provisioning/Repository/RepositoryActions.tsx b/public/app/features/provisioning/Repository/RepositoryActions.tsx
index a0881050e78ab..34fb87ffe6f5b 100644
--- a/public/app/features/provisioning/Repository/RepositoryActions.tsx
+++ b/public/app/features/provisioning/Repository/RepositoryActions.tsx
@@ -29,7 +29,7 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) {
const isReadOnlyRepo = getIsReadOnlyWorkflows(repository.spec?.workflows);
return (
- <Stack wrap="wrap">
+ <Stack wrap="wrap" alignItems="center">
{isReadOnlyRepo && <ReadOnlyBadge repoType={repoType} />}
<StatusBadge repo={repository} displayOnly />
{repoHref && (
diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.test.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.test.tsx
new file mode 100644
index 0000000000000..143b0f968b1aa
--- /dev/null
+++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from 'test/test-utils';
+
+import { TokenPermissionsInfo } from './TokenPermissionsInfo';
+
+describe('TokenPermissionsInfo', () => {
+ it('lists the Administration: Read-only permission for GitHub', async () => {
+ render(<TokenPermissionsInfo type="github" />);
+
+ const row = await screen.findByText(/Administration/);
+ expect(row).toHaveTextContent('Administration: Read-only');
+ });
+
+ it('does not list the Administration permission for non-GitHub providers', async () => {
+ render(<TokenPermissionsInfo type="gitlab" />);
+
+ // Wait for the GitLab list to render before asserting the negative.
+ expect(await screen.findByText(/Repository/)).toBeInTheDocument();
+ expect(screen.queryByText(/Administration/)).not.toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx
index ae1cf890ab549..49be341252f38 100644
--- a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx
+++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx
@@ -74,6 +74,7 @@ function getPermissionsForProvider(type: InstructionAvailability): Permission[]
{ name: 'Metadata', access: 'Read only' },
{ name: 'Pull requests', access: 'Read and write' },
{ name: 'Webhooks', access: 'Read and write' },
+ { name: 'Administration', access: 'Read-only' },
];
case 'gitlab':
return [
diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx
index 44482d7d0973e..311eb15bc1d20 100644
--- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx
+++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx
@@ -1,4 +1,4 @@
-import { HttpResponse, http } from 'msw';
+import { HttpResponse, delay, http } from 'msw';
import { act, render, screen, waitFor } from 'test/test-utils';
import { type Dashboard } from '@grafana/schema';
@@ -375,6 +375,161 @@ describe('SaveProvisionedDashboardForm', () => {
expect(request.body).toEqual(updatedDashboard);
});
+ it('shows the in-progress state on Save while new dashboard validation is pending', async () => {
+ server.use(
+ http.post(`${BASE}/repositories/:name/files/*`, async ({ request }) => {
+ const url = new URL(request.url);
+ capturedRequest = { url, body: await request.json() };
+ return HttpResponse.json({
+ resource: { upsert: { metadata: { name: 'new-dashboard' }, spec: { title: 'New Dashboard' } } },
+ });
+ })
+ );
+
+ // Hold title validation open so the submit stays in its validation phase.
+ let resolveValidation!: (value: unknown) => void;
+ const validationPromise = new Promise((resolve) => {
+ resolveValidation = resolve;
+ });
+ (validationSrv.validateNewDashboardName as jest.Mock).mockReturnValue(validationPromise);
+
+ const newDashboard = {
+ apiVersion: 'dashboard.grafana.app/v1alpha1',
+ kind: 'Dashboard',
+ metadata: { generateName: 'p', name: undefined },
+ spec: { title: 'New Dashboard', panels: [], schemaVersion: 36 },
+ };
+
+ const { user, props } = setup();
+ props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
+
+ const titleInput = screen.getByRole('textbox', { name: /title/i });
+ await user.clear(titleInput);
+ await user.type(titleInput, 'New Dashboard');
+
+ const filenameInput = screen.getByRole('textbox', { name: /filename/i });
+ await user.clear(filenameInput);
+ await user.type(filenameInput, 'custom-filename.json');
+
+ await user.click(screen.getByRole('button', { name: /save/i }));
+
+ // The button reflects the in-progress submit during validation, before the create POST fires.
+ const savingButton = await screen.findByRole('button', { name: /saving/i });
+ expect(savingButton).toBeDisabled();
+ expect(capturedRequest).toBeNull();
+
+ // Completing validation lets the create POST go out.
+ await act(async () => {
+ resolveValidation(true);
+ });
+ await waitFor(() => expect(capturedRequest).not.toBeNull());
+ });
+
+ it('re-enables Save and skips the write when new dashboard validation fails', async () => {
+ server.use(
+ http.post(`${BASE}/repositories/:name/files/*`, async ({ request }) => {
+ const url = new URL(request.url);
+ capturedRequest = { url, body: await request.json() };
+ return HttpResponse.json({
+ resource: { upsert: { metadata: { name: 'new-dashboard' }, spec: { title: 'New Dashboard' } } },
+ });
+ })
+ );
+
+ (validationSrv.validateNewDashboardName as jest.Mock).mockRejectedValue(
+ new Error('A dashboard or a folder with the same name already exists')
+ );
+
+ const { user, props } = setup();
+ props.dashboard.getSaveResource = jest.fn().mockReturnValue({
+ apiVersion: 'dashboard.grafana.app/v1alpha1',
+ kind: 'Dashboard',
+ metadata: { generateName: 'p' },
+ spec: { title: 'New Dashboard', panels: [], schemaVersion: 36 },
+ });
+
+ const titleInput = screen.getByRole('textbox', { name: /title/i });
+ await user.clear(titleInput);
+ await user.type(titleInput, 'New Dashboard');
+
+ await user.click(screen.getByRole('button', { name: /save/i }));
+
+ // Failed validation re-enables the button and never fires the create POST.
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
+ });
+ expect(capturedRequest).toBeNull();
+ });
+
+ it('shows the in-progress state on Save while an existing dashboard is being written', async () => {
+ server.use(
+ http.put(`${BASE}/repositories/:name/files/*`, async ({ request }) => {
+ // Keep the write in flight so the in-progress state is observable.
+ await delay(50);
+ const url = new URL(request.url);
+ capturedRequest = { url, body: await request.json() };
+ return HttpResponse.json({
+ resource: { upsert: { metadata: { name: 'test-dashboard' }, spec: { title: 'Test Dashboard' } } },
+ });
+ })
+ );
+
+ const updatedDashboard = {
+ apiVersion: 'dashboard.grafana.app/vXyz',
+ metadata: {
+ name: 'test-dashboard',
+ annotations: {
+ [AnnoKeyFolder]: 'folder-uid',
+ [AnnoKeySourcePath]: 'path/to/file.json',
+ },
+ },
+ spec: { title: 'Test Dashboard', description: 'Test Description' },
+ };
+ const { user } = setup({
+ isNew: false,
+ dashboard: {
+ state: {
+ meta: {
+ folderUid: updatedDashboard.metadata.annotations[AnnoKeyFolder],
+ slug: 'test-dashboard',
+ uid: updatedDashboard.metadata.name,
+ k8s: updatedDashboard.metadata,
+ },
+ title: 'Test Dashboard',
+ description: 'Test Description',
+ isDirty: true,
+ },
+ useState: () => ({
+ meta: {
+ folderUid: updatedDashboard.metadata.annotations[AnnoKeyFolder],
+ slug: 'test-dashboard',
+ uid: updatedDashboard.metadata.name,
+ k8s: updatedDashboard.metadata,
+ },
+ title: 'Test Dashboard',
+ description: 'Test Description',
+ isDirty: true,
+ }),
+ setState: jest.fn(),
+ closeModal: jest.fn(),
+ getSaveResource: jest.fn().mockReturnValue(updatedDashboard),
+ setManager: jest.fn(),
+ getRawJsonFromEditor: jest.fn().mockReturnValue(undefined),
+ } as unknown as DashboardScene,
+ });
+
+ const commentInput = screen.getByRole('textbox', { name: /comment/i });
+ await user.clear(commentInput);
+ await user.type(commentInput, 'Update dashboard');
+ await user.click(screen.getByRole('button', { name: /save/i }));
+
+ // The in-progress state shows for edits too, while the write is in flight.
+ const savingButton = await screen.findByRole('button', { name: /saving/i });
+ expect(savingButton).toBeDisabled();
+
+ await waitFor(() => expect(capturedRequest).not.toBeNull());
+ });
+
it('should rename file when path changes on existing dashboard', async () => {
server.use(
http.post(`${BASE}/repositories/:name/files/*`, async ({ request }) => {
diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx
index f86a58d41065a..7d33316317b25 100644
--- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx
+++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx
@@ -68,7 +68,7 @@ export function SaveProvisionedDashboardForm({
register,
setValue,
getValues,
- formState: { dirtyFields },
+ formState: { dirtyFields, isSubmitting, isValidating },
} = methods;
const path = watch('path');
@@ -371,8 +371,12 @@ export function SaveProvisionedDashboardForm({
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
</Button>
- <Button variant="primary" type="submit" disabled={request.isLoading || readOnly || !isDirtyState}>
- {request.isLoading
+ <Button
+ variant="primary"
+ type="submit"
+ disabled={request.isLoading || readOnly || !isDirtyState || isSubmitting || isValidating}
+ >
+ {request.isLoading || isSubmitting || isValidating
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
</Button>