@xen-orchestra/web-core 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/assets/css/_reset.pcss +8 -1
  2. package/lib/components/column/VtsColumn.vue +5 -1
  3. package/lib/components/columns/VtsColumns.vue +17 -8
  4. package/lib/components/console/VtsRemoteConsole.vue +25 -19
  5. package/lib/components/form/VtsForm.vue +15 -0
  6. package/lib/components/{quick-info-column/VtsQuickInfoColumn.vue → key-value-list/VtsKeyValueList.vue} +3 -3
  7. package/lib/components/key-value-pair/VtsKeyValuePair.vue +48 -0
  8. package/lib/components/key-value-row/VtsKeyValueRow.vue +45 -0
  9. package/lib/components/status/VtsStatus.vue +1 -7
  10. package/lib/components/table/cells/VtsTagCell.vue +2 -4
  11. package/lib/components/tabular-key-value-list/VtsTabularKeyValueList.vue +29 -0
  12. package/lib/components/tabular-key-value-row/VtsTabularKeyValueRow.vue +42 -0
  13. package/lib/components/tag/VtsTag.vue +30 -0
  14. package/lib/components/ui/chip/UiChip.vue +52 -29
  15. package/lib/components/ui/info/UiInfo.vue +17 -6
  16. package/lib/components/ui/link/UiLink.vue +6 -2
  17. package/lib/components/ui/tag/UiTag.vue +10 -17
  18. package/lib/components/ui/tag/UiTertiaryTag.vue +39 -0
  19. package/lib/icons/action-icons.ts +1 -1
  20. package/lib/locales/cs.json +0 -1
  21. package/lib/locales/da.json +0 -1
  22. package/lib/locales/de.json +0 -1
  23. package/lib/locales/en.json +15 -1
  24. package/lib/locales/es.json +0 -1
  25. package/lib/locales/fr.json +15 -1
  26. package/lib/locales/nl.json +0 -1
  27. package/lib/locales/pt-BR.json +0 -1
  28. package/lib/locales/pt.json +0 -1
  29. package/lib/locales/ru.json +0 -1
  30. package/lib/locales/sk.json +0 -1
  31. package/lib/locales/sv.json +0 -1
  32. package/lib/locales/zh-Hans.json +0 -1
  33. package/lib/packages/form-validation/README.md +273 -0
  34. package/lib/packages/form-validation/custom-rules/out-of-range.rule.ts +15 -0
  35. package/lib/packages/form-validation/index.ts +8 -0
  36. package/lib/packages/form-validation/merge-validation-configs.ts +46 -0
  37. package/lib/packages/form-validation/types.ts +104 -0
  38. package/lib/packages/form-validation/use-form-validation.ts +196 -0
  39. package/lib/packages/modal/types.ts +1 -2
  40. package/lib/packages/remote-resource/define-remote-resource.ts +2 -1
  41. package/lib/packages/remote-resource/types.ts +1 -3
  42. package/lib/packages/request/define-request.ts +1 -2
  43. package/lib/packages/validated-form/README.md +389 -0
  44. package/lib/packages/validated-form/index.ts +2 -0
  45. package/lib/packages/validated-form/use-multi-step-validated-form.ts +203 -0
  46. package/lib/packages/validated-form/use-validated-form.ts +180 -0
  47. package/lib/utils/parse-tag.util.ts +22 -0
  48. package/package.json +11 -8
  49. package/tsconfig.json +2 -3
  50. package/lib/components/quick-info-row/VtsQuickInfoRow.vue +0 -59
  51. package/lib/components/ui/chip/ChipIcon.vue +0 -21
  52. package/lib/components/ui/chip/ChipRemoveIcon.vue +0 -16
