chore(xo-core/xo-6): introduce form validation (#9630)

This commit is contained in:
OlivierFL
2026-05-20 14:47:32 +02:00
committed by GitHub
parent a3d8ab8dbb
commit e3a58ed351
21 changed files with 1511 additions and 10 deletions

View File

@@ -0,0 +1,15 @@
<template>
<form novalidate @submit.prevent="emit('submit')">
<slot />
</form>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
submit: []
}>()
defineSlots<{
default(): any
}>()
</script>

View File

@@ -326,6 +326,10 @@
"for-replication": "For replication",
"force-reboot-blocked": "Force reboot Blocked",
"force-shutdown-blocked": "Force shutdown Blocked",
"form:error:field-required": "{field} is required",
"form:error:integer": "Must be an integer",
"form:error:required": "This field is required",
"form:warning:out-of-range": "Should be between {min} and {max}",
"format": "Format",
"free-space": "Free space",
"fullscreen": "Fullscreen",

View File

@@ -326,6 +326,10 @@
"for-replication": "Pour la réplication",
"force-reboot-blocked": "Redémarrage forcé bloqué",
"force-shutdown-blocked": "Arrêt forcé bloqué",
"form:error:field-required": "Le champ {field} est requis",
"form:error:integer": "Doit être un entier",
"form:error:required": "Ce champ est requis",
"form:warning:out-of-range": "Devrait être compris entre {min} et {max}",
"format": "Format",
"free-space": "Espace libre",
"fullscreen": "Plein écran",

View File

