Skip to content

Commit d4462d8

Browse files
committed
feat(web-core): add queryFilter rule and backTo to multi-step form
Needed by the role creation form: its privilege drawer validates a query filter, and its stepper lets the user jump back to an earlier step. `injectCollectionMarkers` is removed: Regle already treats any field whose value is an array as a collection and returns errors in the `{ $self, $each }` shape, so declaring `$each` by hand is redundant. See XO-3100
1 parent 70b0824 commit d4462d8

6 files changed

Lines changed: 43 additions & 61 deletions

File tree

‎@xen-orchestra/web-core/lib/packages/form-validation/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ All rules from `@regle/rules` are re-exported from this package. Commonly used r
237237
| `withMessage` | Attaches a custom message to any rule |
238238
| `isFilled` | Type guard: checks if a value is defined |
239239
| `outOfRange` | Number must be between `min` and `max` (passes when empty) |
240+
| `queryFilter` | String must be a parsable query filter (passes when empty) |
240241
241242
`type Maybe<T>` and `type FormRuleDeclaration<T>` are also re-exported for use in custom validator signatures.
242243
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createRule, type Maybe } from '@regle/core'
2+
import { isFilled } from '@regle/rules'
3+
import { parse } from 'complex-matcher'
4+
5+
export const queryFilter = createRule({
6+
validator(value: Maybe<string>) {
7+
if (!isFilled(value)) {
8+
return true
9+
}
10+
11+
try {
12+
parse(value)
13+
14+
return true
15+
} catch {
16+
return false
17+
}
18+
},
19+
message: 'Invalid query',
20+
})

‎@xen-orchestra/web-core/lib/packages/form-validation/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './custom-rules/port.rule.ts'
33
export * from './custom-rules/ipv4-or-cidr.rule.ts'
44
export * from './custom-rules/ip-addresses.rule.ts'
55
export * from './custom-rules/ip.regex.ts'
6+
export * from './custom-rules/query-filter.rule.ts'
67
export * from './merge-validation-configs.ts'
78
export * from './types.ts'
89
export * from './use-form-validation.ts'

‎@xen-orchestra/web-core/lib/packages/form-validation/use-form-validation.ts‎

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type {
33
FormFieldMetadata,
44
FormRuleTree,
55
FormValidationConfig,
6-
FormValidationRules,
76
UseFormValidationReturn,
87
} from './types.ts'
98
import { useRegle } from '@regle/core'
@@ -21,45 +20,6 @@ type RegleStatusAccessor = {
2120
$touch: () => void
2221
}
2322

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-
6323
/**
6424
* Calls `useRegle` with a simplified signature.
6525
*
@@ -81,9 +41,10 @@ function toMessage(fieldErrors: unknown): string | undefined {
8141
return fieldErrors[0]
8242
}
8343

84-
// Collection-field errors take the { $self: string[], $each: ... } shape ? surface the first $self message.
44+
// Collection-field errors take the { $self: string[], $each: ... } shape: surface the first $self message.
8545
if (fieldErrors !== null && typeof fieldErrors === 'object' && '$self' in fieldErrors) {
8646
const $self = (fieldErrors as { $self?: unknown }).$self
47+
8748
if (Array.isArray($self)) {
8849
return $self[0]
8950
}
@@ -113,21 +74,10 @@ export function useFormValidation<TData extends Record<string, unknown>>(
11374
): UseFormValidationReturn<TData> {
11475
// All four useRegle calls must be unconditional — Vue composables cannot be called conditionally.
11576
// 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-
)
77+
const { r$: blurErrors$ } = callUseRegle(data, config.errors?.onBlur ?? EMPTY_RULES)
78+
const { r$: submitErrors$ } = callUseRegle(data, config.errors?.onSubmit ?? EMPTY_RULES)
79+
const { r$: blurWarnings$ } = callUseRegle(data, config.warnings?.onBlur ?? EMPTY_RULES)
80+
const { r$: submitWarnings$ } = callUseRegle(data, config.warnings?.onSubmit ?? EMPTY_RULES)
13181

13282
// Cast at the Regle boundary: Regle's inferred types are too complex to thread through
13383
// generics here, but the runtime shape is always compatible with RegleStatusAccessor.

‎@xen-orchestra/web-core/lib/packages/validated-form/README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,12 @@ Each step config is a `FormValidationConfig` scoped to its own sub-object: writi
228228

229229
#### Navigation
230230

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 |
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+
| `backTo` | `(step: keyof TSteps & string) => void` | Goes back to an earlier step without validation; does nothing if the step is not before the current one |
236237

237238
#### Validation state
238239

‎@xen-orchestra/web-core/lib/packages/validated-form/use-multi-step-validated-form.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export function useMultiStepValidatedForm<
106106
}
107107
}
108108

109+
function backTo(stepName: keyof TSteps & string): void {
110+
const stepIndex = stepKeys.indexOf(stepName)
111+
112+
if (stepIndex < currentStepIndex.value) {
113+
currentStepIndex.value = stepIndex
114+
}
115+
}
116+
109117
function resolveStepFromField<K extends FlatKeys<TData>>(key: K): keyof TData & string {
110118
const stepKey = fieldToStep.get(key)
111119

@@ -190,6 +198,7 @@ export function useMultiStepValidatedForm<
190198
currentStep,
191199
next,
192200
back,
201+
backTo,
193202
isStepValid,
194203
areAllStepsValid,
195204
isValidating,

0 commit comments

Comments
 (0)