Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
52 changes: 50 additions & 2 deletions src/form.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { ValidationError, ValidationErrorPath } from './errors'
import type { Field } from './field/type'
import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types'
import type { JsfObjectSchema, JsfSchema, ObjectValue, SchemaValue } from './types'
import type { LegacyOptions } from './validation/schema'
import { getErrorMessage } from './errors/messages'
import { buildFieldSchema } from './field/schema'
import { calculateFinalSchema, updateFieldProperties } from './mutations'
import { addCustomJsonLogicOperations, removeCustomJsonLogicOperations } from './validation/json-logic'
import { validateSchema } from './validation/schema'
import { isObjectValue } from './validation/util'

export { LegacyOptions } from './validation/schema'

Expand Down Expand Up @@ -283,6 +284,47 @@ function validateOptions(options: CreateHeadlessFormOptions) {
}
}

/**
* Recursively fills a value with the schema's `default` keywords.
*
* The `default` is only applied if the initial value is `undefined`.
*
* @param schema - The schema (or sub-schema) to read defaults from.
* @param values - The current values at this path.
* @returns The values with defaults filled in.
*/
function fillDefaults(schema: JsfSchema, values: SchemaValue): SchemaValue {
if (typeof schema === 'boolean') {
return values
}

// Object schema: recurse into properties, filling nested defaults.
if (schema.properties) {
const baseValues: ObjectValue = isObjectValue(values) ? values : {}

for (const [key, propSchema] of Object.entries(schema.properties)) {
const nestedValues = fillDefaults(propSchema, baseValues[key])
if (nestedValues !== undefined) {
Object.assign(baseValues, { [key]: nestedValues })
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
}

return baseValues
}

// Array of objects (group-array): fill defaults for each existing item.
if (schema.items && typeof schema.items !== 'boolean' && Array.isArray(values)) {
const itemSchema = schema.items
return values.map(item => fillDefaults(itemSchema, item))
}

if (values === undefined && schema.default !== undefined) {
return schema.default
}

return values
}

/**
* JSON Logic uses a single global operators registry for all of its calls, so
* we need to take extra measures to keep each createHeadlessForm deterministic.
Expand All @@ -296,12 +338,18 @@ export function createHeadlessForm(
options: CreateHeadlessFormOptions = {},
): FormResult {
validateOptions(options)
const initialValues = options.initialValues || {}
const strictInputType = options.strictInputType || false
const customJsonLogicOps = options?.customJsonLogicOps

addCustomJsonLogicOperations(customJsonLogicOps)

// Default values are obtain based on the base schema and the initial values
// defaults set via sub-schemas (e.g. allOf, anyOf) are not considered here
const initialValues = fillDefaults(
schema,
options.initialValues || {},
)

// 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)
const updatedSchema = calculateFinalSchema({
schema,
Expand Down
195 changes: 195 additions & 0 deletions test/form.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { JsfObjectSchema } from '../src/types'
import { afterEach, describe, expect, it, jest } from '@jest/globals'
import { createHeadlessForm } from '../src'
import { getField } from '../src/utils'

import { schemaWithCustomValidationFunction } from './validation/json-logic.fixtures'

describe('createHeadlessForm', () => {
Expand Down Expand Up @@ -61,4 +63,197 @@ describe('createHeadlessForm', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled()
})
})

describe('defaults on initialization', () => {
describe('conditional options rendered from a default', () => {
// A field with a `default` should trigger conditional rules on
// initialization, without needing a `handleValidation` run first.
const schema: JsfObjectSchema = {
type: 'object',
properties: {
payment_method: {
type: 'string',
default: 'card',
oneOf: [
{ const: 'card', title: 'Card' },
{ const: 'bank_transfer', title: 'Bank Transfer' },
],
},
card_type: {
type: 'string',
},
},
allOf: [
{
if: {
properties: { payment_method: { const: 'card' } },
required: ['payment_method'],
},
then: {
properties: {
card_type: {
oneOf: [
{ const: 'visa', title: 'Visa' },
{ const: 'mastercard', title: 'Mastercard' },
],
},
},
},
else: {
properties: {
card_type: false,
},
},
},
],
}

it('renders conditional options at init when the default matches the "then" branch', () => {
const form = createHeadlessForm(schema, { disallowNewConditionalOptions: true })
const cardTypeField = getField(form.fields, 'card_type')
expect(cardTypeField?.isVisible).toBe(true)
expect(cardTypeField?.options).toEqual([
{ label: 'Visa', value: 'visa' },
{ label: 'Mastercard', value: 'mastercard' },
])
})

it('lets an explicit initialValue override the default', () => {
const form = createHeadlessForm(schema, { initialValues: { payment_method: 'bank_transfer' } })
expect(getField(form.fields, 'card_type')?.isVisible).toBe(false)
})
})

describe('merge semantics', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
withDefault: { type: 'string', default: 'fallback' },
noDefault: { type: 'string' },
zeroDefault: { type: 'number', default: 5 },
},
allOf: [
{
if: { properties: { withDefault: { const: 'fallback' } }, required: ['withDefault'] },
then: { properties: { noDefault: { title: 'Revealed' } } },
else: { properties: { noDefault: false } },
},
],
}

it('applies the default when no initial value is provided', () => {
const form = createHeadlessForm(schema)
expect(getField(form.fields, 'noDefault')?.isVisible).toBe(true)
})

it('allows an explicit falsy initial value to override the default', () => {
const form = createHeadlessForm(schema, { initialValues: { withDefault: null } })
expect(getField(form.fields, 'noDefault')?.isVisible).toBe(false)
})
})

describe('nested object defaults', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
address: {
type: 'object',
properties: {
country: { type: 'string', default: 'PT' },
},
},
vat: { type: 'string' },
},
allOf: [
{
if: {
properties: { address: { properties: { country: { const: 'PT' } }, required: ['country'] } },
required: ['address'],
},
then: { properties: { vat: { title: 'VAT' } } },
else: { properties: { vat: false } },
},
],
}

it('seeds nested defaults so nested conditionals resolve at init', () => {
const form = createHeadlessForm(schema)
expect(getField(form.fields, 'vat')?.isVisible).toBe(true)
})

it('allows an explicit falsy initial value to override the default', () => {
const form = createHeadlessForm(schema, { initialValues: { address: { country: null } } })
expect(getField(form.fields, 'vat')?.isVisible).toBe(false)
})
})

describe('array of objects (group-array) item defaults', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
pets: {
type: 'array',
items: {
type: 'object',
properties: {
species: { type: 'string', default: 'dog' },
name: { type: 'string' },
},
},
},
dog_license: { type: 'string' },
},
allOf: [
{
if: {
properties: {
pets: {
items: { properties: { species: { const: 'dog' } }, required: ['species'] },
},
},
required: ['pets'],
},
then: { properties: { dog_license: { title: 'Dog license' } } },
else: { properties: { dog_license: false } },
},
],
}

it('fills item defaults for each item that already exists in the value', () => {
const form = createHeadlessForm(schema, {
initialValues: { pets: [{ name: 'Rex' }, { name: 'Fido' }] },
})
expect(getField(form.fields, 'dog_license')?.isVisible).toBe(true)
})

it('allows an explicit item value to override the item default', () => {
const form = createHeadlessForm(schema, {
initialValues: { pets: [{ name: 'Rex' }, { name: 'Whiskers', species: 'cat' }] },
})
expect(getField(form.fields, 'dog_license')?.isVisible).toBe(false)
})

it('allows an explicit falsy item value to override the item default', () => {
const form = createHeadlessForm(schema, {
initialValues: { pets: [{ name: 'Rex', species: null }] },
})
expect(getField(form.fields, 'dog_license')?.isVisible).toBe(false)
})

it('does not create items when the array is missing from the value', () => {
const form = createHeadlessForm(schema)
expect(getField(form.fields, 'dog_license')?.isVisible).toBe(false)
})

it('does not create items when the array is empty', () => {
const form = createHeadlessForm(schema, { initialValues: { pets: [] } })
expect(getField(form.fields, 'dog_license')?.isVisible).toBe(false)
})

it('leaves a non-array value untouched, even when the items schema has defaults', () => {
const form = createHeadlessForm(schema, { initialValues: { pets: 'not-an-array' } })
expect(getField(form.fields, 'dog_license')?.isVisible).toBe(false)
})
})
})
})
Loading