@@ -0,0 +1,273 @@
1
+ # `useFormValidation` composable
2
+
3
+ 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.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { integer, outOfRange, required, useFormValidation, withMessage } from '@core/packages/form-validation'
9
+
10
+ const { errors, warnings, validate, handleBlur, reset, useFieldMetadata } = useFormValidation(formData, {
11
+ errors: {
12
+ // onBlur → shown when the user leaves the field
13
+ onBlur: () => ({
14
+ age: { integer },
15
+ }),
16
+ // onSubmit → shown only when validate() is called
17
+ onSubmit: () => ({
18
+ label: { required: withMessage(required, t('label-required')) },
19
+ }),
20
+ },
21
+ warnings: {
22
+ onBlur: () => ({
23
+ age: { outOfRange: outOfRange(0, 150) },
24
+ }),
25
+ },
26
+ })
27
+ ```
28
+
29
+ ## Parameters
30
+
31
+ | | Required | Type | Description |
32
+ | -------- | :------: | ----------- | ----------------------------- |
33
+ | `data` | ✓ | `TData` | The reactive form data object |
34
+ | `config` | ✓ | (see below) | Validation configuration |
35
+
36
+ ### `config` object
37
+
38
+ | | Required | Type | Description |
39
+ | ---------- | :------: | -------------------------------- | ----------------------------- |
40
+ | `errors` | | `FormValidationRuleGroup<TData>` | Rules for blocking validation |
41
+ | `warnings` | | `FormValidationRuleGroup<TData>` | Rules for advisory validation |
42
+
43
+ ### `FormValidationRuleGroup<TData>`
44
+
45
+ Rules are split into two groups that control when they become visible:
46
+
47
+ | Key | Description |
48
+ | ---------- | ----------------------------------------------------------------- |
49
+ | `onBlur` | Rules shown when the user leaves a field (`handleBlur` is called) |
50
+ | `onSubmit` | Rules shown only when `validate()` is called |
51
+
52
+ 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).
53
+
54
+ Each group accepts either a plain rule tree object or a getter function — use a getter to keep translated messages reactive across locale changes:
55
+
56
+ ```ts
57
+ errors: {
58
+ onSubmit: () => ({
59
+ label: { required: withMessage(required, t('label-required')) },
60
+ }),
61
+ }
62
+ ```
63
+
64
+ ## Return value
65
+
66
+ | | Type | Description |
67
+ | ------------------ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
68
+ | `errors` | `ComputedRef<FormFieldMessages<TData>>` | Per-field blocking message strings. A field is populated only after it is touched or `validate()` is called. |
69
+ | `warnings` | `ComputedRef<FormFieldMessages<TData>>` | Per-field advisory message strings. A field is populated after `handleBlur(field)` or `validate()` is called. |
70
+ | `validate` | `() => Promise<boolean>` | Touches all warning fields, validates all error rules, and returns `true` when the form is valid. Call on submit. |
71
+ | `reset` | `() => void` | Resets dirty flags and clears all messages for both errors and warnings. |
72
+ | `handleBlur` | `(field: keyof TData) => void` | Marks a field dirty in the `onBlur` rule groups. Call on field blur. |
73
+ | `useFieldMetadata` | `(field, extras?) => () => FormFieldMetadata & extras` | Returns a metadata factory ready to pass to `useField`. Bundles error, warning, and the blur handler. |
74
+
75
+ `FormFieldMessages<TData>` is `{ [K in keyof TData]: string | undefined }` — all fields are always present; the value is `undefined` when there is no active message.
76
+
77
+ 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.
78
+
79
+ ## Severity levels
80
+
81
+ | Level | Gates submit | When shown |
82
+ | ------- | :----------: | ---------------------------------------------------------- |
83
+ | Error | ✓ | After the field is touched or `validate()` is called |
84
+ | Warning | | After `handleBlur(field)` (blur) or `validate()` is called |
85
+
86
+ ## Example: complete form composable
87
+
88
+ ```ts
89
+ import { useFormValidation, required, integer, outOfRange, withMessage } from '@core/packages/form-validation'
90
+ import { useFormBindings } from '@core/packages/form-bindings'
91
+ import { reactive } from 'vue'
92
+ import { useI18n } from 'vue-i18n'
93
+
94
+ type MyFormData = {
95
+ label: string
96
+ age: number | undefined
97
+ }
98
+
99
+ export function useMyForm() {
100
+ const { t } = useI18n()
101
+
102
+ const formData = reactive<MyFormData>({
103
+ label: '',
104
+ age: undefined,
105
+ })
106
+
107
+ const { validate, reset, useFieldMetadata } = useFormValidation(formData, {
108
+ errors: {
109
+ // Show 'required' only on submit — don't nag the user before they've had a chance to fill in the form.
110
+ onSubmit: () => ({
111
+ label: { required: withMessage(required, t('label-required')) },
112
+ age: { required: withMessage(required, t('age-required')) },
113
+ }),
114
+ // Show format errors as soon as the user leaves the field.
115
+ onBlur: () => ({
116
+ age: { integer },
117
+ }),
118
+ },
119
+ warnings: {
120
+ onBlur: () => ({
121
+ age: { outOfRange: outOfRange(0, 150) },
122
+ }),
123
+ },
124
+ })
125
+
126
+ const { useField } = useFormBindings(formData)
127
+
128
+ const labelField = useField('label', useFieldMetadata('label'))
129
+ const ageField = useField(
130
+ 'age',
131
+ useFieldMetadata('age', () => ({ label: t('age') }))
132
+ )
133
+
134
+ async function validateAndBuildPayload() {
135
+ const valid = await validate()
136
+
137
+ if (!valid) {
138
+ return
139
+ }
140
+ }
141
+
142
+ return { formData, labelField, ageField, validateAndBuildPayload, reset }
143
+ }
144
+ ```
145
+
146
+ ## Merging configs — base / augmented form pattern
147
+
148
+ `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.
149
+
150
+ ```ts
151
+ mergeValidationConfigs(base, extra?)
152
+ ```
153
+
154
+ | Parameter | Required | Type | Description |
155
+ | --------- | :------: | ------------------------------ | ---------------------------------------- |
156
+ | `base` | ✓ | `FormValidationConfig<TBase>` | The base config to extend |
157
+ | `extra` | | `FormValidationConfig<TExtra>` | Additional rules to layer on top of base |
158
+
159
+ - `errors` and `warnings` groups are merged independently.
160
+ - Within each group, `onBlur` and `onSubmit` rule trees are merged by spreading `extra` over `base` — extra fields win on key collision.
161
+ - Both plain objects and getter functions are supported; if either side is a getter the result is a getter.
162
+ - When `extra` is omitted, `base` is returned as-is.
163
+
164
+ ### Example
165
+
166
+ ```ts
167
+ // use-base-form.ts — base composable shared by several form variants
168
+ import {
169
+ mergeValidationConfigs,
170
+ useFormValidation,
171
+ required,
172
+ withMessage,
173
+ type FormValidationConfig,
174
+ } from '@core/packages/form-validation'
175
+ import { reactive } from 'vue'
176
+ import { useI18n } from 'vue-i18n'
177
+
178
+ type BaseFormData = { label: string; description: string }
179
+
180
+ export function useBaseForm(extraConfig?: FormValidationConfig<BaseFormData>) {
181
+ const { t } = useI18n()
182
+ const formData = reactive<BaseFormData>({ label: '', description: '' })
183
+
184
+ const baseConfig: FormValidationConfig<BaseFormData> = {
185
+ errors: {
186
+ onSubmit: () => ({
187
+ label: { required: withMessage(required, t('error:label-required')) },
188
+ }),
189
+ },
190
+ }
191
+
192
+ const { validate, reset, useFieldMetadata } = useFormValidation(
193
+ formData,
194
+ mergeValidationConfigs(baseConfig, extraConfig)
195
+ )
196
+
197
+ // …
198
+ return { formData, validate, reset, useFieldMetadata }
199
+ }
200
+ ```
201
+
202
+ ```ts
203
+ // use-extended-form.ts — augmented form that adds extra validation on top
204
+ import { minLength, type FormValidationConfig } from '@core/packages/form-validation'
205
+ import { useBaseForm } from './use-base-form'
206
+
207
+ type BaseFormData = { label: string; description: string }
208
+
209
+ export function useExtendedForm() {
210
+ const extras: FormValidationConfig<BaseFormData> = {
211
+ errors: {
212
+ onBlur: () => ({
213
+ label: { minLength: minLength(3) },
214
+ }),
215
+ },
216
+ }
217
+
218
+ return useBaseForm(extras)
219
+ }
220
+ ```
221
+
222
+ ## Available rules
223
+
224
+ All rules from `@regle/rules` are re-exported from this package. Commonly used rules:
225
+
226
+ | Rule | Description |
227
+ | ------------- | ---------------------------------------------------------- |
228
+ | `required` | Value must be filled |
229
+ | `integer` | Value must be an integer |
230
+ | `minValue` | Value must be ≥ a minimum |
231
+ | `maxValue` | Value must be ≤ a maximum |
232
+ | `minLength` | String/array length must be ≥ a minimum |
233
+ | `maxLength` | String/array length must be ≤ a maximum |
234
+ | `email` | Value must be a valid email address |
235
+ | `url` | Value must be a valid URL |
236
+ | `requiredIf` | Value is required when a condition holds |
237
+ | `withMessage` | Attaches a custom message to any rule |
238
+ | `isFilled` | Type guard: checks if a value is defined |
239
+ | `outOfRange` | Number must be between `min` and `max` (passes when empty) |
240
+
241
+ `type Maybe<T>` and `type FormRuleDeclaration<T>` are also re-exported for use in custom validator signatures.
242
+
243
+ ## Global configuration
244
+
245
+ `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`:
246
+
247
+ ```ts
248
+ // src/plugins/form-validation.config.ts
249
+ import { defineFormValidationConfig, integer, outOfRange, required, withMessage } from '@core/packages/form-validation'
250
+ import { useI18n } from 'vue-i18n'
251
+
252
+ export const formValidationConfig = defineFormValidationConfig({
253
+ rules: () => {
254
+ const { t } = useI18n()
255
+
256
+ return {
257
+ required: withMessage(required, () => t('form:error:required')),
258
+ integer: withMessage(integer, () => t('form:error:integer')),
259
+ outOfRange: withMessage(outOfRange, ({ $params: [min, max] }) => t('form:warning:out-of-range', { min, max })),
260
+ }
261
+ },
262
+ })
263
+ ```
264
+
265
+ ```ts
266
+ // src/main.ts
267
+ import { formValidationConfig } from '@/plugins/form-validation.config.ts'
268
+ import { RegleVuePlugin } from '@regle/core'
269
+
270
+ app.use(RegleVuePlugin, formValidationConfig)
271
+ ```
272
+
273
+ Once installed, `required`, `integer`, and `outOfRange` show the translated messages automatically — no `withMessage` call is needed at each call site.
@@ -0,0 +1,15 @@
1
+ import { createRule, type Maybe } from '@regle/core'
2
+ import { isFilled } from '@regle/rules'
3
+
4
+ export const outOfRange = createRule({
5
+ validator(value: Maybe<number>, min: number, max: number) {
6
+ if (!isFilled(value)) {
7
+ return true
8
+ }
9
+
10
+ return value >= min && value <= max
11
+ },
12
+ message({ $params: [min, max] }) {
13
+ return `Should be between ${min} and ${max}`
14
+ },
15
+ })
@@ -0,0 +1,8 @@
1
+ export * from './custom-rules/out-of-range.rule.ts'
2
+ export * from './merge-validation-configs.ts'
3
+ export * from './types.ts'
4
+ export * from './use-form-validation.ts'
5
+
6
+ export { defineRegleOptions as defineFormValidationConfig } from '@regle/core'
7
+ export type { FormRuleDeclaration, Maybe } from '@regle/core'
8
+ export * from '@regle/rules'
@@ -0,0 +1,46 @@
1
+ import type { FormValidationConfig, FormValidationRuleGroup, FormValidationRules } from './types.ts'
2
+
3
+ function mergeRules<T extends Record<string, unknown>>(
4
+ baseRules?: FormValidationRules<T>,
5
+ extraRules?: FormValidationRules<T>
6
+ ): FormValidationRules<T> | undefined {
7
+ if (!baseRules) {
8
+ return extraRules
9
+ }
10
+
11
+ if (!extraRules) {
12
+ return baseRules
13
+ }
14
+
15
+ const resolvedBase = typeof baseRules === 'function' ? baseRules : () => baseRules
16
+ const resolvedExtra = typeof extraRules === 'function' ? extraRules : () => extraRules
17
+
18
+ return () => ({ ...resolvedBase(), ...resolvedExtra() })
19
+ }
20
+
21
+ function mergeRuleGroups<T extends Record<string, unknown>>(
22
+ baseGroup?: FormValidationRuleGroup<T>,
23
+ extraGroup?: FormValidationRuleGroup<T>
24
+ ): FormValidationRuleGroup<T> | undefined {
25
+ if (!baseGroup && !extraGroup) {
26
+ return undefined
27
+ }
28
+
29
+ return {
30
+ onBlur: mergeRules(baseGroup?.onBlur, extraGroup?.onBlur),
31
+ onSubmit: mergeRules(baseGroup?.onSubmit, extraGroup?.onSubmit),
32
+ }
33
+ }
34
+
35
+ export function mergeValidationConfigs<TBase extends Record<string, unknown>, TExtra extends TBase>(
36
+ base: FormValidationConfig<TBase>,
37
+ extra?: FormValidationConfig<TExtra>
38
+ ): FormValidationConfig<TExtra> {
39
+ const errors = mergeRuleGroups(base.errors as FormValidationRuleGroup<TExtra>, extra?.errors)
40
+ const warnings = mergeRuleGroups(base.warnings as FormValidationRuleGroup<TExtra>, extra?.warnings)
41
+
42
+ return {
43
+ ...(errors !== undefined && { errors }),
44
+ ...(warnings !== undefined && { warnings }),
45
+ }
46
+ }
@@ -0,0 +1,104 @@
1
+ import type { FormRuleDeclaration } from '@regle/core'
2
+ import type { ComputedRef } from 'vue'
3
+
4
+ // FormRuleDeclaration<unknown> covers built-in Regle rules (required, minLength, …) which are
5
+ // universally typed with `unknown` internally. Listing it explicitly alongside FormRuleDeclaration<T>
6
+ // keeps inline-function completions typed with T while accepting built-in rules.
7
+ type AnyRuleValue<T> = FormRuleDeclaration<T> | FormRuleDeclaration<unknown> | undefined
8
+
9
+ type UniversalRuleKeys<T> = {
10
+ required?: AnyRuleValue<T>
11
+ requiredIf?: AnyRuleValue<T>
12
+ requiredUnless?: AnyRuleValue<T>
13
+ }
14
+
15
+ type StringRuleKeys<T> = [Extract<NonNullable<T>, string>] extends [never]
16
+ ? Record<never, never>
17
+ : {
18
+ minLength?: AnyRuleValue<T>
19
+ maxLength?: AnyRuleValue<T>
20
+ email?: AnyRuleValue<T>
21
+ url?: AnyRuleValue<T>
22
+ httpUrl?: AnyRuleValue<T>
23
+ regex?: AnyRuleValue<T>
24
+ sameAs?: AnyRuleValue<T>
25
+ contains?: AnyRuleValue<T>
26
+ ipAddress?: AnyRuleValue<T>
27
+ macAddress?: AnyRuleValue<T>
28
+ alpha?: AnyRuleValue<T>
29
+ alphaNum?: AnyRuleValue<T>
30
+ numeric?: AnyRuleValue<T>
31
+ }
32
+
33
+ type NumberRuleKeys<T> = [Extract<NonNullable<T>, number>] extends [never]
34
+ ? Record<never, never>
35
+ : {
36
+ minValue?: AnyRuleValue<T>
37
+ maxValue?: AnyRuleValue<T>
38
+ integer?: AnyRuleValue<T>
39
+ between?: AnyRuleValue<T>
40
+ }
41
+
42
+ type BooleanRuleKeys<T> = [Extract<NonNullable<T>, boolean>] extends [never]
43
+ ? Record<never, never>
44
+ : {
45
+ checked?: AnyRuleValue<T>
46
+ }
47
+
48
+ type DateRuleKeys<T> = [Extract<NonNullable<T>, Date>] extends [never]
49
+ ? Record<never, never>
50
+ : {
51
+ after?: AnyRuleValue<T>
52
+ before?: AnyRuleValue<T>
53
+ dateBetween?: AnyRuleValue<T>
54
+ dateAfter?: AnyRuleValue<T>
55
+ dateBefore?: AnyRuleValue<T>
56
+ }
57
+
58
+ export type FormFieldRules<T> = UniversalRuleKeys<T> &
59
+ StringRuleKeys<T> &
60
+ NumberRuleKeys<T> &
61
+ BooleanRuleKeys<T> &
62
+ DateRuleKeys<T> & {
63
+ [customRule: string]: AnyRuleValue<T>
64
+ }
65
+
66
+ export type FormRuleTree<TData extends Record<string, unknown>> = {
67
+ [K in keyof TData]?: FormFieldRules<TData[K]>
68
+ }
69
+
70
+ export type FormValidationRules<TData extends Record<string, unknown>> =
71
+ | FormRuleTree<TData>
72
+ | (() => FormRuleTree<TData>)
73
+
74
+ export type FormValidationRuleGroup<TData extends Record<string, unknown>> = {
75
+ onBlur?: FormValidationRules<TData>
76
+ onSubmit?: FormValidationRules<TData>
77
+ }
78
+
79
+ export type FormValidationConfig<TData extends Record<string, unknown>> = {
80
+ errors?: FormValidationRuleGroup<TData>
81
+ warnings?: FormValidationRuleGroup<TData>
82
+ }
83
+
84
+ export type FormFieldMessages<TData extends Record<string, unknown>> = {
85
+ [K in keyof TData]: string | undefined
86
+ }
87
+
88
+ export type FormFieldMetadata = {
89
+ error: string | undefined
90
+ warning: string | undefined
91
+ onBlur: () => void
92
+ }
93
+
94
+ export type UseFormValidationReturn<TData extends Record<string, unknown>> = {
95
+ errors: ComputedRef<FormFieldMessages<TData>>
96
+ warnings: ComputedRef<FormFieldMessages<TData>>
97
+ validate: () => Promise<boolean>
98
+ reset: () => void
99
+ handleBlur: (field: keyof TData) => void
100
+ useFieldMetadata: {
101
+ (field: keyof TData): () => FormFieldMetadata
102
+ <E extends Record<string, unknown>>(field: keyof TData, extras: () => E): () => FormFieldMetadata & E
103
+ }
104
+ }
@@ -0,0 +1,196 @@
1
+ import type {
2
+ FormFieldMessages,
3
+ FormFieldMetadata,
4
+ FormRuleTree,
5
+ FormValidationConfig,
6
+ FormValidationRules,
7
+ UseFormValidationReturn,
8
+ } from './types.ts'
9
+ import { useRegle } from '@regle/core'
10
+ import { computed } from 'vue'
11
+
12
+ type FieldStatus = {
13
+ $touch: () => void
14
+ }
15
+
16
+ type RegleStatusAccessor = {
17
+ $fields: Record<string, FieldStatus>
18
+ $errors: Record<string, unknown>
19
+ $validate: () => Promise<{ valid: boolean }>
20
+ $reset: () => void
21
+ $touch: () => void
22
+ }
23
+
24
+ /**
25
+ * Automatically injects `$each: {}` into the rule tree for every field whose
26
+ * current value is an array, if that field is present in the rules but has no
27
+ * `$each` declared yet.
28
+ *
29
+ * This is required because Regle needs `$each` to know a field is a collection
30
+ * and to produce the `{ $self, $each }` error shape instead of a flat string[].
31
+ */
32
+ function injectCollectionMarkers<TData extends Record<string, unknown>>(
33
+ data: TData,
34
+ rules: FormValidationRules<TData>
35
+ ): FormValidationRules<TData> {
36
+ const arrayKeys = Object.keys(data).filter(key => Array.isArray(data[key]))
37
+
38
+ if (arrayKeys.length === 0) {
39
+ return rules
40
+ }
41
+
42
+ const inject = (ruleTree: FormRuleTree<TData>): FormRuleTree<TData> => {
43
+ const result = { ...ruleTree } as Record<string, unknown>
44
+
45
+ for (const key of arrayKeys) {
46
+ const fieldRules = result[key]
47
+
48
+ if (fieldRules !== null && typeof fieldRules === 'object' && !('$each' in fieldRules)) {
49
+ result[key] = { ...(fieldRules as object), $each: {} }
50
+ }
51
+ }
52
+
53
+ return result as FormRuleTree<TData>
54
+ }
55
+
56
+ if (typeof rules === 'function') {
57
+ return () => inject((rules as () => FormRuleTree<TData>)())
58
+ }
59
+
60
+ return inject(rules)
61
+ }
62
+
63
+ /**
64
+ * Calls `useRegle` with a simplified signature.
65
+ *
66
+ * Regle's second-parameter type is a deeply conditional type that TypeScript cannot
67
+ * resolve when `TData` is a generic type parameter. Casting through `unknown` at this
68
+ * single call-site keeps the rest of the file type-safe without resorting to `any`.
69
+ */
70
+ function callUseRegle<TData extends Record<string, unknown>>(
71
+ data: TData,
72
+ rules: FormRuleTree<TData> | (() => FormRuleTree<TData>)
73
+ ): { r$: unknown } {
74
+ return (
75
+ useRegle as unknown as (_data: TData, _rules: FormRuleTree<TData> | (() => FormRuleTree<TData>)) => { r$: unknown }
76
+ )(data, rules)
77
+ }
78
+
79
+ function toMessage(fieldErrors: unknown): string | undefined {
80
+ if (Array.isArray(fieldErrors)) {
81
+ return fieldErrors[0]
82
+ }
83
+
84
+ // Collection-field errors take the { $self: string[], $each: ... } shape ? surface the first $self message.
85
+ if (fieldErrors !== null && typeof fieldErrors === 'object' && '$self' in fieldErrors) {
86
+ const $self = (fieldErrors as { $self?: unknown }).$self
87
+ if (Array.isArray($self)) {
88
+ return $self[0]
89
+ }
90
+ }
91
+
92
+ return undefined
93
+ }
94
+
95
+ function buildMessages(regle: RegleStatusAccessor): Record<string, string | undefined> {
96
+ return Object.fromEntries(Object.keys(regle.$fields).map(key => [key, toMessage(regle.$errors[key])]))
97
+ }
98
+
99
+ function mergeMessages(
100
+ blurMessages: Record<string, string | undefined>,
101
+ submitMessages: Record<string, string | undefined>
102
+ ): Record<string, string | undefined> {
103
+ const keys = new Set([...Object.keys(blurMessages), ...Object.keys(submitMessages)])
104
+
105
+ return Object.fromEntries([...keys].map(key => [key, blurMessages[key] ?? submitMessages[key]]))
106
+ }
107
+
108
+ const EMPTY_RULES = {}
109
+
110
+ export function useFormValidation<TData extends Record<string, unknown>>(
111
+ data: TData,
112
+ config: FormValidationConfig<TData>
113
+ ): UseFormValidationReturn<TData> {
114
+ // All four useRegle calls must be unconditional — Vue composables cannot be called conditionally.
115
+ // When a group has no rules, an empty rule tree produces an empty $fields map.
116
+ // injectCollectionMarkers ensures array fields always have $each declared so Regle produces
117
+ // the { $self, $each } error shape.
118
+ const { r$: blurErrors$ } = callUseRegle(data, injectCollectionMarkers(data, config.errors?.onBlur ?? EMPTY_RULES))
119
+ const { r$: submitErrors$ } = callUseRegle(
120
+ data,
121
+ injectCollectionMarkers(data, config.errors?.onSubmit ?? EMPTY_RULES)
122
+ )
123
+ const { r$: blurWarnings$ } = callUseRegle(
124
+ data,
125
+ injectCollectionMarkers(data, config.warnings?.onBlur ?? EMPTY_RULES)
126
+ )
127
+ const { r$: submitWarnings$ } = callUseRegle(
128
+ data,
129
+ injectCollectionMarkers(data, config.warnings?.onSubmit ?? EMPTY_RULES)
130
+ )
131
+
132
+ // Cast at the Regle boundary: Regle's inferred types are too complex to thread through
133
+ // generics here, but the runtime shape is always compatible with RegleStatusAccessor.
134
+ const blurErrorRegle = blurErrors$ as RegleStatusAccessor
135
+ const submitErrorRegle = submitErrors$ as RegleStatusAccessor
136
+ const blurWarningRegle = blurWarnings$ as RegleStatusAccessor
137
+ const submitWarningRegle = submitWarnings$ as RegleStatusAccessor
138
+
139
+ const errors = computed<FormFieldMessages<TData>>(
140
+ () => mergeMessages(buildMessages(blurErrorRegle), buildMessages(submitErrorRegle)) as FormFieldMessages<TData>
141
+ )
142
+
143
+ const warnings = computed<FormFieldMessages<TData>>(
144
+ () => mergeMessages(buildMessages(blurWarningRegle), buildMessages(submitWarningRegle)) as FormFieldMessages<TData>
145
+ )
146
+
147
+ async function validate(): Promise<boolean> {
148
+ // Touch all warning fields so advisory messages become visible regardless of which group they're in.
149
+ blurWarningRegle.$touch()
150
+ submitWarningRegle.$touch()
151
+
152
+ const [blurResult, submitResult] = await Promise.all([blurErrorRegle.$validate(), submitErrorRegle.$validate()])
153
+
154
+ return blurResult.valid && submitResult.valid
155
+ }
156
+
157
+ function reset(): void {
158
+ blurErrorRegle.$reset()
159
+ submitErrorRegle.$reset()
160
+ blurWarningRegle.$reset()
161
+ submitWarningRegle.$reset()
162
+ }
163
+
164
+ function handleBlur(field: keyof TData): void {
165
+ const key = field as string
166
+ // Only touch blur-group fields — submit-group fields stay hidden until validate() is called.
167
+ blurErrorRegle.$fields[key]?.$touch()
168
+ blurWarningRegle.$fields[key]?.$touch()
169
+ }
170
+
171
+ function useFieldMetadata(field: keyof TData): () => FormFieldMetadata
172
+ function useFieldMetadata<E extends Record<string, unknown>>(
173
+ field: keyof TData,
174
+ extras: () => E
175
+ ): () => FormFieldMetadata & E
176
+ function useFieldMetadata<E extends Record<string, unknown> = Record<string, unknown>>(
177
+ field: keyof TData,
178
+ extras?: () => E
179
+ ) {
180
+ return () => ({
181
+ error: errors.value[field],
182
+ warning: warnings.value[field],
183
+ onBlur: () => handleBlur(field),
184
+ ...extras?.(),
185
+ })
186
+ }
187
+
188
+ return {
189
+ errors,
190
+ warnings,
191
+ validate,
192
+ reset,
193
+ handleBlur,
194
+ useFieldMetadata,
195
+ }
196
+ }
@@ -1,5 +1,4 @@
1
- import type { MaybeRef } from '@vueuse/core'
2
- import type { Component, ComputedRef, InjectionKey } from 'vue'
1
+ import type { Component, ComputedRef, InjectionKey, MaybeRef } from 'vue'
3
2
 
