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
8 changes: 8 additions & 0 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ 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: {
type: 'VerticalLayout',
elements: [
{ type: 'Control', scope: '#/properties/price' },
{ type: 'Control', scope: '#/properties/quantity' },
{ type: 'Control', scope: '#/properties/assessedAmount' },
],
} as UISchemaElement,
},
Expand Down
26 changes: 24 additions & 2 deletions packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
// @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'
import { radixRenderers } from './index'

type Data = Record<string, unknown>

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()
})

Expand All @@ -27,6 +31,8 @@ const schema = {
assessedAmount: {
type: 'number',
title: 'Assessed Amount',
exclusiveMinimum: 0,
multipleOf: 0.01,
},
},
allOf: [
Expand Down Expand Up @@ -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)
})
})
27 changes: 27 additions & 0 deletions packages/jsonforms-renderers/src/renderers/NumberControl.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand All @@ -37,6 +46,20 @@ export const NumberControl = ({
}
}

const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (blockSignedKeys && BLOCKED_NUMBER_KEYS.has(e.key)) {
e.preventDefault()
}
}

const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
if (!blockSignedKeys) return
const text = e.clipboardData.getData('text')
if (/[-+eE]/.test(text)) {
e.preventDefault()
}
}

return (
<Box mb="4">
<Flex direction="column" gap="1">
Expand All @@ -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}
Expand Down
18 changes: 18 additions & 0 deletions packages/jsonforms-renderers/src/utils/error.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
12 changes: 11 additions & 1 deletion packages/jsonforms-renderers/src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -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
})
Expand Down
Loading