Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/field/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch
return getInputTypeFromSchema(type || schema.type || 'string', schema)
}

const optionsMap = new Map<string, Array<FieldOption>>()

/**
* Create a hash from options array for caching
* @param opts - The options to hash
* @returns The hash
*/
function hashOptions(opts: JsfSchema[]): string {
if (!opts.length) {
return '0'
}

return JSON.stringify(opts)
}

/**
* Convert options to the required format
* This is used when we have a oneOf or anyOf schema property
Expand All @@ -153,7 +168,13 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch
* If it doesn't, we skip the option.
*/
function convertToOptions(nodeOptions: JsfSchema[]): Array<FieldOption> {
return nodeOptions
const hash = hashOptions(nodeOptions)
const cached = optionsMap.get(hash)
if (cached) {
return cached
}

const converted = nodeOptions
.filter((option): option is NonBooleanJsfSchema =>
option !== null && typeof option === 'object' && option.const !== null,
)
Expand All @@ -176,6 +197,9 @@ function convertToOptions(nodeOptions: JsfSchema[]): Array<FieldOption> {

return { ...result, ...presentation, ...rest }
})

optionsMap.set(hash, converted)
return converted
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export function deepMergeSchemas<T extends Record<string, any>>(schema1: T, sche
// If the value is an array, cycle through it and merge values if they're different (take objects into account)
else if (schema1Value && Array.isArray(schema2Value)) {
const originalArray = schema1Value

// For 'options' arrays, just replace the whole array (they're immutable and cached) rather
// than recursively deep merging them
if (key === 'options') {
schema1[key as keyof T] = schema2Value as T[keyof T]
continue
}

// If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account)
for (const item of schema2Value) {
if (item && typeof item === 'object') {
Expand Down
157 changes: 157 additions & 0 deletions test/fields/options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../../src/form'

describe('Select field options', () => {
it('should return cached options based on content hash', () => {
// Create two separate oneOf arrays with identical content
const options1 = [
{ const: 'value_1', title: 'Option 1' },
{ const: 'value_2', title: 'Option 2' },
{ const: 'value_3', title: 'Option 3' },
]

const options2 = [
{ const: 'value_1', title: 'Option 1' },
{ const: 'value_2', title: 'Option 2' },
{ const: 'value_3', title: 'Option 3' },
]

// Different object references but same content
expect(options1).not.toBe(options2)
expect(options1).toEqual(options2)

const schema = {
type: 'object' as const,
properties: {
field1: {
type: 'string' as const,
oneOf: options1,
},
field2: {
type: 'string' as const,
oneOf: options2,
},
field3: {
type: 'string' as const,
},
},
}

const form = createHeadlessForm(schema)

// Verify both fields have correct options
const field1Options = form.fields.find(f => f.name === 'field1')?.options
const field2Options = form.fields.find(f => f.name === 'field2')?.options

expect(field1Options?.length).toBe(3)
expect(field2Options?.length).toBe(3)
expect(field1Options?.[0]).toEqual({ label: 'Option 1', value: 'value_1' })
expect(field2Options?.[2]).toEqual({ label: 'Option 3', value: 'value_3' })

// Same cached array reference returned for identical content
expect(field1Options).toBe(field2Options)
})

it('should maintain options correctly across validations', () => {
// Create a small options array for testing
const options = [
{ label: 'Option 1', value: 'value_1' },
{ label: 'Option 2', value: 'value_2' },
{ label: 'Option 3', value: 'value_3' },
]

const schemaWithOptions = {
type: 'object' as const,
properties: {
field1: {
'type': 'string' as const,
'x-jsf-presentation': {
inputType: 'select' as const,
options,
},
},
field2: {
'type': 'string' as const,
'x-jsf-presentation': {
inputType: 'select' as const,
options, // Same options array reference
},
},
otherField: {
type: 'string' as const,
},
},
}

const form = createHeadlessForm(schemaWithOptions)

// After validation, options should still be present and correct
form.handleValidation({
field1: 'value_1',
field2: 'value_2',
otherField: 'test',
})

const field1Options = form.fields.find(f => f.name === 'field1')?.options
const field2Options = form.fields.find(f => f.name === 'field2')?.options

// Options should still be present with correct content
expect(field1Options).toBeDefined()
expect(field2Options).toBeDefined()
expect(field1Options?.length).toBe(3)
expect(field2Options?.length).toBe(3)

expect(field1Options?.[0]).toEqual({ label: 'Option 1', value: 'value_1' })
expect(field1Options?.[2]).toEqual({ label: 'Option 3', value: 'value_3' })
})

it('should return different references for options arrays with same length but different content', () => {
// Create two options arrays with same length but different content
const options1 = [
{ label: 'Option A', value: 'value_a' },
{ label: 'Option B', value: 'value_b' },
{ label: 'Option C', value: 'value_c' },
]

const options2 = [
{ label: 'Option A', value: 'value_a' },
{ label: 'Option B', value: 'value_b' },
{ label: 'Option D', value: 'value_d' },
]

const schema = {
type: 'object' as const,
properties: {
field1: {
'type': 'string' as const,
'x-jsf-presentation': {
inputType: 'select' as const,
options: options1,
},
},
field2: {
'type': 'string' as const,
'x-jsf-presentation': {
inputType: 'select' as const,
options: options2,
},
},
},
}

const form = createHeadlessForm(schema)

const field1Options = form.fields.find(f => f.name === 'field1')?.options
const field2Options = form.fields.find(f => f.name === 'field2')?.options

expect(field1Options?.length).toBe(3)
expect(field2Options?.length).toBe(3)

// Should return different references due to different content
expect(field1Options).not.toBe(field2Options)

// Verify the content is different
expect(field1Options?.[2]).toEqual({ label: 'Option C', value: 'value_c' })
expect(field2Options?.[2]).toEqual({ label: 'Option D', value: 'value_d' })
})
})