Skip to content

Commit fc1ef3e

Browse files
Add getField util and improve json-schema-form schema type
- Added proper type safety for `required` fields in JSON Schema type - With this change I was able to simplify some conditional checks across validation and visibility logic - Added new utility function `getField` for safer field access (Based on suggetion from #152) - Improved test readability using the new `getField` utility and added tests for the new utility function
1 parent 3c08886 commit fc1ef3e

File tree

7 files changed

+157
-23
lines changed

7 files changed

+157
-23
lines changed

next/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export type JsfSchema = JSONSchema & {
4545
validations: Record<string, object>
4646
computedValues: Record<string, object>
4747
}
48+
// Note: if we don't have this property here, when inspecting any recursive
49+
// schema (like an if inside another schema), the required property won't be
50+
// present in the type
51+
'required'?: string[]
4852
'x-jsf-order'?: string[]
4953
'x-jsf-presentation'?: JsfPresentation
5054
'x-jsf-errorMessage'?: Record<string, string>

next/src/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Field } from './field/type'
2+
13
type DiskSizeUnit = 'Bytes' | 'KB' | 'MB'
24

35
/**
@@ -22,3 +24,22 @@ export function convertDiskSizeFromTo(
2224
return (value * fromMultiplier) / toMultiplier
2325
}
2426
}
27+
28+
/**
29+
* Get a field from a list of fields by name.
30+
* If the field is nested, you can pass additional names to access a nested field.
31+
* @param fields - The list of fields to search in.
32+
* @param name - The name of the field to search for.
33+
* @param subNames - The names of the nested fields to access.
34+
* @returns The field if found, otherwise undefined.
35+
*/
36+
export function getField(fields: Field[], name: string, ...subNames: string[]) {
37+
const field = fields.find(f => f.name === name)
38+
if (subNames.length) {
39+
if (!field?.fields) {
40+
return undefined
41+
}
42+
return getField(field.fields, subNames[0], ...subNames.slice(1))
43+
}
44+
return field
45+
};

