From fe4abf2221d3c281a85c775cc2b61cc05d696736 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 18 Jun 2025 15:14:03 -0400 Subject: [PATCH] Git Sync UI: Delete Provisioned Dashboard Flow (#106593) * 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 * 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 * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx Co-authored-by: Alex Khomenko * 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 --------- Co-authored-by: Roberto Jimenez Sanchez Co-authored-by: Alex Khomenko --- .../apis/provisioning/resources/dualwriter.go | 8 +- .../DashboardEditFormSharedFields.test.tsx | 244 ++++++++++ .../DashboardEditFormSharedFields.tsx | 88 ++++ .../provisioned/SaveProvisionedDashboard.tsx | 18 +- .../SaveProvisionedDashboardForm.test.tsx | 86 ++-- .../SaveProvisionedDashboardForm.tsx | 365 ++++++--------- .../saving/provisioned/hooks.ts | 74 ++- .../dashboard-scene/saving/shared.tsx | 14 + .../settings/DeleteDashboardButton.tsx | 13 +- .../DeleteProvisionedDashboardDrawer.tsx | 35 ++ .../DeleteProvisionedDashboardForm.test.tsx | 302 ++++++++++++ .../DeleteProvisionedDashboardForm.tsx | 148 ++++++ .../useProvisionedRequestHandler.test.ts | 433 ++++++++++++++++++ .../utils/useProvisionedRequestHandler.ts | 77 ++++ public/locales/en-US/grafana.json | 29 +- 15 files changed, 1639 insertions(+), 295 deletions(-) create mode 100644 public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx create mode 100644 public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.tsx create mode 100644 public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardDrawer.tsx create mode 100644 public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx create mode 100644 public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.test.ts create mode 100644 public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.ts diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index b9f6e71c3a7..7d7b46e0406 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -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) diff --git a/public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx b/public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx new file mode 100644 index 00000000000..1f2a6250e82 --- /dev/null +++ b/public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx @@ -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; + 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 = { + path: '', + comment: '', + ref: '', + workflow: 'write', + ...formDefaultValues, + }; + + const FormWrapper = ({ children }: { children: ReactNode }) => { + const methods = useForm({ + defaultValues: defaultFormValues, + mode: 'onChange', + }); + return {children}; + }; + + const componentProps = { + workflowOptions, + isNew, + readOnly, + workflow, + isGitHub, + }; + + return { + user, + ...render( + + + + ), + }; +} + +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 | undefined; + + const TestComponent = () => { + const methods = useForm({ + defaultValues: { path: '', comment: '', ref: '', workflow: 'write' }, + }); + + // Capture form values for assertion + formValues = methods.watch(); + + return ( + + + + ); + }; + + const user = userEvent.setup(); + render(); + + 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(); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.tsx b/public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.tsx new file mode 100644 index 00000000000..4b0a25ca4e1 --- /dev/null +++ b/public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.tsx @@ -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( + ({ readOnly = false, workflow, workflowOptions, isGitHub, isNew }) => { + const { + control, + register, + formState: { errors }, + } = useFormContext(); + + return ( + <> + {/* Path */} + + + + + {/* Comment */} + +