Validators Example
Fields with validators, and errors rendered by the application.
Demo
A form validated by the built-in validators, an asynchronous validator and a CompareTo between two fields. A field shows its errors once it is touched. The application renders the errors itself: translated into eight languages and with or without markdown. The errors already on the fields follow either choice without revalidating.
{
"username": "",
"email": "",
"emailConfirmation": "",
"age": null,
"role": "",
"bio": ""
}Source Code
<template>
<div class="validators-form-demo">
<v-locale-provider :rtl="text.rtl ?? false">
<v-card class="mb-4">
<v-card-title>{{ text.title }}</v-card-title>
<v-card-subtitle class="intro">{{ text.intro }}</v-card-subtitle>
<v-card-text>
<div class="settings mb-2">
<v-select
v-model="locale"
:items="locales"
density="compact"
hide-details
class="locale"
></v-select>
<v-checkbox v-model="useMarkdown" :label="text.markdown" color="primary" density="compact" hide-details />
</div>
<v-form @submit.prevent>
<!-- Username field (Required) -->
<v-text-field
v-model="validatedForm.fields.username.value"
:label="text.username"
:error-messages="getErrorMessages(validatedForm.fields.username)"
@update:focused="(focused) => focused || (validatedForm.fields.username.touched = true)"
outlined
class="mb-2"
>
<template #message="{ message }"><vue-markdown :source="message" class="demo-message" /></template>
</v-text-field>
<!-- Email field (Pattern) -->
<v-text-field
v-model="validatedForm.fields.email.value"
:label="text.email"
:error-messages="getErrorMessages(validatedForm.fields.email)"
@update:focused="(focused) => focused || (validatedForm.fields.email.touched = true)"
:loading="validatedForm.fields.email.validating"
outlined
class="mb-2"
:hint="text.emailHint"
persistent-hint
>
<template #message="{ message }"><vue-markdown :source="message" class="demo-message" /></template>
</v-text-field>
<!-- Email confirmation field (CompareTo) -->
<v-text-field
v-model="validatedForm.fields.emailConfirmation.value"
:label="text.emailConfirmation"
:error-messages="getErrorMessages(validatedForm.fields.emailConfirmation)"
@update:focused="(focused) => focused || (validatedForm.fields.emailConfirmation.touched = true)"
outlined
class="mb-2"
>
<template #message="{ message }"><vue-markdown :source="message" class="demo-message" /></template>
</v-text-field>
<!-- Age field (ValueInRange) -->
<v-text-field
v-model.number="validatedForm.fields.age.value"
type="number"
:label="text.age"
:error-messages="getErrorMessages(validatedForm.fields.age)"
@update:focused="(focused) => focused || (validatedForm.fields.age.touched = true)"
outlined
class="mb-2"
>
<template #message="{ message }"><vue-markdown :source="message" class="demo-message" /></template>
</v-text-field>
<!-- Role field (InAllowedValues) -->
<v-select
v-model="validatedForm.fields.role.value"
:items="roleItems"
:label="text.role"
:error-messages="getErrorMessages(validatedForm.fields.role)"
@update:focused="(focused) => focused || (validatedForm.fields.role.touched = true)"
outlined
class="mb-2"
>
<template #message="{ message }"><vue-markdown :source="message" class="demo-message" /></template>
</v-select>
<!-- Bio field (LengthInRange) -->
<v-textarea
v-model="validatedForm.fields.bio.value"
:label="text.bio"
:error-messages="getErrorMessages(validatedForm.fields.bio)"
@update:focused="(focused) => focused || (validatedForm.fields.bio.touched = true)"
outlined
counter="200"
class="mb-2"
>
<template #message="{ message }"><vue-markdown :source="message" class="demo-message" /></template>
</v-textarea>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn
color="primary"
:disabled="formBusy"
:loading="formBusy"
@click="submitForm"
>
{{ text.submit }}
</v-btn>
<v-btn
color="secondary"
@click="resetForm"
class="ml-2"
>
{{ text.reset }}
</v-btn>
</v-card-actions>
</v-card>
<v-card>
<v-card-title>{{ text.status }}</v-card-title>
<v-card-text>
<v-alert
:type="formValid ? 'success' : 'error'"
class="mb-3"
>
{{ formValid ? text.valid : text.invalid }}
</v-alert>
<pre class="output" dir="ltr">{{ JSON.stringify(validatedForm.value, null, 2) }}</pre>
<template v-if="submitted">
<p class="mt-4">{{ text.submitted }}</p>
<pre class="output" dir="ltr">{{ submitted }}</pre>
</template>
</v-card-text>
</v-card>
</v-locale-provider>
</div>
</template>
<script setup>
import { formatParams, interpolate } from '@dynamicforms/translatable';
import { computed, ref } from 'vue';
import {
Group,
Field,
Validators,
ValidationError,
} from '../../src'; // from '@dynamicforms/vue-forms'
import messages from './validators-demo.messages.json';
const locales = [
{ value: 'en', title: '🇬🇧 English' },
{ value: 'sl', title: '🇸🇮 Slovenščina' },
{ value: 'de', title: '🇩🇪 Deutsch' },
{ value: 'es', title: '🇪🇸 Español' },
{ value: 'ja', title: '🇯🇵 日本語' },
{ value: 'zh', title: '🇨🇳 中文' },
{ value: 'fa', title: '🇮🇷 فارسی' },
{ value: 'bn', title: '🇧🇩 বাংলা' },
];
const locale = ref('en');
const useMarkdown = ref(false);
// The demo's own text: labels, hints and the messages it gives its own validators
const text = computed(() => messages[locale.value].ui);
// A role is stored as its code and shown by its name in the current language
const roles = ['admin', 'user', 'guest'];
const roleName = (role) => messages[locale.value].roles[role];
const roleItems = computed(() => roles.map((value) => ({ value, title: roleName(value) })));
// The demo's translation function: the message for an error code with the params substituted, or the code
// unchanged where the current language has none
const t = (code, params) => {
const template = messages[locale.value].errors[code];
return template ? interpolate(template, params) : code;
};
// formatParams formats every param before t substitutes it: numbers in the language's digits, and in bold where the
// demo shows markdown
const translate = formatParams(t, (value) => {
const shown = typeof value === 'number' ? new Intl.NumberFormat(locale.value).format(value) : String(value);
return useMarkdown.value ? `**${shown}**` : shown;
});
// The text of an error: the demo's message for its code in the current language, with the params substituted. The
// only list of allowed values in the demo is the roles, shown by their translated names. A code the demo has no
// message for shows the error's English detail. The function is called on every render, so the errors already on
// the fields follow the language and the checkbox without revalidating.
function errorText(error) {
const params =
error.code === 'in_allowed_values'
? { allowedAsText: error.params.allowedValues.map(roleName).join(', ') }
: error.params;
const message = translate(error.code, params);
return message === error.code ? error.detail : message;
}
// Vuetify's error-messages prop takes strings; the message slot renders each one as markdown. A field shows its
// errors once it is touched: on blur, or for every field when the form is submitted.
const getErrorMessages = (field) => (field.touched ? field.errors.map(errorText) : []);
// Create a form group with validated fields
const validatedForm = new Group({
// Required field - cannot be empty
username: new Field({
value: '',
validators: [new Validators.Required()]
}),
// Email field with pattern validation
email: new Field({
value: '',
validators: [
new Validators.Pattern(
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
{ code: 'invalid_email', detail: 'Please enter a valid email address' },
),
// Async validator to simulate email availability check
new Validators.Validator(async (newValue) => {
// Only validate if email format is correct
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(newValue)) {
return null; // Let pattern validator handle format errors
}
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if email is "taken"
if (newValue.endsWith('@taken.com')) {
return [new ValidationError('email_taken', {}, 'This email address is already taken')];
}
return null; // Email is available
})
]
}),
// Field compared against the email field of the same group
emailConfirmation: new Field({
value: '',
validators: [
new Validators.CompareTo('email', (confirmation, email) => confirmation === email, {
code: 'emails_differ',
detail: 'The email addresses do not match',
}),
],
}),
// Number field with range validation
age: new Field({
value: null,
validators: [
new Validators.ValueInRange(18, 100)
]
}),
// Field with allowed values validation
role: new Field({
value: '',
validators: [
new Validators.InAllowedValues(roles)
]
}),
// Text field with length validation
bio: new Field({
value: '',
validators: [
new Validators.LengthInRange(10, 200)
]
})
});
// A group's validity is computed over its members. pending is true while an asynchronous validation or an
// Action.execute() runs anywhere below the group. Both reads are reactive.
const formValid = computed(() => validatedForm.valid);
const formBusy = computed(() => validatedForm.pending);
// What the last submit sent
const submitted = ref('');
// Submit marks every field touched, so every error shows, and sends the form only while it is valid
function submitForm() {
validatedForm.touched = true;
submitted.value = validatedForm.valid ? JSON.stringify(validatedForm.value, null, 2) : '';
}
// Back to the declared values; rebind() also resets touched, so no error shows until a field is left again
function resetForm() {
validatedForm.rebind(validatedForm.originalValue);
submitted.value = '';
}
</script>
<style scoped>
.validators-form-demo {
margin: 2rem 0;
}
.intro {
white-space: normal;
}
.settings {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 1rem;
}
.locale {
flex: 0 1 14rem;
}
.demo-message :deep(p) {
margin: 0;
}
.output {
background-color: #f5f5f5;
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
}
</style>Translations
The demo's text lives in validators-demo.messages.json, one block per language: errors keyed by error code, roles keyed by the stored role, and ui for the demo's own labels. errors holds the codes of the built-in validators and the demo's own codes, invalid_email, email_taken and emails_differ. An excerpt:
{
"sl": {
"errors": {
"required": "Prosimo, vnesite vrednost",
"value_in_range": "Vrednost mora biti med {minValue} in {maxValue}",
"in_allowed_values": "Mora biti ena od [{allowedAsText}]",
"length_in_range": "Dolžina mora biti med {minLength} in {maxLength}",
"invalid_email": "Vnesite veljaven e-poštni naslov",
"emails_differ": "E-poštna naslova se ne ujemata",
…
},
"roles": {
"admin": "Skrbnik",
"user": "Uporabnik",
"guest": "Gost"
},
"ui": {
"title": "Demo validatorjev",
"username": "Uporabniško ime",
"age": "Starost",
"role": "Vloga",
…
}
},
"de": { … }
}Touched Fields and Submitting
A field's errors are shown only where field.touched is true. The demo sets it when an input loses focus, so a field the user has not left yet shows no error. Submit sets validatedForm.touched = true, which a container passes to every member, so every error shows, and the demo sends the form only where it is valid. Reset calls validatedForm.rebind(validatedForm.originalValue), which puts the declared values back and resets touched.
Validation runs whether or not a field is touched: the status panel reports the form invalid while the errors are not yet shown.
Comparing Two Fields
The confirmation field has a CompareTo validator that names the email field. It runs when either field changes, so editing the email after the confirmation updates the confirmation's error.
Asynchronous Validation in This Demo
The email field has an asynchronous validator with a one-second delay, which is longer than the interval between two keystrokes, so several runs are usually pending at the same time. A run's result is applied only while it is the newest run for the field: results for intermediate values are discarded, the field ends with the validation result for its current text, and field.validating (bound to the input's loading prop) is false again once the last run has settled.
The submit button reads validatedForm.pending, which is true while a validation run or an Action.execute() is pending anywhere below the group, so the button is disabled for the duration of the check.
The validation function receives an AbortSignal as its fourth argument. The signal aborts when the run's result is no longer used (a newer keystroke, the validator removed from the field, a transaction rolled back). A real availability check passes it to fetch so the request is cancelled. The timer this demo awaits has nothing to cancel and ignores the signal.
The validator in this demo always resolves, because the delay it awaits cannot fail. A real availability check calls a server and can reject. A rejected run puts a single Validation could not be completed error on the field, so the field is invalid and a submit sends nothing, and the reason is logged with console.error('Validation failed', reason). Catch the network error inside the validation function only when the user should see a more specific message.
Translated Messages in This Demo
The language selector switches the demo between English, Slovenian, German, Spanish, Japanese, Chinese, Persian and Bengali. The library does not render errors: every error has a code, params and an English detail, and the demo's errorText function converts it into the text Vuetify's error-messages prop shows.
- Messages.
errorTextlooks the error's code up undererrorsin the current language and substitutes the params withinterpolatefrom@dynamicforms/translatable. A code the language has no message for shows the error'sdetail. - The demo's own codes. The email pattern validator is given
{ code: 'invalid_email', detail: … }, theCompareTovalidator{ code: 'emails_differ', detail: … }, and the asynchronous validator returns an error with the codeemail_taken. All three are translated like the built-in codes. - Numbers.
formatParams, from the same package, formats the params before they are substituted: numbers go throughIntl.NumberFormatfor the language, so Persian and Bengali show the age range in their own digits. - Role names. A role is stored as its code,
guest, and the error's params carry the codes inallowedValues. The demo names them fromroles, for the message and for the select's items alike. - Markdown. With the checkbox on, the formatting wraps each value in
**. Each field renders its messages through thevue-markdowncomponent in Vuetify'smessageslot.
errorText is called on every render, so the errors already on the fields follow the language and the checkbox without revalidating.
Persian is written right to left: the demo wraps itself in Vuetify's v-locale-provider with rtl set for it, and the Persian message for in_allowed_values isolates the list with U+2068 and U+2069, so its brackets stay in place.
API Reference
- Validators: all built-in validators with signatures and placeholder list
- FieldBase → errors:
errors,valid,validatingandbusyproperties - Errors:
code,params,detailandoriginof an error
Key Features Demonstrated
- Required Validator: Ensures a field is not empty
- Pattern Validator: Validates content against a regular expression (email format)
- CompareTo Validator: Compares the confirmation with the email field
- ValueInRange Validator: Ensures a numeric value is within specified bounds
- InAllowedValues Validator: Restricts input to a predefined set of values
- LengthInRange Validator: Validates that the input length is within specified bounds
- Asynchronous Validation: A promise-returning validator,
field.validatingas the loading state, the newest run determining the validation result, andform.pendingdisabling submit while validation is pending - Touched: Errors shown after a field is left, and on every field after a submit
- Translated Messages: Errors rendered by the application from their code and params, in eight languages, numbers in the language's digits, following a language switch on screen
- Markdown: The application rendering its messages as markdown
- Form-level Validation: Tracking overall form validity based on individual field states
Try It Yourself
Experiment with the validators by:
- Leaving fields empty, then submitting
- Entering an invalid email address, or a confirmation that differs from it
- Setting age outside the valid range
- Selecting different role values
- Entering text that's too short or too long in the bio field
- Switching the language or the markdown setting while errors are showing
