ConfigForm: Add field-specific error handling (#112397)

* ConfigForm: Show api error to field

* i18n

* alert message only if error is not fetch error, consolidate getFormErros helpers
This commit is contained in:
Yunwen Zheng
2025-10-16 10:05:10 -04:00
committed by GitHub
parent ac503c5194
commit 1fb7953a95
3 changed files with 103 additions and 48 deletions
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getAppEvents, isFetchError, reportInteraction } from '@grafana/runtime';
import {
Button,
Checkbox,
@@ -32,6 +33,7 @@ import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { RepositoryFormData } from '../types';
import { dataToSpec } from '../utils/data';
import { getConfigFormErrors } from '../utils/getFormErrors';
import { getHasTokenInstructions } from '../utils/git';
import { getRepositoryTypeConfig, isGitProvider } from '../utils/repositoryTypes';
@@ -138,6 +140,18 @@ export function ConfigForm({ data }: ConfigFormProps) {
try {
const spec = dataToSpec(form);
await submitData(spec, form.token);
} catch (err) {
if (isFetchError(err)) {
const [field, errorMessage] = getConfigFormErrors(err.data?.errors);
if (field && errorMessage) {
setError(field, errorMessage);
return;
}
}
// fallback for non-fetch errors or unmapped fields
defaultAlert();
} finally {
setIsLoading(false);
}
@@ -351,7 +365,12 @@ export function ConfigForm({ data }: ConfigFormProps) {
}}
/>
</Field>
<Field noMargin label={t('provisioning.config-form.label-interval-seconds', 'Interval (seconds)')}>
<Field
noMargin
label={t('provisioning.config-form.label-interval-seconds', 'Interval (seconds)')}
error={errors?.sync?.intervalSeconds?.message}
invalid={!!errors?.sync?.intervalSeconds}
>
<Input
{...register('sync.intervalSeconds', { valueAsNumber: true })}
type={'number'}
@@ -376,3 +395,10 @@ export function ConfigForm({ data }: ConfigFormProps) {
</form>
);
}
const defaultAlert = () => {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [t('provisioning.wizard-content.error-save-repository-setting', 'Failed to save repository setting')],
});
};
@@ -1,63 +1,91 @@
import { Path } from 'react-hook-form';
import { ErrorDetails } from 'app/api/clients/provisioning/v0alpha1';
import { WizardFormData } from '../Wizard/types';
import { RepositoryFormData } from '../types';
export type RepositoryField = keyof WizardFormData['repository'];
export type RepositoryFormPath = `repository.${RepositoryField}` | `repository.sync.intervalSeconds`;
export type FormErrorTuple = [RepositoryFormPath | null, { message: string } | null];
export type RepositoryFormPath = `repository.${RepositoryField}` | 'repository.sync.intervalSeconds';
type GenericFormPath = string;
type GenericFormErrorTuple<T extends GenericFormPath> = [T | null, { message: string } | null];
/**
* Maps API error details to form error fields for React Hook Form
*
* @param errors Array of error details from the API response
* @returns Tuple with form field path and error message
* Normalize API field name by removing "spec." prefix.
*/
export const getFormErrors = (errors: ErrorDetails[]): FormErrorTuple => {
const fieldsToValidate = [
'local.path',
'github.branch',
'github.url',
'github.path',
'secure.token',
'gitlab.branch',
'gitlab.url',
'bitbucket.branch',
'bitbucket.url',
'git.branch',
'git.url',
'sync.intervalSeconds',
];
const normalizeField = (field: string): string => field.replace(/^spec\./, '');
const nestedFieldMap: Record<string, RepositoryFormPath> = {
'sync.intervalSeconds': 'repository.sync.intervalSeconds',
};
const fieldMap: Record<string, RepositoryFormPath> = {
path: 'repository.path',
branch: 'repository.branch',
url: 'repository.url',
token: 'repository.token',
};
/**
* Given a list of error details and a field mapping,
* returns the first matched form error tuple.
*/
function mapErrorsToField<T extends GenericFormPath>(
errors: ErrorDetails[] | undefined,
fieldMap: Record<string, T>,
opts?: { allowPartial?: boolean }
): GenericFormErrorTuple<T> {
if (!errors || errors.length === 0) {
return [null, null];
}
for (const error of errors) {
if (error.field) {
const cleanField = error.field.replace('spec.', '');
if (fieldsToValidate.includes(cleanField)) {
// Check for direct nested field mapping first
if (cleanField in nestedFieldMap) {
return [nestedFieldMap[cleanField], { message: error.detail || `Invalid ${cleanField}` }];
}
if (!error.field) {
continue;
}
// Fall back to simple field mapping for non-nested fields
const fieldParts = cleanField.split('.');
const lastPart = fieldParts[fieldParts.length - 1];
const normalized = normalizeField(error.field);
const segments = normalized.split('.');
const lastPart = segments[segments.length - 1];
if (lastPart in fieldMap) {
return [fieldMap[lastPart], { message: error.detail || `Invalid ${lastPart}` }];
}
}
// Direct full match (e.g. "sync.intervalSeconds")
if (normalized in fieldMap) {
return [fieldMap[normalized], { message: error.detail || `Invalid ${normalized}` }];
}
// Partial match by last key (e.g. "url" -> "repository.url")
if (opts?.allowPartial && lastPart in fieldMap) {
return [fieldMap[lastPart], { message: error.detail || `Invalid ${lastPart}` }];
}
}
return [null, null];
}
// Wizard form errors
export type FormErrorTuple = GenericFormErrorTuple<RepositoryFormPath>;
export const getFormErrors = (errors: ErrorDetails[]): FormErrorTuple => {
const fieldMap: Record<string, RepositoryFormPath> = {
'local.path': 'repository.path',
'github.branch': 'repository.branch',
'github.url': 'repository.url',
'github.path': 'repository.path',
'secure.token': 'repository.token',
'gitlab.branch': 'repository.branch',
'gitlab.url': 'repository.url',
'bitbucket.branch': 'repository.branch',
'bitbucket.url': 'repository.url',
'git.branch': 'repository.branch',
'git.url': 'repository.url',
'sync.intervalSeconds': 'repository.sync.intervalSeconds',
};
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
};
// Config form errors
export type ConfigFormPath = Path<RepositoryFormData>;
export type ConfigFormErrorTuple = GenericFormErrorTuple<ConfigFormPath>;
export const getConfigFormErrors = (errors?: ErrorDetails[]): ConfigFormErrorTuple => {
const fieldMap: Record<string, ConfigFormPath> = {
path: 'path',
branch: 'branch',
url: 'url',
token: 'token',
tokenUser: 'tokenUser',
'sync.intervalSeconds': 'sync.intervalSeconds',
};
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
};
+2 -1
View File
@@ -11828,7 +11828,8 @@
"button-cancelling": "Cancelling...",
"button-previous": "Previous",
"button-submitting": "Submitting...",
"error-instance-repository-exists": "Instance repository already exists"
"error-instance-repository-exists": "Instance repository already exists",
"error-save-repository-setting": "Failed to save repository setting"
},
"workflow-options-label": {
"push-to-a-new-branch": "Push to a new branch",