@@ -0,0 +1,273 @@
# `useFormValidation` composable
Wraps [Regle](https://reglejs.dev) to provide a simple, unified interface for form validation with two severity levels: **errors** (blocking) and **warnings** (advisory). Consumers never need to import from `@regle/core` or `@regle/rules` — everything is re-exported from this package.
## Usage
```ts
import { integer, outOfRange, required, useFormValidation, withMessage } from '@core/packages/form-validation'
const { errors, warnings, validate, handleBlur, reset, useFieldMetadata } = useFormValidation(formData, {
errors: {
// onBlur → shown when the user leaves the field
onBlur: () => ({
age: { integer },
}),
// onSubmit → shown only when validate() is called
onSubmit: () => ({
label: { required: withMessage(required, t('label-required')) },
}),
},
warnings: {
onBlur: () => ({
age: { outOfRange: outOfRange(0, 150) },
}),
},
})
```
## Parameters
| | Required | Type | Description |
| -------- | :------: | ----------- | ----------------------------- |
| `data` | ✓ | `TData` | The reactive form data object |
| `config` | ✓ | (see below) | Validation configuration |
### `config` object
| | Required | Type | Description |
| ---------- | :------: | -------------------------------- | ----------------------------- |
| `errors` | | `FormValidationRuleGroup<TData>` | Rules for blocking validation |
| `warnings` | | `FormValidationRuleGroup<TData>` | Rules for advisory validation |
### `FormValidationRuleGroup<TData>`
Rules are split into two groups that control when they become visible:
| Key | Description |
| ---------- | ----------------------------------------------------------------- |
| `onBlur` | Rules shown when the user leaves a field (`handleBlur` is called) |
| `onSubmit` | Rules shown only when `validate()` is called |
Both groups are optional. A single field can have different rules in each group — for example, `required` in `onSubmit` (avoid surfacing the error while the user is still navigating) and a range check in `onBlur` (immediate feedback when leaving the field).
Each group accepts either a plain rule tree object or a getter function — use a getter to keep translated messages reactive across locale changes:
```ts
errors: {
onSubmit: () => ({
label: { required: withMessage(required, t('label-required')) },
}),
}
```
## Return value
| | Type | Description |
| ------------------ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `errors` | `ComputedRef<FormFieldMessages<TData>>` | Per-field blocking message strings. A field is populated only after it is touched or `validate()` is called. |
| `warnings` | `ComputedRef<FormFieldMessages<TData>>` | Per-field advisory message strings. A field is populated after `handleBlur(field)` or `validate()` is called. |
| `validate` | `() => Promise<boolean>` | Touches all warning fields, validates all error rules, and returns `true` when the form is valid. Call on submit. |
| `reset` | `() => void` | Resets dirty flags and clears all messages for both errors and warnings. |
| `handleBlur` | `(field: keyof TData) => void` | Marks a field dirty in the `onBlur` rule groups. Call on field blur. |
| `useFieldMetadata` | `(field, extras?) => () => FormFieldMetadata & extras` | Returns a metadata factory ready to pass to `useField`. Bundles error, warning, and the blur handler. |
`FormFieldMessages<TData>` is `{ [K in keyof TData]: string | undefined }` — all fields are always present; the value is `undefined` when there is no active message.
To wire these into `VtsInputWrapper`-based components, each string must be converted to `{ content, accent: 'danger' }` for errors and `{ content, accent: 'warning' }` for warnings — a plain string defaults to the `'info'` accent. `useValidatedForm` (from `@core/packages/validated-form`) performs this conversion automatically and is the recommended way to integrate validation with form fields.
## Severity levels
| Level | Gates submit | When shown |
| ------- | :----------: | ---------------------------------------------------------- |
| Error | ✓ | After the field is touched or `validate()` is called |
| Warning | | After `handleBlur(field)` (blur) or `validate()` is called |
## Example: complete form composable
```ts
import { useFormValidation, required, integer, outOfRange, withMessage } from '@core/packages/form-validation'
import { useFormBindings } from '@core/packages/form-bindings'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
type MyFormData = {
label: string
age: number | undefined
}
export function useMyForm() {
const { t } = useI18n()
const formData = reactive<MyFormData>({
label: '',
age: undefined,
})
const { validate, reset, useFieldMetadata } = useFormValidation(formData, {
errors: {
// Show 'required' only on submit — don't nag the user before they've had a chance to fill in the form.
onSubmit: () => ({
label: { required: withMessage(required, t('label-required')) },
age: { required: withMessage(required, t('age-required')) },
}),
// Show format errors as soon as the user leaves the field.
onBlur: () => ({
age: { integer },
}),
},
warnings: {
onBlur: () => ({
age: { outOfRange: outOfRange(0, 150) },
}),
},
})
const { useField } = useFormBindings(formData)
const labelField = useField('label', useFieldMetadata('label'))
const ageField = useField(
'age',
useFieldMetadata('age', () => ({ label: t('age') }))
)
async function validateAndBuildPayload() {
const valid = await validate()
if (!valid) {
return
}
}
return { formData, labelField, ageField, validateAndBuildPayload, reset }
}
```
## Merging configs — base / augmented form pattern
`mergeValidationConfigs` combines two `FormValidationConfig` objects into one. It is useful when a reusable **base form composable** defines common validation rules and a derived **augmented form** composable adds extra fields and rules on top.
```ts
mergeValidationConfigs(base, extra?)
```
| Parameter | Required | Type | Description |
| --------- | :------: | ------------------------------ | ---------------------------------------- |
| `base` | ✓ | `FormValidationConfig<TBase>` | The base config to extend |
| `extra` | | `FormValidationConfig<TExtra>` | Additional rules to layer on top of base |
- `errors` and `warnings` groups are merged independently.
- Within each group, `onBlur` and `onSubmit` rule trees are merged by spreading `extra` over `base` — extra fields win on key collision.
- Both plain objects and getter functions are supported; if either side is a getter the result is a getter.
- When `extra` is omitted, `base` is returned as-is.
### Example
```ts
// use-base-form.ts — base composable shared by several form variants
import {
mergeValidationConfigs,
useFormValidation,
required,
withMessage,
type FormValidationConfig,
} from '@core/packages/form-validation'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
type BaseFormData = { label: string; description: string }
export function useBaseForm(extraConfig?: FormValidationConfig<BaseFormData>) {
const { t } = useI18n()
const formData = reactive<BaseFormData>({ label: '', description: '' })
const baseConfig: FormValidationConfig<BaseFormData> = {
errors: {
onSubmit: () => ({
label: { required: withMessage(required, t('error:label-required')) },
}),
},
}
const { validate, reset, useFieldMetadata } = useFormValidation(
formData,
mergeValidationConfigs(baseConfig, extraConfig)
)
// …
return { formData, validate, reset, useFieldMetadata }
}
```
```ts
// use-extended-form.ts — augmented form that adds extra validation on top
import { minLength, type FormValidationConfig } from '@core/packages/form-validation'
import { useBaseForm } from './use-base-form'
type BaseFormData = { label: string; description: string }
export function useExtendedForm() {
const extras: FormValidationConfig<BaseFormData> = {
errors: {
onBlur: () => ({
label: { minLength: minLength(3) },
}),
},
}
return useBaseForm(extras)
}
```
## Available rules
All rules from `@regle/rules` are re-exported from this package. Commonly used rules:
| Rule | Description |
| ------------- | ---------------------------------------------------------- |
| `required` | Value must be filled |
| `integer` | Value must be an integer |
| `minValue` | Value must be ≥ a minimum |
| `maxValue` | Value must be ≤ a maximum |
| `minLength` | String/array length must be ≥ a minimum |
| `maxLength` | String/array length must be ≤ a maximum |
| `email` | Value must be a valid email address |
| `url` | Value must be a valid URL |
| `requiredIf` | Value is required when a condition holds |
| `withMessage` | Attaches a custom message to any rule |
| `isFilled` | Type guard: checks if a value is defined |
| `outOfRange` | Number must be between `min` and `max` (passes when empty) |
`type Maybe<T>` and `type FormRuleDeclaration<T>` are also re-exported for use in custom validator signatures.
## Global configuration
`defineFormValidationConfig` (re-exported from `@regle/core` as `defineRegleOptions`) lets you override the default message of any rule for the entire application. Install the result via the `RegleVuePlugin`:
```ts
// src/plugins/form-validation.config.ts
import { defineFormValidationConfig, integer, outOfRange, required, withMessage } from '@core/packages/form-validation'
import { useI18n } from 'vue-i18n'
export const formValidationConfig = defineFormValidationConfig({
rules: () => {
const { t } = useI18n()
return {
required: withMessage(required, () => t('form:error:required')),
integer: withMessage(integer, () => t('form:error:integer')),
outOfRange: withMessage(outOfRange, ({ $params: [min, max] }) => t('form:warning:out-of-range', { min, max })),
}
},
})
```
```ts
// src/main.ts
import { formValidationConfig } from '@/plugins/form-validation.config.ts'
import { RegleVuePlugin } from '@regle/core'
app.use(RegleVuePlugin, formValidationConfig)
```
Once installed, `required`, `integer`, and `outOfRange` show the translated messages automatically — no `withMessage` call is needed at each call site.

View File

@@ -0,0 +1,15 @@
import { createRule, type Maybe } from '@regle/core'
import { isFilled } from '@regle/rules'
export const outOfRange = createRule({
validator(value: Maybe<number>, min: number, max: number) {
if (!isFilled(value)) {
return true
}
return value >= min && value <= max
},
message({ $params: [min, max] }) {
return `Should be between ${min} and ${max}`
},
})

View File

@@ -0,0 +1,8 @@
export * from './custom-rules/out-of-range.rule.ts'
export * from './merge-validation-configs.ts'
export * from './types.ts'
export * from './use-form-validation.ts'
export { defineRegleOptions as defineFormValidationConfig } from '@regle/core'
export type { FormRuleDeclaration, Maybe } from '@regle/core'
export * from '@regle/rules'

View File

@@ -0,0 +1,46 @@
import type { FormValidationConfig, FormValidationRuleGroup, FormValidationRules } from './types.ts'
function mergeRules<T extends Record<string, unknown>>(
baseRules?: FormValidationRules<T>,
extraRules?: FormValidationRules<T>
): FormValidationRules<T> | undefined {
if (!baseRules) {
return extraRules
}
if (!extraRules) {
return baseRules
}
const resolvedBase = typeof baseRules === 'function' ? baseRules : () => baseRules
const resolvedExtra = typeof extraRules === 'function' ? extraRules : () => extraRules
return () => ({ ...resolvedBase(), ...resolvedExtra() })
}
function mergeRuleGroups<T extends Record<string, unknown>>(
baseGroup?: FormValidationRuleGroup<T>,
extraGroup?: FormValidationRuleGroup<T>
): FormValidationRuleGroup<T> | undefined {
if (!baseGroup && !extraGroup) {
return undefined
}
return {
onBlur: mergeRules(baseGroup?.onBlur, extraGroup?.onBlur),
onSubmit: mergeRules(baseGroup?.onSubmit, extraGroup?.onSubmit),
}
}
export function mergeValidationConfigs<TBase extends Record<string, unknown>, TExtra extends TBase>(
base: FormValidationConfig<TBase>,
extra?: FormValidationConfig<TExtra>
): FormValidationConfig<TExtra> {
const errors = mergeRuleGroups(base.errors as FormValidationRuleGroup<TExtra>, extra?.errors)
const warnings = mergeRuleGroups(base.warnings as FormValidationRuleGroup<TExtra>, extra?.warnings)
return {
...(errors !== undefined && { errors }),
...(warnings !== undefined && { warnings }),
}
}

View File

@@ -0,0 +1,104 @@
import type { FormRuleDeclaration } from '@regle/core'
import type { ComputedRef } from 'vue'
// FormRuleDeclaration<unknown> covers built-in Regle rules (required, minLength, …) which are
// universally typed with `unknown` internally. Listing it explicitly alongside FormRuleDeclaration<T>
// keeps inline-function completions typed with T while accepting built-in rules.
type AnyRuleValue<T> = FormRuleDeclaration<T> | FormRuleDeclaration<unknown> | undefined
type UniversalRuleKeys<T> = {
required?: AnyRuleValue<T>
requiredIf?: AnyRuleValue<T>
requiredUnless?: AnyRuleValue<T>
}
type StringRuleKeys<T> = [Extract<NonNullable<T>, string>] extends [never]
? Record<never, never>
: {
minLength?: AnyRuleValue<T>
maxLength?: AnyRuleValue<T>
email?: AnyRuleValue<T>
url?: AnyRuleValue<T>
httpUrl?: AnyRuleValue<T>
regex?: AnyRuleValue<T>
sameAs?: AnyRuleValue<T>
contains?: AnyRuleValue<T>
ipAddress?: AnyRuleValue<T>
macAddress?: AnyRuleValue<T>
alpha?: AnyRuleValue<T>
alphaNum?: AnyRuleValue<T>
numeric?: AnyRuleValue<T>
}
type NumberRuleKeys<T> = [Extract<NonNullable<T>, number>] extends [never]
? Record<never, never>
: {
minValue?: AnyRuleValue<T>
maxValue?: AnyRuleValue<T>
integer?: AnyRuleValue<T>
between?: AnyRuleValue<T>
}
type BooleanRuleKeys<T> = [Extract<NonNullable<T>, boolean>] extends [never]
? Record<never, never>
: {
checked?: AnyRuleValue<T>
}
type DateRuleKeys<T> = [Extract<NonNullable<T>, Date>] extends [never]
? Record<never, never>
: {
after?: AnyRuleValue<T>
before?: AnyRuleValue<T>
dateBetween?: AnyRuleValue<T>
dateAfter?: AnyRuleValue<T>
dateBefore?: AnyRuleValue<T>
}
export type FormFieldRules<T> = UniversalRuleKeys<T> &
StringRuleKeys<T> &
NumberRuleKeys<T> &
BooleanRuleKeys<T> &
DateRuleKeys<T> & {
[customRule: string]: AnyRuleValue<T>
}
export type FormRuleTree<TData extends Record<string, unknown>> = {
[K in keyof TData]?: FormFieldRules<TData[K]>
}
export type FormValidationRules<TData extends Record<string, unknown>> =
| FormRuleTree<TData>
| (() => FormRuleTree<TData>)
export type FormValidationRuleGroup<TData extends Record<string, unknown>> = {
onBlur?: FormValidationRules<TData>
onSubmit?: FormValidationRules<TData>
}
export type FormValidationConfig<TData extends Record<string, unknown>> = {
errors?: FormValidationRuleGroup<TData>
warnings?: FormValidationRuleGroup<TData>
}
export type FormFieldMessages<TData extends Record<string, unknown>> = {
[K in keyof TData]: string | undefined
}
export type FormFieldMetadata = {
error: string | undefined
warning: string | undefined
onBlur: () => void
}
export type UseFormValidationReturn<TData extends Record<string, unknown>> = {
errors: ComputedRef<FormFieldMessages<TData>>
warnings: ComputedRef<FormFieldMessages<TData>>
validate: () => Promise<boolean>
reset: () => void
handleBlur: (field: keyof TData) => void
useFieldMetadata: {
(field: keyof TData): () => FormFieldMetadata
<E extends Record<string, unknown>>(field: keyof TData, extras: () => E): () => FormFieldMetadata & E
}
}

View File

@@ -0,0 +1,196 @@
import type {
FormFieldMessages,
FormFieldMetadata,
FormRuleTree,
FormValidationConfig,
FormValidationRules,
UseFormValidationReturn,
} from './types.ts'
import { useRegle } from '@regle/core'
import { computed } from 'vue'
type FieldStatus = {
$touch: () => void
}
type RegleStatusAccessor = {
$fields: Record<string, FieldStatus>
$errors: Record<string, unknown>
$validate: () => Promise<{ valid: boolean }>
$reset: () => void
$touch: () => void
}
/**
* Automatically injects `$each: {}` into the rule tree for every field whose
* current value is an array, if that field is present in the rules but has no
* `$each` declared yet.
*
* This is required because Regle needs `$each` to know a field is a collection
* and to produce the `{ $self, $each }` error shape instead of a flat string[].
*/
function injectCollectionMarkers<TData extends Record<string, unknown>>(
data: TData,
rules: FormValidationRules<TData>
): FormValidationRules<TData> {
const arrayKeys = Object.keys(data).filter(key => Array.isArray(data[key]))
if (arrayKeys.length === 0) {
return rules
}
const inject = (ruleTree: FormRuleTree<TData>): FormRuleTree<TData> => {
const result = { ...ruleTree } as Record<string, unknown>
for (const key of arrayKeys) {
const fieldRules = result[key]
if (fieldRules !== null && typeof fieldRules === 'object' && !('$each' in fieldRules)) {
result[key] = { ...(fieldRules as object), $each: {} }
}
}
return result as FormRuleTree<TData>
}
if (typeof rules === 'function') {
return () => inject((rules as () => FormRuleTree<TData>)())
}
return inject(rules)
}
/**
* Calls `useRegle` with a simplified signature.
*
* Regle's second-parameter type is a deeply conditional type that TypeScript cannot
* resolve when `TData` is a generic type parameter. Casting through `unknown` at this
* single call-site keeps the rest of the file type-safe without resorting to `any`.
*/
function callUseRegle<TData extends Record<string, unknown>>(
data: TData,
rules: FormRuleTree<TData> | (() => FormRuleTree<TData>)
): { r$: unknown } {
return (
useRegle as unknown as (_data: TData, _rules: FormRuleTree<TData> | (() => FormRuleTree<TData>)) => { r$: unknown }
)(data, rules)
}
function toMessage(fieldErrors: unknown): string | undefined {
if (Array.isArray(fieldErrors)) {
return fieldErrors[0]
}
// Collection-field errors take the { $self: string[], $each: ... } shape ? surface the first $self message.
if (fieldErrors !== null && typeof fieldErrors === 'object' && '$self' in fieldErrors) {
const $self = (fieldErrors as { $self?: unknown }).$self
if (Array.isArray($self)) {
return $self[0]
}
}
return undefined
}
function buildMessages(regle: RegleStatusAccessor): Record<string, string | undefined> {
return Object.fromEntries(Object.keys(regle.$fields).map(key => [key, toMessage(regle.$errors[key])]))
}
function mergeMessages(
blurMessages: Record<string, string | undefined>,
submitMessages: Record<string, string | undefined>
): Record<string, string | undefined> {
const keys = new Set([...Object.keys(blurMessages), ...Object.keys(submitMessages)])
return Object.fromEntries([...keys].map(key => [key, blurMessages[key] ?? submitMessages[key]]))
}
const EMPTY_RULES = {}
export function useFormValidation<TData extends Record<string, unknown>>(
data: TData,
config: FormValidationConfig<TData>
): UseFormValidationReturn<TData> {
// All four useRegle calls must be unconditional — Vue composables cannot be called conditionally.
// When a group has no rules, an empty rule tree produces an empty $fields map.
// injectCollectionMarkers ensures array fields always have $each declared so Regle produces
// the { $self, $each } error shape.
const { r$: blurErrors$ } = callUseRegle(data, injectCollectionMarkers(data, config.errors?.onBlur ?? EMPTY_RULES))
const { r$: submitErrors$ } = callUseRegle(
data,
injectCollectionMarkers(data, config.errors?.onSubmit ?? EMPTY_RULES)
)
const { r$: blurWarnings$ } = callUseRegle(
data,
injectCollectionMarkers(data, config.warnings?.onBlur ?? EMPTY_RULES)
)
const { r$: submitWarnings$ } = callUseRegle(
data,
injectCollectionMarkers(data, config.warnings?.onSubmit ?? EMPTY_RULES)
)
// Cast at the Regle boundary: Regle's inferred types are too complex to thread through
// generics here, but the runtime shape is always compatible with RegleStatusAccessor.
const blurErrorRegle = blurErrors$ as RegleStatusAccessor
const submitErrorRegle = submitErrors$ as RegleStatusAccessor
const blurWarningRegle = blurWarnings$ as RegleStatusAccessor
const submitWarningRegle = submitWarnings$ as RegleStatusAccessor
const errors = computed<FormFieldMessages<TData>>(
() => mergeMessages(buildMessages(blurErrorRegle), buildMessages(submitErrorRegle)) as FormFieldMessages<TData>
)
const warnings = computed<FormFieldMessages<TData>>(
() => mergeMessages(buildMessages(blurWarningRegle), buildMessages(submitWarningRegle)) as FormFieldMessages<TData>
)
async function validate(): Promise<boolean> {
// Touch all warning fields so advisory messages become visible regardless of which group they're in.
blurWarningRegle.$touch()
submitWarningRegle.$touch()
const [blurResult, submitResult] = await Promise.all([blurErrorRegle.$validate(), submitErrorRegle.$validate()])
return blurResult.valid && submitResult.valid
}
function reset(): void {
blurErrorRegle.$reset()
submitErrorRegle.$reset()
blurWarningRegle.$reset()
submitWarningRegle.$reset()
}
function handleBlur(field: keyof TData): void {
const key = field as string
// Only touch blur-group fields — submit-group fields stay hidden until validate() is called.
blurErrorRegle.$fields[key]?.$touch()
blurWarningRegle.$fields[key]?.$touch()
}
function useFieldMetadata(field: keyof TData): () => FormFieldMetadata
function useFieldMetadata<E extends Record<string, unknown>>(
field: keyof TData,
extras: () => E
): () => FormFieldMetadata & E
function useFieldMetadata<E extends Record<string, unknown> = Record<string, unknown>>(
field: keyof TData,
extras?: () => E
) {
return () => ({
error: errors.value[field],
warning: warnings.value[field],
onBlur: () => handleBlur(field),
...extras?.(),
})
}
return {
errors,
warnings,
validate,
reset,
handleBlur,
useFieldMetadata,
}
}

View File

@@ -0,0 +1,389 @@
# `validated-form` package
Provides two composables for building validated forms: `useValidatedForm` for single-step forms and `useMultiStepValidatedForm` for wizard-style multi-step forms.
> **Note:** Always use `VtsForm` instead of a raw `<form>` element when using these composables. `VtsForm` sets `novalidate` unconditionally, preventing browser-native validation from interfering with the custom validation logic.
>
> ```vue
> <VtsForm @submit="onSubmit">…</VtsForm>
> ```
>
> If you must use a raw `<form>`, add `novalidate` manually:
>
> ```html
> <form novalidate @submit.prevent="onSubmit">…</form>
> ```
---
## `useValidatedForm` composable
Combines `useFormBindings` and `useFormValidation` into a single composable so each field key appears exactly once — no more passing the same key to both a binding and a metadata helper.
### Usage
```typescript
import { required, outOfRange } from '@core/packages/form-validation'
import { useValidatedForm } from '@core/packages/validated-form'
const { useField, useFormSelect, useSelect, validate, reset, handleBlur } = useValidatedForm(formData, {
errors: {
onSubmit: () => ({
title: { required },
}),
},
warnings: {
onBlur: () => ({
quantity: { outOfRange: outOfRange(1, 999) },
}),
},
})
```
### Parameters
| | Required | Type | Description |
| -------- | :------: | ----------------------------- | ------------------------------------------------------------------------- |
| `data` | ✓ | `TData` | A reactive object holding the form field values |
| `config` | ✓ | `FormValidationConfig<TData>` | Validation rule configuration — same shape as `useFormValidation` accepts |
`FormValidationConfig` is documented in detail in `@core/packages/form-validation`.
### Return value
#### Binding factories
| Property | Signature | Description |
| --------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `useField` | `(key, extras?) => ComputedRef<ModelBinding & FieldMetadata & E>` | Creates a combined v-model + validation binding for a text/number/checkbox input |
| `useFormSelect` | `(key, sources, config?) => UseFormSelectReturn` | Registers a select, auto-wires `model`, and records the key→id mapping in the registry |
| `useSelect` | see overloads below | Creates a binding for a `VtsSelect`-like component |
##### `useField(key, extras?)`
Returns a `ComputedRef` merging:
- `modelValue` / `onUpdate:modelValue` — the v-model pair for the field
- `error`, `warning`, `onBlur` — from `FieldMetadata`
- Any additional props returned by the optional `extras` factory (e.g. `label`, `required`, `info`)
##### `useFormSelect(key, sources, config?)`
A wrapper around `useFormSelect` from `@core/packages/form-select` that:
1. Automatically sets `model: toRef(data, key)` — no need to pass it manually
2. Records the `id → key` mapping so `useSelect(id)` can resolve validation metadata without an explicit key
`config` accepts all `useFormSelect` options **except** `model` (which is injected).
##### `useSelect` overloads
| Overload | When to use |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `useSelect(id, extras?)` | Select was registered with `useFormSelect` in the same `useValidatedForm` call — key is resolved from the registry |
| `useSelect(id, key, extras?)` | Select was created externally (e.g. inside a nested composable) — key cannot be inferred and must be passed explicitly |
Both overloads return a `ComputedRef` merging `{ id }`, `FieldMetadata`, and any extras.
#### Delegated from `useFormValidation`
| Property | Type | Description |
| ------------ | ------------------------------ | -------------------------------------------------------------------------------- |
| `validate` | `() => Promise<boolean>` | Validates all error rules and touches warning fields. Returns `true` when valid |
| `reset` | `() => void` | Resets all dirty flags and clears all messages |
| `handleBlur` | `(field: keyof TData) => void` | Marks a field dirty in the `onBlur` rule groups (called automatically by fields) |
### Example: form with text inputs and a managed select
```typescript
import { useValidatedForm } from '@core/packages/validated-form'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
type ProductFormData = {
category: string | undefined
title: string
}
export function useProductForm() {
const { t } = useI18n()
const formData = reactive<ProductFormData>({ category: undefined, title: '' })
const { useField, useFormSelect, useSelect, validate } = useValidatedForm(formData, {
errors: {
onSubmit: () => ({
category: { required },
title: { required },
}),
},
})
// model is wired automatically; id→key mapping is recorded in the registry
const { id: categorySelectId } = useFormSelect('category', categories, {
searchable: true,
required: true,
option: { label: 'name', value: 'id' },
})
// key is inferred from the registry — no need to repeat 'category'
const categorySelectBindings = useSelect(categorySelectId, () => ({ label: t('category') }))
const titleInputBindings = useField('title', () => ({ label: t('title'), required: true }))
return { categorySelectBindings, titleInputBindings, validate }
}
```
### Example: select created by an external composable (explicit key)
When `useFormSelect` is called inside a nested composable (outside the current `useValidatedForm` scope), the id→key mapping cannot be inferred automatically. Pass the key as the second argument to `useSelect`:
```typescript
const { useField, useSelect, validate } = useValidatedForm(formData, {
errors: {
onSubmit: () => ({
author: { required },
quantity: { required },
}),
},
})
// authorSelectId comes from a composable that called useFormSelect internally
const { authorSelectId } = useAuthorSelect(toRef(formData, 'author'))
// key 'author' must be provided explicitly because it was registered outside this scope
const authorSelectBindings = useSelect(authorSelectId, 'author', () => ({ label: t('author') }))
const quantityInputBindings = useField('quantity', () => ({ label: t('quantity'), required: true }))
```
---
## `defineFormSteps`
Helper that creates a typed reactive object for multi-step forms. It is the recommended way to declare form data for `useMultiStepValidatedForm` — TypeScript infers the full nested shape so no separate type declaration is needed.
```typescript
import { defineFormSteps } from '@core/packages/validated-form'
const formData = defineFormSteps({
general: { category: undefined as string | undefined, title: '' },
details: { region: undefined as string | undefined, quantity: undefined as number | undefined },
})
```
Top-level keys become step names. Values are plain objects whose keys become field names. The returned object is reactive (equivalent to wrapping with `reactive()`).
---
## `useMultiStepValidatedForm` composable
Single entry point for wizard-style forms. The form data is declared as a nested reactive object where each top-level key is a step name — the structure itself encodes step membership, so no fields array is needed and no step prefix is required when creating bindings.
TypeScript infers step names from the data and config keys, giving full autocompletion on `currentStep`, `validateStep('stepName')`, and `isStepValid('stepName')`.
### Usage
```typescript
import { defineFormSteps, useMultiStepValidatedForm } from '@core/packages/validated-form'
const formData = defineFormSteps({
general: { category: undefined as string | undefined, title: '' },
details: { region: undefined as string | undefined, quantity: undefined as number | undefined },
})
const { useField, currentStep, next, back, isStepValid, areAllStepsValid, validateAllSteps } =
useMultiStepValidatedForm(formData, {
general: {
errors: { onSubmit: () => ({ category: { required }, title: { required } }) },
},
details: {
errors: {
onSubmit: () => ({ region: { required }, quantity: { required } }),
onBlur: () => ({ quantity: { outOfRange: outOfRange(1, 999) } }),
},
},
})
```
### Parameters
| | Required | Type | Description |
| ------------- | :------: | -------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `data` | ✓ | `TData` | A nested reactive object — top-level keys are step names, values are field maps |
| `stepConfigs` | ✓ | `{ [K in keyof TData]: FormValidationConfig<TData[K]> }` | Validation config for each step, typed to that step's fields only |
Each step config is a `FormValidationConfig` scoped to its own sub-object: writing a field key from another step inside a step's rules is a TypeScript error.
### Return value
#### Binding factories
| Property | Signature | Description |
| --------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `useField` | `(key, extras?) => ComputedRef<ModelBinding & FieldMetadata & E>` | Same as `useValidatedForm`'s `useField` — step routing is automatic from the data structure |
| `useFormSelect` | `(key, sources, config?) => UseFormSelectReturn` | Same as `useValidatedForm`'s `useFormSelect` — step routing is automatic |
| `useSelect` | see overloads in `useValidatedForm` | Same overloads as `useValidatedForm`'s `useSelect` — resolves step from registry or explicit key |
`key` for `useField` and `useFormSelect` accepts any field name from any step's sub-object. The composable resolves which step's validation instance to use based on where the field is declared in `data`.
#### Navigation
| Property | Type | Description |
| ------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `currentStep` | `ComputedRef<keyof TSteps & string>` | The currently active step name |
| `next` | `() => Promise<boolean>` | Validates the current step; advances to the next step only if validation passes. Returns `true` if the step was valid |
| `back` | `() => void` | Goes to the previous step without validation |
#### Validation state
| Property | Type | Description |
| ------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `areAllStepsValid` | `ComputedRef<boolean>` | `true` only when every step has been validated and all pass |
| `isStepValid` | `(step: keyof TSteps) => boolean \| undefined` | Last known validation result for a step; `undefined` if the step has never been validated |
| `isValidating` | `Ref<boolean>` | `true` while `validateAllSteps()` is in flight; guards against concurrent calls |
| `validateStep` | `(step: keyof TSteps) => Promise<boolean>` | Validates a single step by name and stores the result |
| `validateAllSteps` | `() => Promise<boolean>` | Validates all steps concurrently. Returns `true` only if every step passes |
`validateAllSteps` is a no-op (returns `false`) when `isValidating` is already `true`.
### Example: wizard form with navigation and per-step validation
```typescript
import { required, outOfRange } from '@core/packages/form-validation'
import { defineFormSteps, useMultiStepValidatedForm } from '@core/packages/validated-form'
import { useI18n } from 'vue-i18n'
export function useCreateItemWizard() {
const { t } = useI18n()
const formData = defineFormSteps({
general: {
category: undefined as string | undefined,
title: '',
summary: '',
featured: false,
},
details: {
region: undefined as string | undefined,
quantity: undefined as number | undefined,
},
})
const {
useField,
useFormSelect,
useSelect,
currentStep,
next,
back,
isStepValid,
areAllStepsValid,
validateAllSteps,
} = useMultiStepValidatedForm(formData, {
general: {
errors: {
onSubmit: () => ({
category: { required },
title: { required },
}),
},
},
details: {
errors: {
onSubmit: () => ({
region: { required },
quantity: { required },
}),
onBlur: () => ({ quantity: { outOfRange: outOfRange(1, 999) } }),
},
},
})
// Bindings — no step prefix needed, routing is automatic
const { id: categorySelectId } = useFormSelect('category', categories, {
searchable: true,
option: { label: 'name', value: 'id' },
})
const categorySelectBindings = useSelect(categorySelectId, () => ({ label: t('category') }))
const titleInputBindings = useField('title', () => ({ label: t('title'), required: true }))
const summaryInputBindings = useField('summary', () => ({ label: t('summary') }))
const featuredCheckboxBindings = useField('featured')
const { id: regionSelectId } = useFormSelect('region', regions, {
searchable: true,
option: { label: 'name', value: 'id' },
})
const regionSelectBindings = useSelect(regionSelectId, () => ({ label: t('region') }))
const quantityInputBindings = useField('quantity', () => ({ label: t('quantity'), required: true }))
async function onSubmit() {
const isValid = await validateAllSteps()
if (!isValid) {
return
}
return {
categoryId: formData.general.category!,
title: formData.general.title,
...(formData.general.summary !== '' && { summary: formData.general.summary }),
...(formData.general.featured && { featured: true }),
region: formData.details.region!,
quantity: formData.details.quantity!,
}
}
return {
formData,
currentStep,
next,
back,
isStepValid,
areAllStepsValid,
categorySelectBindings,
titleInputBindings,
summaryInputBindings,
featuredCheckboxBindings,
regionSelectBindings,
quantityInputBindings,
onSubmit,
}
}
```
```vue
<script lang="ts" setup>
const {
currentStep,
next,
back,
areAllStepsValid,
categorySelectBindings,
titleInputBindings,
summaryInputBindings,
featuredCheckboxBindings,
regionSelectBindings,
quantityInputBindings,
onSubmit,
} = useCreateItemWizard()
</script>
<template>
<VtsForm @submit="onSubmit">
<div v-if="currentStep === 'general'">
<VtsSelect v-bind="categorySelectBindings" />
<VtsInput v-bind="titleInputBindings" />
<VtsInput v-bind="summaryInputBindings" />
<VtsCheckbox v-bind="featuredCheckboxBindings" />
</div>
<div v-else-if="currentStep === 'details'">
<VtsSelect v-bind="regionSelectBindings" />
<VtsInput v-bind="quantityInputBindings" />
</div>
<button :disabled="currentStep === 'general'" @click="back">Back</button>
<button v-if="currentStep !== 'details'" @click="next">Next</button>
<button v-else :disabled="!areAllStepsValid" @click="onSubmit">Submit</button>
</VtsForm>
</template>
```

View File

@@ -0,0 +1,2 @@
export * from './use-multi-step-validated-form.ts'
export * from './use-validated-form.ts'

View File

@@ -0,0 +1,203 @@
import type { CollectionItemProperties } from '@core/packages/collection'
import type {
ExtractValue,
FormSelectId,
GetOptionValue,
UseFormSelectReturn,
} from '@core/packages/form-select/types.ts'
import type { FormValidationConfig } from '@core/packages/form-validation/types.ts'
import type { EmptyObject } from '@core/types/utility.type.ts'
import { computed, reactive, ref, shallowReactive, type ComputedRef, type MaybeRefOrGetter } from 'vue'
import {
useValidatedForm,
type FieldMetadata,
type ModelBinding,
type UseFormSelectConfig,
} from './use-validated-form.ts'
export type NestedFormData = Record<string, Record<string, unknown>>
type FlatKeys<TData extends NestedFormData> = { [K in keyof TData]: keyof TData[K] & string }[keyof TData]
type FieldValue<TData extends NestedFormData, K extends string> = {
[S in keyof TData]: K extends keyof TData[S] ? TData[S][K] : never
}[keyof TData]
type StepForms<TData extends NestedFormData> = {
[K in keyof TData]: ReturnType<typeof useValidatedForm<TData[K]>>
}
export function useMultiStepValidatedForm<
TData extends NestedFormData,
TSteps extends { [K in keyof TData]: FormValidationConfig<TData[K]> },
>(data: TData, stepConfigs: TSteps) {
const stepForms = Object.fromEntries(
(Object.keys(stepConfigs) as (keyof TData & string)[]).map(stepKey => [
stepKey,
useValidatedForm(data[stepKey], stepConfigs[stepKey]),
])
) as unknown as StepForms<TData>
const fieldToStep = new Map<FlatKeys<TData>, keyof TData & string>()
for (const stepKey of Object.keys(data) as (keyof TData & string)[]) {
for (const fieldKey of Object.keys(data[stepKey]) as FlatKeys<TData>[]) {
if (fieldToStep.has(fieldKey)) {
throw new Error(`useMultiStepValidatedForm: field "${fieldKey}" is declared in multiple steps`)
}
fieldToStep.set(fieldKey, stepKey)
}
}
const idToStep = new Map<FormSelectId, keyof TData & string>()
const stepKeys = Object.keys(stepConfigs) as (keyof TSteps & string)[]
const currentStepIndex = ref(0)
const currentStep = computed(() => stepKeys[currentStepIndex.value])
const stepValidStates = shallowReactive(new Map<keyof TSteps, boolean>())
const isValidating = ref(false)
const areAllStepsValid = computed(
() => stepKeys.length === stepValidStates.size && [...stepValidStates.values()].every(Boolean)
)
function isStepValid(stepName: keyof TSteps): boolean | undefined {
return stepValidStates.get(stepName)
}
async function validateStep(stepName: keyof TSteps & string): Promise<boolean> {
const isValid = await stepForms[stepName].validate()
stepValidStates.set(stepName, isValid)
return isValid
}
async function validateAllSteps(): Promise<boolean> {
if (isValidating.value) {
return false
}
isValidating.value = true
try {
const results = await Promise.all(stepKeys.map(key => stepForms[key].validate()))
stepValidStates.clear()
stepKeys.forEach((key, index) => stepValidStates.set(key, results[index]))
return results.every(Boolean)
} finally {
isValidating.value = false
}
}
async function next(): Promise<boolean> {
const isValid = await validateStep(stepKeys[currentStepIndex.value])
if (isValid && currentStepIndex.value < stepKeys.length - 1) {
currentStepIndex.value++
}
return isValid
}
function back(): void {
if (currentStepIndex.value > 0) {
currentStepIndex.value--
}
}
function resolveStepFromField<K extends FlatKeys<TData>>(key: K): keyof TData & string {
const stepKey = fieldToStep.get(key)
if (stepKey === undefined) {
throw new Error(`useMultiStepValidatedForm: field "${String(key)}" is not declared in any step`)
}
return stepKey
}
function useField<K extends FlatKeys<TData>>(key: K): ComputedRef<ModelBinding<FieldValue<TData, K>> & FieldMetadata>
function useField<K extends FlatKeys<TData>, E extends Record<string, unknown>>(
key: K,
extras: () => E
): ComputedRef<ModelBinding<FieldValue<TData, K>> & FieldMetadata & E>
function useField<K extends FlatKeys<TData>, E extends Record<string, unknown>>(key: K, extras?: () => E): unknown {
const stepKey = resolveStepFromField(key)
const stepForm = stepForms[stepKey] as StepForms<TData>[keyof TData] & {
useField: (key: string, extras?: () => Record<string, unknown>) => unknown
}
return extras !== undefined ? stepForm.useField(key, extras) : stepForm.useField(key)
}
function useFormSelect<
TSource,
TCustomProperties extends CollectionItemProperties = EmptyObject,
TGetValue extends GetOptionValue<TSource, TCustomProperties> = undefined,
TMultiple extends boolean = false,
TEmptyValue = never,
$TValue = ExtractValue<TSource, TGetValue>,
>(
key: FlatKeys<TData>,
sources: MaybeRefOrGetter<TSource[]>,
formSelectConfig?: UseFormSelectConfig<TSource, TCustomProperties>
): UseFormSelectReturn<TCustomProperties, TSource, $TValue | TEmptyValue, TMultiple> {
const stepKey = resolveStepFromField(key)
const result = stepForms[stepKey].useFormSelect(key as never, sources, formSelectConfig) as UseFormSelectReturn<
TCustomProperties,
TSource,
$TValue | TEmptyValue,
TMultiple
>
idToStep.set(result.id, stepKey)
return result
}
function useSelect(id: FormSelectId): ComputedRef<{ id: FormSelectId } & FieldMetadata>
function useSelect<E extends Record<string, unknown>>(
id: FormSelectId,
extras: () => E
): ComputedRef<{ id: FormSelectId } & FieldMetadata & E>
function useSelect(id: FormSelectId, key: FlatKeys<TData>): ComputedRef<{ id: FormSelectId } & FieldMetadata>
function useSelect<E extends Record<string, unknown>>(
id: FormSelectId,
key: FlatKeys<TData>,
extras: () => E
): ComputedRef<{ id: FormSelectId } & FieldMetadata & E>
function useSelect<E extends Record<string, unknown> = Record<string, unknown>>(
id: FormSelectId,
keyOrExtras?: FlatKeys<TData> | (() => E),
extras?: () => E
) {
if (typeof keyOrExtras === 'string') {
const stepKey = resolveStepFromField(keyOrExtras)
return stepForms[stepKey].useSelect(id, keyOrExtras as never, extras as never)
}
const stepKey = idToStep.get(id)
if (stepKey === undefined) {
throw new Error('useSelect: could not resolve step for select id — ensure useFormSelect was called first')
}
return stepForms[stepKey].useSelect(id, keyOrExtras as never)
}
return {
useField,
useFormSelect,
useSelect,
currentStep,
next,
back,
isStepValid,
areAllStepsValid,
isValidating,
validateStep,
validateAllSteps,
}
}
export function defineFormSteps<T extends NestedFormData>(steps: T): T {
return reactive(steps) as T
}

View File

@@ -0,0 +1,180 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import type { CollectionItemProperties, GetItemId } from '@core/packages/collection'
import { useFormBindings } from '@core/packages/form-bindings'
import { useFormSelect as _useFormSelect } from '@core/packages/form-select'
import type {
ExtractValue,
FormSelectId,
GetOptionLabel,
GetOptionValue,
UseFormSelectReturn,
} from '@core/packages/form-select/types.ts'
import { useFormValidation } from '@core/packages/form-validation'
import type { FormValidationConfig } from '@core/packages/form-validation/types.ts'
import type { EmptyObject, MaybeArray } from '@core/types/utility.type.ts'
import { toRef, type MaybeRefOrGetter, type ComputedRef, type Ref } from 'vue'
export type ModelBinding<T> = { modelValue: T; 'onUpdate:modelValue': (value: T) => void }
export type FieldMetadata = {
error: InputWrapperMessage | undefined
warning: InputWrapperMessage | undefined
onBlur: () => void
}
function toMessage(content: string | undefined, accent: 'danger' | 'warning'): InputWrapperMessage | undefined {
return content !== undefined ? { content, accent } : undefined
}
export type UseFormSelectConfig<TSource, TCustomProperties extends CollectionItemProperties> = {
multiple?: MaybeRefOrGetter<boolean>
disabled?: MaybeRefOrGetter<boolean>
selectedLabel?: (count: number, labels: string[]) => string | undefined
placeholder?: MaybeRefOrGetter<string>
searchPlaceholder?: MaybeRefOrGetter<string>
loading?: MaybeRefOrGetter<boolean>
required?: MaybeRefOrGetter<boolean>
searchable?: MaybeRefOrGetter<boolean>
emptyOption?: MaybeRefOrGetter<{
value: unknown
properties?: TCustomProperties
label: string
selectedLabel?: string
}>
option?: {
id?: GetItemId<TSource>
value?:
| GetOptionValue<TSource, TCustomProperties>
| ((source: TSource, properties: TCustomProperties, index: number) => unknown)
properties?: (source: TSource) => TCustomProperties
label?: GetOptionLabel<TSource, TCustomProperties>
selectedLabel?: (source: TSource, properties: TCustomProperties) => string
disabled?: (source: TSource, properties: TCustomProperties) => boolean
searchableTerm?: (source: TSource, properties: TCustomProperties) => MaybeArray<string>
}
}
export function useValidatedForm<TData extends Record<string, unknown>>(
data: TData,
config: FormValidationConfig<TData>
) {
const { useField: _useField, useSelect: _useSelect } = useFormBindings(data)
const { errors: rawErrors, warnings: rawWarnings, validate, reset, handleBlur } = useFormValidation(data, config)
const selectRegistry = new Map<FormSelectId, keyof TData>()
function fieldMetadata(key: keyof TData): () => FieldMetadata {
return () => ({
error: toMessage(rawErrors.value[key], 'danger'),
warning: toMessage(rawWarnings.value[key], 'warning'),
onBlur: () => handleBlur(key),
})
}
function fieldMetadataWithExtras<E extends Record<string, unknown>>(
key: keyof TData,
extras: () => E
): () => FieldMetadata & E {
return () => ({
error: toMessage(rawErrors.value[key], 'danger'),
warning: toMessage(rawWarnings.value[key], 'warning'),
onBlur: () => handleBlur(key),
...extras(),
})
}
function useField<K extends keyof TData>(key: K): ComputedRef<ModelBinding<TData[K]> & FieldMetadata>
function useField<K extends keyof TData, E extends Record<string, unknown>>(
key: K,
extras: () => E
): ComputedRef<ModelBinding<TData[K]> & FieldMetadata & E>
function useField<K extends keyof TData, E extends Record<string, unknown> = Record<string, unknown>>(
key: K,
extras?: () => E
) {
if (extras !== undefined) {
return _useField(key, fieldMetadataWithExtras(key, extras))
}
return _useField(key, fieldMetadata(key))
}
function useFormSelect<
TSource,
TCustomProperties extends CollectionItemProperties = EmptyObject,
TGetValue extends GetOptionValue<TSource, TCustomProperties> = undefined,
TMultiple extends boolean = false,
TEmptyValue = never,
$TValue = ExtractValue<TSource, TGetValue>,
>(
key: keyof TData,
sources: MaybeRefOrGetter<TSource[]>,
formSelectConfig?: UseFormSelectConfig<TSource, TCustomProperties>
): UseFormSelectReturn<TCustomProperties, TSource, $TValue | TEmptyValue, TMultiple> {
const model = toRef(data, key) as Ref<unknown>
const result = (
_useFormSelect as unknown as (
sources: MaybeRefOrGetter<TSource[]>,
config: UseFormSelectConfig<TSource, TCustomProperties> & { model: Ref<unknown> }
) => UseFormSelectReturn<TCustomProperties, TSource, $TValue | TEmptyValue, TMultiple>
)(sources, {
...formSelectConfig,
model,
})
selectRegistry.set(result.id, key)
return result
}
function useSelect(id: FormSelectId): ComputedRef<{ id: FormSelectId } & FieldMetadata>
function useSelect<E extends Record<string, unknown>>(
id: FormSelectId,
extras: () => E
): ComputedRef<{ id: FormSelectId } & FieldMetadata & E>
function useSelect<E extends Record<string, unknown>>(
id: FormSelectId,
key: keyof TData,
extras?: () => E
): ComputedRef<{ id: FormSelectId } & FieldMetadata & E>
function useSelect<E extends Record<string, unknown> = Record<string, unknown>>(
id: FormSelectId,
keyOrExtras?: keyof TData | (() => E),
extras?: () => E
) {
if (typeof keyOrExtras === 'string') {
const key = keyOrExtras as keyof TData
const metadata = extras !== undefined ? fieldMetadataWithExtras(key, extras) : fieldMetadata(key)
return _useSelect(id, metadata)
}
const extrasFromRegistry = keyOrExtras as (() => E) | undefined
const registryKey = selectRegistry.get(id)
if (registryKey !== undefined) {
const metadata =
extrasFromRegistry !== undefined
? fieldMetadataWithExtras(registryKey, extrasFromRegistry)
: fieldMetadata(registryKey)
return _useSelect(id, metadata)
}
if (extrasFromRegistry !== undefined) {
return _useSelect(id, extrasFromRegistry)
}
return _useSelect(id)
}
return {
useField,
useFormSelect,
useSelect,
validate,
reset,
handleBlur,
}
}

View File

@@ -17,6 +17,9 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@novnc/novnc": "~1.5.0",
"@regle/core": "^1.20.2",
"@regle/rules": "^1.20.2",
"@regle/schemas": "^1.20.2",
"@types/d3-time-format": "^4.0.3",
"@vueuse/core": "^14.0.0",
"@vueuse/math": "^14.0.0",

View File

@@ -21,6 +21,9 @@
"@fortawesome/vue-fontawesome": "^3.0.8",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@novnc/novnc": "~1.5.0",
"@regle/core": "^1.20.2",
"@regle/rules": "^1.20.2",
"@regle/schemas": "^1.20.2",
"@tsconfig/node18": "^18.2.4",
"@types/d3-time-format": "^4.0.3",
"@types/lodash-es": "^4.17.12",

View File

@@ -1,4 +1,6 @@
import { formValidationConfig } from '@/plugins/form-validation.config.ts'
import i18n from '@core/i18n'
import { RegleVuePlugin } from '@regle/core'
import type { XoUser } from '@vates/types'
import { useFetch } from '@vueuse/core'
import { createPinia } from 'pinia'
@@ -43,6 +45,7 @@ async function init() {
app.use(i18n)
app.use(createPinia())
app.use(router)
app.use(RegleVuePlugin, formValidationConfig)
app.mount('#app')
}

View File

@@ -1,8 +1,4 @@
import {
type BaseNetworkFormData,
BOND_MODES,
useNetworkFormBase,
} from '@/modules/network/form/use-network-form-base.ts'
import { type BaseNetworkFormData, useNetworkFormBase } from '@/modules/network/form/use-network-form-base.ts'
import { useNetworkPifSelect } from '@/modules/network/form/use-network-pif-select.ts'
import type { NewBondedNetworkPayload } from '@/modules/network/jobs/xo-bonded-network-create.job.ts'
import { type FrontXoPif } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
@@ -13,6 +9,8 @@ import { BOND_MODE } from '@vates/types'
import { type MaybeRefOrGetter, reactive, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
export const BOND_MODES: BOND_MODE[] = ['balance-slb', 'active-backup', 'lacp']
export type NewBondedNetworkFormData = BaseNetworkFormData & {
pifs: Array<{ id: FrontXoPif['id'] }>
bondMode: BOND_MODE | undefined

View File

@@ -2,12 +2,9 @@ import { type FrontXoPool, useXoPoolCollection } from '@/modules/pool/remote-res
import { useFormBindings } from '@core/packages/form-bindings'
import { useFormSelect } from '@core/packages/form-select'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { BOND_MODE } from '@vates/types'
import { type MaybeRefOrGetter, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
export const BOND_MODES: BOND_MODE[] = ['balance-slb', 'active-backup', 'lacp']
export type BaseNetworkFormData = {
pool: FrontXoPool['id'] | undefined
name: string

View File

@@ -0,0 +1,14 @@
import { defineFormValidationConfig, integer, outOfRange, required, withMessage } from '@core/packages/form-validation'
import { useI18n } from 'vue-i18n'
export const formValidationConfig = defineFormValidationConfig({
rules: () => {
const { t } = useI18n()
return {
required: withMessage(required, () => t('form:error:required')),
integer: withMessage(integer, () => t('form:error:integer')),
outOfRange: withMessage(outOfRange, ({ $params: [min, max] }) => t('form:warning:out-of-range', { min, max })),
}
},
})

View File

@@ -8,11 +8,11 @@
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
RouteRecordInfo,
} from 'vue-router'
import type { _ExtractParamParserType } from 'vue-router/experimental'

View File

@@ -3116,6 +3116,33 @@
resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.1.0.tgz#cba454c05ec201bd5547aaf55286d44682ac8eb5"
integrity sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==
"@regle/core@1.20.3", "@regle/core@^1.20.2":
version "1.20.3"
resolved "https://registry.yarnpkg.com/@regle/core/-/core-1.20.3.tgz#cf610ab9f3353e8c76508668218d08466a6fb8e6"
integrity sha512-L9Im/PIyUJoJjwxCmBQkyqV4Qe9B93SHsapCSQLOEgRi7yOiMggmOC4cvIocPSF+TXQpBlE+M9axXpGZetQx8Q==
dependencies:
"@standard-schema/spec" "1.1.0"
"@vue/devtools-api" "7.7.9"
type-fest "5.4.4"
"@regle/rules@1.20.3", "@regle/rules@^1.20.2":
version "1.20.3"
resolved "https://registry.yarnpkg.com/@regle/rules/-/rules-1.20.3.tgz#79d1e1354ae8ee500134c6f73893ce19bcb034ef"
integrity sha512-4NtXasw2XUYjxR7OqlQY7+xCzcYS42i7ag0AwYFWDviDYkh0y6RqhUU1MAc2n3kkYh8lf8jkQp7hqenqHYIrbA==
dependencies:
"@regle/core" "1.20.3"
type-fest "5.4.4"
"@regle/schemas@^1.20.2":
version "1.20.3"
resolved "https://registry.yarnpkg.com/@regle/schemas/-/schemas-1.20.3.tgz#d74c21d549e719c31f1d282fc82d7f8c9cbdbfe2"
integrity sha512-7kzzEDqFgMhOEmoqlaWx9DPcfq9FgkMEKzwrpSEdFLEEVIAmivVd65JVUfel9KAVmrm2Lxw3D5cz9rBLjXfepA==
dependencies:
"@regle/core" "1.20.3"
"@regle/rules" "1.20.3"
"@standard-schema/spec" "1.1.0"
type-fest "5.4.4"
"@rolldown/binding-android-arm64@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz#aefa7afdcabc1269b1933d50cad31013cb697143"
@@ -3725,6 +3752,11 @@
"@smithy/core" "^3.24.1"
tslib "^2.6.2"
"@standard-schema/spec@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@tapjs/after-each@1.1.22":
version "1.1.22"
resolved "https://registry.yarnpkg.com/@tapjs/after-each/-/after-each-1.1.22.tgz#2bc5ed00b4c8eee4120b249eb1db70e3d1cca4b8"
@@ -4985,7 +5017,7 @@
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/devtools-api@^7.7.7":
"@vue/devtools-api@7.7.9", "@vue/devtools-api@^7.7.7":
version "7.7.9"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz#999dbea50da6b00cf59a1336f11fdc2b43d9e063"
integrity sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==
@@ -18936,6 +18968,11 @@ syslog-client@^1.1.1:
resolved "https://registry.yarnpkg.com/syslog-client/-/syslog-client-1.1.1.tgz#bdb28de3b5b7eb28a11352ec3eb78e55aed2ab6b"
integrity sha512-c3qKw8JzCuHt0mwrzKQr8eqOc3RB28HgOpFuwGMO3GLscVpfR+0ECevWLZq/yIJTbx3WTb3QXBFVpTFtKAPDrw==
tagged-tag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6"
integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==
tap-parser@15.3.2:
version "15.3.2"
resolved "https://registry.yarnpkg.com/tap-parser/-/tap-parser-15.3.2.tgz#594d98b7eee721ca631af19a66589e66015c25e0"
@@ -19412,6 +19449,13 @@ type-detect@^4.1.0:
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"
integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==
type-fest@5.4.4:
version "5.4.4"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.4.4.tgz#577f165b5ecb44cfc686559cc54ca77f62aa374d"
integrity sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==
dependencies:
tagged-tag "^1.0.0"
type-fest@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee"