From b306f3d324d404074a3a862d280f20b91cb23970 Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Thu, 16 Jul 2026 19:42:05 +0100 Subject: [PATCH 1/3] Allow legacy behavior via option --- src/form.ts | 10 ++++ src/mutations.ts | 18 +++---- src/utils.ts | 100 ++++++++++++++++++++---------------- test/fields/options.test.ts | 62 ++++++++++++++++++++-- test/utils.test.ts | 76 ++++++++++++++++++++++----- 5 files changed, 197 insertions(+), 69 deletions(-) diff --git a/src/form.ts b/src/form.ts index bc3034f7..0589647d 100644 --- a/src/form.ts +++ b/src/form.ts @@ -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 */ diff --git a/src/mutations.ts b/src/mutations.ts index f562402b..ec6c57cd 100644 --- a/src/mutations.ts +++ b/src/mutations.ts @@ -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' @@ -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 @@ -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 @@ -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') }) } @@ -89,7 +87,7 @@ function evaluateConditional( function applySchemaRules( schema: JsfObjectSchema, values: SchemaValue = {}, - options: LegacyOptions = {}, + options: CreateHeadlessFormOptions = {}, jsonLogicContext: JsonLogicContext | undefined, ) { if (!isObjectValue(values)) { @@ -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) } /** diff --git a/src/utils.ts b/src/utils.ts index 1701095b..310e5879 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 = { - 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. @@ -102,18 +78,40 @@ 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). + */ +function warnAboutNewConditionalOptions(): void { + if (!hasWarnedAboutNewConditionalOptions) { + hasWarnedAboutNewConditionalOptions = true + console.warn( + '[json-schema-form] A conditional branch introduces option(s) not present on the base field. ' + + '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>(schema1?: T, schema2?: T): void { +export function mergeSchemaBranch>(schema1?: T, schema2?: T, options?: CreateHeadlessFormOptions): void { // Handle null/undefined values if (!schema1 || !schema2) { return @@ -124,6 +122,8 @@ export function mergeSchemaBranch>(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 @@ -133,29 +133,43 @@ export function mergeSchemaBranch>(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 introducesNewOption = schema2Value.some( + (option: unknown) => !allowedOptions.has(getOptionIdentity(option)), + ) + if (introducesNewOption) { + warnAboutNewConditionalOptions() + } + } } // 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) { diff --git a/test/fields/options.test.ts b/test/fields/options.test.ts index da8f26eb..3b95d518 100644 --- a/test/fields/options.test.ts +++ b/test/fields/options.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from '@jest/globals' +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' import { createHeadlessForm } from '../../src/form' describe('Select field options', () => { @@ -310,6 +310,9 @@ describe('conditionally replacing option-like arrays', () => { }) describe('conditionals cannot introduce options that are not in the base field', () => { + // The restriction is opt-in via `disallowNewConditionalOptions: true`. + const options = { disallowNewConditionalOptions: true } + const getOptions = (form: ReturnType, name: string) => form.fields.find(f => f.name === name)?.options @@ -342,7 +345,7 @@ describe('conditionals cannot introduce options that are not in the base field', ], } - const form = createHeadlessForm(schema) + const form = createHeadlessForm(schema, options) form.handleValidation({ trigger: 'go' }) // Only 'c' survives; the injected 'd' is dropped @@ -385,7 +388,7 @@ describe('conditionals cannot introduce options that are not in the base field', ], } - const form = createHeadlessForm(schema) + const form = createHeadlessForm(schema, options) const getEnum = () => form.fields.find(f => f.name === 'permissions')?.enum form.handleValidation({ userType: 'admin' }) @@ -399,3 +402,56 @@ describe('conditionals cannot introduce options that are not in the base field', expect(getEnum()).toEqual(['read', 'write', 'execute']) }) }) + +describe('conditionals can introduce new options by default (legacy behavior)', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + 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: 'admin' } }, required: ['userType'] }, + then: { + properties: { + permissions: { + // 'delete' is not present on the base field but should be kept (legacy behavior) + enum: ['read', 'delete'], + }, + }, + }, + }, + ], + } + + it('should keep options a conditional introduces and warn once', () => { + const form = createHeadlessForm(schema) + const getEnum = () => form.fields.find(f => f.name === 'permissions')?.enum + + form.handleValidation({ userType: 'admin' }) + // The whole branch enum replaces the base, including the new 'delete' option + expect(getEnum()).toEqual(['read', 'delete']) + expect(form.handleValidation({ userType: 'admin', permissions: 'delete' }).formErrors).toBeUndefined() + + // Reverting still restores the full base enum + form.handleValidation({ userType: 'user' }) + expect(getEnum()).toEqual(['read', 'write', 'execute']) + + expect(warnSpy).toHaveBeenCalled() + expect(warnSpy.mock.calls[0][0]).toContain('disallowNewConditionalOptions') + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts index fdcd8b2d..aedf3413 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,5 @@ import type { Field } from '../src/field/type' -import { describe, expect, it } from '@jest/globals' +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' import { convertKBToMB, getField, mergeFieldProperties, mergeSchemaBranch } from '../src/utils' describe('getField', () => { @@ -198,22 +198,25 @@ describe('mergeSchemaBranch', () => { }) describe('restricting option-like arrays to the base options', () => { + // This behavior is opt-in via `disallowNewConditionalOptions: true`. + const options = { disallowNewConditionalOptions: true } + it('should narrow enum arrays to the options present in the base', () => { const schema1: Record = { enum: ['a', 'b', 'c'] } - mergeSchemaBranch(schema1, { enum: ['b', 'c'] }) + mergeSchemaBranch(schema1, { enum: ['b', 'c'] }, options) expect(schema1.enum).toEqual(['b', 'c']) }) it('should ignore enum options that are not present in the base', () => { const schema1: Record = { enum: ['a', 'b', 'c', null] } // 'd' does not exist on the base field and must be dropped - mergeSchemaBranch(schema1, { enum: ['c', 'd', null] }) + mergeSchemaBranch(schema1, { enum: ['c', 'd', null] }, options) expect(schema1.enum).toEqual(['c', null]) }) it('should narrow array of enum objects to the options present in the base, assuming option-like objects', () => { const schema1: Record = { enum: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }] } - mergeSchemaBranch(schema1, { enum: [{ value: 'b', label: 'B' }, { value: 'c', label: 'C' }] }) + mergeSchemaBranch(schema1, { enum: [{ value: 'b', label: 'B' }, { value: 'c', label: 'C' }] }, options) expect(schema1.enum).toEqual([{ value: 'b', label: 'B' }, { value: 'c', label: 'C' }]) }) @@ -223,14 +226,14 @@ describe('mergeSchemaBranch', () => { } mergeSchemaBranch(schema1, { options: [{ value: 'c', label: 'C' }, { value: 'd', label: 'D' }], - }) + }, options) expect(schema1.options).toEqual([{ value: 'c', label: 'C' }]) expect(schema1.options).toHaveLength(1) }) it('should ignore options that are not option-like objects, still replacing the array', () => { const schema1: Record = { options: [{ flag: true, label: 'A' }, { flag: false, label: 'B' }, { flag: true, label: 'C' }] } - mergeSchemaBranch(schema1, { options: [{ flag: true, label: 'A' }, { flag: false, label: 'B' }] }) + mergeSchemaBranch(schema1, { options: [{ flag: true, label: 'A' }, { flag: false, label: 'B' }] }, options) expect(schema1.options).toEqual([]) }) @@ -240,7 +243,7 @@ describe('mergeSchemaBranch', () => { } mergeSchemaBranch(schema1, { anyOf: [{ const: 'C' }, { const: 'X' }], - }) + }, options) expect(schema1.anyOf).toEqual([{ const: 'C' }]) expect(schema1.anyOf).toHaveLength(1) }) @@ -251,7 +254,7 @@ describe('mergeSchemaBranch', () => { } mergeSchemaBranch(schema1, { oneOf: [{ const: 'A' }, { const: 'Z' }], - }) + }, options) expect(schema1.oneOf).toEqual([{ const: 'A' }]) expect(schema1.oneOf).toHaveLength(1) }) @@ -262,7 +265,7 @@ describe('mergeSchemaBranch', () => { } mergeSchemaBranch(schema1, { oneOf: [{ const: 'a', title: 'Relabeled A' }, { const: 'new', title: 'New' }], - }) + }, options) expect(schema1.oneOf).toEqual([{ const: 'a', title: 'Relabeled A' }]) }) @@ -272,29 +275,76 @@ describe('mergeSchemaBranch', () => { } mergeSchemaBranch(schema1, { items: { anyOf: [{ const: 'C' }, { const: 'D' }] }, - }) + }, options) expect(schema1.items.anyOf).toEqual([{ const: 'C' }]) expect(schema1.items.anyOf).toHaveLength(1) }) it('should add the branch options when the base declares none', () => { const schema1: Record = { type: 'string' } - mergeSchemaBranch(schema1, { enum: ['a', 'b'] }) + mergeSchemaBranch(schema1, { enum: ['a', 'b'] }, options) expect(schema1).toEqual({ type: 'string', enum: ['a', 'b'] }) }) it('should add the branch options when the base declares none for option-like arrays', () => { const schema1: Record = { type: 'string' } - mergeSchemaBranch(schema1, { oneOf: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }) + mergeSchemaBranch(schema1, { oneOf: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }, options) expect(schema1.oneOf).toEqual([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]) }) it('should not add the branch options when the base an empty array', () => { const schema1: Record = { type: 'string', enum: [] } - mergeSchemaBranch(schema1, { enum: ['a', 'b'] }) + mergeSchemaBranch(schema1, { enum: ['a', 'b'] }, options) expect(schema1).toEqual({ type: 'string', enum: [] }) }) }) + + describe('legacy behavior: allowing new conditional options (default)', () => { + let warnSpy: ReturnType + + beforeEach(() => { + jest.resetModules() + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('should not warn when a branch only narrows to existing options', () => { + const schema1: Record = { enum: ['a', 'b', 'c'] } + mergeSchemaBranch(schema1, { enum: ['a', 'b'] }) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('should warn once when a branch introduces a new option', () => { + const schema1: Record = { enum: ['a', 'b'] } + expect(warnSpy).toHaveBeenCalledTimes(0) + mergeSchemaBranch(schema1, { enum: ['a', 'c'] }) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0][0]).toContain('disallowNewConditionalOptions') + // A second merge that also introduces a new option should not warn again + const schema2: Record = { enum: ['x', 'y'] } + mergeSchemaBranch(schema2, { enum: ['x', 'z'] }) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('should replace the whole enum array, including new options, when the flag is omitted', () => { + const schema1: Record = { enum: ['a', 'b', 'c'] } + mergeSchemaBranch(schema1, { enum: ['c', 'd'] }) + expect(schema1.enum).toEqual(['c', 'd']) + }) + + it('should replace the whole options array, including new options', () => { + const schema1: Record = { + options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], + } + mergeSchemaBranch(schema1, { + options: [{ value: 'b', label: 'B' }, { value: 'c', label: 'C' }], + }) + expect(schema1.options).toEqual([{ value: 'b', label: 'B' }, { value: 'c', label: 'C' }]) + }) + }) }) describe('mergeFieldProperties', () => { From 6db7a33eb9b525aefd0eb7a75c2272c6e7c98089 Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Fri, 24 Jul 2026 17:10:33 +0100 Subject: [PATCH 2/3] Docs --- MIGRATING.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/MIGRATING.md b/MIGRATING.md index 650006e3..ea5f16c2 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -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** From 59d8c43f5edac498321107b594be643f56f8f86b Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Mon, 27 Jul 2026 16:32:26 +0100 Subject: [PATCH 3/3] Inform of options in warning --- src/utils.ts | 16 +++++++++------- test/utils.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 310e5879..a5869870 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -83,12 +83,14 @@ 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(): void { +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-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)', ) @@ -156,11 +158,11 @@ export function mergeSchemaBranch>(schema1?: T, sc // 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 introducesNewOption = schema2Value.some( - (option: unknown) => !allowedOptions.has(getOptionIdentity(option)), - ) - if (introducesNewOption) { - warnAboutNewConditionalOptions() + const newOptions = schema2Value + .map((option: unknown) => getOptionIdentity(option)) + .filter((identity: unknown) => !allowedOptions.has(identity)) + if (newOptions.length > 0) { + warnAboutNewConditionalOptions(newOptions) } } } diff --git a/test/utils.test.ts b/test/utils.test.ts index aedf3413..e1aeee6a 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -320,9 +320,9 @@ describe('mergeSchemaBranch', () => { it('should warn once when a branch introduces a new option', () => { const schema1: Record = { enum: ['a', 'b'] } expect(warnSpy).toHaveBeenCalledTimes(0) - mergeSchemaBranch(schema1, { enum: ['a', 'c'] }) + mergeSchemaBranch(schema1, { enum: ['a', 'c', 'd'] }) expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy.mock.calls[0][0]).toContain('disallowNewConditionalOptions') + expect(warnSpy.mock.calls[0][0]).toEqual('[json-schema-form] A conditional branch introduces option(s) not present on the base field: [\"c\",\"d\"]. 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)') // A second merge that also introduces a new option should not warn again const schema2: Record = { enum: ['x', 'y'] } mergeSchemaBranch(schema2, { enum: ['x', 'z'] })