Skip to content

Commit 773ecac

Browse files
lukadeng-almeida
andauthored
feat(next): DEVXP-2565: implement visibility calculation (#151)
* implement visibility calculation * popagate const to fields * default initialValues to {} for v0 compatibility --------- Co-authored-by: João Almeida <[email protected]>
1 parent 31a5721 commit 773ecac

File tree

15 files changed

+827
-99
lines changed

15 files changed

+827
-99
lines changed

next/src/field/object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function buildFieldObject(schema: JsfObjectSchema, name: string, required
2828
type: schema['x-jsf-presentation']?.inputType || 'fieldset',
2929
inputType: schema['x-jsf-presentation']?.inputType || 'fieldset',
3030
jsonType: 'object',
31-
name: schema.title || name,
31+
name,
3232
required,
3333
fields: orderedFields,
3434
isVisible: true,

next/src/field/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ export function buildFieldSchema(
137137
...(errorMessage && { errorMessage }),
138138
}
139139

140+
if (schema.const) {
141+
field.const = schema.const
142+
143+
if (inputType === 'checkbox') {
144+
field.checkboxValue = schema.const
145+
}
146+
}
147+
140148
if (schema.title) {
141149
field.label = schema.title
142150
}

next/src/field/type.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface Field {
2222
format?: string
2323
anyOf?: unknown[]
2424
options?: unknown[]
25+
const?: unknown
26+
checkboxValue?: unknown
2527

2628
// Allow additional properties from x-jsf-presentation (e.g. meta from oneOf/anyOf)
2729
[key: string]: unknown

next/src/form.ts

Lines changed: 59 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import type { ValidationError } from './errors'
1+
import type { ValidationError, ValidationErrorPath } from './errors'
22
import type { Field } from './field/type'
33
import type { JsfObjectSchema, JsfSchema, NonBooleanJsfSchema, SchemaValue } from './types'
4+
import type { ValidationOptions } from './validation/schema'
45
import { getErrorMessage } from './errors/messages'
56
import { buildFieldObject } from './field/object'
67
import { validateSchema } from './validation/schema'
78
import { isObjectValue } from './validation/util'
9+
import { updateFieldVisibility } from './visibility'
10+
11+
export { ValidationOptions } from './validation/schema'
812

913
interface FormResult {
1014
fields: Field[]
@@ -26,6 +30,39 @@ export interface ValidationResult {
2630
formErrors?: FormErrors
2731
}
2832

33+
/**
34+
* Remove composition keywords and their indices as well as conditional keywords from the path
35+
* @param path - The path to clean
36+
* @returns The cleaned path
37+
* @example
38+
* ```ts
39+
* cleanErrorPath(['some_object','allOf', 0, 'then', 'field'])
40+
* // ['some_object', 'field']
41+
* ```
42+
*/
43+
function cleanErrorPath(path: ValidationErrorPath): ValidationErrorPath {
44+
const result: ValidationErrorPath = []
45+
46+
for (let i = 0; i < path.length; i++) {
47+
const segment = path[i]
48+
49+
if (['allOf', 'anyOf', 'oneOf'].includes(segment as string)) {
50+
if (i + 1 < path.length && typeof path[i + 1] === 'number') {
51+
i++
52+
}
53+
continue
54+
}
55+
56+
if (segment === 'then' || segment === 'else') {
57+
continue
58+
}
59+
60+
result.push(segment)
61+
}
62+
63+
return result
64+
}
65+
2966
/**
3067
* Transform validation errors into an object with the field names as keys and the error messages as values.
3168
* For nested fields, creates a nested object structure rather than using dot notation.
@@ -56,61 +93,32 @@ function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): For
5693
return result
5794
}
5895

59-
// For conditional validation branches (then/else), show the error at the field level
60-
const thenElseIndex = Math.max(path.indexOf('then'), path.indexOf('else'))
61-
if (thenElseIndex !== -1) {
62-
const fieldName = path[thenElseIndex + 1]
63-
if (fieldName) {
64-
result[fieldName] = error.message
65-
return result
66-
}
67-
}
68-
69-
// For allOf/anyOf/oneOf validation errors, show the error at the field level
70-
const compositionKeywords = ['allOf', 'anyOf', 'oneOf']
71-
const compositionIndex = compositionKeywords.reduce((index, keyword) => {
72-
const keywordIndex = path.indexOf(keyword)
73-
return keywordIndex !== -1 ? keywordIndex : index
74-
}, -1)
75-
76-
if (compositionIndex !== -1) {
77-
// Get the field path before the composition keyword
78-
const fieldPath = path.slice(0, compositionIndex)
79-
if (fieldPath.length > 0) {
80-
let current = result
81-
82-
// Process all segments except the last one
83-
fieldPath.slice(0, -1).forEach((segment) => {
84-
if (!(segment in current) || typeof current[segment] === 'string') {
85-
current[segment] = {}
86-
}
87-
current = current[segment] as FormErrors
88-
})
89-
90-
// Set the message at the last segment
91-
const lastSegment = fieldPath[fieldPath.length - 1]
92-
current[lastSegment] = error.message
93-
return result
94-
}
95-
}
96+
// Clean the path to remove intermediate composition structures
97+
const cleanedPath = cleanErrorPath(path)
9698

97-
// For all other paths, recursively build the nested structure
99+
// For all paths, recursively build the nested structure
98100
let current = result
99101

100102
// Process all segments except the last one (which will hold the message)
101-
path.slice(0, -1).forEach((segment) => {
103+
cleanedPath.slice(0, -1).forEach((segment) => {
102104
// If this segment doesn't exist yet or is currently a string (from a previous error),
103105
// initialize it as an object
104106
if (!(segment in current) || typeof current[segment] === 'string') {
105107
current[segment] = {}
106108
}
107109

108-
current = current[segment]
110+
current = current[segment] as FormErrors
109111
})
110112

111113
// Set the message at the final level
112-
const lastSegment = path[path.length - 1]
113-
current[lastSegment] = error.message
114+
if (cleanedPath.length > 0) {
115+
const lastSegment = cleanedPath[cleanedPath.length - 1]
116+
current[lastSegment] = error.message
117+
}
118+
else {
119+
// Fallback for unexpected path structures
120+
result[''] = error.message
121+
}
114122

115123
return result
116124
}, {})
@@ -257,9 +265,6 @@ function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOpti
257265
const result: ValidationResult = {}
258266
const errors = validateSchema(value, schema, options)
259267

260-
// console.log(errors)
261-
262-
// Apply custom error messages before converting to form errors
263268
const errorsWithMessages = addErrorMessages(value, schema, errors)
264269
const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema)
265270

@@ -272,42 +277,34 @@ function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOpti
272277
return result
273278
}
274279

275-
export interface ValidationOptions {
276-
/**
277-
* A null value will be treated as undefined.
278-
* That means that when validating a null value, against a non-required field that is not of type 'null' or ['null']
279-
* the validation will succeed instead of returning a type error.
280-
* @default false
281-
*/
282-
treatNullAsUndefined?: boolean
283-
}
284-
285280
export interface CreateHeadlessFormOptions {
286281
initialValues?: SchemaValue
287282
validationOptions?: ValidationOptions
288283
}
289284

290285
function buildFields(params: { schema: JsfObjectSchema }): Field[] {
291286
const { schema } = params
292-
return buildFieldObject(schema, 'root', true).fields || []
287+
const fields = buildFieldObject(schema, 'root', true).fields || []
288+
return fields
293289
}
294290

295291
export function createHeadlessForm(
296292
schema: JsfObjectSchema,
297293
options: CreateHeadlessFormOptions = {},
298294
): FormResult {
299-
const errors = validateSchema(options.initialValues, schema, options.validationOptions)
300-
const errorsWithMessages = addErrorMessages(options.initialValues, schema, errors)
301-
const validationResult = validationErrorsToFormErrors(errorsWithMessages)
302-
const isError = validationResult !== null
295+
const initialValues = options.initialValues || {}
296+
const fields = buildFields({ schema })
297+
updateFieldVisibility(fields, initialValues, schema)
298+
const isError = false
303299

304300
const handleValidation = (value: SchemaValue) => {
305301
const result = validate(value, schema, options.validationOptions)
302+
updateFieldVisibility(fields, value, schema, options.validationOptions)
306303
return result
307304
}
308305

309306
return {
310-
fields: buildFields({ schema }),
307+
fields,
311308
isError,
312309
error: null,
313310
handleValidation,

next/src/validation/composition.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function validateAllOf(
3838

3939
for (let i = 0; i < schema.allOf.length; i++) {
4040
const subSchema = schema.allOf[i]
41-
const errors = validateSchema(value, subSchema, options, false, [...path, 'allOf', i])
41+
const errors = validateSchema(value, subSchema, options, [...path, 'allOf', i])
4242
if (errors.length > 0) {
4343
return errors
4444
}
@@ -74,7 +74,7 @@ export function validateAnyOf(
7474
}
7575

7676
for (const subSchema of schema.anyOf) {
77-
const errors = validateSchema(value, subSchema, options, false, path)
77+
const errors = validateSchema(value, subSchema, options, path)
7878
if (errors.length === 0) {
7979
return []
8080
}
@@ -117,7 +117,7 @@ export function validateOneOf(
117117
let validCount = 0
118118

119119
for (let i = 0; i < schema.oneOf.length; i++) {
120-
const errors = validateSchema(value, schema.oneOf[i], options, false, path)
120+
const errors = validateSchema(value, schema.oneOf[i], options, path)
121121
if (errors.length === 0) {
122122
validCount++
123123
if (validCount > 1) {
@@ -180,7 +180,7 @@ export function validateNot(
180180
: []
181181
}
182182

183-
const notErrors = validateSchema(value, schema.not, options, false, path)
183+
const notErrors = validateSchema(value, schema.not, options, path)
184184
return notErrors.length === 0
185185
? [{ path, validation: 'not' }]
186186
: []

next/src/validation/conditions.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,20 @@ export function validateCondition(
77
value: SchemaValue,
88
schema: NonBooleanJsfSchema,
99
options: ValidationOptions,
10-
required: boolean,
1110
path: ValidationErrorPath = [],
1211
): ValidationError[] {
1312
if (schema.if === undefined) {
1413
return []
1514
}
1615

17-
const conditionIsTrue = validateSchema(value, schema.if, options, required, path).length === 0
16+
const conditionIsTrue = validateSchema(value, schema.if, options, path).length === 0
1817

1918
if (conditionIsTrue && schema.then !== undefined) {
20-
return validateSchema(value, schema.then, options, required, [...path, 'then'])
19+
return validateSchema(value, schema.then, options, [...path, 'then'])
2120
}
2221

2322
if (!conditionIsTrue && schema.else !== undefined) {
24-
return validateSchema(value, schema.else, options, required, [...path, 'else'])
23+
return validateSchema(value, schema.else, options, [...path, 'else'])
2524
}
2625

2726
return []

next/src/validation/object.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@ export function validateObject(
2424
if (typeof schema === 'object' && schema.properties && isObjectValue(value)) {
2525
const errors = []
2626
for (const [key, propertySchema] of Object.entries(schema.properties)) {
27-
const propertyValue = value[key]
28-
const propertyIsRequired = schema.required?.includes(key)
29-
const propertyErrors = validateSchema(propertyValue, propertySchema, options, propertyIsRequired, [...path, key])
30-
errors.push(...propertyErrors)
27+
errors.push(...validateSchema(value[key], propertySchema, options, [...path, key]))
3128
}
3229
return errors
3330
}

next/src/validation/schema.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { ValidationError, ValidationErrorPath } from '../errors'
2-
import type { ValidationOptions } from '../form'
32
import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types'
43
import { validateAllOf, validateAnyOf, validateNot, validateOneOf } from './composition'
54
import { validateCondition } from './conditions'
@@ -8,6 +7,17 @@ import { validateEnum } from './enum'
87
import { validateNumber } from './number'
98
import { validateObject } from './object'
109
import { validateString } from './string'
10+
import { isObjectValue } from './util'
11+
12+
export interface ValidationOptions {
13+
/**
14+
* A null value will be treated as undefined.
15+
* That means that when validating a null value, against a non-required field that is not of type 'null' or ['null']
16+
* the validation will succeed instead of returning a type error.
17+
* @default false
18+
*/
19+
treatNullAsUndefined?: boolean
20+
}
1121

1222
/**
1323
* Get the type of a schema
@@ -94,7 +104,6 @@ function validateType(
94104
* @param value - The value to validate
95105
* @param schema - The schema to validate against
96106
* @param options - The validation options
97-
* @param required - Whether the value is required
98107
* @param path - The path to the current field being validated
99108
* @returns An array of validation errors
100109
* @description
@@ -104,7 +113,7 @@ function validateType(
104113
* 2. Handle boolean schemas (true allows everything, false allows nothing)
105114
* 3. Validate against base schema constraints:
106115
* - Type validation (if type is specified)
107-
* - Required properties (for objects)
116+
* - Required properties
108117
* - Boolean validation
109118
* - Enum validation
110119
* - Const validation
@@ -123,15 +132,12 @@ export function validateSchema(
123132
value: SchemaValue,
124133
schema: JsfSchema,
125134
options: ValidationOptions = {},
126-
required: boolean = false,
127135
path: ValidationErrorPath = [],
128136
): ValidationError[] {
129137
const valueIsUndefined = value === undefined || (value === null && options.treatNullAsUndefined)
138+
const errors: ValidationError[] = []
130139

131-
if (valueIsUndefined && required) {
132-
return [{ path, validation: 'required' }]
133-
}
134-
140+
// If value is undefined but not required, no further validation needed
135141
if (valueIsUndefined) {
136142
return []
137143
}
@@ -149,20 +155,23 @@ export function validateSchema(
149155
if (
150156
schema.required
151157
&& Array.isArray(schema.required)
152-
&& typeof value === 'object'
153-
&& value !== null
158+
&& isObjectValue(value)
154159
) {
155-
const missingKeys = schema.required.filter((key: string) => !(key in value))
156-
if (missingKeys.length > 0) {
157-
// Return an error for each missing field.
158-
return missingKeys.map(key => ({
160+
const missingKeys = schema.required.filter((key: string) => {
161+
const fieldValue = value[key]
162+
return fieldValue === undefined || (fieldValue === null && options.treatNullAsUndefined)
163+
})
164+
165+
for (const key of missingKeys) {
166+
errors.push({
159167
path: [...path, key],
160168
validation: 'required',
161-
}))
169+
})
162170
}
163171
}
164172

165173
return [
174+
...errors,
166175
...validateConst(value, schema, path),
167176
...validateEnum(value, schema, path),
168177
...validateObject(value, schema, options, path),
@@ -172,6 +181,6 @@ export function validateSchema(
172181
...validateAllOf(value, schema, options, path),
173182
...validateAnyOf(value, schema, options, path),
174183
...validateOneOf(value, schema, options, path),
175-
...validateCondition(value, schema, options, required, path),
184+
...validateCondition(value, schema, options, path),
176185
]
177186
}

0 commit comments

Comments
 (0)