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
2 changes: 1 addition & 1 deletion packages/jsonforms-renderers/src/hooks/useIsRequired.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
* `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()
const rootData = ctx.core?.data

Check failure on line 16 in packages/jsonforms-renderers/src/hooks/useIsRequired.ts

View workflow job for this annotation

GitHub Actions / Quality Check & Build

Unsafe assignment of an `any` value
const validator = ctx.core?.validator
const rootSchema = ctx.core?.schema

Expand Down
108 changes: 105 additions & 3 deletions packages/jsonforms-renderers/src/utils/required.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
94 changes: 80 additions & 14 deletions packages/jsonforms-renderers/src/utils/required.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, boolean>()

/**
* 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<string, unknown>
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<string, unknown>, new Set())
conditionalRequiredCache.set(schema, result)
return result
}

function resolveLocalRef(root: Record<string, unknown>, ref: string): Record<string, unknown> | 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<string, unknown>)[part]
}
return cur && typeof cur === 'object' ? (cur as Record<string, unknown>) : undefined
}

function scanForConditionalRequired(node: unknown, root: Record<string, unknown>, seen: Set<object>): boolean {
if (!node || typeof node !== 'object') return false
if (seen.has(node)) return false
seen.add(node)

const s = node as Record<string, unknown>

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<string, unknown>
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<string, unknown>)) {
if (scanForConditionalRequired(child, root, seen)) return true
}
}

return false
}

/**
Expand All @@ -37,14 +101,14 @@ export function cloneDataWithoutPath(data: unknown, path: string): unknown {
let parent: Record<string, unknown> | unknown[] | null = root as Record<string, unknown> | 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<string, unknown>)[seg]
if (next == null || typeof next !== 'object') return root
parent = next as Record<string, unknown> | unknown[]
}

const last = segments[segments.length - 1]!
const last = segments[segments.length - 1]
if (parent != null && typeof parent === 'object') {
delete (parent as Record<string, unknown>)[last]
}
Expand All @@ -65,16 +129,18 @@ export function propertyNameFromPath(path: string): string {
}

function matchesRequiredError(error: NonNullable<SchemaValidateFunction['errors']>[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,
Expand Down
Loading