Git Sync UI: Delete Provisioned Dashboard Flow (#106593)
Actionlint / Lint GitHub Actions files (push) Waiting to run Details
Backend Code Checks / Validate Backend Configs (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Backend Unit Tests / All backend unit tests complete (push) Blocked by required conditions Details
CodeQL checks / Analyze (actions) (push) Waiting to run Details
CodeQL checks / Analyze (go) (push) Waiting to run Details
CodeQL checks / Analyze (javascript) (push) Waiting to run Details
Lint Frontend / Verify i18n (push) Waiting to run Details
Lint Frontend / Lint (push) Waiting to run Details
Lint Frontend / Typecheck (push) Waiting to run Details
Lint Frontend / Betterer (push) Waiting to run Details
golangci-lint / lint-go (push) Waiting to run Details
End-to-end tests / Build & Package Grafana (push) Waiting to run Details
End-to-end tests / Build E2E test runner (push) Waiting to run Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/dashboards-suite, dashboards-suite (old arch)) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/panels-suite, panels-suite (old arch)) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/smoke-tests-suite, smoke-tests-suite (old arch)) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/various-suite, various-suite (old arch)) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (e2e/dashboards-suite, dashboards-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (e2e/panels-suite, panels-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (e2e/smoke-tests-suite, smoke-tests-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (e2e/various-suite, various-suite) (push) Blocked by required conditions Details
End-to-end tests / All E2E tests complete (push) Blocked by required conditions Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (1) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (2) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (3) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (4) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (5) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (6) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (7) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (8) (push) Waiting to run Details
Frontend tests / All frontend unit tests complete (push) Blocked by required conditions Details
Integration Tests / Sqlite (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Integration Tests / All backend integration tests complete (push) Blocked by required conditions Details
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run Details
Build Release Packages / setup (push) Waiting to run Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/amd64, darwin-amd64) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/arm64, darwin-arm64) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/amd64,deb:grafana:linux/amd64,rpm:grafana:linux/amd64,docker:grafana:linux/amd64,docker:grafana:linux/amd64:ubuntu,npm:grafana,storybook, linux-amd64) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v6,deb:grafana:linux/arm/v6, linux-armv6) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v7,deb:grafana:linux/arm/v7,docker:grafana:linux/arm/v7,docker:grafana:linux/arm/v7:ubuntu, linux-armv7) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm64,deb:grafana:linux/arm64,rpm:grafana:linux/arm64,docker:grafana:linux/arm64,docker:grafana:linux/arm64:ubuntu, linux-arm64) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/s390x,deb:grafana:linux/s390x,rpm:grafana:linux/s390x,docker:grafana:linux/s390x,docker:grafana:linux/s390x:ubuntu, linux-s390x) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/amd64,zip:grafana:windows/amd64,msi:grafana:windows/amd64, windows-amd64) (push) Blocked by required conditions Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/arm64,zip:grafana:windows/arm64, windows-arm64) (push) Blocked by required conditions Details
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run Details
Shellcheck / Shellcheck scripts (push) Waiting to run Details
Verify Storybook / Verify Storybook (push) Waiting to run Details
Swagger generated code / Verify committed API specs match (push) Waiting to run Details
Dispatch sync to mirror / dispatch-job (push) Waiting to run Details
Trivy Scan / trivy-scan (push) Waiting to run Details
Crowdin Upload Action / upload-sources-to-crowdin (push) Has been cancelled Details
publish-kinds-next / main (push) Has been cancelled Details
trigger-dashboard-search-e2e / trigger-search-e2e (push) Has been cancelled Details

* DeleteProvisionedDashboardDrawer: delete provisioned dashboard flow set up with drawer

* clean up

* add tests

* more test and clean up

* revert endpoint change

* adjust tests

* remove unuse codes

* fix type, fix test, add read only message

* small changes

* fix test, i18n fix

* comments

* Fix bug for file deletion using a branch

* PR comments update

* Use the provided ref for parser so that URLs work

* call useDeleteRepositoryFilesWithPathMutation in delete drawer component directly

* remove console log

* Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* PR comments

* use string for fields

* extract handle request logic from save form and delete form and put into one hook

* Add test for useProvisionedRequestHandler

* Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* use drawer close in both save form and delete form

* Add back panelEditor onDiscard

* add panelEditor onDiscard to delete flow

* Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Yunwen Zheng 2025-06-18 15:14:03 -04:00 committed by GitHub
parent 8598fa213a
commit fe4abf2221
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1639 additions and 295 deletions

View File

@ -81,11 +81,17 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return nil, fmt.Errorf("folder delete not supported")
}
file, err := r.repo.Read(ctx, opts.Path, opts.Ref)
// Read the file from the default branch as it won't exist in the possibly new branch
file, err := r.repo.Read(ctx, opts.Path, "")
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
// HACK: manual set to the provided branch so that the parser can possible read the file
if opts.Ref != "" {
file.Ref = opts.Ref
}
// TODO: document in API specification
// We can only delete parsable things
parsed, err := r.parser.Parse(ctx, file)

View File

