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 */} + +