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
32 changes: 10 additions & 22 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function isObject(value: any): boolean {
* @param schema1 - The first schema to merge
* @param schema2 - The second schema to merge
*/
export function deepMergeSchemas<T extends Record<string, any>>(schema1: T, schema2: T): void {
export function deepMergeSchemas<T extends Record<string, any>>(schema1?: T, schema2?: T): void {
// Handle null/undefined values
if (!schema1 || !schema2) {
return
Expand Down Expand Up @@ -96,33 +96,21 @@ export function deepMergeSchemas<T extends Record<string, any>>(schema1: T, sche
schema1[key as keyof T] = schema2Value
}
}
// If the value is an array, cycle through it and merge values if they're different (take objects into account)
// If the value is an array, replace the whole array
// for the "required" key, we only add new elements to the array
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') {
Comment thread
cursor[bot] marked this conversation as resolved.
deepMergeSchemas(originalArray, schema2Value)
}
// "required" is a special case, it only allows for new elements to be added to the array
else if (key === 'required') {
// Add any new elements to the array
if (!originalArray.find((originalItem: any) => originalItem === item)) {
if (key === 'required') {
for (const item of schema2Value) {
if (!originalArray.includes(item)) {
originalArray.push(item)
}
}
// Otherwise, just assign it
else {
schema1[key as keyof T] = schema2Value as T[keyof T]
}
}
// Otherwise, just assign it
else {
schema1[key as keyof T] = schema2Value as T[keyof T]
}
}
// Finally, if the value is different, just assign it
Expand Down
153 changes: 153 additions & 0 deletions test/fields/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,156 @@ describe('Select field options', () => {
expect(field2Options?.[2]).toEqual({ label: 'Option D', value: 'value_d' })
})
})

describe('conditionally replacing option-like arrays', () => {
const getOptions = (form: ReturnType<typeof createHeadlessForm>, name: string) =>
form.fields.find(f => f.name === name)?.options

it('should fully replace oneOf options when a conditional branch provides updated options', () => {
const schema = {
type: 'object' as const,
properties: {
trigger: { type: 'string' as const },
choice: {
type: 'string' as const,
oneOf: [
{ const: 'a', title: 'A' },
{ const: 'b', title: 'B' },
{ const: 'c', title: 'C' },
],
},
},
allOf: [
{
if: { properties: { trigger: { const: 'only c' } }, required: ['trigger'] },
then: {
properties: {
choice: {
oneOf: [{ const: 'c', title: 'C' }],
},
},
},
},
],
}

const form = createHeadlessForm(schema)

expect(getOptions(form, 'choice')).toEqual([
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
])

expect(form.handleValidation({ choice: 'b' }).formErrors).toBeUndefined()
expect(form.handleValidation({ choice: 'z' }).formErrors).toEqual({
choice: 'The option "z" is not valid.',
})

// Applies the conditional branch options
form.handleValidation({ trigger: 'only c' })
expect(getOptions(form, 'choice')).toEqual([{ label: 'C', value: 'c' }])

expect(form.handleValidation({ trigger: 'only c', choice: 'c' }).formErrors).toBeUndefined()
expect(form.handleValidation({ trigger: 'only c', choice: 'b' }).formErrors).toEqual({
choice: 'The option "b" is not valid.',
})

// When the branch is no longer active, options revert to the default set
form.handleValidation({ trigger: 'stop' })
expect(getOptions(form, 'choice')).toEqual([
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
])

expect(form.handleValidation({ trigger: 'stop', choice: 'b' }).formErrors).toBeUndefined()
})

it('should fully replace anyOf options when a conditional branch provides updated options', () => {
const schema = {
type: 'object' as const,
properties: {
trigger: { type: 'string' as const },
choice: {
type: 'string' as const,
anyOf: [
{ const: 'a', title: 'A' },
{ const: 'b', title: 'B' },
{ const: 'c', title: 'C' },
],
},
},
allOf: [
{
if: { properties: { trigger: { const: 'go' } }, required: ['trigger'] },
then: {
properties: {
choice: {
anyOf: [{ const: 'c', title: 'C' }],
},
},
},
},
],
}

const form = createHeadlessForm(schema)

expect(getOptions(form, 'choice')).toHaveLength(3)

form.handleValidation({ trigger: 'go' })
expect(getOptions(form, 'choice')).toEqual([{ label: 'C', value: 'c' }])

// Only the replacement option validates; the original options are gone
expect(form.handleValidation({ trigger: 'go', choice: 'c' }).formErrors).toBeUndefined()
expect(form.handleValidation({ trigger: 'go', choice: 'b' }).formErrors).toEqual({
choice: 'The option "b" is not valid.',
})
})

it('should fully replace enum options when a conditional branch provides updated options', () => {
const schema = {
type: 'object' as const,
properties: {
userType: { type: 'string' as const },
permissions: {
type: 'string' as const,
enum: ['read', 'write', 'execute'],
},
},
allOf: [
{
if: { properties: { userType: { const: 'guest' } }, required: ['userType'] },
then: {
properties: {
permissions: {
enum: ['read'],
},
},
},
},
],
}

const form = createHeadlessForm(schema)
const getEnum = () => form.fields.find(f => f.name === 'permissions')?.enum

expect(getEnum()).toEqual(['read', 'write', 'execute'])

expect(form.handleValidation({ permissions: 'write' }).formErrors).toBeUndefined()

form.handleValidation({ userType: 'guest' })
expect(getEnum()).toEqual(['read'])

expect(form.handleValidation({ userType: 'guest', permissions: 'read' }).formErrors).toBeUndefined()
expect(form.handleValidation({ userType: 'guest', permissions: 'write' }).formErrors).toEqual({
permissions: 'The option "write" is not valid.',
})

form.handleValidation({ userType: 'user' })
expect(getEnum()).toEqual(['read', 'write', 'execute'])

expect(form.handleValidation({ userType: 'user', permissions: 'write' }).formErrors).toBeUndefined()
})
})
134 changes: 133 additions & 1 deletion test/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Field } from '../src/field/type'
import { describe, expect, it } from '@jest/globals'
import { getField } from '../src/utils'
import { convertKBToMB, deepMergeSchemas, getField } from '../src/utils'

