From d12e52aacf353fff5131d10722e7edb1ea87ab85 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 14 Oct 2026 21:00:36 -0700 Subject: [PATCH 1/7] fix(jsonforms): honor uiSchema options.required for red asterisk Conditional schema required (allOf if/then) does not set ControlProps.required, so fields that are only mandatory when visible never get the red *. Allow uischema.options.required to force the same marker TextControl/NumberControl already use for schema-required fields. Co-authored-by: Cursor --- packages/jsonforms-renderers/src/renderers/NumberControl.tsx | 5 ++++- packages/jsonforms-renderers/src/renderers/TextControl.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx index e2e28bb..10e2f4c 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -23,6 +23,9 @@ export const NumberControl = ({ } const isValid = errors.length === 0 + // Conditional schema required (allOf if/then) does not set ControlProps.required. + // uiSchema options.required marks the red * when the control is shown. + const showRequired = required || uischema.options?.required === true const handleNumberChange = (value: string) => { if (value === '') { @@ -39,7 +42,7 @@ export const NumberControl = ({ - {label} {required && *} + {label} {showRequired && *} - {label} {required && *} + {label} {showRequired && *} Date: Wed, 14 Oct 2026 21:10:44 -0700 Subject: [PATCH 2/7] fix(jsonforms): honor options.required on RadioControl Same escape hatch as TextControl/NumberControl for fields that are only conditionally required (allOf if/then). Co-authored-by: Cursor --- packages/jsonforms-renderers/src/renderers/RadioControl.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/jsonforms-renderers/src/renderers/RadioControl.tsx b/packages/jsonforms-renderers/src/renderers/RadioControl.tsx index 57cb817..81b2ebf 100644 --- a/packages/jsonforms-renderers/src/renderers/RadioControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/RadioControl.tsx @@ -13,6 +13,7 @@ export const RadioControl = ({ required, errors, schema, + uischema, enabled, visible = true, }: ControlProps) => { @@ -22,6 +23,8 @@ export const RadioControl = ({ return null } const isValid = errors.length === 0 + // Conditional schema required (allOf if/then) does not set ControlProps.required. + const showRequired = required || uischema.options?.required === true let options: { value: any; label: string }[] = [] @@ -40,7 +43,7 @@ export const RadioControl = ({ - {label} {required && *} + {label} {showRequired && *} Date: Wed, 14 Oct 2026 22:19:13 -0700 Subject: [PATCH 3/7] fix(jsonforms): derive required asterisk from schema via AJV Replace uiSchema options.required with a shared useIsRequired hook that asks the form validator whether the field is required under current data, and tighten NumberControl with min/step plus blocking of signed keys. Co-authored-by: Cursor --- .../src/hooks/useIsRequired.ts | 20 ++++ .../src/renderers/NumberControl.test.tsx | 105 ++++++++++++++++++ .../src/renderers/NumberControl.tsx | 33 +++++- .../src/renderers/RadioControl.tsx | 5 +- .../src/renderers/SelectControl.tsx | 6 +- .../src/renderers/TextControl.tsx | 5 +- .../src/utils/error.test.ts | 18 +++ .../jsonforms-renderers/src/utils/error.ts | 12 +- .../src/utils/required.test.ts | 99 +++++++++++++++++ .../jsonforms-renderers/src/utils/required.ts | 77 +++++++++++++ 10 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 packages/jsonforms-renderers/src/hooks/useIsRequired.ts create mode 100644 packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx create mode 100644 packages/jsonforms-renderers/src/utils/error.test.ts create mode 100644 packages/jsonforms-renderers/src/utils/required.test.ts create mode 100644 packages/jsonforms-renderers/src/utils/required.ts diff --git a/packages/jsonforms-renderers/src/hooks/useIsRequired.ts b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts new file mode 100644 index 0000000..a4036ce --- /dev/null +++ b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react' +import { useJsonForms } from '@jsonforms/react' +import { isPathRequiredBySchema } from '../utils/required' + +/** + * Whether the control at `path` is required under the current form data. + * ORs JSONForms' static `required` prop with an AJV check that also sees + * conditional schema rules (`allOf`/`if`/`then`, etc.) that core's + * `isRequired()` ignores. + */ +export function useIsRequired(path: string, staticRequired = false): boolean { + const ctx = useJsonForms() + const rootData = ctx.core?.data + const validator = ctx.core?.validator + + return useMemo(() => { + if (staticRequired) return true + return isPathRequiredBySchema(path, rootData, validator) + }, [staticRequired, path, rootData, validator]) +} diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx new file mode 100644 index 0000000..98df107 --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { JsonForms } from '@jsonforms/react' +import { Theme } from '@radix-ui/themes' +import type { JsonSchema, UISchemaElement } from '@jsonforms/core' +import { radixRenderers } from './index' + +type Data = Record + +afterEach(() => { + cleanup() +}) + +const schema = { + type: 'object', + properties: { + reviewOutcome: { + type: 'string', + title: 'Review Outcome', + oneOf: [ + { const: 'approve', title: 'Approve' }, + { const: 'reject', title: 'Reject' }, + ], + }, + assessedLevy: { + type: 'number', + title: 'Assessed SLTB Levy (LKR)', + exclusiveMinimum: 0, + multipleOf: 0.01, + }, + }, + allOf: [ + { + if: { + properties: { reviewOutcome: { const: 'approve' } }, + required: ['reviewOutcome'], + }, + then: { required: ['assessedLevy'] }, + }, + ], +} as JsonSchema + +const uischema = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/reviewOutcome', options: { format: 'radio' } }, + { + type: 'Control', + scope: '#/properties/assessedLevy', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/reviewOutcome', + schema: { const: 'approve' }, + }, + }, + }, + ], +} as UISchemaElement + +function renderForm(seed: Data) { + function Harness() { + const [data, setData] = useState(seed) + return ( + + setData(next as Data)} + /> + + ) + } + return render() +} + +describe('NumberControl required asterisk + constraints', () => { + it('shows a red asterisk only when the levy is conditionally required', () => { + renderForm({ reviewOutcome: 'reject' }) + expect(screen.queryByText('Assessed SLTB Levy (LKR)')).toBeNull() + + cleanup() + renderForm({ reviewOutcome: 'approve' }) + const label = screen.getByText('Assessed SLTB Levy (LKR)') + expect(label.textContent).toContain('*') + }) + + it('sets min from exclusiveMinimum and blocks signed / exponent keys', () => { + renderForm({ reviewOutcome: 'approve' }) + const input = screen.getByLabelText(/Assessed SLTB Levy/) as HTMLInputElement + expect(input).toHaveProperty('type', 'number') + expect(input.min).toBe('0') + expect(input.step).toBe('0.01') + + fireEvent.keyDown(input, { key: '-' }) + fireEvent.keyDown(input, { key: '+' }) + fireEvent.keyDown(input, { key: 'e' }) + // Value stays empty; blocked keys never reach onChange with a signed draft. + expect(input.value).toBe('') + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx index 10e2f4c..27f624a 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -1,8 +1,12 @@ import { type ControlProps, isNumberControl, type RankedTester, rankWith, isIntegerControl, or } from '@jsonforms/core' import { withJsonFormsControlProps } from '@jsonforms/react' import { TextField, Text, Flex, Box } from '@radix-ui/themes' +import type { ClipboardEvent, KeyboardEvent } from 'react' import { getErrorMessage } from '../utils/error' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' + +const BLOCKED_NUMBER_KEYS = new Set(['-', '+', 'e', 'E']) export const NumberControl = ({ data, @@ -17,15 +21,20 @@ export const NumberControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required) if (visible === false) { return null } const isValid = errors.length === 0 - // Conditional schema required (allOf if/then) does not set ControlProps.required. - // uiSchema options.required marks the red * when the control is shown. - const showRequired = required || uischema.options?.required === true + + const exclusiveMinimum = typeof schema.exclusiveMinimum === 'number' ? schema.exclusiveMinimum : undefined + const minimum = typeof schema.minimum === 'number' ? schema.minimum : undefined + const minBound = exclusiveMinimum ?? minimum + const blockSignedKeys = minBound !== undefined && minBound >= 0 + const step = + typeof schema.multipleOf === 'number' ? schema.multipleOf : schema.type === 'integer' ? 1 : undefined const handleNumberChange = (value: string) => { if (value === '') { @@ -38,6 +47,20 @@ export const NumberControl = ({ } } + const onKeyDown = (e: KeyboardEvent) => { + if (blockSignedKeys && BLOCKED_NUMBER_KEYS.has(e.key)) { + e.preventDefault() + } + } + + const onPaste = (e: ClipboardEvent) => { + if (!blockSignedKeys) return + const text = e.clipboardData.getData('text') + if (/[-+eE]/.test(text)) { + e.preventDefault() + } + } + return ( @@ -48,6 +71,10 @@ export const NumberControl = ({ type="number" value={data ?? ''} onChange={(e) => handleNumberChange(e.target.value)} + onKeyDown={onKeyDown} + onPaste={onPaste} + min={minBound} + step={step} disabled={!enabled} placeholder={uischema.options?.placeholder} color={!isValid ? 'red' : undefined} diff --git a/packages/jsonforms-renderers/src/renderers/RadioControl.tsx b/packages/jsonforms-renderers/src/renderers/RadioControl.tsx index 81b2ebf..f710dd7 100644 --- a/packages/jsonforms-renderers/src/renderers/RadioControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/RadioControl.tsx @@ -1,6 +1,7 @@ import { type ControlProps, isEnumControl, isOneOfControl, or, type RankedTester } from '@jsonforms/core' import { withJsonFormsControlProps } from '@jsonforms/react' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { Box, Flex, Text, RadioGroup } from '@radix-ui/themes' import { getErrorMessage } from '../utils/error' @@ -13,18 +14,16 @@ export const RadioControl = ({ required, errors, schema, - uischema, enabled, visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required) if (visible === false) { return null } const isValid = errors.length === 0 - // Conditional schema required (allOf if/then) does not set ControlProps.required. - const showRequired = required || uischema.options?.required === true let options: { value: any; label: string }[] = [] diff --git a/packages/jsonforms-renderers/src/renderers/SelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SelectControl.tsx index 4e38f08..a30f0a5 100644 --- a/packages/jsonforms-renderers/src/renderers/SelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SelectControl.tsx @@ -1,6 +1,7 @@ import { type ControlProps, isEnumControl, type RankedTester, rankWith, isOneOfControl, or } from '@jsonforms/core' import { withJsonFormsControlProps } from '@jsonforms/react' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { useEffect, useRef, useState, type KeyboardEvent, type SyntheticEvent } from 'react' import { Select, Text, Flex, Box, TextField, ScrollArea } from '@radix-ui/themes' @@ -20,6 +21,7 @@ export const SelectControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required) // `options.autocomplete: true` swaps the plain dropdown for a type-to-filter combobox over the // same enum/oneOf options — no remote service involved (see SearchSelectControl for that case). @@ -89,7 +91,7 @@ export const SelectControl = ({ - {label} {required && *} + {label} {showRequired && *}
@@ -227,7 +229,7 @@ export const SelectControl = ({ - {label} {required && *} + {label} {showRequired && *} handleChange(path, val)} disabled={!enabled}> diff --git a/packages/jsonforms-renderers/src/renderers/TextControl.tsx b/packages/jsonforms-renderers/src/renderers/TextControl.tsx index 47b4fe3..f1b63a8 100644 --- a/packages/jsonforms-renderers/src/renderers/TextControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/TextControl.tsx @@ -1,6 +1,7 @@ import { type ControlProps, isStringControl, type RankedTester, rankWith } from '@jsonforms/core' import { withJsonFormsControlProps } from '@jsonforms/react' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { TextField, Text, Flex, Box } from '@radix-ui/themes' import { getErrorMessage } from '../utils/error' @@ -18,15 +19,13 @@ export const TextControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required) if (visible === false) { return null } const isValid = errors.length === 0 - // Conditional schema required (allOf if/then) does not set ControlProps.required. - // uiSchema options.required marks the red * when the control is shown. - const showRequired = required || uischema.options?.required === true return ( diff --git a/packages/jsonforms-renderers/src/utils/error.test.ts b/packages/jsonforms-renderers/src/utils/error.test.ts new file mode 100644 index 0000000..65f098b --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/error.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getErrorMessage } from './error' + +describe('getErrorMessage', () => { + it('rewrites the required property message', () => { + expect(getErrorMessage('is a required property', 'Levy')).toBe('Levy is required') + }) + + it('rewrites exclusiveMinimum / minimum / multipleOf messages', () => { + expect(getErrorMessage('must be > 0', 'Levy')).toBe('Levy must be greater than 0') + expect(getErrorMessage('must be >= 1', 'Qty')).toBe('Qty must be at least 1') + expect(getErrorMessage('must be multiple of 0.01', 'Levy')).toBe('Levy must be a multiple of 0.01') + }) + + it('passes through unknown messages', () => { + expect(getErrorMessage('must be string', 'Name')).toBe('must be string') + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/error.ts b/packages/jsonforms-renderers/src/utils/error.ts index adb0d15..dad0528 100644 --- a/packages/jsonforms-renderers/src/utils/error.ts +++ b/packages/jsonforms-renderers/src/utils/error.ts @@ -1,11 +1,21 @@ export const getErrorMessage = (errors: string, label?: string): string => { if (!errors) return '' + const field = label || 'This field' return errors .split('\n') .map((err) => { const trimmed = err.trim() if (trimmed === 'is a required property') { - return `${label || 'This field'} is required` + return `${field} is required` + } + if (trimmed.startsWith('must be > ')) { + return `${field} must be greater than ${trimmed.slice('must be > '.length)}` + } + if (trimmed.startsWith('must be >= ')) { + return `${field} must be at least ${trimmed.slice('must be >= '.length)}` + } + if (trimmed.startsWith('must be multiple of ')) { + return `${field} must be a multiple of ${trimmed.slice('must be multiple of '.length)}` } return err }) diff --git a/packages/jsonforms-renderers/src/utils/required.test.ts b/packages/jsonforms-renderers/src/utils/required.test.ts new file mode 100644 index 0000000..e83f45e --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/required.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { createAjv } from '@jsonforms/core' +import { + cloneDataWithoutPath, + isPathRequiredBySchema, + parentInstancePath, + propertyNameFromPath, +} from './required' + +describe('propertyNameFromPath / parentInstancePath', () => { + it('splits a dotted path', () => { + expect(propertyNameFromPath('assessedLevy')).toBe('assessedLevy') + expect(propertyNameFromPath('address.city')).toBe('city') + expect(parentInstancePath('assessedLevy')).toBe('') + expect(parentInstancePath('address.city')).toBe('/address') + expect(parentInstancePath('items.0.name')).toBe('/items/0') + }) +}) + +describe('cloneDataWithoutPath', () => { + it('removes a root property without mutating the original', () => { + const data = { a: 1, b: 2 } + expect(cloneDataWithoutPath(data, 'a')).toEqual({ b: 2 }) + expect(data).toEqual({ a: 1, b: 2 }) + }) + + it('removes a nested property', () => { + expect(cloneDataWithoutPath({ address: { city: 'X', zip: '1' } }, 'address.city')).toEqual({ + address: { zip: '1' }, + }) + }) +}) + +describe('isPathRequiredBySchema', () => { + const schema = { + type: 'object', + properties: { + reviewOutcome: { type: 'string' }, + sltbReference: { type: 'string' }, + assessedLevy: { type: 'number', exclusiveMinimum: 0 }, + address: { + type: 'object', + properties: { city: { type: 'string' } }, + }, + }, + allOf: [ + { + if: { + properties: { reviewOutcome: { const: 'approve' } }, + required: ['reviewOutcome'], + }, + then: { required: ['sltbReference', 'assessedLevy'] }, + }, + { + if: { + properties: { reviewOutcome: { const: 'approve' } }, + required: ['reviewOutcome'], + }, + then: { + properties: { + address: { type: 'object', required: ['city'] }, + }, + }, + }, + ], + } + + const validate = createAjv().compile(schema) + + it('is false when the if-condition does not apply', () => { + expect(isPathRequiredBySchema('assessedLevy', { reviewOutcome: 'reject' }, validate)).toBe(false) + expect(isPathRequiredBySchema('sltbReference', { reviewOutcome: 'reject' }, validate)).toBe(false) + }) + + it('is true for then.required fields when the condition matches', () => { + const data = { reviewOutcome: 'approve', sltbReference: 'REF', assessedLevy: 10 } + expect(isPathRequiredBySchema('assessedLevy', data, validate)).toBe(true) + expect(isPathRequiredBySchema('sltbReference', data, validate)).toBe(true) + }) + + it('still reports required when the field is already filled', () => { + // Asterisk must remain while the condition holds, not only when empty. + expect( + isPathRequiredBySchema('assessedLevy', { reviewOutcome: 'approve', assessedLevy: 99 }, validate), + ).toBe(true) + }) + + it('matches nested required under a parent instancePath', () => { + const data = { reviewOutcome: 'approve', address: { city: 'Colombo' } } + expect(isPathRequiredBySchema('address.city', data, validate)).toBe(true) + expect(isPathRequiredBySchema('address.city', { reviewOutcome: 'reject', address: {} }, validate)).toBe( + false, + ) + }) + + it('returns false without a validator', () => { + expect(isPathRequiredBySchema('assessedLevy', { reviewOutcome: 'approve' }, undefined)).toBe(false) + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/required.ts b/packages/jsonforms-renderers/src/utils/required.ts new file mode 100644 index 0000000..f2af676 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/required.ts @@ -0,0 +1,77 @@ +/** Minimal AJV validate function shape (avoids a hard ajv dependency). */ +export type SchemaValidateFunction = ((data: unknown) => boolean) & { + errors?: + | Array<{ + keyword: string + instancePath?: string + params?: { missingProperty?: string } + }> + | null +} + +/** + * Deep-clone form data and remove the value at `path` (dot-separated, as + * JSONForms uses). Used to ask AJV whether that property is currently required. + */ +export function cloneDataWithoutPath(data: unknown, path: string): unknown { + const root = structuredClone(data === undefined || data === null ? {} : data) + if (!path) return root + + const segments = path.split('.') + let parent: Record | unknown[] | null = root as Record | unknown[] + + for (let i = 0; i < segments.length - 1; 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]! + if (parent != null && typeof parent === 'object') { + delete (parent as Record)[last] + } + return root +} + +/** JSONForms path `a.b.0.c` -> AJV instancePath parent `/a/b/0`. */ +export function parentInstancePath(path: string): string { + const i = path.lastIndexOf('.') + if (i === -1) return '' + return '/' + path.slice(0, i).split('.').join('/') +} + +export function propertyNameFromPath(path: string): string { + if (!path) return '' + const i = path.lastIndexOf('.') + return i === -1 ? path : path.slice(i + 1) +} + +function matchesRequiredError( + error: NonNullable[number], + path: string, +): boolean { + if (error.keyword !== 'required') 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. + */ +export function isPathRequiredBySchema( + path: string, + rootData: unknown, + validate: SchemaValidateFunction | undefined, +): boolean { + if (!path || !validate) return false + const without = cloneDataWithoutPath(rootData, path) + validate(without) + const errors = validate.errors + if (!errors?.length) return false + return errors.some((e) => matchesRequiredError(e, path)) +} From 85178c158f25454db7369c585bdb1a0032313f53 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 14 Oct 2026 22:29:00 -0700 Subject: [PATCH 4/7] style(jsonforms-renderers): apply Prettier to required asterisk changes Co-authored-by: Cursor --- .../src/renderers/NumberControl.tsx | 3 +-- .../src/utils/required.test.ts | 15 +++------------ .../jsonforms-renderers/src/utils/required.ts | 17 ++++++----------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx index 27f624a..ec493c6 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -33,8 +33,7 @@ export const NumberControl = ({ const minimum = typeof schema.minimum === 'number' ? schema.minimum : undefined const minBound = exclusiveMinimum ?? minimum const blockSignedKeys = minBound !== undefined && minBound >= 0 - const step = - typeof schema.multipleOf === 'number' ? schema.multipleOf : schema.type === 'integer' ? 1 : undefined + const step = typeof schema.multipleOf === 'number' ? schema.multipleOf : schema.type === 'integer' ? 1 : undefined const handleNumberChange = (value: string) => { if (value === '') { diff --git a/packages/jsonforms-renderers/src/utils/required.test.ts b/packages/jsonforms-renderers/src/utils/required.test.ts index e83f45e..94c6142 100644 --- a/packages/jsonforms-renderers/src/utils/required.test.ts +++ b/packages/jsonforms-renderers/src/utils/required.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest' import { createAjv } from '@jsonforms/core' -import { - cloneDataWithoutPath, - isPathRequiredBySchema, - parentInstancePath, - propertyNameFromPath, -} from './required' +import { cloneDataWithoutPath, isPathRequiredBySchema, parentInstancePath, propertyNameFromPath } from './required' describe('propertyNameFromPath / parentInstancePath', () => { it('splits a dotted path', () => { @@ -80,17 +75,13 @@ describe('isPathRequiredBySchema', () => { it('still reports required when the field is already filled', () => { // Asterisk must remain while the condition holds, not only when empty. - expect( - isPathRequiredBySchema('assessedLevy', { reviewOutcome: 'approve', assessedLevy: 99 }, validate), - ).toBe(true) + expect(isPathRequiredBySchema('assessedLevy', { reviewOutcome: 'approve', assessedLevy: 99 }, validate)).toBe(true) }) it('matches nested required under a parent instancePath', () => { const data = { reviewOutcome: 'approve', address: { city: 'Colombo' } } expect(isPathRequiredBySchema('address.city', data, validate)).toBe(true) - expect(isPathRequiredBySchema('address.city', { reviewOutcome: 'reject', address: {} }, validate)).toBe( - false, - ) + expect(isPathRequiredBySchema('address.city', { reviewOutcome: 'reject', address: {} }, validate)).toBe(false) }) it('returns false without a validator', () => { diff --git a/packages/jsonforms-renderers/src/utils/required.ts b/packages/jsonforms-renderers/src/utils/required.ts index f2af676..db28c01 100644 --- a/packages/jsonforms-renderers/src/utils/required.ts +++ b/packages/jsonforms-renderers/src/utils/required.ts @@ -1,12 +1,10 @@ /** Minimal AJV validate function shape (avoids a hard ajv dependency). */ export type SchemaValidateFunction = ((data: unknown) => boolean) & { - errors?: - | Array<{ - keyword: string - instancePath?: string - params?: { missingProperty?: string } - }> - | null + errors?: Array<{ + keyword: string + instancePath?: string + params?: { missingProperty?: string } + }> | null } /** @@ -48,10 +46,7 @@ export function propertyNameFromPath(path: string): string { return i === -1 ? path : path.slice(i + 1) } -function matchesRequiredError( - error: NonNullable[number], - path: string, -): boolean { +function matchesRequiredError(error: NonNullable[number], path: string): boolean { if (error.keyword !== 'required') return false if (error.params?.missingProperty !== propertyNameFromPath(path)) return false return (error.instancePath ?? '') === parentInstancePath(path) From c067b5f39873af66f645d6e5d9ed88a8eafbeb6f Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 14 Oct 2026 22:50:22 -0700 Subject: [PATCH 5/7] fix(jsonforms): share useIsRequired across controls and skip AJV when possible Wire the AJV-backed asterisk into Boolean, Date, SearchSelect, File, Xml and Spreadsheet, and avoid per-keystroke clone/validate when the control is hidden or the root schema has no conditional required constructs. Co-authored-by: Cursor --- .../src/hooks/useIsRequired.ts | 12 ++++++--- .../src/renderers/BooleanControl.tsx | 4 ++- .../src/renderers/DateControl.tsx | 4 ++- .../src/renderers/FileControl.tsx | 4 ++- .../src/renderers/NumberControl.tsx | 2 +- .../src/renderers/RadioControl.tsx | 2 +- .../src/renderers/SearchSelectControl.tsx | 4 ++- .../src/renderers/SelectControl.tsx | 2 +- .../src/renderers/SpreadsheetControl.tsx | 4 ++- .../src/renderers/TextControl.tsx | 2 +- .../src/renderers/XmlControl.tsx | 4 ++- .../src/utils/required.test.ts | 26 ++++++++++++++++++- .../jsonforms-renderers/src/utils/required.ts | 14 ++++++++++ 13 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/jsonforms-renderers/src/hooks/useIsRequired.ts b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts index a4036ce..9968c1b 100644 --- a/packages/jsonforms-renderers/src/hooks/useIsRequired.ts +++ b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts @@ -1,20 +1,26 @@ import { useMemo } from 'react' import { useJsonForms } from '@jsonforms/react' -import { isPathRequiredBySchema } from '../utils/required' +import { isPathRequiredBySchema, schemaHasConditionalRequired } from '../utils/required' /** * Whether the control at `path` is required under the current form data. * ORs JSONForms' static `required` prop with an AJV check that also sees * conditional schema rules (`allOf`/`if`/`then`, etc.) that core's * `isRequired()` ignores. + * + * Skips the AJV path when the control is hidden, already statically required, + * or the root schema has no conditional-required constructs. */ -export function useIsRequired(path: string, staticRequired = false): boolean { +export function useIsRequired(path: string, staticRequired = false, visible = true): boolean { const ctx = useJsonForms() const rootData = ctx.core?.data const validator = ctx.core?.validator + const rootSchema = ctx.core?.schema return useMemo(() => { + if (!visible) return false if (staticRequired) return true + if (!schemaHasConditionalRequired(rootSchema)) return false return isPathRequiredBySchema(path, rootData, validator) - }, [staticRequired, path, rootData, validator]) + }, [visible, staticRequired, path, rootData, validator, rootSchema]) } diff --git a/packages/jsonforms-renderers/src/renderers/BooleanControl.tsx b/packages/jsonforms-renderers/src/renderers/BooleanControl.tsx index 6744a3f..a0e128a 100644 --- a/packages/jsonforms-renderers/src/renderers/BooleanControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/BooleanControl.tsx @@ -2,6 +2,7 @@ import { type ControlProps, isBooleanControl, type RankedTester, rankWith } from import { withJsonFormsControlProps } from '@jsonforms/react' import { Checkbox, Text, Flex, Box } from '@radix-ui/themes' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { getErrorMessage } from '../utils/error' @@ -17,6 +18,7 @@ export const BooleanControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null @@ -34,7 +36,7 @@ export const BooleanControl = ({ id={path} /> - {label} {required && '*'} + {label} {showRequired && '*'} {!isValid && ( diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.tsx index c685571..3265017 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.tsx @@ -12,6 +12,7 @@ import { TextField, Text, Flex, Box } from '@radix-ui/themes' import { useEffect, type ReactNode } from 'react' import dayjs from 'dayjs' +import { useIsRequired } from '../hooks/useIsRequired' import { getErrorMessage } from '../utils/error' import { timeInputValue, toRfc3339Time } from '../utils/time' @@ -65,6 +66,7 @@ export const DateControl = ({ handleChange(path, undefined) } }, [visible, path, handleChange]) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null @@ -77,7 +79,7 @@ export const DateControl = ({ const timeStep = showSeconds ? 1 : 60 const shell = (children: ReactNode) => ( - + {children} ) diff --git a/packages/jsonforms-renderers/src/renderers/FileControl.tsx b/packages/jsonforms-renderers/src/renderers/FileControl.tsx index c96ac5f..09f3d4e 100644 --- a/packages/jsonforms-renderers/src/renderers/FileControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/FileControl.tsx @@ -14,6 +14,7 @@ import { useUpload } from '../contexts/UploadContext' import { getErrorMessage } from '../utils/error' import { formatBytes, formatAccept } from '../utils/format' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { downloadFromUrl, downloadName } from '../utils/download' import { isBrowserViewable, matchesAccept } from '../utils/file' import * as React from 'react' @@ -76,6 +77,7 @@ const FileControl = ({ // shown field stuck on "must be string". undefined restores the pristine // "nothing selected" state instead. useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) const isValid = !errors || errors.length === 0 @@ -277,7 +279,7 @@ const FileControl = ({ {label} - {required && *} + {showRequired && *} {isMulti diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx index ec493c6..f540571 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -21,7 +21,7 @@ export const NumberControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) - const showRequired = useIsRequired(path, required) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null diff --git a/packages/jsonforms-renderers/src/renderers/RadioControl.tsx b/packages/jsonforms-renderers/src/renderers/RadioControl.tsx index f710dd7..c7b5530 100644 --- a/packages/jsonforms-renderers/src/renderers/RadioControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/RadioControl.tsx @@ -18,7 +18,7 @@ export const RadioControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) - const showRequired = useIsRequired(path, required) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 389cbc6..3fa1140 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -5,6 +5,7 @@ import { ChevronDownIcon } from '@radix-ui/react-icons' import { useState, useRef, useEffect, useCallback, useMemo, type KeyboardEvent } from 'react' import { useSearchService, type SearchOption } from '../contexts/SearchServiceContext' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { getErrorMessage } from '../utils/error' import { renderTemplate } from '../utils/template' import * as React from 'react' @@ -349,6 +350,7 @@ const SearchSelectControl = ({ // satisfy, so a hidden-then-shown field would be stuck on "must be string". // undefined restores the pristine "nothing selected" state in either mode. useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null @@ -390,7 +392,7 @@ const SearchSelectControl = ({ - {label} {required && *} + {label} {showRequired && *}
diff --git a/packages/jsonforms-renderers/src/renderers/SelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SelectControl.tsx index a30f0a5..d541a98 100644 --- a/packages/jsonforms-renderers/src/renderers/SelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SelectControl.tsx @@ -21,7 +21,7 @@ export const SelectControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) - const showRequired = useIsRequired(path, required) + const showRequired = useIsRequired(path, required, visible) // `options.autocomplete: true` swaps the plain dropdown for a type-to-filter combobox over the // same enum/oneOf options — no remote service involved (see SearchSelectControl for that case). diff --git a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx index d010ec3..fc31889 100644 --- a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx @@ -4,6 +4,7 @@ import { Box, Flex, IconButton, Spinner, Table, Text, Tooltip } from '@radix-ui/ import { UploadIcon, Cross2Icon, ExclamationTriangleIcon } from '@radix-ui/react-icons' import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from 'react' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { isEditable } from '../utils/editable' import { getErrorMessage } from '../utils/error' import { formatBytes, formatAccept } from '../utils/format' @@ -115,6 +116,7 @@ const SpreadsheetControl = ({ // which is exactly the state a never-touched field is already in: valid when // the field is optional, and a plain "is required" error when it isn't. useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) const isValid = !errors || errors.length === 0 // `enabled` and `readonly` are independently computed by @jsonforms/core @@ -605,7 +607,7 @@ const SpreadsheetControl = ({ {label} - {required && *} + {showRequired && *} {hasValue && canEdit && ( <> diff --git a/packages/jsonforms-renderers/src/renderers/TextControl.tsx b/packages/jsonforms-renderers/src/renderers/TextControl.tsx index f1b63a8..24ccda5 100644 --- a/packages/jsonforms-renderers/src/renderers/TextControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/TextControl.tsx @@ -19,7 +19,7 @@ export const TextControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) - const showRequired = useIsRequired(path, required) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null diff --git a/packages/jsonforms-renderers/src/renderers/XmlControl.tsx b/packages/jsonforms-renderers/src/renderers/XmlControl.tsx index 21f9daf..c3a014c 100644 --- a/packages/jsonforms-renderers/src/renderers/XmlControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/XmlControl.tsx @@ -5,6 +5,7 @@ import { Box, Button, Flex, IconButton, Spinner, Text, Tooltip } from '@radix-ui import { UploadIcon, Cross2Icon, ExclamationTriangleIcon } from '@radix-ui/react-icons' import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from 'react' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { useIsRequired } from '../hooks/useIsRequired' import { isEditable } from '../utils/editable' import { getErrorMessage } from '../utils/error' import { formatBytes, formatAccept } from '../utils/format' @@ -88,6 +89,7 @@ const XmlControl = ({ // recover short of uploading again. `undefined` means "no file uploaded", // which is exactly the state a never-touched field is already in. useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) const isValid = !errors || errors.length === 0 // `enabled` and `readonly` are computed independently by @jsonforms/core, and @@ -247,7 +249,7 @@ const XmlControl = ({ {label} - {required && *} + {showRequired && *} {!isImporter && hasValue && canEdit && ( <> diff --git a/packages/jsonforms-renderers/src/utils/required.test.ts b/packages/jsonforms-renderers/src/utils/required.test.ts index 94c6142..a6c097b 100644 --- a/packages/jsonforms-renderers/src/utils/required.test.ts +++ b/packages/jsonforms-renderers/src/utils/required.test.ts @@ -1,6 +1,30 @@ import { describe, expect, it } from 'vitest' import { createAjv } from '@jsonforms/core' -import { cloneDataWithoutPath, isPathRequiredBySchema, parentInstancePath, propertyNameFromPath } from './required' +import { + cloneDataWithoutPath, + isPathRequiredBySchema, + parentInstancePath, + propertyNameFromPath, + schemaHasConditionalRequired, +} from './required' + +describe('schemaHasConditionalRequired', () => { + it('is false for plain required / properties schemas', () => { + expect( + schemaHasConditionalRequired({ type: 'object', required: ['a'], properties: { a: { type: 'string' } } }), + ).toBe(false) + expect(schemaHasConditionalRequired(undefined)).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({ dependentRequired: { a: ['b'] } })).toBe(true) + expect(schemaHasConditionalRequired({ dependencies: { a: ['b'] } })).toBe(true) + }) +}) describe('propertyNameFromPath / parentInstancePath', () => { it('splits a dotted path', () => { diff --git a/packages/jsonforms-renderers/src/utils/required.ts b/packages/jsonforms-renderers/src/utils/required.ts index db28c01..7a3a7b8 100644 --- a/packages/jsonforms-renderers/src/utils/required.ts +++ b/packages/jsonforms-renderers/src/utils/required.ts @@ -7,6 +7,20 @@ 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 + +/** + * True when the root 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. + */ +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) +} + /** * Deep-clone form data and remove the value at `path` (dot-separated, as * JSONForms uses). Used to ask AJV whether that property is currently required. From b1eb25be507e7f90c7792371b484b0a7b935c299 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 15 Oct 2026 09:29:34 -0700 Subject: [PATCH 6/7] fix(jsonforms): harden NumberControl key/paste tests and document root-only scan Assert preventDefault via fireEvent return values (including paste), and note that schemaHasConditionalRequired only inspects the root schema's own keys. Co-authored-by: Cursor --- .../src/renderers/NumberControl.test.tsx | 30 ++++++++++--------- .../jsonforms-renderers/src/utils/required.ts | 4 +++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx index 98df107..9f02667 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx @@ -24,9 +24,9 @@ const schema = { { const: 'reject', title: 'Reject' }, ], }, - assessedLevy: { + assessedAmount: { type: 'number', - title: 'Assessed SLTB Levy (LKR)', + title: 'Assessed Amount', exclusiveMinimum: 0, multipleOf: 0.01, }, @@ -37,7 +37,7 @@ const schema = { properties: { reviewOutcome: { const: 'approve' } }, required: ['reviewOutcome'], }, - then: { required: ['assessedLevy'] }, + then: { required: ['assessedAmount'] }, }, ], } as JsonSchema @@ -48,7 +48,7 @@ const uischema = { { type: 'Control', scope: '#/properties/reviewOutcome', options: { format: 'radio' } }, { type: 'Control', - scope: '#/properties/assessedLevy', + scope: '#/properties/assessedAmount', rule: { effect: 'SHOW', condition: { @@ -79,27 +79,29 @@ function renderForm(seed: Data) { } describe('NumberControl required asterisk + constraints', () => { - it('shows a red asterisk only when the levy is conditionally required', () => { + it('shows a red asterisk only when the amount is conditionally required', () => { renderForm({ reviewOutcome: 'reject' }) - expect(screen.queryByText('Assessed SLTB Levy (LKR)')).toBeNull() + expect(screen.queryByText('Assessed Amount')).toBeNull() cleanup() renderForm({ reviewOutcome: 'approve' }) - const label = screen.getByText('Assessed SLTB Levy (LKR)') + const label = screen.getByText('Assessed Amount') expect(label.textContent).toContain('*') }) - it('sets min from exclusiveMinimum and blocks signed / exponent keys', () => { + it('sets min from exclusiveMinimum and blocks signed / exponent keys and pastes', () => { renderForm({ reviewOutcome: 'approve' }) - const input = screen.getByLabelText(/Assessed SLTB Levy/) as HTMLInputElement + const input = screen.getByLabelText(/Assessed Amount/) as HTMLInputElement expect(input).toHaveProperty('type', 'number') expect(input.min).toBe('0') expect(input.step).toBe('0.01') - fireEvent.keyDown(input, { key: '-' }) - fireEvent.keyDown(input, { key: '+' }) - fireEvent.keyDown(input, { key: 'e' }) - // Value stays empty; blocked keys never reach onChange with a signed draft. - expect(input.value).toBe('') + // jsdom does not type on keydown; fireEvent returns false when preventDefault ran. + expect(fireEvent.keyDown(input, { key: '-' })).toBe(false) + expect(fireEvent.keyDown(input, { key: '+' })).toBe(false) + expect(fireEvent.keyDown(input, { key: 'e' })).toBe(false) + expect(fireEvent.keyDown(input, { key: '5' })).toBe(true) + expect(fireEvent.paste(input, { clipboardData: { getData: () => '-5' } })).toBe(false) + expect(fireEvent.paste(input, { clipboardData: { getData: () => '5.25' } })).toBe(true) }) }) diff --git a/packages/jsonforms-renderers/src/utils/required.ts b/packages/jsonforms-renderers/src/utils/required.ts index 7a3a7b8..970097d 100644 --- a/packages/jsonforms-renderers/src/utils/required.ts +++ b/packages/jsonforms-renderers/src/utils/required.ts @@ -14,6 +14,10 @@ const CONDITIONAL_SCHEMA_KEYS = ['if', 'allOf', 'anyOf', 'oneOf', 'dependentRequ * True when the root 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. */ export function schemaHasConditionalRequired(schema: unknown): boolean { if (!schema || typeof schema !== 'object') return false From 075b98ea4ecabe7210ddb9f785888dd501811ec6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 15 Oct 2026 12:44:33 -0700 Subject: [PATCH 7/7] fix(jsonforms-renderers): keep #112 to conditional required asterisk only Move NumberControl min/step/key/paste and exclusiveMinimum error wording out of this PR, and add a required-conditional playground fixture. Co-authored-by: Cursor --- packages/jsonforms-renderers/dev/fixtures.ts | 84 +++++++++++++++++++ .../src/renderers/NumberControl.test.tsx | 22 +---- .../src/renderers/NumberControl.tsx | 27 ------ .../src/utils/error.test.ts | 18 ---- .../jsonforms-renderers/src/utils/error.ts | 12 +-- 5 files changed, 87 insertions(+), 76 deletions(-) delete mode 100644 packages/jsonforms-renderers/src/utils/error.test.ts diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 3d526fb..ddea32b 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -48,6 +48,90 @@ export const fixtures: Fixture[] = [ ], } as UISchemaElement, }, + { + id: 'required-conditional', + name: 'Required (conditional)', + schema: { + type: 'object', + properties: { + reviewOutcome: { + type: 'string', + title: 'Review Outcome', + oneOf: [ + { const: 'approve', title: 'Approve' }, + { const: 'reject', title: 'Reject' }, + ], + }, + notes: { type: 'string', title: 'Notes' }, + assessedAmount: { type: 'number', title: 'Assessed Amount' }, + category: { + type: 'string', + title: 'Category', + enum: ['A', 'B', 'C'], + }, + effectiveDate: { type: 'string', title: 'Effective Date', format: 'date' }, + confirmed: { type: 'boolean', title: 'Confirmed' }, + }, + allOf: [ + { + if: { + properties: { reviewOutcome: { const: 'approve' } }, + required: ['reviewOutcome'], + }, + then: { + required: ['notes', 'assessedAmount', 'category', 'effectiveDate', 'confirmed'], + }, + }, + ], + } as JsonSchema, + uischema: { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/reviewOutcome', options: { format: 'radio' } }, + { + type: 'Control', + scope: '#/properties/notes', + rule: { + effect: 'SHOW', + condition: { scope: '#/properties/reviewOutcome', schema: { const: 'approve' } }, + }, + }, + { + type: 'Control', + scope: '#/properties/assessedAmount', + rule: { + effect: 'SHOW', + condition: { scope: '#/properties/reviewOutcome', schema: { const: 'approve' } }, + }, + }, + { + type: 'Control', + scope: '#/properties/category', + rule: { + effect: 'SHOW', + condition: { scope: '#/properties/reviewOutcome', schema: { const: 'approve' } }, + }, + }, + { + type: 'Control', + scope: '#/properties/effectiveDate', + rule: { + effect: 'SHOW', + condition: { scope: '#/properties/reviewOutcome', schema: { const: 'approve' } }, + }, + }, + { + type: 'Control', + scope: '#/properties/confirmed', + rule: { + effect: 'SHOW', + condition: { scope: '#/properties/reviewOutcome', schema: { const: 'approve' } }, + }, + }, + ], + } as UISchemaElement, + data: { reviewOutcome: 'approve' }, + }, { id: 'boolean', name: 'Boolean', diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx index 9f02667..ca1b4ba 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' import { useState } from 'react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { JsonForms } from '@jsonforms/react' import { Theme } from '@radix-ui/themes' import type { JsonSchema, UISchemaElement } from '@jsonforms/core' @@ -27,8 +27,6 @@ const schema = { assessedAmount: { type: 'number', title: 'Assessed Amount', - exclusiveMinimum: 0, - multipleOf: 0.01, }, }, allOf: [ @@ -78,7 +76,7 @@ function renderForm(seed: Data) { return render() } -describe('NumberControl required asterisk + constraints', () => { +describe('NumberControl required asterisk', () => { it('shows a red asterisk only when the amount is conditionally required', () => { renderForm({ reviewOutcome: 'reject' }) expect(screen.queryByText('Assessed Amount')).toBeNull() @@ -88,20 +86,4 @@ describe('NumberControl required asterisk + constraints', () => { const label = screen.getByText('Assessed Amount') expect(label.textContent).toContain('*') }) - - it('sets min from exclusiveMinimum and blocks signed / exponent keys and pastes', () => { - renderForm({ reviewOutcome: 'approve' }) - const input = screen.getByLabelText(/Assessed Amount/) as HTMLInputElement - expect(input).toHaveProperty('type', 'number') - expect(input.min).toBe('0') - expect(input.step).toBe('0.01') - - // jsdom does not type on keydown; fireEvent returns false when preventDefault ran. - expect(fireEvent.keyDown(input, { key: '-' })).toBe(false) - expect(fireEvent.keyDown(input, { key: '+' })).toBe(false) - expect(fireEvent.keyDown(input, { key: 'e' })).toBe(false) - expect(fireEvent.keyDown(input, { key: '5' })).toBe(true) - expect(fireEvent.paste(input, { clipboardData: { getData: () => '-5' } })).toBe(false) - expect(fireEvent.paste(input, { clipboardData: { getData: () => '5.25' } })).toBe(true) - }) }) diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx index f540571..0a43e54 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -1,13 +1,10 @@ import { type ControlProps, isNumberControl, type RankedTester, rankWith, isIntegerControl, or } from '@jsonforms/core' import { withJsonFormsControlProps } from '@jsonforms/react' import { TextField, Text, Flex, Box } from '@radix-ui/themes' -import type { ClipboardEvent, KeyboardEvent } from 'react' import { getErrorMessage } from '../utils/error' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' import { useIsRequired } from '../hooks/useIsRequired' -const BLOCKED_NUMBER_KEYS = new Set(['-', '+', 'e', 'E']) - export const NumberControl = ({ data, handleChange, @@ -29,12 +26,6 @@ export const NumberControl = ({ const isValid = errors.length === 0 - const exclusiveMinimum = typeof schema.exclusiveMinimum === 'number' ? schema.exclusiveMinimum : undefined - const minimum = typeof schema.minimum === 'number' ? schema.minimum : undefined - const minBound = exclusiveMinimum ?? minimum - const blockSignedKeys = minBound !== undefined && minBound >= 0 - const step = typeof schema.multipleOf === 'number' ? schema.multipleOf : schema.type === 'integer' ? 1 : undefined - const handleNumberChange = (value: string) => { if (value === '') { handleChange(path, undefined) @@ -46,20 +37,6 @@ export const NumberControl = ({ } } - const onKeyDown = (e: KeyboardEvent) => { - if (blockSignedKeys && BLOCKED_NUMBER_KEYS.has(e.key)) { - e.preventDefault() - } - } - - const onPaste = (e: ClipboardEvent) => { - if (!blockSignedKeys) return - const text = e.clipboardData.getData('text') - if (/[-+eE]/.test(text)) { - e.preventDefault() - } - } - return ( @@ -70,10 +47,6 @@ export const NumberControl = ({ type="number" value={data ?? ''} onChange={(e) => handleNumberChange(e.target.value)} - onKeyDown={onKeyDown} - onPaste={onPaste} - min={minBound} - step={step} disabled={!enabled} placeholder={uischema.options?.placeholder} color={!isValid ? 'red' : undefined} diff --git a/packages/jsonforms-renderers/src/utils/error.test.ts b/packages/jsonforms-renderers/src/utils/error.test.ts deleted file mode 100644 index 65f098b..0000000 --- a/packages/jsonforms-renderers/src/utils/error.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getErrorMessage } from './error' - -describe('getErrorMessage', () => { - it('rewrites the required property message', () => { - expect(getErrorMessage('is a required property', 'Levy')).toBe('Levy is required') - }) - - it('rewrites exclusiveMinimum / minimum / multipleOf messages', () => { - expect(getErrorMessage('must be > 0', 'Levy')).toBe('Levy must be greater than 0') - expect(getErrorMessage('must be >= 1', 'Qty')).toBe('Qty must be at least 1') - expect(getErrorMessage('must be multiple of 0.01', 'Levy')).toBe('Levy must be a multiple of 0.01') - }) - - it('passes through unknown messages', () => { - expect(getErrorMessage('must be string', 'Name')).toBe('must be string') - }) -}) diff --git a/packages/jsonforms-renderers/src/utils/error.ts b/packages/jsonforms-renderers/src/utils/error.ts index dad0528..adb0d15 100644 --- a/packages/jsonforms-renderers/src/utils/error.ts +++ b/packages/jsonforms-renderers/src/utils/error.ts @@ -1,21 +1,11 @@ export const getErrorMessage = (errors: string, label?: string): string => { if (!errors) return '' - const field = label || 'This field' return errors .split('\n') .map((err) => { const trimmed = err.trim() if (trimmed === 'is a required property') { - return `${field} is required` - } - if (trimmed.startsWith('must be > ')) { - return `${field} must be greater than ${trimmed.slice('must be > '.length)}` - } - if (trimmed.startsWith('must be >= ')) { - return `${field} must be at least ${trimmed.slice('must be >= '.length)}` - } - if (trimmed.startsWith('must be multiple of ')) { - return `${field} must be a multiple of ${trimmed.slice('must be multiple of '.length)}` + return `${label || 'This field'} is required` } return err })