mirror of https://github.com/grafana/grafana.git
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { Controller, useForm, FormProvider } from 'react-hook-form';
|
|
import { useNavigate } from 'react-router-dom-v5-compat';
|
|
|
|
import { AppEvents, locationUtil } from '@grafana/data';
|
|
import { Trans, t } from '@grafana/i18n';
|
|
import { getAppEvents, locationService } from '@grafana/runtime';
|
|
import { Dashboard } from '@grafana/schema';
|
|
import { Button, Field, Input, Stack, TextArea } from '@grafana/ui';
|
|
import { RepositoryView, Unstructured } from 'app/api/clients/provisioning/v0alpha1';
|
|
import kbn from 'app/core/utils/kbn';
|
|
import { Resource } from 'app/features/apiserver/types';
|
|
import { SaveDashboardFormCommonOptions } from 'app/features/dashboard-scene/saving/SaveDashboardForm';
|
|
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
|
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
|
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
|
|
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
|
|
import {
|
|
ProvisionedOperationInfo,
|
|
useProvisionedRequestHandler,
|
|
} from 'app/features/provisioning/hooks/useProvisionedRequestHandler';
|
|
import { SaveDashboardResponseDTO } from 'app/types/dashboard';
|
|
|
|
import { ProvisionedDashboardFormData } from '../../types/form';
|
|
import { buildResourceBranchRedirectUrl } from '../../utils/redirect';
|
|
import { ProvisioningAwareFolderPicker } from '../Shared/ProvisioningAwareFolderPicker';
|
|
import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner';
|
|
import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields';
|
|
import { getProvisionedMeta } from '../utils/getProvisionedMeta';
|
|
|
|
import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard';
|
|
|
|
export interface Props extends SaveProvisionedDashboardProps {
|
|
isNew: boolean;
|
|
defaultValues: ProvisionedDashboardFormData;
|
|
workflowOptions: Array<{ label: string; value: string }>;
|
|
readOnly: boolean;
|
|
repository?: RepositoryView;
|
|
}
|
|
|
|
export function SaveProvisionedDashboardForm({
|
|
defaultValues,
|
|
dashboard,
|
|
drawer,
|
|
changeInfo,
|
|
isNew,
|
|
workflowOptions,
|
|
readOnly,
|
|
repository,
|
|
}: Props) {
|
|
const navigate = useNavigate();
|
|
const appEvents = getAppEvents();
|
|
const { isDirty, editPanel: panelEditor } = dashboard.useState();
|
|
|
|
const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path);
|
|
|
|
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
|
|
const { handleSubmit, watch, control, reset, register } = methods;
|
|
const [workflow] = watch(['workflow']);
|
|
|
|
// Update the form if default values change
|
|
useEffect(() => {
|
|
reset(defaultValues);
|
|
}, [defaultValues, reset]);
|
|
|
|
const onRequestError = (error: unknown) => {
|
|
appEvents.publish({
|
|
type: AppEvents.alertError.name,
|
|
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), error],
|
|
});
|
|
};
|
|
|
|
const handleNewDashboard = (upsert: Resource<Dashboard>) => {
|
|
// Navigation for new dashboards
|
|
const url = locationUtil.assureBaseUrl(
|
|
getDashboardUrl({
|
|
uid: upsert.metadata.name,
|
|
slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
|
|
currentQueryParams: window.location.search,
|
|
})
|
|
);
|
|
navigate(url);
|
|
};
|
|
|
|
const onWriteSuccess = (_: ProvisionedOperationInfo, upsert: Resource<Dashboard>) => {
|
|
handleDismiss();
|
|
if (isNew && upsert?.metadata.name) {
|
|
handleNewDashboard(upsert);
|
|
} else {
|
|
locationService.partial({
|
|
viewPanel: null,
|
|
editPanel: null,
|
|
});
|
|
}
|
|
};
|
|
|
|
const onBranchSuccess = (ref: string, path: string, info: ProvisionedOperationInfo, upsert: Resource<Dashboard>) => {
|
|
handleDismiss();
|
|
if (isNew && upsert?.metadata?.name) {
|
|
handleNewDashboard(upsert);
|
|
} else {
|
|
const url = buildResourceBranchRedirectUrl({
|
|
baseUrl: `${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}`,
|
|
paramName: 'ref',
|
|
paramValue: ref,
|
|
repoType: info.repoType,
|
|
});
|
|
navigate(url);
|
|
}
|
|
};
|
|
|
|
const handleDismiss = () => {
|
|
panelEditor?.onDiscard();
|
|
|
|
const model = dashboard.getSaveModel();
|
|
const resourceData = request?.data?.resource.upsert || request?.data?.resource.dryRun;
|
|
const saveResponse = createSaveResponseFromResource(resourceData);
|
|
dashboard.saveCompleted(model, saveResponse, defaultValues.folder?.uid);
|
|
|
|
drawer.onClose();
|
|
};
|
|
|
|
useProvisionedRequestHandler<Dashboard>({
|
|
folderUID: defaultValues.folder?.uid,
|
|
request,
|
|
workflow,
|
|
resourceType: 'dashboard',
|
|
handlers: {
|
|
onBranchSuccess: ({ ref, path }, info, resource) => onBranchSuccess(ref, path, info, resource),
|
|
onWriteSuccess,
|
|
onError: onRequestError,
|
|
},
|
|
});
|
|
|
|
// Submit handler for saving the form data
|
|
const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: ProvisionedDashboardFormData) => {
|
|
// Validate required fields
|
|
if (!repo || !path) {
|
|
console.error('Missing required fields for saving:', { repo, path });
|
|
return;
|
|
}
|
|
|
|
// TODO: Revisit after we decide on whether to keep the branch selection functionality
|
|
// If user is updating a dashboard in the original branch, override ref with whatever we loaded from
|
|
// if (workflow === 'write' && !isNew) {
|
|
// ref = loadedFromRef;
|
|
// }
|
|
|
|
const message = comment || `Save dashboard: ${dashboard.state.title}`;
|
|
|
|
const body = dashboard.getSaveResource({
|
|
isNew,
|
|
title,
|
|
description,
|
|
copyTags: true,
|
|
});
|
|
|
|
createOrUpdateFile({
|
|
// Skip adding ref to the default branch request
|
|
ref: ref === repository?.branch ? undefined : ref,
|
|
name: repo,
|
|
path,
|
|
message,
|
|
body,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<FormProvider {...methods}>
|
|
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
|
|
<Stack direction="column" gap={2}>
|
|
{readOnly && (
|
|
<RepoInvalidStateBanner
|
|
noRepository={false}
|
|
isReadOnlyRepo={true}
|
|
readOnlyMessage="If you have direct access to the target, copy the JSON and paste it there."
|
|
/>
|
|
)}
|
|
|
|
{isNew && (
|
|
<>
|
|
<Field
|
|
noMargin
|
|
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')}
|
|
invalid={!!methods.formState.errors.title}
|
|
error={methods.formState.errors.title?.message}
|
|
>
|
|
<Input
|
|
id="dashboard-title"
|
|
{...register('title', {
|
|
required: t(
|
|
'dashboard-scene.save-provisioned-dashboard-form.title-required',
|
|
'Dashboard title is required'
|
|
),
|
|
validate: validateTitle,
|
|
})}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
noMargin
|
|
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')}
|
|
invalid={!!methods.formState.errors.description}
|
|
error={methods.formState.errors.description?.message}
|
|
>
|
|
<TextArea id="dashboard-description" {...register('description')} />
|
|
</Field>
|
|
|
|
<Field
|
|
noMargin
|
|
label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}
|
|
>
|
|
<Controller
|
|
control={control}
|
|
name={'folder'}
|
|
render={({ field: { ref, value, onChange, ...field } }) => {
|
|
return (
|
|
<ProvisioningAwareFolderPicker
|
|
onChange={async (uid?: string, title?: string) => {
|
|
onChange({ uid, title });
|
|
updateURLParams('folderUid', uid);
|
|
const meta = await getProvisionedMeta(uid);
|
|
dashboard.setState({
|
|
meta: {
|
|
...meta,
|
|
folderUid: uid,
|
|
},
|
|
});
|
|
}}
|
|
value={value.uid}
|
|
{...field}
|
|
showAllFolders
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
|
|
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
|
|
|
|
<ResourceEditFormSharedFields
|
|
resourceType="dashboard"
|
|
readOnly={readOnly}
|
|
workflow={workflow}
|
|
workflowOptions={workflowOptions}
|
|
repository={repository}
|
|
isNew={isNew}
|
|
/>
|
|
|
|
<Stack gap={2}>
|
|
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
|
|
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
|
|
</Button>
|
|
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty || readOnly}>
|
|
{request.isLoading
|
|
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
|
|
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</form>
|
|
</FormProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dashboard title validation to ensure it's not the same as the folder name
|
|
* and meets other naming requirements.
|
|
*/
|
|
async function validateTitle(title: string, formValues: ProvisionedDashboardFormData) {
|
|
if (title === formValues.folder.title?.trim()) {
|
|
return t(
|
|
'dashboard-scene.save-provisioned-dashboard-form.title-same-as-folder',
|
|
'Dashboard name cannot be the same as the folder name'
|
|
);
|
|
}
|
|
try {
|
|
await validationSrv.validateNewDashboardName(formValues.folder.uid ?? 'general', title);
|
|
return true;
|
|
} catch (error) {
|
|
return error instanceof Error
|
|
? error.message
|
|
: t(
|
|
'dashboard-scene.save-provisioned-dashboard-form.title-validation-failed',
|
|
'Dashboard title validation failed.'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update the URL params without reloading the page
|
|
function updateURLParams(param: string, value?: string) {
|
|
// only check undefine and null, empty string = root folder, we still want to update the URL
|
|
if (value === undefined || value === null) {
|
|
return;
|
|
}
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set(param, value);
|
|
window.history.replaceState({}, '', url);
|
|
}
|
|
|
|
/**
|
|
* Creates a SaveDashboardResponseDTO from a provisioning resource response
|
|
* This allows us to use the standard dashboard save completion flow
|
|
*/
|
|
function createSaveResponseFromResource(resource?: Unstructured): SaveDashboardResponseDTO {
|
|
const uid = resource?.metadata?.name;
|
|
const title = resource?.spec?.title;
|
|
const slug = kbn.slugifyForUrl(title);
|
|
|
|
return {
|
|
uid,
|
|
// Use the current dashboard state version to maintain consistency
|
|
version: resource?.metadata?.generation,
|
|
id: resource?.spec?.id || 0,
|
|
status: 'success',
|
|
url: locationUtil.assureBaseUrl(
|
|
getDashboardUrl({
|
|
uid,
|
|
slug,
|
|
currentQueryParams: '',
|
|
})
|
|
),
|
|
slug,
|
|
};
|
|
}
|