@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.
- package/lib/assets/css/_reset.pcss +8 -1
- package/lib/components/column/VtsColumn.vue +5 -1
- package/lib/components/columns/VtsColumns.vue +17 -8
- package/lib/components/console/VtsRemoteConsole.vue +25 -19
- package/lib/components/form/VtsForm.vue +15 -0
- package/lib/components/{quick-info-column/VtsQuickInfoColumn.vue → key-value-list/VtsKeyValueList.vue} +3 -3
- package/lib/components/key-value-pair/VtsKeyValuePair.vue +48 -0
- package/lib/components/key-value-row/VtsKeyValueRow.vue +45 -0
- package/lib/components/status/VtsStatus.vue +1 -7
- package/lib/components/table/cells/VtsTagCell.vue +2 -4
- package/lib/components/tabular-key-value-list/VtsTabularKeyValueList.vue +29 -0
- package/lib/components/tabular-key-value-row/VtsTabularKeyValueRow.vue +42 -0
- package/lib/components/tag/VtsTag.vue +30 -0
- package/lib/components/ui/chip/UiChip.vue +52 -29
- package/lib/components/ui/info/UiInfo.vue +17 -6
- package/lib/components/ui/link/UiLink.vue +6 -2
- package/lib/components/ui/tag/UiTag.vue +10 -17
- package/lib/components/ui/tag/UiTertiaryTag.vue +39 -0
- package/lib/icons/action-icons.ts +1 -1
- package/lib/locales/cs.json +0 -1
- package/lib/locales/da.json +0 -1
- package/lib/locales/de.json +0 -1
- package/lib/locales/en.json +15 -1
- package/lib/locales/es.json +0 -1
- package/lib/locales/fr.json +15 -1
- package/lib/locales/nl.json +0 -1
- package/lib/locales/pt-BR.json +0 -1
- package/lib/locales/pt.json +0 -1
- package/lib/locales/ru.json +0 -1
- package/lib/locales/sk.json +0 -1
- package/lib/locales/sv.json +0 -1
- package/lib/locales/zh-Hans.json +0 -1
- package/lib/packages/form-validation/README.md +273 -0
- package/lib/packages/form-validation/custom-rules/out-of-range.rule.ts +15 -0
- package/lib/packages/form-validation/index.ts +8 -0
- package/lib/packages/form-validation/merge-validation-configs.ts +46 -0
- package/lib/packages/form-validation/types.ts +104 -0
- package/lib/packages/form-validation/use-form-validation.ts +196 -0
- package/lib/packages/modal/types.ts +1 -2
- package/lib/packages/remote-resource/define-remote-resource.ts +2 -1
- package/lib/packages/remote-resource/types.ts +1 -3
- package/lib/packages/request/define-request.ts +1 -2
- package/lib/packages/validated-form/README.md +389 -0
- package/lib/packages/validated-form/index.ts +2 -0
- package/lib/packages/validated-form/use-multi-step-validated-form.ts +203 -0
- package/lib/packages/validated-form/use-validated-form.ts +180 -0
- package/lib/utils/parse-tag.util.ts +22 -0
- package/package.json +11 -8
- package/tsconfig.json +2 -3
- package/lib/components/quick-info-row/VtsQuickInfoRow.vue +0 -59
- package/lib/components/ui/chip/ChipIcon.vue +0 -21
- 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 '
|
|
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 {
|
|
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 '
|
|
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
|