Skip to content

Commit 6f2e7d7

Browse files
chore(next): refactor field building approach + fix some issues with v0 backward compatibility (#193)
# Refactor Form Validation and Schema Processing ## Changes - Refactored schema processing and validation logic to be more maintainable, performant, and efficient - we now calculate a _final schema_ and just run the field "building" on top of it. - this creates less "tree traversals" and less mutations happening - Fixed checkbox validation handling with dedicated error messages - Added support for hidden field type - Enhanced schema merging and property updates - Fixed a bug where react would go in an infinite update cycle (by returning new object instances, react would render the components multiple times. If these components had a `useEffect` that triggered a validation callback, an infinite loop might happen) ## Key Improvements - Centralized schema calculation with new `calculateFinalSchema` function - Better handling of conditional rules and computed attributes - More robust field property updates with proper cleanup - Cleaner error message handling for checkboxes ## Technical Details - Replaced `mutateFields` with more focused `updateFieldProperties` and `calculateFinalSchema` - Added `isCheckbox` helper and `CHECKBOX_ERROR_MESSAGES` constants - Improved type safety with better schema merging - Enhanced nested object handling in schema processing
1 parent 4efb07e commit 6f2e7d7

File tree

17 files changed

+443
-183
lines changed

17 files changed

+443
-183
lines changed

next/src/errors/messages.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ import { randexp } from 'randexp'
44
import { convertKBToMB } from '../utils'
55
import { DATE_FORMAT } from '../validation/custom/date'
66

7+
/**
8+
* Check if the schema is a checkbox
9+
* @param schema - The schema to check
10+
* @returns True if the schema is a checkbox, false otherwise
11+
*/
12+
function isCheckbox(schema: NonBooleanJsfSchema): boolean {
13+
return schema['x-jsf-presentation']?.inputType === 'checkbox'
14+
}
15+
16+
// Both required and const error messages are the same for checkboxes
17+
const CHECKBOX_ACK_ERROR_MESSAGE = 'Please acknowledge this field'
18+
719
export function getErrorMessage(
820
schema: NonBooleanJsfSchema,
921
value: SchemaValue,
@@ -16,13 +28,17 @@ export function getErrorMessage(
1628
case 'type':
1729
return getTypeErrorMessage(schema.type)
1830
case 'required':
19-
if (schema['x-jsf-presentation']?.inputType === 'checkbox') {
20-
return 'Please acknowledge this field'
31+
if (isCheckbox(schema)) {
32+
return CHECKBOX_ACK_ERROR_MESSAGE
2133
}
2234
return 'Required field'
2335
case 'forbidden':
2436
return 'Not allowed'
2537
case 'const':
38+
// Boolean checkboxes that are required will come as a "const" validation error as the "empty" value is false
39+
if (isCheckbox(schema) && value === false) {
40+
return CHECKBOX_ACK_ERROR_MESSAGE
41+
}
2642
return `The only accepted value is ${JSON.stringify(schema.const)}.`
2743
case 'enum':
2844
return `The option "${valueToString(value)}" is not valid.`

next/src/field/schema.ts

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JsfObjectSchema, JsfSchema, JsfSchemaType, NonBooleanJsfSchema } from '../types'
1+
import type { JsfObjectSchema, JsfSchema, JsfSchemaType, NonBooleanJsfSchema, SchemaValue } from '../types'
22
import type { Field, FieldOption, FieldType } from './type'
33
import { setCustomOrder } from '../custom/order'
44

@@ -42,13 +42,15 @@ function addOptions(field: Field, schema: NonBooleanJsfSchema) {
4242
* Add fields attribute to a field
4343
* @param field - The field to add the fields to
4444
* @param schema - The schema of the field
45+
* @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields)
46+
* @param strictInputType - Whether to strictly enforce the input type
4547
* @description
4648
* This adds the fields attribute to based on the schema's items.
4749
* Since options and fields are mutually exclusive, we only add fields if no options were provided.
4850
*/
49-
function addFields(field: Field, schema: NonBooleanJsfSchema, strictInputType?: boolean) {
51+
function addFields(field: Field, schema: NonBooleanJsfSchema, originalSchema: JsfSchema, strictInputType?: boolean) {
5052
if (field.options === undefined) {
51-
const fields = getFields(schema, strictInputType)
53+
const fields = getFields(schema, originalSchema, strictInputType)
5254
if (fields) {
5355
field.fields = fields
5456
}
@@ -129,8 +131,6 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch
129131
if (schema.properties) {
130132
return 'select'
131133
}
132-
133-
// Otherwise, assume "string" as the fallback type and get input from it
134134
}
135135

136136
// Get input type from schema (fallback type is "string")
@@ -201,7 +201,7 @@ function getFieldOptions(schema: NonBooleanJsfSchema) {
201201
if (schema.enum) {
202202
const enumAsOneOf: JsfSchema['oneOf'] = schema.enum?.map(value => ({
203203
title: typeof value === 'string' ? value : JSON.stringify(value),
204-
const: value,
204+
const: value as SchemaValue,
205205
})) || []
206206
return convertToOptions(enumAsOneOf)
207207
}
@@ -212,15 +212,22 @@ function getFieldOptions(schema: NonBooleanJsfSchema) {
212212
/**
213213
* Get the fields for an object schema
214214
* @param schema - The schema of the field
215+
* @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields)
215216
* @param strictInputType - Whether to strictly enforce the input type
216217
* @returns The fields for the schema or an empty array if the schema does not define any properties
217218
*/
218-
function getObjectFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null {
219+
function getObjectFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null {
219220
const fields: Field[] = []
220221

221222
for (const key in schema.properties) {
222223
const isRequired = schema.required?.includes(key) || false
223-
const field = buildFieldSchema(schema.properties[key], key, isRequired, strictInputType)
224+
const field = buildFieldSchema({
225+
schema: schema.properties[key],
226+
name: key,
227+
required: isRequired,
228+
originalSchema: originalSchema.properties?.[key] || schema.properties[key],
229+
strictInputType,
230+
})
224231
if (field) {
225232
fields.push(field)
226233
}
@@ -234,10 +241,11 @@ function getObjectFields(schema: NonBooleanJsfSchema, strictInputType?: boolean)
234241
/**
235242
* Get the fields for an array schema
236243
* @param schema - The schema of the field
244+
* @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields)
237245
* @param strictInputType - Whether to strictly enforce the input type
238246
* @returns The fields for the schema or an empty array if the schema does not define any items
239247
*/
240-
function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] {
248+
function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] {
241249
const fields: Field[] = []
242250

243251
if (typeof schema.items !== 'object' || schema.items === null) {
@@ -249,15 +257,27 @@ function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean):
249257

250258
for (const key in objectSchema.properties) {
251259
const isFieldRequired = objectSchema.required?.includes(key) || false
252-
const field = buildFieldSchema(objectSchema.properties[key], key, isFieldRequired, strictInputType)
260+
const field = buildFieldSchema({
261+
schema: objectSchema.properties[key],
262+
name: key,
263+
required: isFieldRequired,
264+
originalSchema,
265+
strictInputType,
266+
})
253267
if (field) {
254268
field.nameKey = key
255269
fields.push(field)
256270
}
257271
}
258272
}
259273
else {
260-
const field = buildFieldSchema(schema.items, 'item', false, strictInputType)
274+
const field = buildFieldSchema({
275+
schema: schema.items,
276+
name: 'item',
277+
required: false,
278+
originalSchema,
279+
strictInputType,
280+
})
261281
if (field) {
262282
fields.push(field)
263283
}
@@ -271,15 +291,16 @@ function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean):
271291
/**
272292
* Get the fields for a schema from either `items` or `properties`
273293
* @param schema - The schema of the field
294+
* @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields)
274295
* @param strictInputType - Whether to strictly enforce the input type
275296
* @returns The fields for the schema
276297
*/
277-
function getFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null {
298+
function getFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null {
278299
if (typeof schema.properties === 'object' && schema.properties !== null) {
279-
return getObjectFields(schema, strictInputType)
300+
return getObjectFields(schema, originalSchema, strictInputType)
280301
}
281302
else if (typeof schema.items === 'object' && schema.items !== null) {
282-
return getArrayFields(schema, strictInputType)
303+
return getArrayFields(schema, originalSchema, strictInputType)
283304
}
284305

285306
return null
@@ -298,26 +319,48 @@ const excludedSchemaProps = [
298319
'properties', // Handled separately
299320
]
300321

322+
interface BuildFieldSchemaParams {
323+
schema: JsfSchema
324+
name: string
325+
required?: boolean
326+
originalSchema: NonBooleanJsfSchema
327+
strictInputType?: boolean
328+
type?: JsfSchemaType
329+
}
330+
301331
/**
302332
* Build a field from any schema
333+
* @param params - The parameters for building the field
334+
* @param params.schema - The schema of the field
335+
* @param params.name - The name of the field
336+
* @param params.required - Whether the field is required
337+
* @param params.originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields)
338+
* @param params.strictInputType - Whether to strictly enforce the input type
339+
* @param params.type - The schema type
340+
* @returns The field
303341
*/
304-
export function buildFieldSchema(
305-
schema: JsfSchema,
306-
name: string,
307-
required: boolean = false,
308-
strictInputType: boolean = false,
309-
type: JsfSchemaType = undefined,
310-
): Field | null {
342+
export function buildFieldSchema({
343+
schema,
344+
name,
345+
required = false,
346+
originalSchema,
347+
strictInputType = false,
348+
type = undefined,
349+
}: BuildFieldSchemaParams): Field | null {
311350
// If schema is boolean false, return a field with isVisible=false
312351
if (schema === false) {
313-
const inputType = getInputType(type, name, schema, strictInputType)
352+
// If the schema is false (hidden field), we use the original schema to get the input type
353+
const inputType = getInputType(type, name, originalSchema, strictInputType)
354+
const inputHasInnerFields = ['fieldset', 'group-array'].includes(inputType)
355+
314356
return {
315357
type: inputType,
316358
name,
317359
inputType,
318360
jsonType: 'boolean',
319361
required,
320362
isVisible: false,
363+
...(inputHasInnerFields && { fields: [] }),
321364
}
322365
}
323366

@@ -369,7 +412,7 @@ export function buildFieldSchema(
369412
}
370413

371414
addOptions(field, schema)
372-
addFields(field, schema)
415+
addFields(field, schema, originalSchema)
373416

374417
return field
375418
}

next/src/field/type.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface Field {
2727
options?: unknown[]
2828
const?: unknown
2929
checkboxValue?: unknown
30+
default?: unknown
3031

3132
// Allow additional properties from x-jsf-presentation (e.g. meta from oneOf/anyOf)
3233
[key: string]: unknown
@@ -44,4 +45,4 @@ export interface FieldOption {
4445
[key: string]: unknown
4546
}
4647

47-
export type FieldType = 'text' | 'number' | 'select' | 'file' | 'radio' | 'group-array' | 'email' | 'date' | 'checkbox' | 'fieldset' | 'money' | 'country' | 'textarea'
48+
export type FieldType = 'text' | 'number' | 'select' | 'file' | 'radio' | 'group-array' | 'email' | 'date' | 'checkbox' | 'fieldset' | 'money' | 'country' | 'textarea' | 'hidden'

next/src/form.ts

Lines changed: 24 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types'
44
import type { ValidationOptions } from './validation/schema'
55
import { getErrorMessage } from './errors/messages'
66
import { buildFieldSchema } from './field/schema'
7-
import { mutateFields } from './mutations'
8-
import { applyComputedAttrsToSchema } from './validation/json-logic'
7+
import { calculateFinalSchema, updateFieldProperties } from './mutations'
98
import { validateSchema } from './validation/schema'
109

1110
export { ValidationOptions } from './validation/schema'
@@ -231,9 +230,15 @@ export interface CreateHeadlessFormOptions {
231230
strictInputType?: boolean
232231
}
233232

234-
function buildFields(params: { schema: JsfObjectSchema, strictInputType?: boolean }): Field[] {
235-
const { schema, strictInputType } = params
236-
const fields = buildFieldSchema(schema, 'root', true, strictInputType, 'object')?.fields || []
233+
function buildFields(params: { schema: JsfObjectSchema, originalSchema: JsfObjectSchema, strictInputType?: boolean }): Field[] {
234+
const { schema, originalSchema, strictInputType } = params
235+
const fields = buildFieldSchema({
236+
schema,
237+
name: 'root',
238+
required: true,
239+
originalSchema,
240+
strictInputType,
241+
})?.fields || []
237242
return fields
238243
}
239244

@@ -243,25 +248,29 @@ export function createHeadlessForm(
243248
): FormResult {
244249
const initialValues = options.initialValues || {}
245250
const strictInputType = options.strictInputType || false
246-
// Make a (new) version with all the computed attrs computed and applied
247-
const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, initialValues)
248-
const fields = buildFields({ schema: updatedSchema, strictInputType })
251+
// Make a new version of the schema with all the computed attrs applied, as well as the final version of each property (taking into account conditional rules)
252+
const updatedSchema = calculateFinalSchema({
253+
schema,
254+
values: initialValues,
255+
options: options.validationOptions,
256+
})
249257

250-
// Making sure field properties are correct for the initial values
251-
mutateFields(fields, initialValues, updatedSchema, options.validationOptions)
258+
const fields = buildFields({ schema: updatedSchema, originalSchema: schema, strictInputType })
252259

253260
// TODO: check if we need this isError variable exposed
254261
const isError = false
255262

256263
const handleValidation = (value: SchemaValue) => {
257-
const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, value)
264+
const updatedSchema = calculateFinalSchema({
265+
schema,
266+
values: value,
267+
options: options.validationOptions,
268+
})
269+
258270
const result = validate(value, updatedSchema, options.validationOptions)
259271

260272
// Fields properties might have changed, so we need to reset the fields by updating them in place
261-
buildFieldsInPlace(fields, updatedSchema)
262-
263-
// Updating field properties based on the new form value
264-
mutateFields(fields, value, updatedSchema, options.validationOptions)
273+
updateFieldProperties(fields, updatedSchema, schema)
265274

266275
return result
267276
}
@@ -273,28 +282,3 @@ export function createHeadlessForm(
273282
handleValidation,
274283
}
275284
}
276-
277-
/**
278-
* Updates fields in place based on a schema, recursively if needed
279-
* @param fields - The fields array to mutate
280-
* @param schema - The schema to use for updating fields
281-
*/
282-
function buildFieldsInPlace(fields: Field[], schema: JsfObjectSchema): void {
283-
// Clear existing fields array
284-
fields.length = 0
285-
286-
// Get new fields from schema
287-
const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || []
288-
289-
// Push all new fields into existing array
290-
fields.push(...newFields)
291-
292-
// Recursively update any nested fields
293-
for (const field of fields) {
294-
// eslint-disable-next-line ts/ban-ts-comment
295-
// @ts-expect-error
296-
if (field.fields && schema.properties?.[field.name]?.type === 'object') {
297-
buildFieldsInPlace(field.fields, schema.properties[field.name] as JsfObjectSchema)
298-
}
299-
}
300-
}

0 commit comments

Comments
 (0)