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
72 changes: 72 additions & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,78 @@ if (formErrors?.address?.street) {
}
```

### 3. **Conditional options behavior (`disallowNewConditionalOptions`)**

Conditional branches (`if`/`then`/`else`) can override a field's option-like arrays
(`enum`, `oneOf`, `anyOf`, and `x-jsf-presentation.options`). Historically a branch could
introduce brand-new options that weren't present on the base field. That behavior is being
tightened to match the spec: going forward, a branch may only **narrow or re-label** options
already declared on the base field. Any option a branch introduces that isn't on the base is dropped.

If the base field doesn't apply a base options array (e.g. no `oneOf` key), then the previous
behavior still applies, any new option would be accepted.

Setting `disallowNewConditionalOptions: true` option lets you opt into the new behavior today:

```typescript
const form = createHeadlessForm(schema, {
disallowNewConditionalOptions: true,
})
```

Example: a `paymentMethod` field whose options depend on the selected `country`. With
`disallowNewConditionalOptions: true`, the `"cash"` option the branch tries to add is ignored
because it isn't declared on the base field:

```typescript
const schema = {
type: 'object',
properties: {
country: {
type: 'string',
title: 'Country',
oneOf: [
{ const: 'US', title: 'United States' },
{ const: 'PT', title: 'Portugal' },
],
},
paymentMethod: {
type: 'string',
title: 'Payment method',
oneOf: [
{ const: 'card', title: 'Credit card' },
{ const: 'paypal', title: 'PayPal' },
{ const: 'bank_transfer', title: 'Bank transfer' },
],
},
},
allOf: [
{
if: { properties: { country: { const: 'US' } }, required: ['country'] },
then: {
properties: {
paymentMethod: {
// 'cash' is not declared on the base field, so it is ignored
oneOf: [
{ const: 'card', title: 'Credit card' },
{ const: 'cash', title: 'Cash' },
],
},
},
},
},
],
}

