Skip to content
84 changes: 84 additions & 0 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions packages/jsonforms-renderers/src/hooks/useIsRequired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useMemo } from 'react'
import { useJsonForms } from '@jsonforms/react'
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, 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

return useMemo(() => {
Comment thread
ginaxu1 marked this conversation as resolved.
if (!visible) return false
if (staticRequired) return true
if (!schemaHasConditionalRequired(rootSchema)) return false
Comment thread
ginaxu1 marked this conversation as resolved.
return isPathRequiredBySchema(path, rootData, validator)
}, [visible, staticRequired, path, rootData, validator, rootSchema])
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -34,7 +36,7 @@ export const BooleanControl = ({
id={path}
/>
<Text as="label" size="2" htmlFor={path}>
{label} {required && '*'}
{label} {showRequired && '*'}
</Text>
</Flex>
{!isValid && (
Expand Down
4 changes: 3 additions & 1 deletion packages/jsonforms-renderers/src/renderers/DateControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -65,6 +66,7 @@ export const DateControl = ({
handleChange(path, undefined)
}
}, [visible, path, handleChange])
const showRequired = useIsRequired(path, required, visible)

if (visible === false) {
return null
Expand All @@ -77,7 +79,7 @@ export const DateControl = ({
const timeStep = showSeconds ? 1 : 60

const shell = (children: ReactNode) => (
<FieldShell path={path} label={label} required={required} errors={errors} description={schema.description}>
<FieldShell path={path} label={label} required={showRequired} errors={errors} description={schema.description}>
{children}
</FieldShell>
)
Expand Down
4 changes: 3 additions & 1 deletion packages/jsonforms-renderers/src/renderers/FileControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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'
Expand Down Expand Up @@ -57,7 +58,7 @@
return Array.isArray(data) ? data : [data]
}

const FileControl = ({

Check failure on line 61 in packages/jsonforms-renderers/src/renderers/FileControl.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

Fast refresh only works when a file only exports components. Move your component(s) to a separate file
data,
handleChange,
path,
Expand All @@ -76,6 +77,7 @@
// 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

Expand Down Expand Up @@ -277,7 +279,7 @@
<Flex align="center" justify="between" mb="2">
<Text as="label" size="2" weight="bold">
{label}
{required && <Text color="red"> *</Text>}
{showRequired && <Text color="red"> *</Text>}
</Text>
<Text size="1" color="gray">
{isMulti
Expand Down
89 changes: 89 additions & 0 deletions packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { useState } from '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'
import { radixRenderers } from './index'

type Data = Record<string, unknown>

afterEach(() => {
cleanup()
})

const schema = {
type: 'object',
properties: {
reviewOutcome: {
type: 'string',
title: 'Review Outcome',
oneOf: [
{ const: 'approve', title: 'Approve' },
{ const: 'reject', title: 'Reject' },
],
},
assessedAmount: {
type: 'number',
title: 'Assessed Amount',
},
},
allOf: [
{
if: {
properties: { reviewOutcome: { const: 'approve' } },
required: ['reviewOutcome'],
},
then: { required: ['assessedAmount'] },
},
],
} as JsonSchema

const uischema = {
type: 'VerticalLayout',
elements: [
{ type: 'Control', scope: '#/properties/reviewOutcome', options: { format: 'radio' } },
{
type: 'Control',
scope: '#/properties/assessedAmount',
rule: {
effect: 'SHOW',
condition: {
scope: '#/properties/reviewOutcome',
schema: { const: 'approve' },
},
},
},
],
} as UISchemaElement

function renderForm(seed: Data) {
function Harness() {
const [data, setData] = useState(seed)
return (
<Theme>
<JsonForms
schema={schema}
uischema={uischema}
data={data}
renderers={radixRenderers}
onChange={({ data: next }) => setData(next as Data)}
/>
</Theme>
)
}
return render(<Harness />)
}

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

cleanup()
renderForm({ reviewOutcome: 'approve' })
const label = screen.getByText('Assessed Amount')
expect(label.textContent).toContain('*')
})
})
4 changes: 3 additions & 1 deletion packages/jsonforms-renderers/src/renderers/NumberControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { withJsonFormsControlProps } from '@jsonforms/react'
import { TextField, Text, Flex, Box } from '@radix-ui/themes'
import { getErrorMessage } from '../utils/error'
import { useClearWhenHidden } from '../hooks/useClearWhenHidden'
import { useIsRequired } from '../hooks/useIsRequired'

export const NumberControl = ({
data,
Expand All @@ -17,6 +18,7 @@ export const NumberControl = ({
visible = true,
}: ControlProps) => {
useClearWhenHidden(visible, path, handleChange)
const showRequired = useIsRequired(path, required, visible)

if (visible === false) {
return null
Expand All @@ -39,7 +41,7 @@ export const NumberControl = ({
<Box mb="4">
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="bold" htmlFor={path}>
{label} {required && <Text color="red">*</Text>}
{label} {showRequired && <Text color="red">*</Text>}
</Text>
<TextField.Root
type="number"
Expand Down
4 changes: 3 additions & 1 deletion packages/jsonforms-renderers/src/renderers/RadioControl.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +18,7 @@ export const RadioControl = ({
visible = true,
}: ControlProps) => {
useClearWhenHidden(visible, path, handleChange)
const showRequired = useIsRequired(path, required, visible)

if (visible === false) {
return null
Expand All @@ -40,7 +42,7 @@ export const RadioControl = ({
<Box mb="4">
<Flex direction="column" gap="1">
<Text as="div" size="2" weight="bold">
{label} {required && <Text color="red">*</Text>}
{label} {showRequired && <Text color="red">*</Text>}
</Text>

<RadioGroup.Root
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -390,7 +392,7 @@ const SearchSelectControl = ({
<Box mb="4">
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="bold" htmlFor={path}>
{label} {required && <Text color="red">*</Text>}
{label} {showRequired && <Text color="red">*</Text>}
</Text>

<div ref={containerRef} style={{ position: 'relative' }}>
Expand Down
6 changes: 4 additions & 2 deletions packages/jsonforms-renderers/src/renderers/SelectControl.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,6 +21,7 @@ export const SelectControl = ({
visible = true,
}: ControlProps) => {
useClearWhenHidden(visible, path, handleChange)
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).
Expand Down Expand Up @@ -89,7 +91,7 @@ export const SelectControl = ({
<Box mb="4">
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="bold" htmlFor={path}>
{label} {required && <Text color="red">*</Text>}
{label} {showRequired && <Text color="red">*</Text>}
</Text>

<div ref={containerRef} style={{ position: 'relative' }}>
Expand Down Expand Up @@ -227,7 +229,7 @@ export const SelectControl = ({
<Box mb="4">
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="bold" htmlFor={path}>
{label} {required && <Text color="red">*</Text>}
{label} {showRequired && <Text color="red">*</Text>}
</Text>
<Select.Root value={value} onValueChange={(val) => handleChange(path, val)} disabled={!enabled}>
<Select.Trigger placeholder={placeholder} color={!isValid ? 'red' : undefined} id={path} />
Expand Down
Loading
Loading