Skip to content
Open
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
9 changes: 9 additions & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ const { schema, warnings } = modify(schemaPet, {
});
```

### 7. **Default values are only applied when the field's initial value is `undefined`**

Both versions fill a "missing" initial value with the schema's `default`, but they don't match on what counts as a "missing" initial value.

- **v0** applied the default whenever the initial value was **falsy**, so `false`, `0`, `''` and `null` were all replaced by the default.
- **v1** applies the default only when the initial value is **`undefined`**, so falsy values you explicitly pass are preserved.

> **Note:** in v1, defaults are read from the base schema's `properties` (recursing into nested objects and into each existing item of an array of objects). Defaults declared inside conditional sub-schemas (`allOf`, `anyOf`, `if`/`then`) are not applied to the initial values.

## Migration Steps

### Step 1: Update Package
Expand Down
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) {
baseValues[key] = nestedValues
}
}

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
209 changes: 209 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,211 @@ 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)
})

it('initialValues are not mutated', () => {
const initialValues = { card_type: 'visa' }
const form = createHeadlessForm(schema, { initialValues })

expect(initialValues).toStrictEqual({ card_type: 'visa' })

const cardTypeField = getField(form.fields, 'card_type')
expect(cardTypeField?.isVisible).toBe(true)
expect(cardTypeField?.options).toEqual([
{ label: 'Visa', value: 'visa' },
{ label: 'Mastercard', value: 'mastercard' },
])
})
})

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