const form = createHeadlessForm(schema, { disallowNewConditionalOptions: true })
form.handleValidation({ country: 'US' })
// `paymentMethod` now only offers [{ label: 'Credit card', value: 'card' }] , 'cash' was dropped.
```

> **Deprecation warning:** while running with the default (`false`), a branch that introduces a
> new option still works but logs a one-time console warning. Set `disallowNewConditionalOptions: true`
> to silence it and adopt the future behavior early.

## Common Migration Issues

### 1. **ESM Import Errors**
Expand Down
10 changes: 10 additions & 0 deletions src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ export interface CreateHeadlessFormOptions {
*/
strictInputType?: boolean

/**
* When true, conditional branches (if/then/else) can only narrow options already present on
* a base field; options a branch introduces that aren't on the base are dropped, unless the
* base has no options property declared at all.
* When false (default), branches may introduce new options anytime (legacy behavior) and a
* deprecation warning is emitted. Will default to true in a future major release.
* @default false
*/
disallowNewConditionalOptions?: boolean

/**
* Custom user defined functions. A dictionary of name and function
*/
Expand Down
18 changes: 8 additions & 10 deletions src/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Field } from './field/type'
import type { CreateHeadlessFormOptions } from './form'
import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types'
import type { LegacyOptions } from './validation/schema'
import { buildFieldSchema } from './field/schema'
import { mergeFieldProperties, mergeSchemaBranch } from './utils'
import { evaluateIfCondition } from './validation/conditions'
Expand Down Expand Up @@ -29,15 +28,14 @@ export function calculateFinalSchema({
}): JsfObjectSchema {
const jsonLogicContext = schema['x-jsf-logic'] ? getJsonLogicContextFromSchema(schema['x-jsf-logic'], values) : undefined
const schemaCopy = safeDeepClone(schema)
const { legacyOptions } = options

applySchemaRules(schemaCopy, values, legacyOptions, jsonLogicContext)
applySchemaRules(schemaCopy, values, options, jsonLogicContext)

if (jsonLogicContext?.schema.computedValues) {
applyComputedAttrsToSchema(schemaCopy, jsonLogicContext.schema.computedValues, values)
// If we had computed values applied to the schema,
// we need to re-apply the schema rules to update the fields
applySchemaRules(schemaCopy, values, legacyOptions, jsonLogicContext)
applySchemaRules(schemaCopy, values, options, jsonLogicContext)
}

return schemaCopy
Expand All @@ -55,11 +53,11 @@ function evaluateConditional(
values: ObjectValue,
schema: JsfObjectSchema,
rule: NonBooleanJsfSchema,
options: LegacyOptions = {},
options: CreateHeadlessFormOptions = {},
jsonLogicContext: JsonLogicContext | undefined,
) {
// At this point, we know that the rule has an if property
const conditionIsTrue = evaluateIfCondition(values, rule.if!, options, jsonLogicContext)
const conditionIsTrue = evaluateIfCondition(values, rule.if!, options.legacyOptions ?? {}, jsonLogicContext)

// Prevent fields from being shown when required fields have type errors
let hasTypeErrors = false
Expand All @@ -71,7 +69,7 @@ function evaluateConditional(
}
const fieldSchema = schema.properties[fieldName]
const fieldValue = values[fieldName]
const fieldErrors = validateSchema(fieldValue, fieldSchema, options)
const fieldErrors = validateSchema(fieldValue, fieldSchema, options.legacyOptions ?? {})
return fieldErrors.some(error => error.validation === 'type')
})
}
Expand All @@ -89,7 +87,7 @@ function evaluateConditional(
function applySchemaRules(
schema: JsfObjectSchema,
values: SchemaValue = {},
options: LegacyOptions = {},
options: CreateHeadlessFormOptions = {},
jsonLogicContext: JsonLogicContext | undefined,
) {
if (!isObjectValue(values)) {
Expand Down Expand Up @@ -164,11 +162,11 @@ function applySchemaRules(
* @param options - Validation options
* @param jsonLogicContext - JSON Logic context
*/
function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: JsfSchema, options: LegacyOptions = {}, jsonLogicContext: JsonLogicContext | undefined) {
function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: JsfSchema, options: CreateHeadlessFormOptions = {}, jsonLogicContext: JsonLogicContext | undefined) {
const branchSchema = branch as JsfObjectSchema

applySchemaRules(branchSchema, values, options, jsonLogicContext)
mergeSchemaBranch(schema, branchSchema)
mergeSchemaBranch(schema, branchSchema, options)
}

/**
Expand Down
102 changes: 59 additions & 43 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,7 @@
import type { Field } from './field/type'
import type { CreateHeadlessFormOptions } from './form'
import type { JsfSchema } from './types'

type DiskSizeUnit = 'Bytes' | 'KB' | 'MB'

/**
* @todo: Remove this.
*
* This utility only exists as an example of using V1 tests for V2 source.
* It should not be tested, or even part of JSON Schema Form.
*/
export function convertDiskSizeFromTo(
from: DiskSizeUnit,
to: DiskSizeUnit,
): (value: number) => number {
const multipliers: Record<DiskSizeUnit, number> = {
Bytes: 1,
KB: 1024,
MB: 1024 * 1024,
}

return (value: number): number => {
const fromMultiplier = multipliers[from]
const toMultiplier = multipliers[to]
return (value * fromMultiplier) / toMultiplier
}
}

/**
* Get a field from a list of fields by name.
* If the field is nested, you can pass additional names to access a nested field.
Expand Down Expand Up @@ -102,18 +78,42 @@ function getOptionIdentity(option: unknown): unknown {
return option
}

let hasWarnedAboutNewConditionalOptions = false

/**
* Warns (once) that a conditional branch introduces option(s) not present on the base field.
* This is only relevant while running with the legacy behavior (disallowNewConditionalOptions: false).
*
* @param newOptions - The option identities introduced by the branch that are not present on the base field
*/
function warnAboutNewConditionalOptions(newOptions: unknown[]): void {
if (!hasWarnedAboutNewConditionalOptions) {
hasWarnedAboutNewConditionalOptions = true
console.warn(
`[json-schema-form] A conditional branch introduces option(s) not present on the base field: ${JSON.stringify(newOptions)}. `
+ 'This currently works but is deprecated and will be disallowed in a future major version. '
+ 'Set `disallowNewConditionalOptions: true` to opt into the new behavior now. (see PR #265)',
)
}
}

/**
* Merges a conditional branch schema into the base schema recursively.
*
* Option-like arrays (enum/oneOf/anyOf/options) are restricted to the options already
* present on the base field: the branch may narrow or re-label existing options, but any
* option whose value isn't present in the base is ignored. If the base field declares no
* option array for a given key, the branch's options are dropped entirely.
* When `options.disallowNewConditionalOptions` is true, option-like arrays (enum/oneOf/anyOf/options)
* are restricted to the options already present on the base field: the branch may narrow or re-label
* existing options, but any option whose value isn't present in the base is ignored. If the base
* field declares no option array for a given key, the branch's options are dropped entirely.
*
* When it is false (default, legacy behavior), option-like arrays are replaced wholesale, so a branch
* may introduce new options. In that case, if a branch would introduce an option that the new
* behavior would drop, a one-time deprecation warning is emitted.
*
* @param schema1 - The base schema to merge into
* @param schema2 - The conditional branch schema to merge from
* @param options - The form options
*/
export function mergeSchemaBranch<T extends Record<string, any>>(schema1?: T, schema2?: T): void {
export function mergeSchemaBranch<T extends Record<string, any>>(schema1?: T, schema2?: T, options?: CreateHeadlessFormOptions): void {
// Handle null/undefined values
if (!schema1 || !schema2) {
return
Expand All @@ -124,6 +124,8 @@ export function mergeSchemaBranch<T extends Record<string, any>>(schema1?: T, sc
return
}

const { disallowNewConditionalOptions = false } = options ?? {}

// Merge all properties from schema2 into schema1
for (const [key, schema2Value] of Object.entries(schema2)) {
// let's skip merging some properties
Expand All @@ -133,29 +135,43 @@ export function mergeSchemaBranch<T extends Record<string, any>>(schema1?: T, sc

const schema1Value = schema1[key]

// Restrict option-like arrays to the options already present on the base field
if (isOptionsLikeSchema(key, schema2Value)) {
// Base declares no options for this key, let a conditional branch introduce them
if (!Array.isArray(schema1Value)) {
schema1[key as keyof T] = schema2Value
// Restrict option-like arrays to the options already present on the base field
if (disallowNewConditionalOptions) {
// Base declares no options for this key, let a conditional branch introduce them
if (!Array.isArray(schema1Value)) {
schema1[key as keyof T] = schema2Value
continue
}

const allowedOptions = new Set(schema1Value.map(option => getOptionIdentity(option)))
// Keep the branch's option objects (so changing options properties works),
// but only for values that are already present in the base
// Note: this will set an empty array if the options are not of an expected format
schema1[key as keyof T] = schema2Value.filter(
(option: unknown) => allowedOptions.has(getOptionIdentity(option)),
)
continue
}

const allowedOptions = new Set(schema1Value.map(option => getOptionIdentity(option)))
// Keep the branch's option objects (so changing options properties works),
// but only for values that are already present in the base
// Note: this will set an empty array if the options are not of an expected format
schema1[key as keyof T] = schema2Value.filter(
(option: unknown) => allowedOptions.has(getOptionIdentity(option)),
)
continue
// Legacy behavior: option-like arrays are fully replaced below, but warn (once) if the
// branch introduces an option that the new behavior would have dropped.
else if (Array.isArray(schema1Value)) {
const allowedOptions = new Set(schema1Value.map(option => getOptionIdentity(option)))
const newOptions = schema2Value
.map((option: unknown) => getOptionIdentity(option))
.filter((identity: unknown) => !allowedOptions.has(identity))
if (newOptions.length > 0) {
warnAboutNewConditionalOptions(newOptions)
}
}
}

// If the value is an object:
if (isObject(schema2Value)) {
// If both schemas have this key and it's an object, merge recursively
if (isObject(schema1Value)) {
mergeSchemaBranch(schema1Value, schema2Value)
mergeSchemaBranch(schema1Value, schema2Value, options)
}
// Otherwise, if the value is different, just assign it
else if (schema1Value !== schema2Value) {
Expand Down
Loading
Loading