next/src/validation/composition.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function validateAllOf(
3232
options: ValidationOptions,
3333
path: ValidationErrorPath = [],
3434
): ValidationError[] {
35-
if (!schema.allOf || !Array.isArray(schema.allOf)) {
35+
if (!schema.allOf) {
3636
return []
3737
}
3838

@@ -69,7 +69,7 @@ export function validateAnyOf(
6969
options: ValidationOptions,
7070
path: ValidationErrorPath = [],
7171
): ValidationError[] {
72-
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
72+
if (!schema.anyOf) {
7373
return []
7474
}
7575

@@ -110,7 +110,7 @@ export function validateOneOf(
110110
options: ValidationOptions,
111111
path: ValidationErrorPath = [],
112112
): ValidationError[] {
113-
if (!schema.oneOf || !Array.isArray(schema.oneOf)) {
113+
if (!schema.oneOf) {
114114
return []
115115
}
116116

next/src/validation/schema.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,7 @@ export function validateSchema(
159159

160160
// If the schema defines "required", run required checks even when type is undefined.
161161
if (
162-
schema.required
163-
&& Array.isArray(schema.required)
164-
&& isObjectValue(value)
162+
schema.required && isObjectValue(value)
165163
) {
166164
const missingKeys = schema.required.filter((key: string) => {
167165
const fieldValue = value[key]

next/src/visibility.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,7 @@ function evaluateConditional(
6969

7070
// Prevent fields from being shown when required fields have type errors
7171
let hasTypeErrors = false
72-
if (matches
73-
&& typeof rule.if === 'object'
74-
&& rule.if !== null
75-
&& Array.isArray(rule.if.required)) {
72+
if (matches && rule.if?.required) {
7673
const requiredFields = rule.if.required
7774
hasTypeErrors = requiredFields.some((fieldName) => {
7875
if (!schema.properties || !schema.properties[fieldName]) {
@@ -118,7 +115,7 @@ function applySchemaRules(
118115

119116
// If the schema has an allOf property, evaluate each rule and add it to the conditional rules array
120117
(schema.allOf ?? [])
121-
.filter(rule => typeof rule === 'object' && rule !== null && 'if' in rule)
118+
.filter((rule: JsfSchema) => rule.if)
122119
.forEach((rule) => {
123120
const result = evaluateConditional(values, schema, rule as NonBooleanJsfSchema, options)
124121
conditionalRules.push(result)

next/test/utils.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { Field } from '../src/field/type'
2+
import { describe, expect, it } from '@jest/globals'
3+
import { getField } from '../src/utils'
4+
5+
describe('getField', () => {
6+
const mockFields: Field[] = [
7+
{
8+
name: 'name',
9+
type: 'text',
10+
inputType: 'text',
11+
jsonType: 'string',
12+
label: 'Name',
13+
required: false,
14+
isVisible: true,
15+
},
16+
{
17+
name: 'address',
18+
type: 'object',
19+
inputType: 'object',
20+
jsonType: 'object',
21+
label: 'Address',
22+
required: false,
23+
isVisible: true,
24+
fields: [
25+
{
26+
name: 'street',
27+
type: 'text',
28+
inputType: 'text',
29+
jsonType: 'string',
30+
label: 'Street',
31+
required: false,
32+
isVisible: true,
33+
},
34+
{
35+
name: 'city',
36+
type: 'text',
37+
inputType: 'text',
38+
jsonType: 'string',
39+
label: 'City',
40+
required: false,
41+
isVisible: true,
42+
},
43+
],
44+
},
45+
]
46+
47+
it('should find a top-level field by name', () => {
48+
const field = getField(mockFields, 'name')
49+
expect(field).toBeDefined()
50+
expect(field?.name).toBe('name')
51+
})
52+
53+
it('should find a nested field using path', () => {
54+
const field = getField(mockFields, 'address', 'street')
55+
expect(field).toBeDefined()
56+
expect(field?.name).toBe('street')
57+
})
58+
59+
it('should return undefined for non-existent field', () => {
60+
const field = getField(mockFields, 'nonexistent')
61+
expect(field).toBeUndefined()
62+
})
63+
64+
it('should return undefined for non-existent nested field', () => {
65+
const field = getField(mockFields, 'address', 'nonexistent')
66+
expect(field).toBeUndefined()
67+
})
68+
69+
it('should return undefined when trying to access nested field on non-object field', () => {
70+
const field = getField(mockFields, 'name', 'something')
71+
expect(field).toBeUndefined()
72+
})
73+
74+
it('should handle multiple levels of nesting', () => {
75+
const deepFields: Field[] = [
76+
{
77+
name: 'level1',
78+
type: 'object',
79+
inputType: 'object',
80+
jsonType: 'object',
81+
label: 'Level 1',
82+
required: false,
83+
isVisible: true,
84+
fields: [
85+
{
86+
name: 'level2',
87+
type: 'object',
88+
inputType: 'object',
89+
jsonType: 'object',
90+
label: 'Level 2',
91+
required: false,
92+
isVisible: true,
93+
fields: [
94+
{
95+
name: 'level3',
96+
type: 'text',
97+
inputType: 'text',
98+
jsonType: 'string',
99+
label: 'Level 3',
100+
required: false,
101+
isVisible: true,
102+
},
103+
],
104+
},
105+
],
106+
},
107+
]
108+
109+
const field = getField(deepFields, 'level1', 'level2', 'level3')
110+
expect(field).toBeDefined()
111+
expect(field?.name).toBe('level3')
112+
})
113+
})

next/test/visibility.test.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { JsfObjectSchema } from '../src/types'
22
import { describe, expect, it } from '@jest/globals'
33
import { createHeadlessForm } from '../src'
4+
import { getField } from '../src/utils'
45

56
describe('Field visibility', () => {
67
describe('if inside allOf', () => {
@@ -36,22 +37,22 @@ describe('Field visibility', () => {
3637

3738
it('should hide the password field by default', () => {
3839
const form = createHeadlessForm(schema, { initialValues: { name: 'asd', password: null } })
39-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(false)
40+
expect(getField(form.fields, 'password')?.isVisible).toBe(false)
4041

4142
// Different name provided
4243
form.handleValidation({
4344
name: 'some name',
4445
password: null,
4546
})
46-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(false)
47+
expect(getField(form.fields, 'password')?.isVisible).toBe(false)
4748
})
4849

4950
it('should show the password field if the name is admin', () => {
5051
const form = createHeadlessForm(schema)
5152
form.handleValidation({
5253
name: 'admin',
5354
})
54-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(true)
55+
expect(getField(form.fields, 'password')?.isVisible).toBe(true)
5556
})
5657
})
5758
describe('if an "else" branch is not provided', () => {
@@ -88,18 +89,18 @@ describe('Field visibility', () => {
8889
it('should show the password field by default', () => {
8990
const form = createHeadlessForm(schema)
9091
// No name provided
91-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(true)
92+
expect(getField(form.fields, 'password')?.isVisible).toBe(true)
9293

9394
// Different name provided
9495
form.handleValidation({
9596
name: 'some name',
9697
})
97-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(true)
98+
expect(getField(form.fields, 'password')?.isVisible).toBe(true)
9899
})
99100

100101
it('should hide the password field if the name is "user that does not need password field visible"', () => {
101102
const form = createHeadlessForm(schema, { initialValues: { name: userName, password: null } })
102-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(false)
103+
expect(getField(form.fields, 'password')?.isVisible).toBe(false)
103104
})
104105
})
105106
describe('if no "else" or "then" branch are provided', () => {
@@ -131,21 +132,21 @@ describe('Field visibility', () => {
131132
it('should show the password field by default', () => {
132133
const form = createHeadlessForm(schema)
133134
// No name provided
134-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(true)
135+
expect(getField(form.fields, 'password')?.isVisible).toBe(true)
135136

136137
// Different name provided
137138
form.handleValidation({
138139
name: 'some name',
139140
})
140-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(true)
141+
expect(getField(form.fields, 'password')?.isVisible).toBe(true)
141142
})
142143

143144
it('should show the password field if the name is "admin"', () => {
144145
const form = createHeadlessForm(schema)
145146
form.handleValidation({
146147
name: userName,
147148
})
148-
expect(form.fields.find(field => field.name === 'password')?.isVisible).toBe(true)
149+
expect(getField(form.fields, 'password')?.isVisible).toBe(true)
149150
})
150151
})
151152
})
@@ -194,7 +195,7 @@ describe('Field visibility', () => {
194195
it('should hide the password field by default', () => {
195196
const form = createHeadlessForm(schema, { initialValues: { form: { name: '', password: null } } })
196197
// No name provided
197-
expect(form.fields.find(field => field.name === 'form')?.fields?.find(field => field.name === 'password')?.isVisible).toBe(false)
198+
expect(getField(form.fields, 'form', 'password')?.isVisible).toBe(false)
198199

199200
// Different name provided
200201
form.handleValidation({
@@ -203,15 +204,15 @@ describe('Field visibility', () => {
203204
password: null,
204205
},
205206
})
206-
expect(form.fields.find(field => field.name === 'form')?.fields?.find(field => field.name === 'password')?.isVisible).toBe(false)
207+
expect(getField(form.fields, 'form', 'password')?.isVisible).toBe(false)
207208
})
208209

209210
it('should show the password field if the name is admin', () => {
210211
const form = createHeadlessForm(schema, { initialValues: { form: { name: 'admin', password: null } } })
211212
form.handleValidation({ form: {
212213
name: 'admin',
213214
} })
214-
expect(form.fields.find(field => field.name === 'form')?.fields?.find(field => field.name === 'password')?.isVisible).toBe(true)
215+
expect(getField(form.fields, 'form', 'password')?.isVisible).toBe(true)
215216
})
216217
})
217218
})

0 commit comments

Comments
 (0)