Skip to content

Commit 3c08886

Browse files
authored
fix(next): DEVXP-2802: add some simple unit tests that highlight the wrong visibility behavior
## Description This MR aims to fix some of the problems our _visibility_ calculation had, which happened when evaluating conditional (inside an `if/then/else` statement) visibility of fields inside a `fieldset`: - tests were added to `visibility.test.ts` to highlight the main behavior we should implement - a fix was added, and the main logic is: - fields are visible by default - a field is not visible if a schema for that field is has a `false` boolean value Some quality of life improvements: - a `dev` script that runs `tsup` in watch mode - this script also sets an env variable that disables minification in the final build. This makes setting breakpoints in the consumer app way easier 🤓
1 parent 773ecac commit 3c08886

File tree

6 files changed

+270
-304
lines changed

6 files changed

+270
-304
lines changed

next/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"scripts": {
3030
"build": "tsup",
31+
"dev": "NODE_ENV=development tsup --watch",
3132
"test": "jest",
3233
"test:watch": "jest --watchAll",
3334
"test:file": "jest --runTestsByPath",

next/src/form.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,19 @@ export function createHeadlessForm(
294294
): FormResult {
295295
const initialValues = options.initialValues || {}
296296
const fields = buildFields({ schema })
297+
298+
// Making sure visibility is set correctly upon form creation, for initial values
297299
updateFieldVisibility(fields, initialValues, schema)
300+
301+
// TODO: check if we need this isError variable exposed
298302
const isError = false
299303

300304
const handleValidation = (value: SchemaValue) => {
301305
const result = validate(value, schema, options.validationOptions)
306+
307+
// Updating field visibility based on the new value
302308
updateFieldVisibility(fields, value, schema, options.validationOptions)
309+
303310
return result
304311
}
305312

next/src/validation/schema.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@ export function validateSchema(
143143
}
144144

145145
if (typeof schema === 'boolean') {
146-
return schema ? [] : [{ path, validation: 'valid' }]
146+
// It means the property does not exist in the payload
147+
if (!schema && typeof value !== 'undefined') {
148+
return [{ path, validation: 'valid' }]
149+
}
150+
else {
151+
return []
152+
}
147153
}
148154

149155
const typeValidationErrors = validateType(value, schema, path)

next/src/visibility.ts

Lines changed: 100 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import type { Field } from './field/type'
2-
import type { JsfObjectSchema, JsfSchema, NonBooleanJsfSchema, SchemaValue } from './types'
2+
import type { JsfObjectSchema, JsfSchema, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types'
33
import type { ValidationOptions } from './validation/schema'
44
import { validateSchema } from './validation/schema'
55
import { isObjectValue } from './validation/util'
66

7+
/**
8+
* Resets the visibility of all fields to true
9+
* @param fields - The fields to reset
10+
*/
11+
function resetVisibility(fields: Field[]) {
12+
for (const field of fields) {
13+
field.isVisible = true
14+
if (field.fields) {
15+
resetVisibility(field.fields)
16+
}
17+
}
18+
}
719
/**
820
* Updates field visibility based on JSON schema conditional rules
921
* @param fields - The fields to update
@@ -21,41 +33,59 @@ export function updateFieldVisibility(
2133
return
2234
}
2335

36+
// Reseting fields visibility to the default before re-calculating it
37+
resetVisibility(fields)
38+
2439
// Apply rules to current level of fields
2540
applySchemaRules(fields, values, schema, options)
2641

2742
// Process nested object fields that have conditional logic
2843
for (const fieldName in schema.properties) {
2944
const fieldSchema = schema.properties[fieldName]
45+
const field = fields.find(field => field.name === fieldName)
3046

31-
// Only process object schemas with conditional logic (allOf)
32-
if (typeof fieldSchema !== 'object' || fieldSchema === null
33-
|| Array.isArray(fieldSchema) || !fieldSchema.allOf) {
34-
continue
35-
}
36-
37-
const objectField = fields.find(field => field.name === fieldSchema.title || field.name === fieldName)
38-
if (!objectField || !objectField.fields || objectField.fields.length === 0) {
39-
continue
47+
if (field?.fields) {
48+
applySchemaRules(field.fields, values[fieldName], fieldSchema as JsfObjectSchema, options)
4049
}
50+
}
51+
}
4152

42-
const fieldValues = isObjectValue(values[fieldName])
43-
? values[fieldName]
44-
: isObjectValue(values[objectField.name]) ? values[objectField.name] : {}
45-
46-
// Apply rules to nested fields
47-
applySchemaRules(objectField.fields, fieldValues, fieldSchema as JsfObjectSchema, options)
48-
49-
// Only process nested fields if parent is visible
50-
if (objectField.isVisible) {
51-
updateFieldVisibility(
52-
objectField.fields,
53-
fieldValues,
54-
fieldSchema as JsfObjectSchema,
55-
options,
56-
)
57-
}
53+
/**
54+
* Evaluates the conditional rules for a field
55+
* @param values - The current field values
56+
* @param schema - The JSON schema definition
57+
* @param rule - Schema identifying the conditional rule
58+
* @param options - Validation options
59+
* @returns An object containing the rule and whether it matches
60+
*/
61+
function evaluateConditional(
62+
values: ObjectValue,
63+
schema: JsfObjectSchema,
64+
rule: NonBooleanJsfSchema,
65+
options: ValidationOptions = {},
66+
) {
67+
const ifErrors = validateSchema(values, rule.if!, options)
68+
const matches = ifErrors.length === 0
69+
70+
// Prevent fields from being shown when required fields have type errors
71+
let hasTypeErrors = false
72+
if (matches
73+
&& typeof rule.if === 'object'
74+
&& rule.if !== null
75+
&& Array.isArray(rule.if.required)) {
76+
const requiredFields = rule.if.required
77+
hasTypeErrors = requiredFields.some((fieldName) => {
78+
if (!schema.properties || !schema.properties[fieldName]) {
79+
return false
80+
}
81+
const fieldSchema = schema.properties[fieldName]
82+
const fieldValue = values[fieldName]
83+
const fieldErrors = validateSchema(fieldValue, fieldSchema, options)
84+
return fieldErrors.some(error => error.validation === 'type')
85+
})
5886
}
87+
88+
return { rule, matches: matches && !hasTypeErrors }
5989
}
6090

6191
/**
@@ -65,99 +95,71 @@ export function updateFieldVisibility(
6595
* @param schema - The JSON schema containing the rules
6696
* @param options - Validation options
6797
*
68-
* Fields start with visibility based on their required status.
69-
* Conditional rules in the schema's allOf property can then:
70-
* - Make fields visible by including them in a required array
71-
* - Make fields hidden by setting them to false in properties
98+
* Fields start visible by default, and they're set to hidden if their schema is
99+
* set to false (a falsy schema means the schema fails whenever a value is sent for that field)
100+
*
72101
*/
73102
function applySchemaRules(
74103
fields: Field[],
75104
values: SchemaValue,
76105
schema: JsfObjectSchema,
77106
options: ValidationOptions = {},
78107
) {
79-
if (!schema.allOf || !Array.isArray(schema.allOf) || !isObjectValue(values)) {
108+
if (!isObjectValue(values)) {
80109
return
81110
}
82111

83-
const conditionalRules = schema.allOf
84-
.filter(rule => typeof rule === 'object' && rule !== null && 'if' in rule)
85-
.map((rule) => {
86-
const ruleObj = rule as NonBooleanJsfSchema
87-
88-
const ifErrors = validateSchema(values, ruleObj.if!, options)
89-
const matches = ifErrors.length === 0
112+
const conditionalRules: { rule: NonBooleanJsfSchema, matches: boolean }[] = []
90113

91-
// Prevent fields from being shown when required fields have type errors
92-
let hasTypeErrors = false
93-
if (matches
94-
&& typeof ruleObj.if === 'object'
95-
&& ruleObj.if !== null
96-
&& Array.isArray(ruleObj.if.required)) {
97-
const requiredFields = ruleObj.if.required
98-
hasTypeErrors = requiredFields.some((fieldName) => {
99-
if (!schema.properties || !schema.properties[fieldName]) {
100-
return false
101-
}
102-
const fieldSchema = schema.properties[fieldName]
103-
const fieldValue = values[fieldName]
104-
const fieldErrors = validateSchema(fieldValue, fieldSchema, options)
105-
return fieldErrors.some(error => error.validation === 'type')
106-
})
107-
}
114+
// If the schema has an if property, evaluate it and add it to the conditional rules array
115+
if (schema.if) {
116+
conditionalRules.push(evaluateConditional(values, schema, schema, options))
117+
}
108118

109-
return { rule: ruleObj, matches: matches && !hasTypeErrors }
119+
// If the schema has an allOf property, evaluate each rule and add it to the conditional rules array
120+
(schema.allOf ?? [])
121+
.filter(rule => typeof rule === 'object' && rule !== null && 'if' in rule)
122+
.forEach((rule) => {
123+
const result = evaluateConditional(values, schema, rule as NonBooleanJsfSchema, options)
124+
conditionalRules.push(result)
110125
})
111126

112-
for (const field of fields) {
113-
// Default visibility is based on required status
114-
let isVisible = field.required
115-
116-
for (const { rule, matches } of conditionalRules) {
117-
if (matches && rule.then) {
118-
isVisible = isFieldVisible(field.name, rule.then, isVisible)
119-
}
120-
else if (!matches && rule.else) {
121-
isVisible = isFieldVisible(field.name, rule.else, isVisible)
122-
}
127+
// Process the conditional rules
128+
for (const { rule, matches } of conditionalRules) {
129+
// If the rule matches, process the then branch
130+
if (matches && rule.then) {
131+
processBranch(fields, rule.then)
132+
}
133+
// If the rule doesn't match, process the else branch
134+
else if (!matches && rule.else) {
135+
processBranch(fields, rule.else)
123136
}
124-
125-
field.isVisible = isVisible
126137
}
127138
}
128139

129140
/**
130-
* Determines whether a field should be visible based on a schema branch
131-
* @param fieldName - The name of the field
132-
* @param branch - The schema clause (either 'then' or 'else')
133-
* @param currentVisibility - The current visibility state
134-
* @returns The updated visibility state
135-
*
136-
* Visibility logic:
137-
* - If the field is in the required array → make visible
138-
* - If the field is explicitly false in properties → make hidden
139-
* - Otherwise, preserve current visibility
141+
* Processes a branch of a conditional rule, updating the visibility of fields based on the branch's schema
142+
* @param fields - The fields to process
143+
* @param branch - The branch (schema representing and then/else) to process
140144
*/
141-
function isFieldVisible(
142-
fieldName: string,
143-
branch: JsfSchema,
144-
currentVisibility: boolean,
145-
): boolean {
146-
let isVisible = currentVisibility
147-
148-
if (typeof branch === 'object' && branch !== null) {
149-
if (Array.isArray(branch.required)) {
150-
if (branch.required.includes(fieldName)) {
151-
isVisible = true
152-
}
153-
}
154-
155-
if (branch.properties !== undefined) {
156-
if (fieldName in branch.properties && branch.properties[fieldName] === false) {
157-
isVisible = false
145+
function processBranch(fields: Field[], branch: JsfSchema) {
146+
if (branch.properties) {
147+
// Cycle through each property in the schema and search for any (possibly nested)
148+
// fields that have a false boolean schema. If found, set the field's visibility to false
149+
for (const fieldName in branch.properties) {
150+
const fieldSchema = branch.properties[fieldName]
151+
const field = fields.find(e => e.name === fieldName)
152+
if (field) {
153+
if (field?.fields) {
154+
processBranch(field.fields, fieldSchema)
155+
}
156+
else if (fieldSchema === false) {
157+
field.isVisible = false
158+
}
159+
else {
160+
field.isVisible = true
161+
}
158162
}
159163
}
160164
}
161-
162-
return isVisible
163165
}

0 commit comments

Comments
 (0)