4
3
  export type ModalPropsOption<TProps> = {
5
4
  [K in keyof TProps]: MaybeRef<TProps[K]>
@@ -7,7 +7,7 @@ import {
7
7
  import type { ResourceContext, UseRemoteResource } from '@core/packages/remote-resource/types.ts'
8
8
  import type { VoidFunction } from '@core/types/utility.type.ts'
9
9
  import { ifElse } from '@core/utils/if-else.utils.ts'
10
- import { type MaybeRef, noop, useDebounceFn, useTimeoutPoll } from '@vueuse/core'
10
+ import { noop, useDebounceFn, useTimeoutPoll } from '@vueuse/core'
11
11
  import { merge, remove } from 'lodash-es'
12
12
  import readNDJSONStream from 'ndjson-readablestream'
13
13
  import {
@@ -26,6 +26,7 @@ import {
26
26
  toValue,
27
27
  watch,
28
28
  effectScope,
29
+ type MaybeRef,
29
30
  } from 'vue'
30
31
 
31
32
  const DEFAULT_CACHE_EXPIRATION_MS = 10_000
@@ -1,6 +1,4 @@
1
- import type { MaybeRef } from '@vueuse/core'
2
- import type { MaybeRefOrGetter } from '@vueuse/shared'
3
- import type { ComputedRef, EffectScope, Ref, ToRef } from 'vue'
1
+ import type { ComputedRef, EffectScope, MaybeRef, MaybeRefOrGetter, Ref, ToRef } from 'vue'
4
2
 
5
3
  export type ResourceContext<TArgs extends any[]> = {
6
4
  scope: EffectScope