diff --git a/MIGRATING.md b/MIGRATING.md index ea5f16c2..d598f0f0 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -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 diff --git a/src/form.ts b/src/form.ts index 0589647d..c14aea8a 100644 --- a/src/form.ts +++ b/src/form.ts @@ -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' @@ -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. @@ -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, diff --git a/test/form.test.ts b/test/form.test.ts index 2e8f70da..097a20d1 100644 --- a/test/form.test.ts +++ b/test/form.test.ts @@ -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', () => { @@ -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) + }) + }) + }) })