diff --git a/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx b/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx index fbcca6165e8..320aed9782e 100644 --- a/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx @@ -233,7 +233,7 @@ describe('BulkDeleteProvisionedResource', () => { const { user, mockCreateBulkJob, defaultRepository } = setup(null); // Switch to write workflow - const writeRadio = screen.getByRole('radio', { name: /Save/i }); + const writeRadio = screen.getByRole('radio', { name: /Push to an existing branch/i }); await user.click(writeRadio); await user.click(screen.getByRole('button', { name: /Delete/i })); diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index 7506431534e..f144f03fda0 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -76,6 +76,11 @@ jest.mock('react-router-dom-v5-compat', () => { }; }); +// Mock RTK Query hook used inside ResourceEditFormSharedFields to avoid requiring a Redux Provider +jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ + useGetRepositoryRefsQuery: jest.fn().mockReturnValue({ data: { items: [] }, isLoading: false, error: null }), +})); + jest.mock('app/features/dashboard-scene/saving/SaveDashboardForm', () => { const actual = jest.requireActual('app/features/dashboard-scene/saving/SaveDashboardForm'); return { diff --git a/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx b/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx index e7ad552c78a..5b76b4a534a 100644 --- a/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx +++ b/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx @@ -149,7 +149,6 @@ function FormContent({ initialValues, parentFolder, repository, workflowOptions, export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteProvisionedFolderFormProps) { const { workflowOptions, repository, folder, initialValues, isReadOnlyRepo } = useProvisionedFolderFormData({ folderUid: parentFolder?.uid, - action: 'delete', title: parentFolder?.title, }); diff --git a/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.test.tsx b/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.test.tsx index 037aea4f0cb..0bd80b282a9 100644 --- a/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.test.tsx +++ b/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.test.tsx @@ -40,6 +40,7 @@ jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => { jest.mock('app/api/clients/provisioning/v0alpha1', () => { return { useCreateRepositoryFilesWithPathMutation: jest.fn(), + useGetRepositoryRefsQuery: jest.fn().mockReturnValue({ data: { items: [] }, isLoading: false, error: null }), provisioningAPIv0alpha1: { endpoints: { listRepository: { diff --git a/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.tsx b/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.tsx index f39fffd9296..98d228dbc76 100644 --- a/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.tsx +++ b/public/app/features/provisioning/components/Folders/NewProvisionedFolderForm.tsx @@ -205,7 +205,6 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) { const { workflowOptions, repository, folder, initialValues, isReadOnlyRepo } = useProvisionedFolderFormData({ folderUid: parentFolder?.uid, - action: 'create', title: '', // Empty title for new folders }); diff --git a/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.test.tsx b/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.test.tsx index 5451a6580bf..306571926c1 100644 --- a/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.test.tsx +++ b/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.test.tsx @@ -3,11 +3,15 @@ import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import type { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; import { ProvisionedDashboardFormData } from '../../types/form'; import { ResourceEditFormSharedFields } from './ResourceEditFormSharedFields'; +// Mock RTK Query hook used inside ResourceEditFormSharedFields to avoid requiring a Redux Provider +jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ + useGetRepositoryRefsQuery: jest.fn().mockReturnValue({ data: { items: [] }, isLoading: false, error: null }), +})); const mockRepo: { github: RepositoryView; local: RepositoryView } = { github: { diff --git a/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.tsx b/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.tsx index 3196099e8a2..c9cd844f5ff 100644 --- a/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.tsx +++ b/public/app/features/provisioning/components/Shared/ResourceEditFormSharedFields.tsx @@ -1,14 +1,17 @@ -import { memo } from 'react'; +import { skipToken } from '@reduxjs/toolkit/query/react'; +import { memo, useMemo } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { t } from '@grafana/i18n'; -import { Field, TextArea, Input, RadioButtonGroup } from '@grafana/ui'; -import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { Combobox, Field, Input, RadioButtonGroup, TextArea } from '@grafana/ui'; +import { RepositoryView, useGetRepositoryRefsQuery } from 'app/api/clients/provisioning/v0alpha1'; import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError'; import { WorkflowOption } from 'app/features/provisioning/types'; import { validateBranchName } from 'app/features/provisioning/utils/git'; import { isGitProvider } from 'app/features/provisioning/utils/repositoryTypes'; +import { generateNewBranchName } from '../utils/newBranchName'; + interface DashboardEditFormSharedFieldsProps { resourceType: 'dashboard' | 'folder'; workflowOptions: Array<{ label: string; value: string }>; @@ -24,13 +27,59 @@ export const ResourceEditFormSharedFields = memo { + const options: Array<{ label: string; value: string }> = []; + + const configuredBranch = repository?.branch; + const prefix = t( + 'provisioned-resource-form.save-or-delete-resource-shared-fields.suffix-configured-branch', + '(Configured branch)' + ); + // Show the configured branch first in the list + if (configuredBranch) { + options.push({ + label: `${configuredBranch} ${prefix}`, + value: configuredBranch, + }); + } + + // Create combobox options + if (branchData?.items) { + for (const ref of branchData.items) { + if (ref.name !== configuredBranch) { + options.push({ label: ref.name, value: ref.name }); + } + } + } + + return options; + }, [branchData?.items, repository?.branch]); + + const newBranchDefaultName = useMemo(() => generateNewBranchName(resourceType), [resourceType]); + const pathText = resourceType === 'dashboard' - ? 'File path inside the repository (.json or .yaml)' - : 'Folder path inside the repository'; + ? t( + 'provisioned-resource-form.save-or-delete-resource-shared-fields.description-file-path', + 'File path inside the repository (.json or .yaml)' + ) + : t( + 'provisioned-resource-form.save-or-delete-resource-shared-fields.description-folder-path', + 'Folder path inside the repository' + ); return ( <> @@ -70,28 +119,82 @@ export const ResourceEditFormSharedFields = memo ( - + render={({ field: { ref, onChange, ...field } }) => ( + { + onChange(nextWorkflow); + clearErrors('ref'); + if (nextWorkflow === 'branch') { + setValue('ref', newBranchDefaultName); + } else if (nextWorkflow === 'write' && repository?.branch) { + setValue('ref', repository.branch); + } + }} + options={workflowOptions} + /> )} /> - {workflow === 'branch' && ( + {(workflow === 'write' || workflow === 'branch') && ( } + invalid={Boolean(errors.ref || branchError)} + error={ + errors.ref ? ( + + ) : branchError ? ( + t('provisioning.config-form.error-fetch-branches', 'Failed to fetch branches') + ) : undefined + } > - + + workflow === 'write' ? ( + onChange(option ? option.value : '')} + placeholder={t( + 'provisioned-resource-form.save-or-delete-resource-shared-fields.placeholder-branch', + 'Select or enter branch name' + )} + options={branchOptions} + loading={branchLoading} + createCustomValue + isClearable + /> + ) : ( + + ) + } + /> )} diff --git a/public/app/features/provisioning/components/defaults.ts b/public/app/features/provisioning/components/defaults.ts index 113d2ac5e11..5dc3c7c8f3e 100644 --- a/public/app/features/provisioning/components/defaults.ts +++ b/public/app/features/provisioning/components/defaults.ts @@ -8,7 +8,7 @@ export function getDefaultWorkflow(config?: RepositoryView, loadedFromRef?: stri return config?.workflows?.[0]; } -export function getWorkflowOptions(config?: RepositoryView, ref?: string) { +export function getWorkflowOptions(config?: RepositoryView) { if (!config) { return []; } @@ -17,19 +17,17 @@ export function getWorkflowOptions(config?: RepositoryView, ref?: string) { return [{ label: `Save`, value: 'write' }]; } - // When a branch is configured, show it - if (!ref && config.branch) { - ref = config.branch; - } - // Return the workflows in the configured order return config.workflows.map((value) => { switch (value) { case 'write': - return { label: ref ? `Push to ${ref}` : 'Save', value }; + return { + label: t('provisioning.workflow-options-label.push-to-existing-branch', 'Push to an existing branch'), + value, + }; case 'branch': return { - label: t('dashboard-scene.get-workflow-options.label.push-to-a-new-branch', 'Push to a new branch'), + label: t('provisioning.workflow-options-label.push-to-a-new-branch', 'Push to a new branch'), value, }; } diff --git a/public/app/features/provisioning/components/utils/newBranchName.ts b/public/app/features/provisioning/components/utils/newBranchName.ts new file mode 100644 index 00000000000..ec92a6cc5ee --- /dev/null +++ b/public/app/features/provisioning/components/utils/newBranchName.ts @@ -0,0 +1,9 @@ +import { generateTimestamp } from './timestamp'; + +/** + * Generate a new branch name for provisioned resources. + * Uses the resource type as a prefix and appends a timestamp. + */ +export function generateNewBranchName(resourceType: string): string { + return `${resourceType}/${generateTimestamp()}`; +} diff --git a/public/app/features/provisioning/hooks/useProvisionedDashboardData.ts b/public/app/features/provisioning/hooks/useProvisionedDashboardData.ts index abbd3ec1990..52facc06a05 100644 --- a/public/app/features/provisioning/hooks/useProvisionedDashboardData.ts +++ b/public/app/features/provisioning/hooks/useProvisionedDashboardData.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; import { useUrlParams } from 'app/core/navigation/hooks'; @@ -29,8 +29,8 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription, loade name: managerKind === 'repo' ? managerIdentity : undefined, folderName: meta.folderUid, }); - const timestamp = generateTimestamp(); + const timestamp = generateTimestamp(); const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath]; const dashboardPath = generatePath({ @@ -40,13 +40,15 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription, loade folderPath, }); + const defaultWorkflow = getDefaultWorkflow(repository, loadedFromRef); + if (isLoading || !repository) { return null; } return { values: { - ref: `dashboard/${timestamp}`, + ref: defaultWorkflow === 'branch' ? `dashboard/${timestamp}` : (repository?.branch ?? ''), path: dashboardPath, repo: managerIdentity || repository?.name || '', comment: '', @@ -66,7 +68,7 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription, loade export interface ProvisionedDashboardData { isReady: boolean; isLoading: boolean; - setIsLoading: React.Dispatch>; + setIsLoading: Dispatch>; defaultValues: ProvisionedDashboardFormData | null; repository?: RepositoryView; loadedFromRef?: string; @@ -108,7 +110,7 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio } const { values, isNew, repository } = defaultValuesResult; - const workflowOptions = getWorkflowOptions(repository, loadedFromRef); + const workflowOptions = getWorkflowOptions(repository); return { isReady: true, diff --git a/public/app/features/provisioning/hooks/useProvisionedFolderFormData.ts b/public/app/features/provisioning/hooks/useProvisionedFolderFormData.ts index 90f8eb7dfd0..ead7c012e07 100644 --- a/public/app/features/provisioning/hooks/useProvisionedFolderFormData.ts +++ b/public/app/features/provisioning/hooks/useProvisionedFolderFormData.ts @@ -11,7 +11,6 @@ import { BaseProvisionedFormData } from '../types/form'; interface UseProvisionedFolderFormDataProps { folderUid?: string; - action: 'create' | 'delete'; title?: string; } @@ -28,29 +27,29 @@ export interface ProvisionedFolderFormDataResult { */ export function useProvisionedFolderFormData({ folderUid, - action, title, }: UseProvisionedFolderFormDataProps): ProvisionedFolderFormDataResult { const { repository, folder, isLoading, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUid }); - const workflowOptions = getWorkflowOptions(repository); const timestamp = generateTimestamp(); + const workflowOptions = getWorkflowOptions(repository); const initialValues = useMemo(() => { // Only create initial values when we have the data if (!repository || isLoading) { return undefined; } + const defaultWorkflow = getDefaultWorkflow(repository); return { title: title || '', comment: '', - ref: `folder/${timestamp}`, + ref: defaultWorkflow === 'branch' ? `folder/${timestamp}` : (repository?.branch ?? ''), repo: repository.name || '', path: folder?.metadata?.annotations?.[AnnoKeySourcePath] || '', workflow: getDefaultWorkflow(repository), }; - }, [repository, folder, title, isLoading, timestamp]); + }, [repository, isLoading, title, timestamp, folder?.metadata?.annotations]); return { repository, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4ecbce8fd2b..855db903047 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5846,11 +5846,6 @@ "transparent-background": "Transparent background" } }, - "get-workflow-options": { - "label": { - "push-to-a-new-branch": "Push to a new branch" - } - }, "group-by-variable-form": { "alert-not-supported": "This data source does not support group by variables", "description-enables-users-custom-values": "Enables users to add custom values to the list", @@ -11159,11 +11154,16 @@ "save-or-delete-resource-shared-fields": { "comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)", "description-branch-name-in-git-hub": "Branch name in GitHub", + "description-file-path": "File path inside the repository (.json or .yaml)", + "description-folder-path": "Folder path inside the repository", "description-inside-repository": "", "label-branch": "Branch", "label-comment": "Comment", "label-path": "Path", - "label-workflow": "Workflow" + "label-workflow": "Workflow", + "placeholder-branch": "Select or enter branch name", + "placeholder-new-branch": "Enter new branch name", + "suffix-configured-branch": "(Configured branch)" } }, "provisioned-resource-preview-banner": { @@ -11697,6 +11697,10 @@ "button-previous": "Previous", "button-submitting": "Submitting...", "error-instance-repository-exists": "Instance repository already exists" + }, + "workflow-options-label": { + "push-to-a-new-branch": "Push to a new branch", + "push-to-existing-branch": "Push to an existing branch" } }, "public-dashboard": {