mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-29 02:49:55 -05:00
feat: surface API validation errors to registration form fields (#1902)
This PR surfaces API validation errors from the registration endpoint directly onto the corresponding form fields, instead of only showing a generic "invalid data" message. A new `parseValidationErrors` helper extracts field names and messages from the API's `invalid_fields` array (e.g. `["email: email is not a valid email address"]`) and maps them to the appropriate form fields. The Register component integrates this parser into its error handling, prioritizing client-side validation but falling back to server-side field errors when present. Errors are cleared as the user types. A follow-up commit addressed PR review feedback: the `ValidationError` interface is now exported from the parser module and reused in `Register.vue` (eliminating a duplicate `ApiValidationError` interface), the type guard was tightened to check specifically for `invalid_fields` rather than broadly matching any object with a `message` property, the fallback error message always uses the localized translation key instead of potentially surfacing raw backend messages, and the `serverValidationErrors` ref uses `Partial<Record>` to accurately reflect that keys are optional. 🐰 A parser hops through error fields, Catching field names as each one yields, Client and server now both agree, Validation flows harmoniously free! Whitespace trimmed, no colon? It hops along, Register form now validated strong! ✨ --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kolaente <k@knt.li>
This commit is contained in:
96
frontend/src/helpers/parseValidationErrors.test.ts
Normal file
96
frontend/src/helpers/parseValidationErrors.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {parseValidationErrors} from './parseValidationErrors'
|
||||
|
||||
describe('parseValidationErrors', () => {
|
||||
it('returns empty object when no invalid_fields present', () => {
|
||||
const error = {
|
||||
message: 'invalid data',
|
||||
code: 2002,
|
||||
}
|
||||
|
||||
const result = parseValidationErrors(error)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('parses single field error', () => {
|
||||
const error = {
|
||||
message: 'invalid data',
|
||||
code: 2002,
|
||||
invalid_fields: ['email: email is not a valid email address'],
|
||||
}
|
||||
|
||||
const result = parseValidationErrors(error)
|
||||
|
||||
expect(result).toEqual({
|
||||
email: 'email is not a valid email address',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses multiple field errors', () => {
|
||||
const error = {
|
||||
message: 'invalid data',
|
||||
code: 2002,
|
||||
invalid_fields: [
|
||||
'email: email is not a valid email address',
|
||||
'username: username must not contain spaces',
|
||||
],
|
||||
}
|
||||
|
||||
const result = parseValidationErrors(error)
|
||||
|
||||
expect(result).toEqual({
|
||||
email: 'email is not a valid email address',
|
||||
username: 'username must not contain spaces',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles fields without colon separator', () => {
|
||||
const error = {
|
||||
message: 'invalid data',
|
||||
code: 2002,
|
||||
invalid_fields: ['something went wrong'],
|
||||
}
|
||||
|
||||
const result = parseValidationErrors(error)
|
||||
|
||||
// Fields without colon are ignored (can't map to specific field)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('handles errors with whitespace around field names', () => {
|
||||
const error = {
|
||||
message: 'invalid data',
|
||||
code: 2002,
|
||||
invalid_fields: ['email : not a valid email'],
|
||||
}
|
||||
|
||||
const result = parseValidationErrors(error)
|
||||
|
||||
expect(result).toEqual({
|
||||
email: 'not a valid email',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty object for null/undefined error', () => {
|
||||
expect(parseValidationErrors(null)).toEqual({})
|
||||
expect(parseValidationErrors(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
it('handles last occurrence when same field appears multiple times', () => {
|
||||
const error = {
|
||||
message: 'invalid data',
|
||||
code: 2002,
|
||||
invalid_fields: [
|
||||
'email: first error',
|
||||
'email: second error',
|
||||
],
|
||||
}
|
||||
|
||||
const result = parseValidationErrors(error)
|
||||
|
||||
expect(result).toEqual({
|
||||
email: 'second error',
|
||||
})
|
||||
})
|
||||
})
|
||||
44
frontend/src/helpers/parseValidationErrors.ts
Normal file
44
frontend/src/helpers/parseValidationErrors.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface ValidationError {
|
||||
message?: string
|
||||
code?: number
|
||||
invalid_fields?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses validation errors from API responses into a field-to-error map.
|
||||
* Extracts field names and messages from the invalid_fields array.
|
||||
*
|
||||
* @param error - The error object from API response
|
||||
* @returns Object mapping field names to error messages
|
||||
*
|
||||
* @example
|
||||
* // Returns: { email: "email is not a valid email address" }
|
||||
* parseValidationErrors({
|
||||
* message: 'invalid data',
|
||||
* invalid_fields: ['email: email is not a valid email address']
|
||||
* })
|
||||
*/
|
||||
export function parseValidationErrors(error: ValidationError | null | undefined): Record<string, string> {
|
||||
if (!error || !error.invalid_fields || error.invalid_fields.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
|
||||
for (const fieldError of error.invalid_fields) {
|
||||
// Split on first colon to separate field name from message
|
||||
const colonIndex = fieldError.indexOf(':')
|
||||
if (colonIndex === -1) {
|
||||
// No field prefix, can't map to a specific field, skip it
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract field name and error message
|
||||
const fieldName = fieldError.substring(0, colonIndex).trim()
|
||||
const errorMessage = fieldError.substring(colonIndex + 1).trim()
|
||||
|
||||
fieldErrors[fieldName] = errorMessage
|
||||
}
|
||||
|
||||
return fieldErrors
|
||||
}
|
||||
@@ -68,7 +68,8 @@
|
||||
"alreadyHaveAnAccount": "Already have an account?",
|
||||
"remember": "Stay logged in",
|
||||
"registrationDisabled": "Registration is disabled.",
|
||||
"passwordResetTokenMissing": "Password reset token is missing."
|
||||
"passwordResetTokenMissing": "Password reset token is missing.",
|
||||
"registrationFailed": "An error occurred during registration. Please check your input and try again."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
required
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:error="usernameValid !== true ? usernameValid : null"
|
||||
:error="usernameError"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateUsername(); validateUsernameAfterFirst = true"
|
||||
@keyup="validateUsernameAfterFirst && validateUsername()"
|
||||
@keyup="handleUsernameKeyup"
|
||||
/>
|
||||
<FormField
|
||||
id="email"
|
||||
@@ -34,11 +34,11 @@
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
required
|
||||
type="email"
|
||||
:error="emailValid ? null : $t('user.auth.emailInvalid')"
|
||||
:error="emailError"
|
||||
autocomplete="email"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateEmail(); validateEmailAfterFirst = true"
|
||||
@keyup="validateEmailAfterFirst && validateEmail()"
|
||||
@keyup="handleEmailKeyup"
|
||||
/>
|
||||
<div class="field">
|
||||
<label
|
||||
@@ -50,6 +50,12 @@
|
||||
@submit="submit"
|
||||
@update:modelValue="v => credentials.password = v"
|
||||
/>
|
||||
<p
|
||||
v-if="passwordError"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<XButton
|
||||
@@ -98,6 +104,7 @@ import Message from '@/components/misc/Message.vue'
|
||||
import {isEmail} from '@/helpers/isEmail'
|
||||
import Password from '@/components/input/Password.vue'
|
||||
import FormField from '@/components/input/FormField.vue'
|
||||
import {parseValidationErrors, type ValidationError} from '@/helpers/parseValidationErrors'
|
||||
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
@@ -126,6 +133,7 @@ const credentials = reactive({
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
const errorMessage = ref('')
|
||||
const validatePasswordInitially = ref(false)
|
||||
const serverValidationErrors = ref<Partial<Record<string, string>>>({})
|
||||
|
||||
const DEBOUNCE_TIME = 100
|
||||
|
||||
@@ -165,8 +173,52 @@ const everythingValid = computed(() => {
|
||||
usernameValid.value === true
|
||||
})
|
||||
|
||||
const usernameError = computed(() => {
|
||||
// Client-side validation takes priority
|
||||
if (usernameValid.value !== true) {
|
||||
return usernameValid.value
|
||||
}
|
||||
// Show server-side error if present
|
||||
return serverValidationErrors.value.username || null
|
||||
})
|
||||
|
||||
const emailError = computed(() => {
|
||||
// Client-side validation takes priority
|
||||
if (!emailValid.value) {
|
||||
return t('user.auth.emailInvalid')
|
||||
}
|
||||
// Show server-side error if present
|
||||
return serverValidationErrors.value.email || null
|
||||
})
|
||||
|
||||
const passwordError = computed(() => {
|
||||
// Show server-side error if present
|
||||
return serverValidationErrors.value.password || null
|
||||
})
|
||||
|
||||
function handleUsernameKeyup() {
|
||||
if (validateUsernameAfterFirst.value) {
|
||||
validateUsername()
|
||||
}
|
||||
delete serverValidationErrors.value.username
|
||||
}
|
||||
|
||||
function handleEmailKeyup() {
|
||||
if (validateEmailAfterFirst.value) {
|
||||
validateEmail()
|
||||
}
|
||||
delete serverValidationErrors.value.email
|
||||
}
|
||||
|
||||
function isApiValidationError(error: unknown): error is ValidationError {
|
||||
return error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'invalid_fields' in error
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
serverValidationErrors.value = {}
|
||||
validatePasswordInitially.value = true
|
||||
|
||||
if (!everythingValid.value) {
|
||||
@@ -176,8 +228,24 @@ async function submit() {
|
||||
try {
|
||||
await authStore.register(toRaw(credentials))
|
||||
redirectIfSaved()
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message
|
||||
} catch (e: unknown) {
|
||||
// Parse field-specific validation errors
|
||||
if (isApiValidationError(e)) {
|
||||
const fieldErrors = parseValidationErrors(e)
|
||||
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
// Apply field-level errors (computed properties will display them)
|
||||
serverValidationErrors.value = fieldErrors
|
||||
} else {
|
||||
// Fallback to general error message if no field errors
|
||||
errorMessage.value = t('user.auth.registrationFailed')
|
||||
}
|
||||
} else if (e instanceof Object && 'message' in e && typeof e.message === 'string') {
|
||||
// Non-validation backend errors (e.g. duplicate username) - show their message
|
||||
errorMessage.value = e.message
|
||||
} else {
|
||||
errorMessage.value = t('user.auth.registrationFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user