Provisioning: Add branch dropdown to save drawer (#110270)

* Provisioning: Add branch dropdown to save modals

* Allow custom value

* Fix

* Refactor branch selection

* SHow configured branch first

* Update validation error

* Update tests

* Move workflow toggle into onChange

* Clear errors on switch

* Fix tests

* Comments
This commit is contained in:
Alex Khomenko
2025-08-29 17:47:24 +03:00
committed by GitHub
parent de1cc4c1a7
commit 48ad2fe46b
12 changed files with 162 additions and 39 deletions
@@ -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 }));
@@ -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 {
@@ -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,
});
@@ -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: {
@@ -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
});
@@ -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: {
@@ -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<DashboardEditFormSharedFieldsPr
const {
control,
register,
setValue,
clearErrors,
formState: { errors },
} = useFormContext();
const {
data: branchData,
isLoading: branchLoading,
error: branchError,
} = useGetRepositoryRefsQuery(
!repository?.name || !isGitProvider(repository.type) ? skipToken : { name: repository.name }
);
const branchOptions = useMemo(() => {
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<DashboardEditFormSharedFieldsPr
<>
<Field
noMargin
style={{ overflow: 'auto' }} // TODO Fix radio button group display on smaller screens
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-workflow', 'Workflow')}
>
<Controller
control={control}
name="workflow"
render={({ field: { ref: _, ...field } }) => (
<RadioButtonGroup id="provisioned-resource-form-workflow" {...field} options={workflowOptions} />
render={({ field: { ref, onChange, ...field } }) => (
<RadioButtonGroup
id="provisioned-resource-form-workflow"
{...field}
onChange={(nextWorkflow) => {
onChange(nextWorkflow);
clearErrors('ref');
if (nextWorkflow === 'branch') {
setValue('ref', newBranchDefaultName);
} else if (nextWorkflow === 'write' && repository?.branch) {
setValue('ref', repository.branch);
}
}}
options={workflowOptions}
/>
)}
/>
</Field>
{workflow === 'branch' && (
{(workflow === 'write' || workflow === 'branch') && (
<Field
htmlFor="provisioned-ref"
noMargin
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-branch', 'Branch')}
description={t(
'provisioned-resource-form.save-or-delete-resource-shared-fields.description-branch-name-in-git-hub',
'Branch name in GitHub'
)}
invalid={!!errors.ref}
error={errors.ref && <BranchValidationError />}
invalid={Boolean(errors.ref || branchError)}
error={
errors.ref ? (
<BranchValidationError />
) : branchError ? (
t('provisioning.config-form.error-fetch-branches', 'Failed to fetch branches')
) : undefined
}
>
<Input id="provisioned-resource-form-branch" {...register('ref', { validate: validateBranchName })} />
<Controller
name="ref"
control={control}
rules={{ validate: validateBranchName }}
render={({ field: { ref, onChange, ...field } }) =>
workflow === 'write' ? (
<Combobox
{...field}
invalid={!!errors.ref}
id="provisioned-ref"
onChange={(option) => 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
/>
) : (
<Input
{...field}
invalid={!!errors.ref}
id="provisioned-ref"
onChange={onChange}
placeholder={t(
'provisioned-resource-form.save-or-delete-resource-shared-fields.placeholder-new-branch',
'Enter new branch name'
)}
/>
)
}
/>
</Field>
)}
</>
@@ -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,
};
}
@@ -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()}`;
}
@@ -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<React.SetStateAction<boolean>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
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,
@@ -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,
+10 -6
View File
@@ -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": {