describe('getField', () => {
const mockFields: Field[] = [
Expand Down Expand Up @@ -111,3 +111,135 @@ describe('getField', () => {
expect(field?.name).toBe('level3')
})
})

describe('convertKBToMB', () => {
it('should return 0 when given 0', () => {
expect(convertKBToMB(0)).toBe(0)
})

it('should convert KB to MB', () => {
expect(convertKBToMB(1024)).toBe(1)
expect(convertKBToMB(2048)).toBe(2)
})

it('should round the result to 2 decimal places', () => {
// 1505 / 1024 = 1.469726562 -> 1.46
expect(convertKBToMB(1505)).toBe(1.47)
// 1590 / 1024 = 1.552734375 -> 1.55
expect(convertKBToMB(1590)).toBe(1.55)
})

it('should handle values smaller than 1 MB', () => {
// 512 / 1024 = 0.5
expect(convertKBToMB(512)).toBe(0.5)
})
})

describe('deepMergeSchemas', () => {
it('should do nothing when either schema is missing', () => {
const schema1 = { type: 'string' }
expect(() => deepMergeSchemas(undefined, schema1)).not.toThrow()
expect(() => deepMergeSchemas(schema1, undefined)).not.toThrow()
expect(schema1).toEqual({ type: 'string' })
})

it('should return early without mutating when a schema is a truthy non-object', () => {
const schema1: Record<string, any> = { type: 'string' }
expect(() => deepMergeSchemas(42 as any, schema1)).not.toThrow()
expect(() => deepMergeSchemas(schema1, 'not-an-object' as any)).not.toThrow()
expect(schema1).toEqual({ type: 'string' })
})

it('should copy over properties that only exist in schema2', () => {
const schema1: Record<string, any> = { type: 'string' }
deepMergeSchemas(schema1, { title: 'Name' })
expect(schema1).toEqual({ type: 'string', title: 'Name' })
})

it('should overwrite primitive values that differ', () => {
const schema1: Record<string, any> = { title: 'Old' }
deepMergeSchemas(schema1, { title: 'New' })
expect(schema1.title).toBe('New')
})

it('should merge nested objects recursively', () => {
const schema1: Record<string, any> = {
properties: { name: { type: 'string' } },
}
deepMergeSchemas(schema1, {
properties: { age: { type: 'number' } },
})
expect(schema1.properties).toEqual({
name: { type: 'string' },
age: { type: 'number' },
})
})

it('should assign an object when the target value is not an object', () => {
const schema1: Record<string, any> = { meta: 'string' }
deepMergeSchemas(schema1, { meta: { nested: true } })
expect(schema1.meta).toEqual({ nested: true })
})

it('should skip if/then/else properties', () => {
const schema1: Record<string, any> = { type: 'object' }
deepMergeSchemas(schema1, {
if: { properties: { a: { const: 1 } } },
then: { required: ['b'] },
else: { required: ['c'] },
})
expect(schema1).toEqual({ type: 'object' })
})

it('should replace enum arrays', () => {
const schema1: Record<string, any> = { enum: ['a', 'b'] }
deepMergeSchemas(schema1, { enum: ['c', 'd'] })
expect(schema1.enum).toEqual(['c', 'd'])
})

it('should only add new elements to the required array', () => {
const schema1: Record<string, any> = { required: ['a', 'b'] }
deepMergeSchemas(schema1, { required: ['b', 'c'] })
expect(schema1.required).toEqual(['a', 'b', 'c'])
})

it('should replace the whole options array', () => {
const schema1: Record<string, any> = {
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }],
}
const incomingOptions = [{ value: 'c', label: 'C' }]
deepMergeSchemas(schema1, {
options: incomingOptions,
})
expect(schema1.options).toStrictEqual([{ value: 'c', label: 'C' }])
// preserves the reference identity of the incoming options array
expect(schema1.options).toEqual(incomingOptions)
expect(schema1.options).toHaveLength(1)
})

it('should replace the whole anyOf options array', () => {
const schema1: Record<string, any> = {
anyOf: [{ const: 'A', label: 'A' }, { const: 'B', label: 'B' }, { const: 'C', label: 'C' }],
}
const incomingAnyOf = [{ const: 'C', label: 'C' }]
deepMergeSchemas(schema1, {
anyOf: incomingAnyOf,
})
// preserves the reference identity of the incoming anyOf array
expect(schema1.anyOf).toEqual(incomingAnyOf)
expect(schema1.anyOf).toHaveLength(1)
})

it('should replace a nested items.anyOf options array', () => {
const schema1: Record<string, any> = {
items: { anyOf: [{ const: 'A', label: 'A' }, { const: 'B', label: 'B' }, { const: 'C', label: 'C' }] },
}
const incomingItemsAnyOf = [{ const: 'C', label: 'C' }]
deepMergeSchemas(schema1, {
items: { anyOf: incomingItemsAnyOf },
})
// preserves the reference identity of the incoming anyOf array
expect(schema1.items.anyOf).toEqual(incomingItemsAnyOf)
expect(schema1.items.anyOf).toHaveLength(1)
})
})
Loading