diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index ddea32b..7cbeef2 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -38,6 +38,13 @@ export const fixtures: Fixture[] = [ properties: { price: { type: 'number', description: 'Decimal value' }, quantity: { type: 'integer', minimum: 0, description: 'Whole number ≥ 0' }, + assessedAmount: { + type: 'number', + title: 'Assessed Amount', + exclusiveMinimum: 0, + multipleOf: 0.01, + description: 'Positive amount; exclusiveMinimum 0 and multipleOf 0.01', + }, }, }, uischema: { @@ -45,6 +52,7 @@ export const fixtures: Fixture[] = [ elements: [ { type: 'Control', scope: '#/properties/price' }, { type: 'Control', scope: '#/properties/quantity' }, + { type: 'Control', scope: '#/properties/assessedAmount' }, ], } as UISchemaElement, }, diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx index ca1b4ba..70c3d00 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, render, screen } from '@testing-library/react' +import { act, 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' @@ -9,7 +9,11 @@ import { radixRenderers } from './index' type Data = Record -afterEach(() => { +afterEach(async () => { + // JsonForms debounces onChange by 10ms. Flush it before jsdom is torn down. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) cleanup() }) @@ -27,6 +31,8 @@ const schema = { assessedAmount: { type: 'number', title: 'Assessed Amount', + exclusiveMinimum: 0, + multipleOf: 0.01, }, }, allOf: [ @@ -86,4 +92,20 @@ describe('NumberControl required asterisk', () => { 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 0a43e54..f540571 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -1,10 +1,13 @@ 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, @@ -26,6 +29,12 @@ 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) @@ -37,6 +46,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 ( @@ -47,6 +70,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/utils/error.test.ts b/packages/jsonforms-renderers/src/utils/error.test.ts new file mode 100644 index 0000000..6c94d88 --- /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', 'Amount')).toBe('Amount is required') + }) + + it('rewrites exclusiveMinimum / minimum / multipleOf messages', () => { + expect(getErrorMessage('must be > 0', 'Amount')).toBe('Amount 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', 'Amount')).toBe('Amount 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 })