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:
+1
-1
@@ -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 }));
|
||||
|
||||
+5
@@ -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
|
||||
});
|
||||
|
||||
|
||||
+5
-1
@@ -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: {
|
||||
|
||||
+114
-11
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user