- {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))
+}