@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
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useTimeoutPoll } from '@vueuse/core'
|
|
2
|
-
import type { MaybeRefOrGetter } from '@vueuse/shared'
|
|
3
2
|
// eslint-disable-next-line import/namespace,import/default,import/no-named-as-default,import/no-named-as-default-member -- https://github.com/pamelafox/ndjson-readablestream/pull/13
|
|
4
3
|
import readNDJSONStream from 'ndjson-readablestream'
|
|
5
|
-
import { computed, isRef, toValue, watch } from 'vue'
|
|
4
|
+
import { computed, isRef, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
|
6
5
|
|
|
7
6
|
export function defineRequest<TState>(options: {
|
|
8
7
|
url: string
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# `validated-form` package
|
|
2
|
+
|
|
3
|
+
Provides two composables for building validated forms: `useValidatedForm` for single-step forms and `useMultiStepValidatedForm` for wizard-style multi-step forms.
|
|
4
|
+
|
|
5
|
+
> **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.
|
|
6
|
+
>
|
|
7
|
+
> ```vue
|
|
8
|
+
> <VtsForm @submit="onSubmit">…</VtsForm>
|
|
9
|
+
> ```
|
|
10
|
+
>
|
|
11
|
+
> If you must use a raw `<form>`, add `novalidate` manually:
|
|
12
|
+
>
|
|
13
|
+
> ```html
|
|
14
|
+
> <form novalidate @submit.prevent="onSubmit">…</form>
|
|
15
|
+
> ```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## `useValidatedForm` composable
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
### Usage
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { required, outOfRange } from '@core/packages/form-validation'
|
|
27
|
+
import { useValidatedForm } from '@core/packages/validated-form'
|
|
28
|
+
|
|
29
|
+
const { useField, useFormSelect, useSelect, validate, reset, handleBlur } = useValidatedForm(formData, {
|
|
30
|
+
errors: {
|
|
31
|
+
onSubmit: () => ({
|
|
32
|
+
title: { required },
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
warnings: {
|
|
36
|
+
onBlur: () => ({
|
|
37
|
+
quantity: { outOfRange: outOfRange(1, 999) },
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Parameters
|
|
44
|
+
|
|
45
|
+
| | Required | Type | Description |
|
|
46
|
+
| -------- | :------: | ----------------------------- | ------------------------------------------------------------------------- |
|
|
47
|
+
| `data` | ✓ | `TData` | A reactive object holding the form field values |
|
|
48
|
+
| `config` | ✓ | `FormValidationConfig<TData>` | Validation rule configuration — same shape as `useFormValidation` accepts |
|
|
49
|
+
|
|
50
|
+
`FormValidationConfig` is documented in detail in `@core/packages/form-validation`.
|
|
51
|
+
|
|
52
|
+
### Return value
|
|
53
|
+
|
|
54
|
+
#### Binding factories
|
|
55
|
+
|
|
56
|
+
| Property | Signature | Description |
|
|
57
|
+
| --------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
58
|
+
| `useField` | `(key, extras?) => ComputedRef<ModelBinding & FieldMetadata & E>` | Creates a combined v-model + validation binding for a text/number/checkbox input |
|
|
59
|
+
| `useFormSelect` | `(key, sources, config?) => UseFormSelectReturn` | Registers a select, auto-wires `model`, and records the key→id mapping in the registry |
|
|
60
|
+
| `useSelect` | see overloads below | Creates a binding for a `VtsSelect`-like component |
|
|
61
|
+
|
|
62
|
+
##### `useField(key, extras?)`
|
|
63
|
+
|
|
64
|
+
Returns a `ComputedRef` merging:
|
|
65
|
+
|
|
66
|
+
- `modelValue` / `onUpdate:modelValue` — the v-model pair for the field
|
|
67
|
+
- `error`, `warning`, `onBlur` — from `FieldMetadata`
|
|
68
|
+
- Any additional props returned by the optional `extras` factory (e.g. `label`, `required`, `info`)
|
|
69
|
+
|
|
70
|
+
##### `useFormSelect(key, sources, config?)`
|
|
71
|
+
|
|
72
|
+
A wrapper around `useFormSelect` from `@core/packages/form-select` that:
|
|
73
|
+
|
|
74
|
+
1. Automatically sets `model: toRef(data, key)` — no need to pass it manually
|
|
75
|
+
2. Records the `id → key` mapping so `useSelect(id)` can resolve validation metadata without an explicit key
|
|
76
|
+
|
|
77
|
+
`config` accepts all `useFormSelect` options **except** `model` (which is injected).
|
|
78
|
+
|
|
79
|
+
##### `useSelect` overloads
|
|
80
|
+
|
|
81
|
+
| Overload | When to use |
|
|
82
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `useSelect(id, extras?)` | Select was registered with `useFormSelect` in the same `useValidatedForm` call — key is resolved from the registry |
|
|
84
|
+
| `useSelect(id, key, extras?)` | Select was created externally (e.g. inside a nested composable) — key cannot be inferred and must be passed explicitly |
|
|
85
|
+
|
|
86
|
+
Both overloads return a `ComputedRef` merging `{ id }`, `FieldMetadata`, and any extras.
|
|
87
|
+
|
|
88
|
+
#### Delegated from `useFormValidation`
|
|
89
|
+
|
|
90
|
+
| Property | Type | Description |
|
|
91
|
+
| ------------ | ------------------------------ | -------------------------------------------------------------------------------- |
|
|
92
|
+
| `validate` | `() => Promise<boolean>` | Validates all error rules and touches warning fields. Returns `true` when valid |
|
|
93
|
+
| `reset` | `() => void` | Resets all dirty flags and clears all messages |
|
|
94
|
+
| `handleBlur` | `(field: keyof TData) => void` | Marks a field dirty in the `onBlur` rule groups (called automatically by fields) |
|
|
95
|
+
|
|
96
|
+
### Example: form with text inputs and a managed select
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { useValidatedForm } from '@core/packages/validated-form'
|
|
100
|
+
import { reactive } from 'vue'
|
|
101
|
+
import { useI18n } from 'vue-i18n'
|
|
102
|
+
|
|
103
|
+
type ProductFormData = {
|
|
104
|
+
category: string | undefined
|
|
105
|
+
title: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function useProductForm() {
|
|
109
|
+
const { t } = useI18n()
|
|
110
|
+
|
|
111
|
+
const formData = reactive<ProductFormData>({ category: undefined, title: '' })
|
|
112
|
+
|
|
113
|
+
const { useField, useFormSelect, useSelect, validate } = useValidatedForm(formData, {
|
|
114
|
+
errors: {
|
|
115
|
+
onSubmit: () => ({
|
|
116
|
+
category: { required },
|
|
117
|
+
title: { required },
|
|
118
|
+
}),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// model is wired automatically; id→key mapping is recorded in the registry
|
|
123
|
+
const { id: categorySelectId } = useFormSelect('category', categories, {
|
|
124
|
+
searchable: true,
|
|
125
|
+
required: true,
|
|
126
|
+
option: { label: 'name', value: 'id' },
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// key is inferred from the registry — no need to repeat 'category'
|
|
130
|
+
const categorySelectBindings = useSelect(categorySelectId, () => ({ label: t('category') }))
|
|
131
|
+
const titleInputBindings = useField('title', () => ({ label: t('title'), required: true }))
|
|
132
|
+
|
|
133
|
+
return { categorySelectBindings, titleInputBindings, validate }
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Example: select created by an external composable (explicit key)
|
|
138
|
+
|
|
139
|
+
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`:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const { useField, useSelect, validate } = useValidatedForm(formData, {
|
|
143
|
+
errors: {
|
|
144
|
+
onSubmit: () => ({
|
|
145
|
+
author: { required },
|
|
146
|
+
quantity: { required },
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// authorSelectId comes from a composable that called useFormSelect internally
|
|
152
|
+
const { authorSelectId } = useAuthorSelect(toRef(formData, 'author'))
|
|
153
|
+
|
|
154
|
+
// key 'author' must be provided explicitly because it was registered outside this scope
|
|
155
|
+
const authorSelectBindings = useSelect(authorSelectId, 'author', () => ({ label: t('author') }))
|
|
156
|
+
const quantityInputBindings = useField('quantity', () => ({ label: t('quantity'), required: true }))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## `defineFormSteps`
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
import { defineFormSteps } from '@core/packages/validated-form'
|
|
167
|
+
|
|
168
|
+
const formData = defineFormSteps({
|
|
169
|
+
general: { category: undefined as string | undefined, title: '' },
|
|
170
|
+
details: { region: undefined as string | undefined, quantity: undefined as number | undefined },
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
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()`).
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## `useMultiStepValidatedForm` composable
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
TypeScript infers step names from the data and config keys, giving full autocompletion on `currentStep`, `validateStep('stepName')`, and `isStepValid('stepName')`.
|
|
183
|
+
|
|
184
|
+
### Usage
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import { defineFormSteps, useMultiStepValidatedForm } from '@core/packages/validated-form'
|
|
188
|
+
|
|
189
|
+
const formData = defineFormSteps({
|
|
190
|
+
general: { category: undefined as string | undefined, title: '' },
|
|
191
|
+
details: { region: undefined as string | undefined, quantity: undefined as number | undefined },
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const { useField, currentStep, next, back, isStepValid, areAllStepsValid, validateAllSteps } =
|
|
195
|
+
useMultiStepValidatedForm(formData, {
|
|
196
|
+
general: {
|
|
197
|
+
errors: { onSubmit: () => ({ category: { required }, title: { required } }) },
|
|
198
|
+
},
|
|
199
|
+
details: {
|
|
200
|
+
errors: {
|
|
201
|
+
onSubmit: () => ({ region: { required }, quantity: { required } }),
|
|
202
|
+
onBlur: () => ({ quantity: { outOfRange: outOfRange(1, 999) } }),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Parameters
|
|
209
|
+
|
|
210
|
+
| | Required | Type | Description |
|
|
211
|
+
| ------------- | :------: | -------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
212
|
+
| `data` | ✓ | `TData` | A nested reactive object — top-level keys are step names, values are field maps |
|
|
213
|
+
| `stepConfigs` | ✓ | `{ [K in keyof TData]: FormValidationConfig<TData[K]> }` | Validation config for each step, typed to that step's fields only |
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
### Return value
|
|
218
|
+
|
|
219
|
+
#### Binding factories
|
|
220
|
+
|
|
221
|
+
| Property | Signature | Description |
|
|
222
|
+
| --------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
223
|
+
| `useField` | `(key, extras?) => ComputedRef<ModelBinding & FieldMetadata & E>` | Same as `useValidatedForm`'s `useField` — step routing is automatic from the data structure |
|
|
224
|
+
| `useFormSelect` | `(key, sources, config?) => UseFormSelectReturn` | Same as `useValidatedForm`'s `useFormSelect` — step routing is automatic |
|
|
225
|
+
| `useSelect` | see overloads in `useValidatedForm` | Same overloads as `useValidatedForm`'s `useSelect` — resolves step from registry or explicit key |
|
|
226
|
+
|
|
227
|
+
`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`.
|
|
228
|
+
|
|
229
|
+
#### Navigation
|
|
230
|
+
|
|
231
|
+
| Property | Type | Description |
|
|
232
|
+
| ------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
233
|
+
| `currentStep` | `ComputedRef<keyof TSteps & string>` | The currently active step name |
|
|
234
|
+
| `next` | `() => Promise<boolean>` | Validates the current step; advances to the next step only if validation passes. Returns `true` if the step was valid |
|
|
235
|
+
| `back` | `() => void` | Goes to the previous step without validation |
|
|
236
|
+
|
|
237
|
+
#### Validation state
|
|
238
|
+
|
|
239
|
+
| Property | Type | Description |
|
|
240
|
+
| ------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
241
|
+
| `areAllStepsValid` | `ComputedRef<boolean>` | `true` only when every step has been validated and all pass |
|
|
242
|
+
| `isStepValid` | `(step: keyof TSteps) => boolean \| undefined` | Last known validation result for a step; `undefined` if the step has never been validated |
|
|
243
|
+
| `isValidating` | `Ref<boolean>` | `true` while `validateAllSteps()` is in flight; guards against concurrent calls |
|
|
244
|
+
| `validateStep` | `(step: keyof TSteps) => Promise<boolean>` | Validates a single step by name and stores the result |
|
|
245
|
+
| `validateAllSteps` | `() => Promise<boolean>` | Validates all steps concurrently. Returns `true` only if every step passes |
|
|
246
|
+
|
|
247
|
+
`validateAllSteps` is a no-op (returns `false`) when `isValidating` is already `true`.
|
|
248
|
+
|
|
249
|
+
### Example: wizard form with navigation and per-step validation
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { required, outOfRange } from '@core/packages/form-validation'
|
|
253
|
+
import { defineFormSteps, useMultiStepValidatedForm } from '@core/packages/validated-form'
|
|
254
|
+
import { useI18n } from 'vue-i18n'
|
|
255
|
+
|
|
256
|
+
export function useCreateItemWizard() {
|
|
257
|
+
const { t } = useI18n()
|
|
258
|
+
|
|
259
|
+
const formData = defineFormSteps({
|
|
260
|
+
general: {
|
|
261
|
+
category: undefined as string | undefined,
|
|
262
|
+
title: '',
|
|
263
|
+
summary: '',
|
|
264
|
+
featured: false,
|
|
265
|
+
},
|
|
266
|
+
details: {
|
|
267
|
+
region: undefined as string | undefined,
|
|
268
|
+
quantity: undefined as number | undefined,
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const {
|
|
273
|
+
useField,
|
|
274
|
+
useFormSelect,
|
|
275
|
+
useSelect,
|
|
276
|
+
currentStep,
|
|
277
|
+
next,
|
|
278
|
+
back,
|
|
279
|
+
isStepValid,
|
|
280
|
+
areAllStepsValid,
|
|
281
|
+
validateAllSteps,
|
|
282
|
+
} = useMultiStepValidatedForm(formData, {
|
|
283
|
+
general: {
|
|
284
|
+
errors: {
|
|
285
|
+
onSubmit: () => ({
|
|
286
|
+
category: { required },
|
|
287
|
+
title: { required },
|
|
288
|
+
}),
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
details: {
|
|
292
|
+
errors: {
|
|
293
|
+
onSubmit: () => ({
|
|
294
|
+
region: { required },
|
|
295
|
+
quantity: { required },
|
|
296
|
+
}),
|
|
297
|
+
onBlur: () => ({ quantity: { outOfRange: outOfRange(1, 999) } }),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// Bindings — no step prefix needed, routing is automatic
|
|
303
|
+
const { id: categorySelectId } = useFormSelect('category', categories, {
|
|
304
|
+
searchable: true,
|
|
305
|
+
option: { label: 'name', value: 'id' },
|
|
306
|
+
})
|
|
307
|
+
const categorySelectBindings = useSelect(categorySelectId, () => ({ label: t('category') }))
|
|
308
|
+
const titleInputBindings = useField('title', () => ({ label: t('title'), required: true }))
|
|
309
|
+
const summaryInputBindings = useField('summary', () => ({ label: t('summary') }))
|
|
310
|
+
const featuredCheckboxBindings = useField('featured')
|
|
311
|
+
|
|
312
|
+
const { id: regionSelectId } = useFormSelect('region', regions, {
|
|
313
|
+
searchable: true,
|
|
314
|
+
option: { label: 'name', value: 'id' },
|
|
315
|
+
})
|
|
316
|
+
const regionSelectBindings = useSelect(regionSelectId, () => ({ label: t('region') }))
|
|
317
|
+
const quantityInputBindings = useField('quantity', () => ({ label: t('quantity'), required: true }))
|
|
318
|
+
|
|
319
|
+
async function onSubmit() {
|
|
320
|
+
const isValid = await validateAllSteps()
|
|
321
|
+
|
|
322
|
+
if (!isValid) {
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
categoryId: formData.general.category!,
|
|
328
|
+
title: formData.general.title,
|
|
329
|
+
...(formData.general.summary !== '' && { summary: formData.general.summary }),
|
|
330
|
+
...(formData.general.featured && { featured: true }),
|
|
331
|
+
region: formData.details.region!,
|
|
332
|
+
quantity: formData.details.quantity!,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
formData,
|
|
338
|
+
currentStep,
|
|
339
|
+
next,
|
|
340
|
+
back,
|
|
341
|
+
isStepValid,
|
|
342
|
+
areAllStepsValid,
|
|
343
|
+
categorySelectBindings,
|
|
344
|
+
titleInputBindings,
|
|
345
|
+
summaryInputBindings,
|
|
346
|
+
featuredCheckboxBindings,
|
|
347
|
+
regionSelectBindings,
|
|
348
|
+
quantityInputBindings,
|
|
349
|
+
onSubmit,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
```vue
|
|
355
|
+
<script lang="ts" setup>
|
|
356
|
+
const {
|
|
357
|
+
currentStep,
|
|
358
|
+
next,
|
|
359
|
+
back,
|
|
360
|
+
areAllStepsValid,
|
|
361
|
+
categorySelectBindings,
|
|
362
|
+
titleInputBindings,
|
|
363
|
+
summaryInputBindings,
|
|
364
|
+
featuredCheckboxBindings,
|
|
365
|
+
regionSelectBindings,
|
|
366
|
+
quantityInputBindings,
|
|
367
|
+
onSubmit,
|
|
368
|
+
} = useCreateItemWizard()
|
|
369
|
+
</script>
|
|
370
|
+
|
|
371
|
+
<template>
|
|
372
|
+
<VtsForm @submit="onSubmit">
|
|
373
|
+
<div v-if="currentStep === 'general'">
|
|
374
|
+
<VtsSelect v-bind="categorySelectBindings" />
|
|
375
|
+
<VtsInput v-bind="titleInputBindings" />
|
|
376
|
+
<VtsInput v-bind="summaryInputBindings" />
|
|
377
|
+
<VtsCheckbox v-bind="featuredCheckboxBindings" />
|
|
378
|
+
</div>
|
|
379
|
+
<div v-else-if="currentStep === 'details'">
|
|
380
|
+
<VtsSelect v-bind="regionSelectBindings" />
|
|
381
|
+
<VtsInput v-bind="quantityInputBindings" />
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<button :disabled="currentStep === 'general'" @click="back">Back</button>
|
|
385
|
+
<button v-if="currentStep !== 'details'" @click="next">Next</button>
|
|
386
|
+
<button v-else :disabled="!areAllStepsValid" @click="onSubmit">Submit</button>
|
|
387
|
+
</VtsForm>
|
|
388
|
+
</template>
|
|
389
|
+
```
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { CollectionItemProperties } from '@core/packages/collection'
|
|
2
|
+
import type {
|
|
3
|
+
ExtractValue,
|
|
4
|
+
FormSelectId,
|
|
5
|
+
GetOptionValue,
|
|
6
|
+
UseFormSelectReturn,
|
|
7
|
+
} from '@core/packages/form-select/types.ts'
|
|
8
|
+
import type { FormValidationConfig } from '@core/packages/form-validation/types.ts'
|
|
9
|
+
import type { EmptyObject } from '@core/types/utility.type.ts'
|
|
10
|
+
import { computed, reactive, ref, shallowReactive, type ComputedRef, type MaybeRefOrGetter } from 'vue'
|
|
11
|
+
import {
|
|
12
|
+
useValidatedForm,
|
|
13
|
+
type FieldMetadata,
|
|
14
|
+
type ModelBinding,
|
|
15
|
+
type UseFormSelectConfig,
|
|
16
|
+
} from './use-validated-form.ts'
|
|
17
|
+
|
|
18
|
+
export type NestedFormData = Record<string, Record<string, unknown>>
|
|
19
|
+
|
|
20
|
+
type FlatKeys<TData extends NestedFormData> = { [K in keyof TData]: keyof TData[K] & string }[keyof TData]
|
|
21
|
+
|
|
22
|
+
type FieldValue<TData extends NestedFormData, K extends string> = {
|
|
23
|
+
[S in keyof TData]: K extends keyof TData[S] ? TData[S][K] : never
|
|
24
|
+
}[keyof TData]
|
|
25
|
+
|
|
26
|
+
type StepForms<TData extends NestedFormData> = {
|
|
27
|
+
[K in keyof TData]: ReturnType<typeof useValidatedForm<TData[K]>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useMultiStepValidatedForm<
|
|
31
|
+
TData extends NestedFormData,
|
|
32
|
+
TSteps extends { [K in keyof TData]: FormValidationConfig<TData[K]> },
|
|
33
|
+
>(data: TData, stepConfigs: TSteps) {
|
|
34
|
+
const stepForms = Object.fromEntries(
|
|
35
|
+
(Object.keys(stepConfigs) as (keyof TData & string)[]).map(stepKey => [
|
|
36
|
+
stepKey,
|
|
37
|
+
useValidatedForm(data[stepKey], stepConfigs[stepKey]),
|
|
38
|
+
])
|
|
39
|
+
) as unknown as StepForms<TData>
|
|
40
|
+
|
|
41
|
+
const fieldToStep = new Map<FlatKeys<TData>, keyof TData & string>()
|
|
42
|
+
|
|
43
|
+
for (const stepKey of Object.keys(data) as (keyof TData & string)[]) {
|
|
44
|
+
for (const fieldKey of Object.keys(data[stepKey]) as FlatKeys<TData>[]) {
|
|
45
|
+
if (fieldToStep.has(fieldKey)) {
|
|
46
|
+
throw new Error(`useMultiStepValidatedForm: field "${fieldKey}" is declared in multiple steps`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fieldToStep.set(fieldKey, stepKey)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const idToStep = new Map<FormSelectId, keyof TData & string>()
|
|
54
|
+
|
|
55
|
+
const stepKeys = Object.keys(stepConfigs) as (keyof TSteps & string)[]
|
|
56
|
+
const currentStepIndex = ref(0)
|
|
57
|
+
const currentStep = computed(() => stepKeys[currentStepIndex.value])
|
|
58
|
+
|
|
59
|
+
const stepValidStates = shallowReactive(new Map<keyof TSteps, boolean>())
|
|
60
|
+
const isValidating = ref(false)
|
|
61
|
+
|
|
62
|
+
const areAllStepsValid = computed(
|
|
63
|
+
() => stepKeys.length === stepValidStates.size && [...stepValidStates.values()].every(Boolean)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
function isStepValid(stepName: keyof TSteps): boolean | undefined {
|
|
67
|
+
return stepValidStates.get(stepName)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function validateStep(stepName: keyof TSteps & string): Promise<boolean> {
|
|
71
|
+
const isValid = await stepForms[stepName].validate()
|
|
72
|
+
stepValidStates.set(stepName, isValid)
|
|
73
|
+
return isValid
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function validateAllSteps(): Promise<boolean> {
|
|
77
|
+
if (isValidating.value) {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isValidating.value = true
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const results = await Promise.all(stepKeys.map(key => stepForms[key].validate()))
|
|
85
|
+
stepValidStates.clear()
|
|
86
|
+
stepKeys.forEach((key, index) => stepValidStates.set(key, results[index]))
|
|
87
|
+
return results.every(Boolean)
|
|
88
|
+
} finally {
|
|
89
|
+
isValidating.value = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function next(): Promise<boolean> {
|
|
94
|
+
const isValid = await validateStep(stepKeys[currentStepIndex.value])
|
|
95
|
+
|
|
96
|
+
if (isValid && currentStepIndex.value < stepKeys.length - 1) {
|
|
97
|
+
currentStepIndex.value++
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return isValid
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function back(): void {
|
|
104
|
+
if (currentStepIndex.value > 0) {
|
|
105
|
+
currentStepIndex.value--
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveStepFromField<K extends FlatKeys<TData>>(key: K): keyof TData & string {
|
|
110
|
+
const stepKey = fieldToStep.get(key)
|
|
111
|
+
|
|
112
|
+
if (stepKey === undefined) {
|
|
113
|
+
throw new Error(`useMultiStepValidatedForm: field "${String(key)}" is not declared in any step`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return stepKey
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function useField<K extends FlatKeys<TData>>(key: K): ComputedRef<ModelBinding<FieldValue<TData, K>> & FieldMetadata>
|
|
120
|
+
function useField<K extends FlatKeys<TData>, E extends Record<string, unknown>>(
|
|
121
|
+
key: K,
|
|
122
|
+
extras: () => E
|
|
123
|
+
): ComputedRef<ModelBinding<FieldValue<TData, K>> & FieldMetadata & E>
|
|
124
|
+
function useField<K extends FlatKeys<TData>, E extends Record<string, unknown>>(key: K, extras?: () => E): unknown {
|
|
125
|
+
const stepKey = resolveStepFromField(key)
|
|
126
|
+
const stepForm = stepForms[stepKey] as StepForms<TData>[keyof TData] & {
|
|
127
|
+
useField: (key: string, extras?: () => Record<string, unknown>) => unknown
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return extras !== undefined ? stepForm.useField(key, extras) : stepForm.useField(key)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function useFormSelect<
|
|
134
|
+
TSource,
|
|
135
|
+
TCustomProperties extends CollectionItemProperties = EmptyObject,
|
|
136
|
+
TGetValue extends GetOptionValue<TSource, TCustomProperties> = undefined,
|
|
137
|
+
TMultiple extends boolean = false,
|
|
138
|
+
TEmptyValue = never,
|
|
139
|
+
$TValue = ExtractValue<TSource, TGetValue>,
|
|
140
|
+
>(
|
|
141
|
+
key: FlatKeys<TData>,
|
|
142
|
+
sources: MaybeRefOrGetter<TSource[]>,
|
|
143
|
+
formSelectConfig?: UseFormSelectConfig<TSource, TCustomProperties>
|
|
144
|
+
): UseFormSelectReturn<TCustomProperties, TSource, $TValue | TEmptyValue, TMultiple> {
|
|
145
|
+
const stepKey = resolveStepFromField(key)
|
|
146
|
+
const result = stepForms[stepKey].useFormSelect(key as never, sources, formSelectConfig) as UseFormSelectReturn<
|
|
147
|
+
TCustomProperties,
|
|
148
|
+
TSource,
|
|
149
|
+
$TValue | TEmptyValue,
|
|
150
|
+
TMultiple
|
|
151
|
+
>
|
|
152
|
+
idToStep.set(result.id, stepKey)
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function useSelect(id: FormSelectId): ComputedRef<{ id: FormSelectId } & FieldMetadata>
|
|
157
|
+
function useSelect<E extends Record<string, unknown>>(
|
|
158
|
+
id: FormSelectId,
|
|
159
|
+
extras: () => E
|
|
160
|
+
): ComputedRef<{ id: FormSelectId } & FieldMetadata & E>
|
|
161
|
+
function useSelect(id: FormSelectId, key: FlatKeys<TData>): ComputedRef<{ id: FormSelectId } & FieldMetadata>
|
|
162
|
+
function useSelect<E extends Record<string, unknown>>(
|
|
163
|
+
id: FormSelectId,
|
|
164
|
+
key: FlatKeys<TData>,
|
|
165
|
+
extras: () => E
|
|
166
|
+
): ComputedRef<{ id: FormSelectId } & FieldMetadata & E>
|
|
167
|
+
function useSelect<E extends Record<string, unknown> = Record<string, unknown>>(
|
|
168
|
+
id: FormSelectId,
|
|
169
|
+
keyOrExtras?: FlatKeys<TData> | (() => E),
|
|
170
|
+
extras?: () => E
|
|
171
|
+
) {
|
|
172
|
+
if (typeof keyOrExtras === 'string') {
|
|
173
|
+
const stepKey = resolveStepFromField(keyOrExtras)
|
|
174
|
+
return stepForms[stepKey].useSelect(id, keyOrExtras as never, extras as never)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const stepKey = idToStep.get(id)
|
|
178
|
+
|
|
179
|
+
if (stepKey === undefined) {
|
|
180
|
+
throw new Error('useSelect: could not resolve step for select id — ensure useFormSelect was called first')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return stepForms[stepKey].useSelect(id, keyOrExtras as never)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
useField,
|
|
188
|
+
useFormSelect,
|
|
189
|
+
useSelect,
|
|
190
|
+
currentStep,
|
|
191
|
+
next,
|
|
192
|
+
back,
|
|
193
|
+
isStepValid,
|
|
194
|
+
areAllStepsValid,
|
|
195
|
+
isValidating,
|
|
196
|
+
validateStep,
|
|
197
|
+
validateAllSteps,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function defineFormSteps<T extends NestedFormData>(steps: T): T {
|
|
202
|
+
return reactive(steps) as T
|
|
203
|
+
}
|