diff --git a/packages/jsonforms-renderers/src/hooks/useIsRequired.ts b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts index 9968c1b..2647075 100644 --- a/packages/jsonforms-renderers/src/hooks/useIsRequired.ts +++ b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts @@ -9,7 +9,7 @@ import { isPathRequiredBySchema, schemaHasConditionalRequired } from '../utils/r * `isRequired()` ignores. * * Skips the AJV path when the control is hidden, already statically required, - * or the root schema has no conditional-required constructs. + * or the schema has no conditional-required constructs (including nested / `$ref`). */ export function useIsRequired(path: string, staticRequired = false, visible = true): boolean { const ctx = useJsonForms() diff --git a/packages/jsonforms-renderers/src/utils/required.test.ts b/packages/jsonforms-renderers/src/utils/required.test.ts index a6c097b..4d15380 100644 --- a/packages/jsonforms-renderers/src/utils/required.test.ts +++ b/packages/jsonforms-renderers/src/utils/required.test.ts @@ -16,14 +16,95 @@ describe('schemaHasConditionalRequired', () => { expect(schemaHasConditionalRequired(undefined)).toBe(false) }) + it('is false for property-level enum oneOf without required branches', () => { + expect( + schemaHasConditionalRequired({ + type: 'object', + properties: { + status: { + oneOf: [ + { const: 'a', title: 'A' }, + { const: 'b', title: 'B' }, + ], + }, + }, + }), + ).toBe(false) + }) + it('is true when the root uses conditional constructs', () => { - expect(schemaHasConditionalRequired({ allOf: [] })).toBe(true) expect(schemaHasConditionalRequired({ if: {}, then: {} })).toBe(true) - expect(schemaHasConditionalRequired({ oneOf: [] })).toBe(true) - expect(schemaHasConditionalRequired({ anyOf: [] })).toBe(true) + expect(schemaHasConditionalRequired({ oneOf: [{ required: ['a'] }, { required: ['b'] }] })).toBe(true) + expect(schemaHasConditionalRequired({ allOf: [{ if: {}, then: { required: ['x'] } }] })).toBe(true) expect(schemaHasConditionalRequired({ dependentRequired: { a: ['b'] } })).toBe(true) expect(schemaHasConditionalRequired({ dependencies: { a: ['b'] } })).toBe(true) }) + + it('is true for nested if/then under properties', () => { + expect( + schemaHasConditionalRequired({ + type: 'object', + properties: { + address: { + type: 'object', + if: { properties: { country: { const: 'XX' } } }, + then: { required: ['district'] }, + }, + }, + }), + ).toBe(true) + }) + + it('is true when a conditional is reached via $ref into $defs', () => { + expect( + schemaHasConditionalRequired({ + type: 'object', + properties: { + address: { $ref: '#/$defs/address' }, + }, + $defs: { + address: { + type: 'object', + if: { properties: { country: { const: 'XX' } } }, + then: { required: ['district'] }, + }, + }, + }), + ).toBe(true) + }) + + it('handles a self-referential $ref without looping', () => { + const schema = { + type: 'object', + properties: { + node: { $ref: '#/$defs/node' }, + }, + $defs: { + node: { + type: 'object', + properties: { + child: { $ref: '#/$defs/node' }, + }, + }, + }, + } + expect(schemaHasConditionalRequired(schema)).toBe(false) + + const withConditional = { + ...schema, + $defs: { + node: { + type: 'object', + if: { properties: { kind: { const: 'leaf' } } }, + then: { required: ['value'] }, + properties: { + child: { $ref: '#/$defs/node' }, + }, + }, + }, + } + expect(schemaHasConditionalRequired(withConditional)).toBe(true) + }) }) describe('propertyNameFromPath / parentInstancePath', () => { @@ -111,4 +192,25 @@ describe('isPathRequiredBySchema', () => { it('returns false without a validator', () => { expect(isPathRequiredBySchema('assessedLevy', { reviewOutcome: 'approve' }, undefined)).toBe(false) }) + + it('treats property-array dependencies as required when the trigger is present', () => { + // AJV reports these under keyword `dependencies`, not `required`. + const depsSchema = { + type: 'object', + properties: { + billingSameAsShipping: { type: 'boolean' }, + billingAddress: { type: 'string' }, + }, + dependencies: { + billingSameAsShipping: ['billingAddress'], + }, + } + const depsValidate = createAjv().compile(depsSchema) + + expect(isPathRequiredBySchema('billingAddress', {}, depsValidate)).toBe(false) + expect( + isPathRequiredBySchema('billingAddress', { billingSameAsShipping: true, billingAddress: '1 Main' }, depsValidate), + ).toBe(true) + expect(isPathRequiredBySchema('billingAddress', { billingSameAsShipping: true }, depsValidate)).toBe(true) + }) }) diff --git a/packages/jsonforms-renderers/src/utils/required.ts b/packages/jsonforms-renderers/src/utils/required.ts index 970097d..45da0e3 100644 --- a/packages/jsonforms-renderers/src/utils/required.ts +++ b/packages/jsonforms-renderers/src/utils/required.ts @@ -7,22 +7,86 @@ export type SchemaValidateFunction = ((data: unknown) => boolean) & { }> | null } -/** Keywords that can make `required` depend on data (beyond a plain `required` array). */ -const CONDITIONAL_SCHEMA_KEYS = ['if', 'allOf', 'anyOf', 'oneOf', 'dependentRequired', 'dependencies'] as const +/** Cache `schemaHasConditionalRequired` per schema object identity. */ +const conditionalRequiredCache = new WeakMap() /** - * True when the root schema may express conditional required rules that + * True when the schema may express conditional required rules that * `@jsonforms/core`'s static `isRequired()` misses. Used to skip the per- * keystroke AJV clone/validate when a plain `required` array is enough. * - * Only the root schema's own keys are inspected. A conditional inside a nested - * property, or behind a `$ref`, is not seen, so its fields get no asterisk - * until this is widened. + * Walks the whole schema (properties, items, then/else, etc.), follows local + * `$ref`s into `$defs` / `definitions`, and caches the result per schema object. + * Property-level enum `oneOf` / `anyOf` that only list `const` options do not + * count; branches that add `required` or further conditionals do. */ export function schemaHasConditionalRequired(schema: unknown): boolean { if (!schema || typeof schema !== 'object') return false - const s = schema as Record - return CONDITIONAL_SCHEMA_KEYS.some((k) => s[k] != null) + const cached = conditionalRequiredCache.get(schema) + if (cached !== undefined) return cached + const result = scanForConditionalRequired(schema, schema as Record, new Set()) + conditionalRequiredCache.set(schema, result) + return result +} + +function resolveLocalRef(root: Record, ref: string): Record | undefined { + if (!ref.startsWith('#/')) return undefined + const parts = ref.slice(2).split('/') + let cur: unknown = root + for (const part of parts) { + if (!cur || typeof cur !== 'object') return undefined + cur = (cur as Record)[part] + } + return cur && typeof cur === 'object' ? (cur as Record) : undefined +} + +function scanForConditionalRequired(node: unknown, root: Record, seen: Set): boolean { + if (!node || typeof node !== 'object') return false + if (seen.has(node)) return false + seen.add(node) + + const s = node as Record + + if (s.if != null || s.dependentRequired != null || s.dependencies != null) return true + + for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { + const arr = s[key] + if (!Array.isArray(arr)) continue + for (const item of arr) { + if (!item || typeof item !== 'object') continue + const branch = item as Record + if (Array.isArray(branch.required) && branch.required.length > 0) return true + if (scanForConditionalRequired(branch, root, seen)) return true + } + } + + if (typeof s.$ref === 'string') { + const target = resolveLocalRef(root, s.$ref) + if (target && scanForConditionalRequired(target, root, seen)) return true + } + + for (const key of ['then', 'else', 'not', 'contains', 'additionalProperties', 'items'] as const) { + const child = s[key] + if (child && typeof child === 'object' && !Array.isArray(child)) { + if (scanForConditionalRequired(child, root, seen)) return true + } + } + + if (Array.isArray(s.items)) { + for (const item of s.items) { + if (scanForConditionalRequired(item, root, seen)) return true + } + } + + for (const mapKey of ['properties', 'patternProperties', '$defs', 'definitions'] as const) { + const map = s[mapKey] + if (!map || typeof map !== 'object') continue + for (const child of Object.values(map as Record)) { + if (scanForConditionalRequired(child, root, seen)) return true + } + } + + return false } /** @@ -37,14 +101,14 @@ export function cloneDataWithoutPath(data: unknown, path: string): unknown { let parent: Record | unknown[] | null = root as Record | unknown[] for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]! + const seg = segments[i] if (parent == null || typeof parent !== 'object') return root const next = (parent as Record)[seg] if (next == null || typeof next !== 'object') return root parent = next as Record | unknown[] } - const last = segments[segments.length - 1]! + const last = segments[segments.length - 1] if (parent != null && typeof parent === 'object') { delete (parent as Record)[last] } @@ -65,16 +129,18 @@ export function propertyNameFromPath(path: string): string { } function matchesRequiredError(error: NonNullable[number], path: string): boolean { - if (error.keyword !== 'required') return false + // Property-array `dependencies` reports under its own keyword; schema-form + // dependencies that use nested `required` already come through as `required`. + if (error.keyword !== 'required' && error.keyword !== 'dependencies') return false if (error.params?.missingProperty !== propertyNameFromPath(path)) return false return (error.instancePath ?? '') === parentInstancePath(path) } /** * True when submitting the form with this path empty would produce a - * `required` AJV error for that property. Covers plain `required`, - * `allOf`/`if`/`then`, `oneOf`, `dependentRequired`, etc., without parsing - * those constructs by hand. + * missing-property AJV error for that field. Covers plain `required`, + * `allOf`/`if`/`then`, `oneOf`, property-array `dependencies`, etc., without + * parsing those constructs by hand. */ export function isPathRequiredBySchema( path: string,