mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
chore(xo-core/xo-6): introduce form validation (#9630)
This commit is contained in:
15
@xen-orchestra/web-core/lib/components/form/VtsForm.vue
Normal file
15
@xen-orchestra/web-core/lib/components/form/VtsForm.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
273
@xen-orchestra/web-core/lib/packages/form-validation/README.md
Normal file
273
@xen-orchestra/web-core/lib/packages/form-validation/README.md
Normal 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.
|
||||
@@ -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}`
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
104
@xen-orchestra/web-core/lib/packages/form-validation/types.ts
Normal file
104
@xen-orchestra/web-core/lib/packages/form-validation/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
389
@xen-orchestra/web-core/lib/packages/validated-form/README.md
Normal file
389
@xen-orchestra/web-core/lib/packages/validated-form/README.md
Normal 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>
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './use-multi-step-validated-form.ts'
|
||||
export * from './use-validated-form.ts'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
14
@xen-orchestra/web/src/plugins/form-validation.config.ts
Normal file
14
@xen-orchestra/web/src/plugins/form-validation.config.ts
Normal 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 })),
|
||||
}
|
||||
},
|
||||
})
|
||||
2
@xen-orchestra/web/src/route-map.d.ts
vendored
2
@xen-orchestra/web/src/route-map.d.ts
vendored
@@ -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'
|
||||
|
||||
|
||||
46
yarn.lock
46
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user