@ -0,0 +1,244 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { ProvisionedDashboardFormData } from '../../saving/shared';
import { DashboardEditFormSharedFields } from './DashboardEditFormSharedFields';
// Mock the i18n hook since it's used in the component
jest.mock('@grafana/i18n', () => ({
t: (_: string, defaultValue: string) => defaultValue,
Trans: ({ children }: { children: React.ReactNode }) => children,
}));
interface SetupOptions {
formDefaultValues?: Partial<ProvisionedDashboardFormData>;
workflowOptions?: Array<{ label: string; value: string }>;
isNew?: boolean;
readOnly?: boolean;
workflow?: 'write' | 'branch';
isGitHub?: boolean;
}
function setup(options: SetupOptions = {}) {
const {
formDefaultValues = {},
workflowOptions = [
{ label: 'Write directly', value: 'write' },
{ label: 'Create branch', value: 'branch' },
],
isNew,
readOnly,
workflow,
isGitHub,
} = options;
const user = userEvent.setup();
const defaultFormValues: Partial<ProvisionedDashboardFormData> = {
path: '',
comment: '',
ref: '',
workflow: 'write',
...formDefaultValues,
};
const FormWrapper = ({ children }: { children: ReactNode }) => {
const methods = useForm<ProvisionedDashboardFormData>({
defaultValues: defaultFormValues,
mode: 'onChange',
});
return <FormProvider {...methods}>{children}</FormProvider>;
};
const componentProps = {
workflowOptions,
isNew,
readOnly,
workflow,
isGitHub,
};
return {
user,
...render(
<FormWrapper>
<DashboardEditFormSharedFields {...componentProps} />
</FormWrapper>
),
};
}
describe('DashboardEditFormSharedFields', () => {
describe('Basic Rendering', () => {
it('should render path and comment fields by default', () => {
setup();
expect(screen.getByRole('textbox', { name: /Path/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeInTheDocument();
});
it('should not render workflow fields when isGitHub is false', () => {
setup({ isGitHub: false });
expect(screen.queryByText('Workflow')).not.toBeInTheDocument();
});
it('should render workflow fields when isGitHub is true', () => {
setup({ isGitHub: true });
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Write directly' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Create branch' })).toBeInTheDocument();
});
});
describe('ReadOnly State', () => {
it('should make path field readonly when readOnly is true', () => {
setup({ readOnly: true });
const pathInput = screen.getByRole('textbox', { name: /path/i });
expect(pathInput).toHaveAttribute('readonly');
});
it('should disable comment field when readOnly is true', () => {
setup({ readOnly: true });
const commentTextarea = screen.getByRole('textbox', { name: /comment/i });
expect(commentTextarea).toBeDisabled();
});
it('should not render workflow fields when readOnly is true and isGitHub is true', () => {
setup({ readOnly: true, isGitHub: true });
expect(screen.queryByText('Workflow')).not.toBeInTheDocument();
});
});
describe('Workflow Fields', () => {
it('should not render branch field when workflow is write', () => {
setup({ formDefaultValues: { workflow: 'write' }, isGitHub: true, workflow: 'write' });
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument();
});
it('should render branch field when workflow is branch', () => {
setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /branch/i })).toBeInTheDocument();
expect(screen.getByText('Branch name in GitHub')).toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('should allow typing in path field', async () => {
const { user } = setup({ isNew: true });
const pathInput = screen.getByRole('textbox', { name: /path/i });
await user.type(pathInput, 'dashboards/test.json');
expect(pathInput).toHaveValue('dashboards/test.json');
});
it('should allow typing in comment field', async () => {
const { user } = setup();
const commentTextarea = screen.getByRole('textbox', { name: /comment/i });
await user.type(commentTextarea, 'Test comment');
expect(commentTextarea).toHaveValue('Test comment');
});
it('should allow selecting workflow options', async () => {
const { user } = setup({ isGitHub: true });
const branchOption = screen.getByRole('radio', { name: 'Create branch' });
await user.click(branchOption);
expect(branchOption).toBeChecked();
});
it('should allow typing in branch field when workflow is branch', async () => {
const { user } = setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
const branchInput = screen.getByRole('textbox', { name: /branch/i });
await user.type(branchInput, 'feature-branch');
expect(branchInput).toHaveValue('feature-branch');
});
});
describe('Form Integration', () => {
it('should update form state when fields are changed', async () => {
let formValues: Partial<ProvisionedDashboardFormData> | undefined;
const TestComponent = () => {
const methods = useForm<ProvisionedDashboardFormData>({
defaultValues: { path: '', comment: '', ref: '', workflow: 'write' },
});
// Capture form values for assertion
formValues = methods.watch();
return (
<FormProvider {...methods}>
<DashboardEditFormSharedFields
workflowOptions={[
{ label: 'Write directly', value: 'write' },
{ label: 'Create branch', value: 'branch' },
]}
isNew={true}
/>
</FormProvider>
);
};
const user = userEvent.setup();
render(<TestComponent />);
const pathInput = screen.getByRole('textbox', { name: /path/i });
const commentTextarea = screen.getByRole('textbox', { name: /comment/i });
await user.type(pathInput, 'test.json');
await user.type(commentTextarea, 'Test comment');
expect(formValues?.path).toBe('test.json');
expect(formValues?.comment).toBe('Test comment');
});
});
describe('Validation', () => {
it('should show validation error for invalid branch name', async () => {
const { user } = setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
const branchInput = screen.getByRole('textbox', { name: /branch/i });
await user.type(branchInput, 'invalid//branch'); // Invalid branch name with consecutive slashes
// Trigger validation by blurring the field
await user.tab();
// Check if validation error appears
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty workflowOptions', () => {
setup({ workflowOptions: [], isGitHub: true });
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
it('should handle undefined props', () => {
setup({ readOnly: undefined, isGitHub: undefined });
expect(screen.getByRole('textbox', { name: /Path/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,88 @@
import { memo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { t } from '@grafana/i18n';
import { Field, TextArea, Input, RadioButtonGroup } from '@grafana/ui';
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git';
interface DashboardEditFormSharedFieldsProps {
workflowOptions: Array<{ label: string; value: string }>;
isNew?: boolean;
readOnly?: boolean;
workflow?: WorkflowOption;
isGitHub?: boolean;
}
export const DashboardEditFormSharedFields = memo<DashboardEditFormSharedFieldsProps>(
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew }) => {
const {
control,
register,
formState: { errors },
} = useFormContext();
return (
<>
{/* Path */}
<Field
noMargin
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-path', 'Path')}
description={t(
'dashboard-scene.save-or-delete-provisioned-dashboard-form.description-inside-repository',
'File path inside the repository (.json or .yaml)'
)}
>
<Input id="dashboard-path" type="text" {...register('path')} readOnly={!isNew} />
</Field>
{/* Comment */}
<Field noMargin label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-comment', 'Comment')}>
<TextArea
id="dashboard-comment"
{...register('comment')}
disabled={readOnly}
placeholder={t(
'dashboard-scene.save-or-delete-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional',
'Add a note to describe your changes (optional)'
)}
rows={5}
/>
</Field>
{/* Workflow */}
{isGitHub && !readOnly && (
<>
<Field
noMargin
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-workflow', 'Workflow')}
>
<Controller
control={control}
name="workflow"
render={({ field: { ref: _, ...field } }) => (
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} />
)}
/>
</Field>
{workflow === 'branch' && (
<Field
noMargin
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-branch', 'Branch')}
description={t(
'dashboard-scene.save-or-delete-provisioned-dashboard-form.description-branch-name-in-git-hub',
'Branch name in GitHub'
)}
invalid={!!errors.ref}
error={errors.ref && <BranchValidationError />}
>
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} />
</Field>
)}
</>
)}
</>
);
}
);

View File

@ -1,11 +1,9 @@
import { useUrlParams } from 'app/core/navigation/hooks';
import { DashboardScene } from '../../scene/DashboardScene';
import { SaveDashboardDrawer } from '../SaveDashboardDrawer';
import { DashboardChangeInfo } from '../shared';
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
import { useDefaultValues } from './hooks';
import { useProvisionedDashboardData } from './hooks';
export interface SaveProvisionedDashboardProps {
dashboard: DashboardScene;
@ -14,17 +12,12 @@ export interface SaveProvisionedDashboardProps {
}
export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) {
const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState();
const [params] = useUrlParams();
const loadedFromRef = params.get('ref') ?? undefined;
const defaultValues = useDefaultValues({ meta, defaultTitle, defaultDescription, loadedFromRef });
const { isNew, defaultValues, loadedFromRef, isGitHub, workflowOptions, readOnly } =
useProvisionedDashboardData(dashboard);
if (!defaultValues) {
return null;
}
const { values, isNew, isGitHub, repository } = defaultValues;
return (
<SaveProvisionedDashboardForm
@ -32,10 +25,11 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Save
drawer={drawer}
changeInfo={changeInfo}
isNew={isNew}
defaultValues={values}
defaultValues={defaultValues}
loadedFromRef={loadedFromRef}
isGitHub={isGitHub}
repository={repository}
workflowOptions={workflowOptions}
readOnly={readOnly}
/>
);
}

View File

@ -1,8 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppEvents } from '@grafana/data';
import { getAppEvents, locationService } from '@grafana/runtime';
import { getAppEvents } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { AnnoKeyFolder, AnnoKeySourcePath } from 'app/features/apiserver/types';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
@ -32,6 +31,12 @@ jest.mock('@grafana/runtime', () => {
};
});
jest.mock('app/features/dashboard-scene/utils/useProvisionedRequestHandler', () => {
return {
useProvisionedRequestHandler: jest.fn(),
};
});
jest.mock('app/core/components/Select/FolderPicker', () => {
const actual = jest.requireActual('app/core/components/Select/FolderPicker');
return {
@ -127,13 +132,11 @@ function setup(props: Partial<Props> = {}) {
description: 'Test Description',
workflow: 'write',
},
repository: {
name: 'repo-xyz',
type: 'github',
workflows: ['write', 'branch'],
title: 'Test Repository',
target: 'folder',
},
readOnly: false,
workflowOptions: [
{ label: 'Branch', value: 'branch' },
{ label: 'Write', value: 'write' },
],
...props,
};
@ -186,6 +189,10 @@ describe('SaveProvisionedDashboardForm', () => {
});
it('should save a new dashboard successfully', async () => {
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const { user, props } = setup();
const newDashboard = {
apiVersion: 'dashboard.grafana.app/v1alpha1',
@ -202,9 +209,7 @@ describe('SaveProvisionedDashboardForm', () => {
},
};
props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const titleInput = screen.getByRole('textbox', { name: /title/i });
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
const pathInput = screen.getByRole('textbox', { name: /path/i });
@ -223,9 +228,6 @@ describe('SaveProvisionedDashboardForm', () => {
const submitButton = screen.getByRole('button', { name: /save/i });
await user.click(submitButton);
await waitFor(() => {
expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
});
await waitFor(() => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
@ -235,16 +237,13 @@ describe('SaveProvisionedDashboardForm', () => {
body: newDashboard,
});
});
const appEvents = getAppEvents();
expect(appEvents.publish).toHaveBeenCalledWith({
type: AppEvents.alertSuccess.name,
payload: ['Dashboard changes saved'],
});
expect(props.dashboard.closeModal).toHaveBeenCalled();
expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
});
it('should update an existing dashboard successfully', async () => {
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const updatedDashboard = {
apiVersion: 'dashboard.grafana.app/vXyz',
metadata: {
@ -256,7 +255,7 @@ describe('SaveProvisionedDashboardForm', () => {
},
spec: { title: 'Test Dashboard', description: 'Test Description' },
};
const { user, props } = setup({
const { user } = setup({
isNew: false,
dashboard: {
useState: () => ({
@ -276,9 +275,7 @@ describe('SaveProvisionedDashboardForm', () => {
setManager: jest.fn(),
} as unknown as DashboardScene,
});
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const pathInput = screen.getByRole('textbox', { name: /path/i });
expect(pathInput).toHaveAttribute('readonly'); // can not edit the path value
pathInput.removeAttribute('readonly'); // save won't get called unless we have a value
@ -290,9 +287,6 @@ describe('SaveProvisionedDashboardForm', () => {
await user.type(commentInput, 'Update dashboard');
const submitButton = screen.getByRole('button', { name: /save/i });
await user.click(submitButton);
await waitFor(() => {
expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
});
await waitFor(() => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
@ -302,11 +296,17 @@ describe('SaveProvisionedDashboardForm', () => {
body: updatedDashboard,
});
});
expect(props.dashboard.closeModal).toHaveBeenCalled();
expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
});
it('should show error when save fails', async () => {
const mockAction = jest.fn();
const mockRequest = {
...mockRequestBase,
isSuccess: false,
isError: true,
error: 'Failed to save dashboard',
};
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const { user, props } = setup();
const newDashboard = {
apiVersion: 'dashboard.grafana.app/v1alpha1',
@ -323,14 +323,7 @@ describe('SaveProvisionedDashboardForm', () => {
},
};
props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
const mockAction = jest.fn();
const mockRequest = {
...mockRequestBase,
isSuccess: false,
isError: true,
error: 'Failed to save dashboard',
};
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const titleInput = screen.getByRole('textbox', { name: /title/i });
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
const pathInput = screen.getByRole('textbox', { name: /path/i });
@ -358,13 +351,6 @@ describe('SaveProvisionedDashboardForm', () => {
body: newDashboard,
});
});
await waitFor(() => {
const appEvents = getAppEvents();
expect(appEvents.publish).toHaveBeenCalledWith({
type: AppEvents.alertError.name,
payload: ['Error saving dashboard', 'Failed to save dashboard'],
});
});
});
it('should disable save button when dashboard is not dirty', () => {
@ -393,13 +379,7 @@ describe('SaveProvisionedDashboardForm', () => {
it('should properly handle read-only state for a repository without workflows', () => {
setup({
isNew: false,
repository: {
name: 'repo-abc',
type: 'github',
workflows: [],
target: 'folder',
title: 'Read-only Repository',
},
readOnly: true,
});
// Alert is shown

View File

@ -1,50 +1,35 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Controller, useForm, FormProvider } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents, locationUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { Alert, Button, Field, Input, Stack, TextArea } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import kbn from 'app/core/utils/kbn';
import { Resource } from 'app/features/apiserver/types';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git';
import { DashboardEditFormSharedFields } from '../../components/Provisioned/DashboardEditFormSharedFields';
import { getDashboardUrl } from '../../utils/getDashboardUrl';
import { useProvisionedRequestHandler } from '../../utils/useProvisionedRequestHandler';
import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm';
import { ProvisionedDashboardFormData } from '../shared';
import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard';
import { getWorkflowOptions } from './defaults';
import { getProvisionedMeta } from './utils/getProvisionedMeta';
type FormData = {
ref?: string;
path: string;
comment?: string;
repo: string;
workflow?: WorkflowOption;
title: string;
description: string;
folder: {
uid?: string;
title?: string;
};
};
export interface Props extends SaveProvisionedDashboardProps {
isNew: boolean;
defaultValues: FormData;
defaultValues: ProvisionedDashboardFormData;
isGitHub: boolean;
repository?: RepositoryView;
loadedFromRef?: string;
workflowOptions: Array<{ label: string; value: string }>;
readOnly: boolean;
}
export function SaveProvisionedDashboardForm({
@ -54,8 +39,9 @@ export function SaveProvisionedDashboardForm({
changeInfo,
isNew,
loadedFromRef,
repository,
isGitHub,
workflowOptions,
readOnly,
}: Props) {
const navigate = useNavigate();
const appEvents = getAppEvents();
@ -63,76 +49,69 @@ export function SaveProvisionedDashboardForm({
const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path);
const {
register,
handleSubmit,
watch,
formState: { errors },
control,
reset,
} = useForm<FormData>({ defaultValues });
const [ref, workflow, path] = watch(['ref', 'workflow', 'path']);
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
const { handleSubmit, watch, control, reset, register } = methods;
const [workflow] = watch(['workflow']);
// Update the form if default values change
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
useEffect(() => {
if (request.isSuccess) {
dashboard.setState({ isDirty: false });
const onRequestError = (error: unknown) => {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), error],
});
};
const { ref, path } = request.data;
const onWriteSuccess = () => {
panelEditor?.onDiscard();
drawer.onClose();
locationService.partial({
viewPanel: null,
editPanel: null,
});
};
if (workflow === 'branch' && ref && path) {
dashboard.closeModal();
panelEditor?.onDiscard();
// Redirect to the provisioning preview pages
navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`);
return;
}
const onNewDashboardSuccess = (upsert: Resource<Dashboard>) => {
panelEditor?.onDiscard();
drawer.onClose();
const url = locationUtil.assureBaseUrl(
getDashboardUrl({
uid: upsert.metadata.name,
slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
currentQueryParams: window.location.search,
})
);
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-success', 'Dashboard changes saved')],
});
navigate(url);
};
// Load the new URL
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const upsert = request.data.resource.upsert as Resource<Dashboard>;
if (isNew && upsert?.metadata?.name) {
const url = locationUtil.assureBaseUrl(
getDashboardUrl({
uid: upsert.metadata.name,
slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
currentQueryParams: window.location.search,
})
);
const onBranchSuccess = (ref: string, path: string) => {
panelEditor?.onDiscard();
drawer.onClose();
navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`);
};
navigate(url);
return;
}
// Keep the same URL
dashboard.closeModal();
locationService.partial({
viewPanel: null,
editPanel: null,
});
} else if (request.isError) {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [
t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'),
request.error,
],
});
}
}, [appEvents, dashboard, defaultValues.repo, drawer, isNew, navigate, panelEditor, path, ref, request, workflow]);
useProvisionedRequestHandler({
dashboard,
request,
workflow,
handlers: {
onBranchSuccess: ({ ref, path }) => onBranchSuccess(ref, path),
onWriteSuccess,
onNewDashboardSuccess,
onError: onRequestError,
},
isNew,
});
// Submit handler for saving the form data
const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: FormData) => {
const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: ProvisionedDashboardFormData) => {
// Validate required fields
if (!repo || !path) {
console.error('Missing required fields for saving:', { repo, path });
return;
}
@ -156,150 +135,106 @@ export function SaveProvisionedDashboardForm({
});
};
const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
const readOnly = !repository?.workflows?.length;
return (
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message">
If you have direct access to the target, copy the JSON and paste it there.
</Trans>
</Alert>
)}
{isNew && (
<>
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')}
invalid={!!errors.title}
error={errors.title?.message}
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Input
id="dashboard-title"
{...register('title', {
required: t(
'dashboard-scene.save-provisioned-dashboard-form.title-required',
'Dashboard title is required'
),
validate: validateTitle,
})}
/>
</Field>
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')}
invalid={!!errors.description}
error={errors.description?.message}
>
<TextArea id="dashboard-description" {...register('description')} />
</Field>
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}
>
<Controller
control={control}
name={'folder'}
render={({ field: { ref, value, onChange, ...field } }) => {
return (
<FolderPicker
onChange={async (uid?: string, title?: string) => {
onChange({ uid, title });
// Update folderUid URL param
updateURLParams('folderUid', uid);
const meta = await getProvisionedMeta(uid);
dashboard.setState({
meta: {
...meta,
folderUid: uid,
},
});
}}
value={value.uid}
{...field}
/>
);
}}
/>
</Field>
</>
)}
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-path', 'Path')}
description={t(
'dashboard-scene.save-provisioned-dashboard-form.description-inside-repository',
'File path inside the repository (.json or .yaml)'
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message">
If you have direct access to the target, copy the JSON and paste it there.
</Trans>
</Alert>
)}
>
<Input id="dashboard-path" {...register('path')} readOnly={!isNew} />
</Field>
<Field noMargin label={t('dashboard-scene.save-provisioned-dashboard-form.label-comment', 'Comment')}>
<TextArea
id="dashboard-comment"
{...register('comment')}
disabled={readOnly}
placeholder={t(
'dashboard-scene.save-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional',
'Add a note to describe your changes (optional)'
)}
rows={5}
/>
</Field>
{isGitHub && !readOnly && (
<>
<Field noMargin label={t('dashboard-scene.save-provisioned-dashboard-form.label-workflow', 'Workflow')}>
<Controller
control={control}
name="workflow"
render={({ field: { ref: _, ...field } }) => (
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} />
)}
/>
</Field>
{workflow === 'branch' && (
{isNew && (
<>
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-branch', 'Branch')}
description={t(
'dashboard-scene.save-provisioned-dashboard-form.description-branch-name-in-git-hub',
'Branch name in GitHub'
)}
invalid={!!errors.ref}
error={errors.ref && <BranchValidationError />}
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')}
invalid={!!methods.formState.errors.title}
error={methods.formState.errors.title?.message}
>
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} />
<Input
id="dashboard-title"
{...register('title', {
required: t(
'dashboard-scene.save-provisioned-dashboard-form.title-required',
'Dashboard title is required'
),
validate: validateTitle,
})}
/>
</Field>
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')}
invalid={!!methods.formState.errors.description}
error={methods.formState.errors.description?.message}
>
<TextArea id="dashboard-description" {...register('description')} />
</Field>
)}
</>
)}
<Stack gap={2}>
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty || readOnly}>
{request.isLoading
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
</Button>
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
</Button>
<Field
noMargin
label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}
>
<Controller
control={control}
name={'folder'}
render={({ field: { ref, value, onChange, ...field } }) => {
return (
<FolderPicker
onChange={async (uid?: string, title?: string) => {
onChange({ uid, title });
// Update folderUid URL param
updateURLParams('folderUid', uid);
const meta = await getProvisionedMeta(uid);
dashboard.setState({
meta: {
...meta,
folderUid: uid,
},
});
}}
value={value.uid}
{...field}
/>
);
}}
/>
</Field>
</>
)}
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
<DashboardEditFormSharedFields
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
isNew={isNew}
/>
<Stack gap={2}>
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty || readOnly}>
{request.isLoading
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
</Button>
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
</Button>
</Stack>
</Stack>
</Stack>
</form>
</form>
</FormProvider>
);
}
@ -307,7 +242,7 @@ export function SaveProvisionedDashboardForm({
* Dashboard title validation to ensure it's not the same as the folder name
* and meets other naming requirements.
*/
async function validateTitle(title: string, formValues: FormData) {
async function validateTitle(title: string, formValues: ProvisionedDashboardFormData) {
if (title === formValues.folder.title?.trim()) {
return t(
'dashboard-scene.save-provisioned-dashboard-form.title-same-as-folder',

View File

@ -1,8 +1,15 @@
import { useState } from 'react';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { useUrlParams } from 'app/core/navigation/hooks';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { DashboardMeta } from 'app/types';
import { getDefaultWorkflow } from './defaults';
import { DashboardScene } from '../../scene/DashboardScene';
import { ProvisionedDashboardFormData } from '../shared';
import { getDefaultWorkflow, getWorkflowOptions } from './defaults';
import { generatePath } from './utils/path';
import { generateTimestamp } from './utils/timestamp';
@ -56,3 +63,68 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription, loade
repository,
};
}
export interface ProvisionedDashboardData {
isReady: boolean;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
defaultValues: ProvisionedDashboardFormData | null;
repository?: RepositoryView;
loadedFromRef?: string;
workflowOptions: Array<{ label: string; value: string }>;
isNew: boolean;
isGitHub: boolean;
readOnly: boolean;
}
/**
* Custom hook to fetch and prepare data for a provisioned dashboard update/delete form.
* It retrieves default values, repository information, and workflow options based on the current dashboard state.
*/
export function useProvisionedDashboardData(dashboard: DashboardScene): ProvisionedDashboardData {
const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState();
const [params] = useUrlParams();
const [isLoading, setIsLoading] = useState(false);
const loadedFromRef = params.get('ref') ?? undefined;
const defaultValuesResult = useDefaultValues({
meta,
defaultTitle,
defaultDescription,
loadedFromRef,
});
if (!defaultValuesResult) {
return {
isReady: false,
isLoading,
setIsLoading,
defaultValues: null,
repository: undefined,
loadedFromRef,
workflowOptions: [],
isNew: false,
isGitHub: false,
readOnly: true,
};
}
const { values, isNew, isGitHub, repository } = defaultValuesResult;
const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
const readOnly = !repository?.workflows?.length;
return {
isReady: true,
defaultValues: values,
repository,
loadedFromRef,
workflowOptions,
isNew,
isGitHub,
readOnly,
isLoading,
setIsLoading,
};
}

View File

@ -6,6 +6,7 @@ import { config, isFetchError } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Alert, Box, Button, Stack } from '@grafana/ui';
import { WorkflowOption } from 'app/features/provisioning/types';
import { Diffs } from '../settings/version-history/utils';
@ -22,6 +23,19 @@ export interface DashboardChangeInfo {
hasFolderChanges?: boolean;
hasMigratedToV2?: boolean;
}
export interface ProvisionedDashboardFormData {
ref?: string; // Branch or tag in the repository
path: string; // Path to the dashboard file in the repository
comment?: string; // Commit message for the change
repo: string; // Repository name
workflow?: WorkflowOption;
title: string; // Title of the dashboard
description: string;
folder: {
uid?: string;
title?: string;
};
}
export function isVersionMismatchError(error?: Error) {
return isFetchError(error) && error.data && error.data.status === 'version-mismatch';

View File

@ -6,9 +6,10 @@ import { config, reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, Modal, Space, Text, TextLink } from '@grafana/ui';
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
import { ProvisionedResourceDeleteModal } from '../saving/provisioned/ProvisionedResourceDeleteModal';
import { DashboardScene } from '../scene/DashboardScene';
import { DeleteProvisionedDashboardDrawer } from './DeleteProvisionedDashboardDrawer';
interface ButtonProps {
dashboard: DashboardScene;
}
@ -50,12 +51,14 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
await dashboard.onDashboardDelete();
}, [dashboard, toggleModal]);
if (dashboard.state.meta.provisioned && showModal) {
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
// Git managed dashboard
if (dashboard.isManagedRepository() && showModal) {
return <DeleteProvisionedDashboardDrawer dashboard={dashboard} onDismiss={toggleModal} />;
}
if (dashboard.isManagedRepository() && showModal) {
return <ProvisionedResourceDeleteModal resource={dashboard} onDismiss={toggleModal} />;
// classic provisioning
if (dashboard.state.meta.provisioned && showModal) {
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
}
return (

View File

@ -0,0 +1,35 @@
import { useProvisionedDashboardData } from '../saving/provisioned/hooks';
import { DashboardScene } from '../scene/DashboardScene';
import { DeleteProvisionedDashboardForm } from './DeleteProvisionedDashboardForm';
export interface Props {
dashboard: DashboardScene;
onDismiss: () => void;
}
/**
* @description
* Drawer component for deleting a git provisioned dashboard.
*/
export function DeleteProvisionedDashboardDrawer({ dashboard, onDismiss }: Props) {
const { defaultValues, loadedFromRef, readOnly, isGitHub, workflowOptions, isNew } =
useProvisionedDashboardData(dashboard);
if (!defaultValues) {
return null;
}
return (
<DeleteProvisionedDashboardForm
dashboard={dashboard}
defaultValues={defaultValues}
loadedFromRef={loadedFromRef}
readOnly={readOnly}
isGitHub={isGitHub}
isNew={isNew}
workflowOptions={workflowOptions}
onDismiss={onDismiss}
/>
);
}

View File

@ -0,0 +1,302 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { useProvisionedDashboardData, ProvisionedDashboardData } from '../saving/provisioned/hooks';
import { DashboardScene } from '../scene/DashboardScene';
import { DeleteProvisionedDashboardDrawer, Props } from './DeleteProvisionedDashboardDrawer';
// Mock the hooks and dependencies
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useDeleteRepositoryFilesWithPathMutation: jest.fn(),
provisioningAPIv0alpha1: {
endpoints: {
listRepository: {
select: jest.fn(() => () => ({ data: { items: [] } })),
},
},
},
}));
jest.mock('../saving/provisioned/hooks');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn(),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigate: () => mockNavigate,
}));
// Add this variable declaration near your other mock variables
const mockNavigate = jest.fn();
// Mock shared form components
jest.mock('../components/Provisioned/DashboardEditFormSharedFields', () => ({
DashboardEditFormSharedFields: ({ disabled }: { disabled: boolean }) => (
<textarea data-testid="shared-fields" disabled={disabled} />
),
}));
const mockDeleteRepoFile = jest.fn();
const mockPublish = jest.fn();
const mockUseDeleteRepositoryFiles = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction<
typeof useDeleteRepositoryFilesWithPathMutation
>;
const mockUseProvisionedDashboardData = useProvisionedDashboardData as jest.MockedFunction<
typeof useProvisionedDashboardData
>;
// Mock request state helper
type MockRequestState = {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
error?: Error;
reset: () => void;
};
const createMockRequestState = (overrides: Partial<MockRequestState> = {}): MockRequestState => ({
isLoading: false,
isSuccess: false,
isError: false,
reset: jest.fn(),
...overrides,
});
interface SetupOptions extends Partial<Props> {
provisionedData?: Partial<ProvisionedDashboardData>;
requestState?: Partial<MockRequestState>;
}
function setup(options: SetupOptions = {}) {
const { provisionedData = {}, requestState = {}, ...props } = options;
const user = userEvent.setup();
const defaultDashboard = new DashboardScene({
title: 'Test Dashboard',
uid: 'test-uid',
meta: { slug: 'test-slug' },
});
const defaultProvisionedData: ProvisionedDashboardData = {
isReady: true,
isLoading: false,
setIsLoading: jest.fn(),
defaultValues: {
repo: 'test-repo',
ref: 'main',
workflow: 'branch' as const,
path: 'dashboards/test.json',
comment: '',
title: 'Test Dashboard',
description: 'Test Description',
folder: {
uid: 'test-folder',
title: 'Test Folder',
},
},
repository: {
name: 'test-repo',
target: 'folder' as const,
title: 'Test Repository',
type: 'github' as const,
workflows: ['branch', 'write'] as Array<'branch' | 'write'>,
},
loadedFromRef: 'main',
readOnly: false,
isGitHub: true,
workflowOptions: [
{ label: 'Branch', value: 'branch' },
{ label: 'Write', value: 'write' },
],
isNew: false,
...provisionedData,
};
const defaultProps: Props = {
dashboard: defaultDashboard,
onDismiss: jest.fn(),
...props,
};
// Set up mocks with the merged data
mockUseProvisionedDashboardData.mockReturnValue(defaultProvisionedData);
mockUseDeleteRepositoryFiles.mockReturnValue([
mockDeleteRepoFile,
createMockRequestState(requestState) as ReturnType<typeof useDeleteRepositoryFilesWithPathMutation>[1],
]);
return {
user,
props: defaultProps,
defaultProvisionedData,
...render(<DeleteProvisionedDashboardDrawer {...defaultProps} />),
};
}
describe('DeleteProvisionedDashboardDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock getAppEvents
(getAppEvents as jest.Mock).mockReturnValue({
publish: mockPublish,
});
});
describe('Rendering', () => {
it('should render the drawer with correct title and subtitle', () => {
setup();
expect(screen.getByRole('heading', { name: 'Delete Provisioned Dashboard' })).toBeInTheDocument();
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
it('should return null when defaultValues are not provided', () => {
setup({
provisionedData: {
defaultValues: null,
},
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should render shared form fields correctly', () => {
setup();
expect(screen.getByTestId('shared-fields')).toBeInTheDocument();
});
it('should render delete and cancel buttons', () => {
setup();
expect(screen.getByRole('button', { name: /delete dashboard/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('should successfully delete dashboard with branch workflow', async () => {
const { user } = setup();
const deleteButton = screen.getByRole('button', { name: /delete dashboard/i });
await user.click(deleteButton);
await waitFor(() => {
expect(mockDeleteRepoFile).toHaveBeenCalledWith({
name: 'test-repo',
path: 'dashboards/test.json',
ref: 'main',
message: 'Delete dashboard: Test Dashboard',
});
});
});
it('should handle missing repository name', async () => {
const { user } = setup({
provisionedData: {
defaultValues: {
repo: '',
ref: 'main',
workflow: 'branch' as const,
path: 'dashboards/test.json',
comment: '',
title: 'Test Dashboard',
description: 'Test Description',
folder: { uid: 'test-folder', title: 'Test Folder' },
},
repository: undefined,
},
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const deleteButton = screen.getByRole('button', { name: /delete dashboard/i });
await user.click(deleteButton);
expect(consoleSpy).toHaveBeenCalledWith('Missing required fields for deletion:', {
repo: '',
path: 'dashboards/test.json',
});
expect(mockDeleteRepoFile).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should handle missing path', async () => {
const { user } = setup({
provisionedData: {
defaultValues: {
repo: 'test-repo',
ref: 'main',
workflow: 'branch' as const,
path: '',
comment: '',
title: 'Test Dashboard',
description: 'Test Description',
folder: { uid: 'test-folder', title: 'Test Folder' },
},
},
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const deleteButton = screen.getByRole('button', { name: /delete dashboard/i });
await user.click(deleteButton);
expect(consoleSpy).toHaveBeenCalledWith('Missing required fields for deletion:', {
repo: 'test-repo',
path: '',
});
expect(mockDeleteRepoFile).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Error Handling', () => {
it('should handle error state', async () => {
const error = new Error('API Error');
setup({
requestState: {
isError: true,
error,
},
});
await waitFor(() => {
expect(mockPublish).toHaveBeenCalledWith({
type: AppEvents.alertError.name,
payload: ['Failed to delete dashboard', error],
});
});
});
});
describe('Loading State', () => {
it('should show loading state when deletion is in progress', () => {
setup({
requestState: {
isLoading: true,
},
});
const deleteButton = screen.getByRole('button', { name: /deleting/i });
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveTextContent('Deleting...');
});
});
describe('User Interactions', () => {
it('should call onDismiss when cancel button is clicked', async () => {
const { user, props } = setup();
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(props.onDismiss).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,148 @@
import { useForm, FormProvider } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents } from '@grafana/runtime';
import { Alert, Button, Drawer, Stack } from '@grafana/ui';
import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { DashboardEditFormSharedFields } from '../components/Provisioned/DashboardEditFormSharedFields';
import { ProvisionedDashboardFormData } from '../saving/shared';
import { DashboardScene } from '../scene/DashboardScene';
import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler';
export interface Props {
dashboard: DashboardScene;
defaultValues: ProvisionedDashboardFormData;
readOnly: boolean;
isGitHub: boolean;
isNew?: boolean;
workflowOptions: Array<{ label: string; value: string }>;
loadedFromRef?: string;
onDismiss: () => void;
}
/**
* @description
* Drawer component for deleting a git provisioned dashboard.
*/
export function DeleteProvisionedDashboardForm({
dashboard,
defaultValues,
loadedFromRef,
readOnly,
isGitHub,
isNew,
workflowOptions,
onDismiss,
}: Props) {
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
const { editPanel: panelEditor } = dashboard.useState();
const { handleSubmit, watch } = methods;
const [ref, workflow] = watch(['ref', 'workflow']);
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
const handleSubmitForm = async ({ repo, path, comment }: ProvisionedDashboardFormData) => {
if (!repo || !path) {
console.error('Missing required fields for deletion:', { repo, path });
return;
}
// If writing to the original branch, use the loaded reference; otherwise, use the selected ref.
const branchRef = workflow === 'write' ? loadedFromRef : ref;
const commitMessage = comment || `Delete dashboard: ${dashboard.state.title}`;
deleteRepoFile({
name: repo,
path: path,
ref: branchRef,
message: commitMessage,
});
};
const navigate = useNavigate();
const onRequestError = (error: unknown) => {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [t('dashboard-scene.delete-provisioned-dashboard-form.api-error', 'Failed to delete dashboard'), error],
});
};
const onWriteSuccess = () => {
panelEditor?.onDiscard();
onDismiss();
// TODO reset search state instead
window.location.href = '/dashboards';
};
const onBranchSuccess = (path: string, urls?: Record<string, string>) => {
panelEditor?.onDiscard();
onDismiss();
navigate(
`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?pull_request_url=${urls?.newPullRequestURL}`
);
};
useProvisionedRequestHandler({
dashboard,
request,
workflow,
handlers: {
onBranchSuccess: ({ path, urls }) => onBranchSuccess(path, urls),
onWriteSuccess,
onError: onRequestError,
},
});
return (
<Drawer
title={t('dashboard-scene.delete-provisioned-dashboard-form.drawer-title', 'Delete Provisioned Dashboard')}
subtitle={dashboard.state.title}
onClose={onDismiss}
>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.delete-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.delete-read-only-file-message">
This dashboard cannot be deleted directly from Grafana because the repository is read-only. To delete
this dashboard, please remove the file from your Git repository.
</Trans>
</Alert>
)}
<DashboardEditFormSharedFields
isNew={isNew}
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
/>
{/* Save / Cancel button */}
<Stack gap={2}>
<Button variant="destructive" type="submit" disabled={request.isLoading || readOnly}>
{request.isLoading
? t('dashboard-scene.delete-provisioned-dashboard-form.deleting', 'Deleting...')
: t('dashboard-scene.delete-provisioned-dashboard-form.delete-action', 'Delete dashboard')}
</Button>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.cancel-action">Cancel</Trans>
</Button>
</Stack>
</Stack>
</form>
</FormProvider>
</Drawer>
);
}

View File

@ -0,0 +1,433 @@
import { renderHook } from '@testing-library/react';
import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import {
DeleteRepositoryFilesWithPathApiResponse,
GetRepositoryFilesWithPathApiResponse,
ResourceWrapper,
} from 'app/api/clients/provisioning/v0alpha1';
import { Resource } from 'app/features/apiserver/types';
import { DashboardScene } from '../scene/DashboardScene';
import { useProvisionedRequestHandler } from './useProvisionedRequestHandler';
// Mock dependencies
jest.mock('@grafana/runtime', () => ({
getAppEvents: jest.fn(),
}));
jest.mock('@grafana/i18n', () => ({
t: jest.fn((key: string, defaultValue: string) => defaultValue),
}));
const mockGetAppEvents = jest.mocked(getAppEvents);
describe('useProvisionedRequestHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when request has an error', () => {
it('should call onError handler', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: true,
isSuccess: false,
error: new Error('Test error'),
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
})
);
expect(handlers.onError).toHaveBeenCalledWith(new Error('Test error'));
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
});
});
describe('when request is successful', () => {
it('should set dashboard isDirty to false', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {
ref: 'main',
path: '/path/to/dashboard',
},
},
workflowOverride: 'branch',
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
workflow: 'branch',
handlers,
})
);
expect(dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
});
it('should publish success event', () => {
const { request, handlers, dashboard, mockPublish } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {},
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
})
);
expect(mockPublish).toHaveBeenCalledWith({
type: AppEvents.alertSuccess.name,
payload: ['Dashboard changes saved successfully'],
});
});
describe('branch workflow', () => {
it('should call onBranchSuccess when workflow is branch and data has ref and path', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {
ref: 'feature-branch',
path: '/path/to/dashboard.json',
urls: { compareURL: 'http://example.com/edit' },
},
},
workflowOverride: 'branch',
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
workflow: 'branch',
handlers,
})
);
expect(handlers.onBranchSuccess).toHaveBeenCalledWith({
ref: 'feature-branch',
path: '/path/to/dashboard.json',
urls: { compareURL: 'http://example.com/edit' },
});
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
});
it('should not call onBranchSuccess when ref is missing', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {
path: '/path/to/dashboard.json',
},
},
workflowOverride: 'branch',
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
workflow: 'branch',
handlers,
})
);
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
expect(handlers.onWriteSuccess).toHaveBeenCalled();
});
});
describe('new dashboard flow', () => {
it('should call onNewDashboardSuccess when isNew is true and resource.upsert exists', () => {
const mockUpsertResource = {
metadata: {
name: 'test-dashboard',
uid: 'test-uid',
resourceVersion: '1',
creationTimestamp: new Date().toISOString(),
},
spec: { title: 'Test Dashboard' } as Dashboard,
apiVersion: 'v1',
kind: 'Dashboard',
};
const mockResource = {
metadata: {
name: 'test-dashboard',
uid: 'test-uid',
resourceVersion: '1',
creationTimestamp: new Date().toISOString(),
},
spec: { title: 'Test Dashboard' } as Dashboard,
apiVersion: 'v1',
kind: 'Dashboard',
upsert: mockUpsertResource,
} as Resource<Dashboard> & { upsert: Resource<Dashboard> };
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {
repository: 'test-repo',
resource: mockResource,
} as unknown as ProvisionedRequestData,
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
isNew: true,
})
);
expect(handlers.onNewDashboardSuccess).toHaveBeenCalledWith(mockResource.upsert);
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
});
it('should not call onNewDashboardSuccess when isNew is false', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {
repository: 'test-repo',
resource: {
upsert: {
apiVersion: 'v1',
kind: 'Dashboard',
metadata: { name: 'test-dashboard' },
spec: { title: 'Test Dashboard' } as Dashboard,
},
metadata: { name: 'test-dashboard' },
spec: { title: 'Test Dashboard' } as Dashboard,
apiVersion: 'v1',
kind: 'Dashboard',
} as unknown as Resource<Dashboard>,
} as unknown as ProvisionedRequestData,
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
isNew: false,
})
);
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
expect(handlers.onWriteSuccess).toHaveBeenCalled();
});
it('should not call onNewDashboardSuccess when resource.upsert is missing', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {
resource: {},
} as ResourceWrapper,
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
isNew: true,
})
);
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
expect(handlers.onWriteSuccess).toHaveBeenCalled();
});
});
describe('write workflow', () => {
it('should call onWriteSuccess as fallback', () => {
const { request, handlers, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: {} as GetRepositoryFilesWithPathApiResponse,
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
})
);
expect(handlers.onWriteSuccess).toHaveBeenCalled();
});
});
});
describe('when request is neither error nor success', () => {
it('should not call any handlers', () => {
const { request, handlers, dashboard, mockPublish } = setup({
requestOverrides: {
isError: false,
isSuccess: false,
isLoading: true,
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
})
);
expect(handlers.onError).not.toHaveBeenCalled();
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
expect(dashboard.setState).not.toHaveBeenCalled();
expect(mockPublish).not.toHaveBeenCalled();
});
});
describe('when request success but no data', () => {
it('should not call any handlers when data is undefined', () => {
const { request, handlers, dashboard, mockPublish } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
data: undefined,
},
});
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers,
})
);
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
expect(dashboard.setState).not.toHaveBeenCalled();
expect(mockPublish).not.toHaveBeenCalled();
});
});
describe('optional handlers', () => {
it('should not throw when optional handlers are not provided', () => {
const { request, dashboard } = setup({
requestOverrides: {
isError: false,
isSuccess: true,
},
handlersOverrides: {},
});
expect(() => {
renderHook(() =>
useProvisionedRequestHandler({
dashboard,
request,
handlers: {},
})
);
}).not.toThrow();
});
});
});
type ProvisionedRequestData = DeleteRepositoryFilesWithPathApiResponse | GetRepositoryFilesWithPathApiResponse;
function setup({
requestOverrides = {},
handlersOverrides = {},
workflowOverride,
}: {
requestOverrides?: Partial<{
isError: boolean;
isSuccess: boolean;
isLoading?: boolean;
error?: unknown;
data?: Partial<ProvisionedRequestData>;
}>;
handlersOverrides?: Partial<{
onBranchSuccess?: jest.Mock;
onWriteSuccess?: jest.Mock;
onNewDashboardSuccess?: jest.Mock;
onError?: jest.Mock;
}>;
workflowOverride?: string;
} = {}) {
const mockPublish = jest.fn();
const mockSetState = jest.fn();
mockGetAppEvents.mockReturnValue({
publish: mockPublish,
} as unknown as ReturnType<typeof getAppEvents>);
const dashboard = {
setState: mockSetState,
} as unknown as DashboardScene;
const request = {
isError: false,
isSuccess: false,
isLoading: false,
error: undefined,
data: undefined,
...(requestOverrides as ResourceWrapper),
};
const handlers = {
onError: jest.fn(),
onBranchSuccess: jest.fn(),
onWriteSuccess: jest.fn(),
onNewDashboardSuccess: jest.fn(),
...handlersOverrides,
};
return {
dashboard,
request,
handlers,
mockPublish,
mockSetState,
workflow: workflowOverride,
};
}

View File

@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getAppEvents } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import {
DeleteRepositoryFilesWithPathApiResponse,
GetRepositoryFilesWithPathApiResponse,
} from 'app/api/clients/provisioning/v0alpha1';
import { Resource } from 'app/features/apiserver/types';
import { DashboardScene } from '../scene/DashboardScene';
interface RequestHandlers {
onBranchSuccess?: (data: { ref: string; path: string; urls?: Record<string, string> }) => void;
onWriteSuccess?: () => void;
onNewDashboardSuccess?: (resource: Resource<Dashboard>) => void;
onError?: (error: unknown) => void;
}
interface ProvisionedRequest {
isError: boolean;
isSuccess: boolean;
isLoading?: boolean;
error?: unknown;
data?: DeleteRepositoryFilesWithPathApiResponse | GetRepositoryFilesWithPathApiResponse;
}
// This hook handles save new dashboard, edit existing dashboard, and delete dashboard response logic for provisioned dashboards.
export function useProvisionedRequestHandler({
dashboard,
request,
workflow,
handlers,
isNew,
}: {
dashboard: DashboardScene;
request: ProvisionedRequest;
workflow?: string;
handlers: RequestHandlers;
isNew?: boolean;
}) {
useEffect(() => {
if (request.isError) {
handlers.onError?.(request.error);
return;
}
if (request.isSuccess && request.data) {
dashboard.setState({ isDirty: false });
const { ref, path, urls, resource } = request.data;
// Branch workflow
if (workflow === 'branch' && ref && path) {
handlers.onBranchSuccess?.({ ref, path, urls });
return;
}
// Success message (could be configurable)
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [t('dashboard-scene.edit-provisioned-dashboard-form.success', 'Dashboard changes saved successfully')],
});
// New dashboard flow
if (isNew && resource?.upsert && handlers.onNewDashboardSuccess) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handlers.onNewDashboardSuccess(resource.upsert as Resource<Dashboard>);
return;
}
// Write workflow
handlers.onWriteSuccess?.();
}
}, [request, workflow, handlers, isNew, dashboard]);
}

View File

@ -5041,6 +5041,15 @@
}
}
},
"delete-provisioned-dashboard-form": {
"api-error": "Failed to delete dashboard",
"cancel-action": "Cancel",
"delete-action": "Delete dashboard",
"delete-read-only-file-message": "This dashboard cannot be deleted directly from Grafana because the repository is read-only. To delete this dashboard, please remove the file from your Git repository.",
"deleting": "Deleting...",
"drawer-title": "Delete Provisioned Dashboard",
"title-this-repository-is-read-only": "This repository is read only"
},
"description-label": {
"description": "Description"
},
@ -5051,6 +5060,9 @@
}
}
},
"edit-provisioned-dashboard-form": {
"success": "Dashboard changes saved successfully"
},
"email-list": {
"aria-label-emailmenu": "Toggle email menu"
},
@ -5323,24 +5335,25 @@
"placeholder-search-affected-dashboards": "Search affected dashboards",
"update-all": "Update all"
},
"save-or-delete-provisioned-dashboard-form": {
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
"description-branch-name-in-git-hub": "Branch name in GitHub",
"description-inside-repository": "File path inside the repository (.json or .yaml)",
"label-branch": "Branch",
"label-comment": "Comment",
"label-path": "Path",
"label-workflow": "Workflow"
},
"save-provisioned-dashboard-form": {
"api-error": "Error saving dashboard",
"api-success": "Dashboard changes saved",
"cancel": "Cancel",
"cannot-be-saved": "This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy the JSON or save it to a file below, then you can update your dashboard in the provisioning source.",
"copy-json-message": "If you have direct access to the target, copy the JSON and paste it there.",
"copy-json-to-clipboard": "Copy JSON to clipboard",
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
"description-branch-name-in-git-hub": "Branch name in GitHub",
"description-inside-repository": "File path inside the repository (.json or .yaml)",
"file-path": "<0>File path:</0> {{filePath}}",
"label-branch": "Branch",
"label-comment": "Comment",
"label-description": "Description",
"label-path": "Path",
"label-target-folder": "Target folder",
"label-title": "Title",
"label-workflow": "Workflow",
"save": "Save",
"save-json-to-file": "Save JSON to file",
"saving": "Saving...",