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/hooks/useIsRequired.ts b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts new file mode 100644 index 0000000..9968c1b --- /dev/null +++ b/packages/jsonforms-renderers/src/hooks/useIsRequired.ts @@ -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 + 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) + }, [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.test.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx new file mode 100644 index 0000000..ca1b4ba --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.test.tsx @@ -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 + +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 ( + + setData(next as Data)} + /> + + ) + } + return render() +} + +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('*') + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx index e2e28bb..0a43e54 100644 --- a/packages/jsonforms-renderers/src/renderers/NumberControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/NumberControl.tsx @@ -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, @@ -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 @@ -39,7 +41,7 @@ export const NumberControl = ({ - {label} {required && *} + {label} {showRequired && *} { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null @@ -40,7 +42,7 @@ export const RadioControl = ({ - {label} {required && *} + {label} {showRequired && *} - {label} {required && *} + {label} {showRequired && *}
diff --git a/packages/jsonforms-renderers/src/renderers/SelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SelectControl.tsx index 4e38f08..d541a98 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, 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). @@ -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/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 b7876b2..24ccda5 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,6 +19,7 @@ export const TextControl = ({ visible = true, }: ControlProps) => { useClearWhenHidden(visible, path, handleChange) + const showRequired = useIsRequired(path, required, visible) if (visible === false) { return null @@ -29,7 +31,7 @@ export const TextControl = ({ - {label} {required && *} + {label} {showRequired && *} {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 new file mode 100644 index 0000000..a6c097b --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/required.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { createAjv } from '@jsonforms/core' +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', () => { + 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..970097d --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/required.ts @@ -0,0 +1,90 @@ +/** 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 +} + +/** 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. + * + * 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 + 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. + */ +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)) +}