From 19fa8467c8206f3a58ebd331b46eb3085a49c07f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 10:07:41 -0700 Subject: [PATCH 1/7] feat(jsonforms-renderers): honor x-notLaterThan on DateControl Move date-order validation into the date control (and an exported collectNotLaterThanErrors helper for host submit gating) so apps do not need to walk the whole form schema for this keyword. Co-authored-by: Cursor --- packages/jsonforms-renderers/README.md | 2 +- packages/jsonforms-renderers/src/index.ts | 1 + .../src/renderers/DateControl.tsx | 16 ++- .../src/utils/notLaterThan.test.ts | 96 ++++++++++++++++ .../src/utils/notLaterThan.ts | 103 ++++++++++++++++++ 5 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 packages/jsonforms-renderers/src/utils/notLaterThan.test.ts create mode 100644 packages/jsonforms-renderers/src/utils/notLaterThan.ts diff --git a/packages/jsonforms-renderers/README.md b/packages/jsonforms-renderers/README.md index 03df5a5..786d4ed 100644 --- a/packages/jsonforms-renderers/README.md +++ b/packages/jsonforms-renderers/README.md @@ -15,7 +15,7 @@ This package provides a collection of JSON Forms controls and layout renderers t ### Controls -- **[DateControl](src/renderers/DateControl.tsx)**: Formats `date`, `time`, and `date-time` inputs. Combines native inputs that split space equally in `date-time` combination mode and expand to `100%` container width otherwise. +- **[DateControl](src/renderers/DateControl.tsx)**: Formats `date`, `time`, and `date-time` inputs. Combines native inputs that split space equally in `date-time` combination mode and expand to `100%` container width otherwise. Honors optional `x-notLaterThan: ""` (inline error when this date is after the sibling). Hosts that gate submit can also use exported `collectNotLaterThanErrors`. - **[TextControl](src/renderers/TextControl.tsx)**: Handles standard string schemas with optional placeholders. - **[NumberControl](src/renderers/NumberControl.tsx)**: Handles numeric and integer inputs. - **[BooleanControl](src/renderers/BooleanControl.tsx)**: Toggle switch control for boolean values. diff --git a/packages/jsonforms-renderers/src/index.ts b/packages/jsonforms-renderers/src/index.ts index 64d5be1..f94f13a 100644 --- a/packages/jsonforms-renderers/src/index.ts +++ b/packages/jsonforms-renderers/src/index.ts @@ -3,3 +3,4 @@ import './index.css' export * from './contexts/UploadContext' export * from './contexts/SearchServiceContext' export * from './renderers' +export * from './utils/notLaterThan' diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.tsx index 4210cae..d5300e5 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.tsx @@ -7,12 +7,13 @@ import { type RankedTester, rankWith, } from '@jsonforms/core' -import { withJsonFormsControlProps } from '@jsonforms/react' +import { useJsonForms, withJsonFormsControlProps } from '@jsonforms/react' import { TextField, Text, Flex, Box } from '@radix-ui/themes' import { useEffect, type ReactNode } from 'react' import dayjs from 'dayjs' import { getErrorMessage } from '../utils/error' +import { notLaterThanControlError } from '../utils/notLaterThan' // dayjs().format() defaults to RFC 3339 (e.g. 2026-06-05T12:30:00+05:30) with // seconds + local offset, which ajv's strict "date-time" format check requires. @@ -58,6 +59,10 @@ export const DateControl = ({ enabled, visible = true, }: ControlProps) => { + // Sibling lookup for x-notLaterThan: only root data changes when the limit + // field is edited, not this control's own `data`. + const ctx = useJsonForms() + useEffect(() => { if (visible === false) { handleChange(path, undefined) @@ -67,16 +72,19 @@ export const DateControl = ({ if (visible === false) { return null } - const isValid = errors.length === 0 + + const orderError = notLaterThanControlError(ctx.core?.data, path, data, schema, label) + const displayErrors = [errors, orderError].filter((part) => typeof part === 'string' && part.length > 0).join('\n') + const isValid = displayErrors.length === 0 const value: string = typeof data === 'string' ? data : '' const shell = (children: ReactNode) => ( - + {children} ) - // Time-only: native picker is plenty. + // Time-only: native picker is plenty. x-notLaterThan does not apply to time. if (schema.format === 'time') { return shell( { + it('is true when left is after right for plain dates', () => { + expect(isLaterThan('2026-06-20', '2026-06-12')).toBe(true) + }) + + it('is false when equal or earlier', () => { + expect(isLaterThan('2026-06-12', '2026-06-12')).toBe(false) + expect(isLaterThan('2026-06-11', '2026-06-12')).toBe(false) + }) + + it('compares date-times, not only lexical strings', () => { + expect(isLaterThan('2026-06-12T18:00:00+05:30', '2026-06-12T08:00:00+05:30')).toBe(true) + }) +}) + +describe('resolveSiblingValue', () => { + it('reads a top-level sibling', () => { + expect(resolveSiblingValue({ a: '1', b: '2' }, 'a', 'b')).toBe('2') + }) + + it('reads a sibling under the same parent object', () => { + expect(resolveSiblingValue({ nest: { a: '1', b: '2' } }, 'nest.a', 'b')).toBe('2') + }) +}) + +describe('notLaterThanControlError', () => { + it('returns the title message when the control is later than its sibling', () => { + const err = notLaterThanControlError( + { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, + 'date_of_containerization', + '2026-06-20', + { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' }, + 'Date of Containerization', + ) + expect(err).toBe(notLaterThanMessage('Date of Containerization', 'date_of_sailing')) + }) + + it('returns undefined when the order is valid or a side is empty', () => { + expect( + notLaterThanControlError( + { date_of_containerization: '2026-06-11', date_of_sailing: '2026-06-12' }, + 'date_of_containerization', + '2026-06-11', + { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' }, + 'Date of Containerization', + ), + ).toBeUndefined() + expect( + notLaterThanControlError( + { date_of_containerization: '2026-06-20' }, + 'date_of_containerization', + '2026-06-20', + { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' }, + 'Date of Containerization', + ), + ).toBeUndefined() + }) +}) + +describe('collectNotLaterThanErrors', () => { + it('emits an AJV-shaped error with field titles', () => { + const errors = collectNotLaterThanErrors( + { + type: 'object', + properties: { + date_of_containerization: { + type: 'string', + format: 'date', + title: 'Date of Containerization', + 'x-notLaterThan': 'date_of_sailing', + }, + date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, + }, + }, + { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, + ) + expect(errors).toEqual([ + { + instancePath: '/date_of_containerization', + schemaPath: '#/properties/date_of_containerization/x-notLaterThan', + keyword: 'x-notLaterThan', + params: { limitField: 'date_of_sailing' }, + message: 'Date of Containerization cannot be later than Date of Sailing', + }, + ]) + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.ts new file mode 100644 index 0000000..6b9aa29 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.ts @@ -0,0 +1,103 @@ +import type { JsonSchema } from '@jsonforms/core' +import { Resolve } from '@jsonforms/core' +import dayjs from 'dayjs' + +// Schema keyword on a date / date-time property: the local value must not be +// later than the named sibling field (same parent object). Compared with +// dayjs so plain `YYYY-MM-DD` and RFC 3339 date-times both work; equal is OK. + +export type NotLaterThanError = { + instancePath: string + schemaPath: string + keyword: 'x-notLaterThan' + params: { limitField: string } + message: string +} + +export function notLaterThanMessage(leftTitle: string, rightTitle: string): string { + return `${leftTitle} cannot be later than ${rightTitle}` +} + +// True when both parse as dates and left is strictly after right. +export function isLaterThan(left: string, right: string): boolean { + const l = dayjs(left) + const r = dayjs(right) + if (!l.isValid() || !r.isValid()) return false + return l.isAfter(r) +} + +// Sibling value for a control at JsonForms `path` (dot-separated). +export function resolveSiblingValue(rootData: unknown, controlPath: string, siblingKey: string): unknown { + const parentPath = controlPath.split('.').slice(0, -1).join('.') + const siblingPath = parentPath ? `${parentPath}.${siblingKey}` : siblingKey + return Resolve.data(rootData, siblingPath) +} + +function propertyTitle(propSchema: JsonSchema | undefined, key: string): string { + if (propSchema && typeof propSchema === 'object' && typeof propSchema.title === 'string') { + return propSchema.title + } + return key +} + +// Inline error for DateControl when this field's x-notLaterThan sibling is earlier. +// `rightTitle` defaults to the sibling property name when the host has no +// schema title handy (collectNotLaterThanErrors resolves titles for submit). +export function notLaterThanControlError( + rootData: unknown, + controlPath: string, + data: unknown, + schema: JsonSchema, + label: string, + rightTitle?: string, +): string | undefined { + const limitField = (schema as { 'x-notLaterThan'?: unknown })['x-notLaterThan'] + if (typeof limitField !== 'string' || typeof data !== 'string' || data === '') return undefined + const sibling = resolveSiblingValue(rootData, controlPath, limitField) + if (typeof sibling !== 'string' || sibling === '' || !isLaterThan(data, sibling)) return undefined + return notLaterThanMessage(label || controlPath, rightTitle || limitField) +} + +// AJV-shaped errors for JsonForms `additionalErrors` (submit gating in hosts). +// Walks object / array schemas the same way trader FormRenderer walks required. +export function collectNotLaterThanErrors( + schema: JsonSchema | undefined, + data: unknown, + instancePath = '', +): NotLaterThanError[] { + if (!schema || typeof schema !== 'object') return [] + const properties = schema.properties + const items = schema.items + const out: NotLaterThanError[] = [] + + if (properties && data && typeof data === 'object' && !Array.isArray(data)) { + const obj = data as Record + for (const [key, propSchema] of Object.entries(properties)) { + if (!propSchema || typeof propSchema !== 'object') continue + const limitField = (propSchema as { 'x-notLaterThan'?: unknown })['x-notLaterThan'] + if (typeof limitField === 'string') { + const left = obj[key] + const right = obj[limitField] + if (typeof left === 'string' && typeof right === 'string' && isLaterThan(left, right)) { + out.push({ + instancePath: `${instancePath}/${key}`, + schemaPath: `#/properties/${key}/x-notLaterThan`, + keyword: 'x-notLaterThan', + params: { limitField }, + message: notLaterThanMessage(propertyTitle(propSchema, key), propertyTitle(properties[limitField], limitField)), + }) + } + } + if (obj[key] === undefined) continue + out.push(...collectNotLaterThanErrors(propSchema, obj[key], `${instancePath}/${key}`)) + } + } + + if (items && !Array.isArray(items) && Array.isArray(data)) { + data.forEach((item, index) => { + out.push(...collectNotLaterThanErrors(items, item, `${instancePath}/${index}`)) + }) + } + + return out +} From c7c8428a5f326286fb91a6cfd10f8575241c1cf6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 10:08:36 -0700 Subject: [PATCH 2/7] refactor(jsonforms-renderers): slim x-notLaterThan to the minimum surface Keep DateControl inline check + collectNotLaterThanErrors for submit gating; drop unused exported helpers and extra tests. Co-authored-by: Cursor --- packages/jsonforms-renderers/README.md | 2 +- .../src/utils/notLaterThan.test.ts | 95 +++++++------------ .../src/utils/notLaterThan.ts | 44 ++++----- 3 files changed, 52 insertions(+), 89 deletions(-) diff --git a/packages/jsonforms-renderers/README.md b/packages/jsonforms-renderers/README.md index 786d4ed..d6cc69a 100644 --- a/packages/jsonforms-renderers/README.md +++ b/packages/jsonforms-renderers/README.md @@ -15,7 +15,7 @@ This package provides a collection of JSON Forms controls and layout renderers t ### Controls -- **[DateControl](src/renderers/DateControl.tsx)**: Formats `date`, `time`, and `date-time` inputs. Combines native inputs that split space equally in `date-time` combination mode and expand to `100%` container width otherwise. Honors optional `x-notLaterThan: ""` (inline error when this date is after the sibling). Hosts that gate submit can also use exported `collectNotLaterThanErrors`. +- **[DateControl](src/renderers/DateControl.tsx)**: Formats `date`, `time`, and `date-time` inputs. Combines native inputs that split space equally in `date-time` combination mode and expand to `100%` container width otherwise. Supports `x-notLaterThan` (inline error; export `collectNotLaterThanErrors` for submit gating). - **[TextControl](src/renderers/TextControl.tsx)**: Handles standard string schemas with optional placeholders. - **[NumberControl](src/renderers/NumberControl.tsx)**: Handles numeric and integer inputs. - **[BooleanControl](src/renderers/BooleanControl.tsx)**: Toggle switch control for boolean values. diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts index 5188f53..bc46cff 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts @@ -1,56 +1,28 @@ import { describe, expect, it } from 'vitest' -import { - collectNotLaterThanErrors, - isLaterThan, - notLaterThanControlError, - notLaterThanMessage, - resolveSiblingValue, -} from './notLaterThan' - -describe('isLaterThan', () => { - it('is true when left is after right for plain dates', () => { - expect(isLaterThan('2026-06-20', '2026-06-12')).toBe(true) - }) - - it('is false when equal or earlier', () => { - expect(isLaterThan('2026-06-12', '2026-06-12')).toBe(false) - expect(isLaterThan('2026-06-11', '2026-06-12')).toBe(false) - }) - - it('compares date-times, not only lexical strings', () => { - expect(isLaterThan('2026-06-12T18:00:00+05:30', '2026-06-12T08:00:00+05:30')).toBe(true) - }) -}) - -describe('resolveSiblingValue', () => { - it('reads a top-level sibling', () => { - expect(resolveSiblingValue({ a: '1', b: '2' }, 'a', 'b')).toBe('2') - }) - - it('reads a sibling under the same parent object', () => { - expect(resolveSiblingValue({ nest: { a: '1', b: '2' } }, 'nest.a', 'b')).toBe('2') - }) -}) +import { collectNotLaterThanErrors, notLaterThanControlError } from './notLaterThan' describe('notLaterThanControlError', () => { - it('returns the title message when the control is later than its sibling', () => { - const err = notLaterThanControlError( - { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, - 'date_of_containerization', - '2026-06-20', - { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' }, - 'Date of Containerization', - ) - expect(err).toBe(notLaterThanMessage('Date of Containerization', 'date_of_sailing')) + const schema = { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' } + + it('errors when this date is after the sibling', () => { + expect( + notLaterThanControlError( + { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, + 'date_of_containerization', + '2026-06-20', + schema, + 'Date of Containerization', + ), + ).toBe('Date of Containerization cannot be later than date_of_sailing') }) - it('returns undefined when the order is valid or a side is empty', () => { + it('is silent when equal, earlier, or sibling missing', () => { expect( notLaterThanControlError( - { date_of_containerization: '2026-06-11', date_of_sailing: '2026-06-12' }, + { date_of_containerization: '2026-06-12', date_of_sailing: '2026-06-12' }, 'date_of_containerization', - '2026-06-11', - { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' }, + '2026-06-12', + schema, 'Date of Containerization', ), ).toBeUndefined() @@ -59,7 +31,7 @@ describe('notLaterThanControlError', () => { { date_of_containerization: '2026-06-20' }, 'date_of_containerization', '2026-06-20', - { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' }, + schema, 'Date of Containerization', ), ).toBeUndefined() @@ -67,23 +39,24 @@ describe('notLaterThanControlError', () => { }) describe('collectNotLaterThanErrors', () => { - it('emits an AJV-shaped error with field titles', () => { - const errors = collectNotLaterThanErrors( - { - type: 'object', - properties: { - date_of_containerization: { - type: 'string', - format: 'date', - title: 'Date of Containerization', - 'x-notLaterThan': 'date_of_sailing', + it('builds one AJV-shaped error with schema titles', () => { + expect( + collectNotLaterThanErrors( + { + type: 'object', + properties: { + date_of_containerization: { + type: 'string', + format: 'date', + title: 'Date of Containerization', + 'x-notLaterThan': 'date_of_sailing', + }, + date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, }, - date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, }, - }, - { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, - ) - expect(errors).toEqual([ + { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, + ), + ).toEqual([ { instancePath: '/date_of_containerization', schemaPath: '#/properties/date_of_containerization/x-notLaterThan', diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.ts index 6b9aa29..893b83a 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.ts @@ -2,9 +2,8 @@ import type { JsonSchema } from '@jsonforms/core' import { Resolve } from '@jsonforms/core' import dayjs from 'dayjs' -// Schema keyword on a date / date-time property: the local value must not be -// later than the named sibling field (same parent object). Compared with -// dayjs so plain `YYYY-MM-DD` and RFC 3339 date-times both work; equal is OK. +// x-notLaterThan: "" on a date/date-time field — local value must not +// be after the sibling (equal OK). dayjs covers YYYY-MM-DD and RFC 3339. export type NotLaterThanError = { instancePath: string @@ -14,52 +13,43 @@ export type NotLaterThanError = { message: string } -export function notLaterThanMessage(leftTitle: string, rightTitle: string): string { +function message(leftTitle: string, rightTitle: string): string { return `${leftTitle} cannot be later than ${rightTitle}` } -// True when both parse as dates and left is strictly after right. -export function isLaterThan(left: string, right: string): boolean { +function isLaterThan(left: string, right: string): boolean { const l = dayjs(left) const r = dayjs(right) - if (!l.isValid() || !r.isValid()) return false - return l.isAfter(r) + return l.isValid() && r.isValid() && l.isAfter(r) } -// Sibling value for a control at JsonForms `path` (dot-separated). -export function resolveSiblingValue(rootData: unknown, controlPath: string, siblingKey: string): unknown { +function siblingValue(rootData: unknown, controlPath: string, siblingKey: string): unknown { const parentPath = controlPath.split('.').slice(0, -1).join('.') - const siblingPath = parentPath ? `${parentPath}.${siblingKey}` : siblingKey - return Resolve.data(rootData, siblingPath) + return Resolve.data(rootData, parentPath ? `${parentPath}.${siblingKey}` : siblingKey) } -function propertyTitle(propSchema: JsonSchema | undefined, key: string): string { - if (propSchema && typeof propSchema === 'object' && typeof propSchema.title === 'string') { - return propSchema.title - } - return key +function titleOf(propSchema: JsonSchema | undefined, key: string): string { + return propSchema && typeof propSchema === 'object' && typeof propSchema.title === 'string' + ? propSchema.title + : key } -// Inline error for DateControl when this field's x-notLaterThan sibling is earlier. -// `rightTitle` defaults to the sibling property name when the host has no -// schema title handy (collectNotLaterThanErrors resolves titles for submit). +/** Inline error string for DateControl, or undefined when OK / inapplicable. */ export function notLaterThanControlError( rootData: unknown, controlPath: string, data: unknown, schema: JsonSchema, label: string, - rightTitle?: string, ): string | undefined { const limitField = (schema as { 'x-notLaterThan'?: unknown })['x-notLaterThan'] if (typeof limitField !== 'string' || typeof data !== 'string' || data === '') return undefined - const sibling = resolveSiblingValue(rootData, controlPath, limitField) + const sibling = siblingValue(rootData, controlPath, limitField) if (typeof sibling !== 'string' || sibling === '' || !isLaterThan(data, sibling)) return undefined - return notLaterThanMessage(label || controlPath, rightTitle || limitField) + return message(label || controlPath, limitField) } -// AJV-shaped errors for JsonForms `additionalErrors` (submit gating in hosts). -// Walks object / array schemas the same way trader FormRenderer walks required. +/** AJV-shaped errors for JsonForms `additionalErrors` (host submit gating). */ export function collectNotLaterThanErrors( schema: JsonSchema | undefined, data: unknown, @@ -67,7 +57,6 @@ export function collectNotLaterThanErrors( ): NotLaterThanError[] { if (!schema || typeof schema !== 'object') return [] const properties = schema.properties - const items = schema.items const out: NotLaterThanError[] = [] if (properties && data && typeof data === 'object' && !Array.isArray(data)) { @@ -84,7 +73,7 @@ export function collectNotLaterThanErrors( schemaPath: `#/properties/${key}/x-notLaterThan`, keyword: 'x-notLaterThan', params: { limitField }, - message: notLaterThanMessage(propertyTitle(propSchema, key), propertyTitle(properties[limitField], limitField)), + message: message(titleOf(propSchema, key), titleOf(properties[limitField], limitField)), }) } } @@ -93,6 +82,7 @@ export function collectNotLaterThanErrors( } } + const items = schema.items if (items && !Array.isArray(items) && Array.isArray(data)) { data.forEach((item, index) => { out.push(...collectNotLaterThanErrors(items, item, `${instancePath}/${index}`)) From 8873b92442b0412d961ea41313120e01dfe0d917 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 10:17:08 -0700 Subject: [PATCH 3/7] fix(jsonforms-renderers): cast x-notLaterThan fixtures for tsc JsonSchema7 rejects custom keywords on object literals; match the package's existing as-unknown-as-JsonSchema test pattern. Co-authored-by: Cursor --- .../src/utils/notLaterThan.test.ts | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts index bc46cff..d92f8cd 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts @@ -1,8 +1,13 @@ +import type { JsonSchema } from '@jsonforms/core' import { describe, expect, it } from 'vitest' import { collectNotLaterThanErrors, notLaterThanControlError } from './notLaterThan' describe('notLaterThanControlError', () => { - const schema = { type: 'string', format: 'date', 'x-notLaterThan': 'date_of_sailing' } + const schema = { + type: 'string', + format: 'date', + 'x-notLaterThan': 'date_of_sailing', + } as unknown as JsonSchema it('errors when this date is after the sibling', () => { expect( @@ -40,23 +45,20 @@ describe('notLaterThanControlError', () => { describe('collectNotLaterThanErrors', () => { it('builds one AJV-shaped error with schema titles', () => { - expect( - collectNotLaterThanErrors( - { - type: 'object', - properties: { - date_of_containerization: { - type: 'string', - format: 'date', - title: 'Date of Containerization', - 'x-notLaterThan': 'date_of_sailing', - }, - date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, - }, + const schema = { + type: 'object', + properties: { + date_of_containerization: { + type: 'string', + format: 'date', + title: 'Date of Containerization', + 'x-notLaterThan': 'date_of_sailing', }, - { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, - ), - ).toEqual([ + date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, + }, + } as unknown as JsonSchema + + expect(collectNotLaterThanErrors(schema, { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' })).toEqual([ { instancePath: '/date_of_containerization', schemaPath: '#/properties/date_of_containerization/x-notLaterThan', From dccd17451766a2efb93303d7b823f60ade72a47a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 10:18:34 -0700 Subject: [PATCH 4/7] style(jsonforms-renderers): prettier-format notLaterThan helpers Co-authored-by: Cursor --- packages/jsonforms-renderers/src/utils/notLaterThan.test.ts | 4 +++- packages/jsonforms-renderers/src/utils/notLaterThan.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts index d92f8cd..2959275 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts @@ -58,7 +58,9 @@ describe('collectNotLaterThanErrors', () => { }, } as unknown as JsonSchema - expect(collectNotLaterThanErrors(schema, { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' })).toEqual([ + expect( + collectNotLaterThanErrors(schema, { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }), + ).toEqual([ { instancePath: '/date_of_containerization', schemaPath: '#/properties/date_of_containerization/x-notLaterThan', diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.ts index 893b83a..3bef10d 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.ts @@ -29,9 +29,7 @@ function siblingValue(rootData: unknown, controlPath: string, siblingKey: string } function titleOf(propSchema: JsonSchema | undefined, key: string): string { - return propSchema && typeof propSchema === 'object' && typeof propSchema.title === 'string' - ? propSchema.title - : key + return propSchema && typeof propSchema === 'object' && typeof propSchema.title === 'string' ? propSchema.title : key } /** Inline error string for DateControl, or undefined when OK / inapplicable. */ From b6c2af03909f6f10e9bfc69896988fb64c461c6f Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 7 Oct 2026 21:45:11 -0700 Subject: [PATCH 5/7] fix(jsonforms-renderers): address x-notLaterThan review feedback Dedupe inline vs additionalErrors, honor validationMode, align collect with Resolve.data paths and sibling titles, day-compare mixed date/ date-time, and cover DateControl plus nested collector cases. Co-authored-by: Cursor --- packages/jsonforms-renderers/README.md | 2 +- .../src/renderers/DateControl.test.tsx | 94 ++++++++ .../src/renderers/DateControl.tsx | 54 ++++- .../src/utils/notLaterThan.test.ts | 213 ++++++++++++++++-- .../src/utils/notLaterThan.ts | 75 +++++- 5 files changed, 397 insertions(+), 41 deletions(-) create mode 100644 packages/jsonforms-renderers/src/renderers/DateControl.test.tsx diff --git a/packages/jsonforms-renderers/README.md b/packages/jsonforms-renderers/README.md index d6cc69a..17b1ad2 100644 --- a/packages/jsonforms-renderers/README.md +++ b/packages/jsonforms-renderers/README.md @@ -15,7 +15,7 @@ This package provides a collection of JSON Forms controls and layout renderers t ### Controls -- **[DateControl](src/renderers/DateControl.tsx)**: Formats `date`, `time`, and `date-time` inputs. Combines native inputs that split space equally in `date-time` combination mode and expand to `100%` container width otherwise. Supports `x-notLaterThan` (inline error; export `collectNotLaterThanErrors` for submit gating). +- **[DateControl](src/renderers/DateControl.tsx)**: Formats `date`, `time`, and `date-time` inputs. Combines native inputs that split space equally in `date-time` combination mode and expand to `100%` container width otherwise. Supports `x-notLaterThan` on `date` / `date-time` (inline error under `ValidateAndShow`). Export `collectNotLaterThanErrors` to gate submit; if you also pass it as `additionalErrors`, DateControl dedupes so the message shows once. The collector walks plain `properties` / `items` only (no `$ref` / combinators). - **[TextControl](src/renderers/TextControl.tsx)**: Handles standard string schemas with optional placeholders. - **[NumberControl](src/renderers/NumberControl.tsx)**: Handles numeric and integer inputs. - **[BooleanControl](src/renderers/BooleanControl.tsx)**: Toggle switch control for boolean values. diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx new file mode 100644 index 0000000..8d921c6 --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { useState } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { JsonForms } from '@jsonforms/react' +import { Theme } from '@radix-ui/themes' +import type { ErrorObject, JsonSchema, UISchemaElement } from '@jsonforms/core' +import { radixRenderers } from './index' +import { collectNotLaterThanErrors } from '../utils/notLaterThan' + +afterEach(() => { + cleanup() +}) + +const schema = { + type: 'object', + properties: { + date_of_containerization: { + type: 'string', + format: 'date', + title: 'Date of Containerization', + 'x-notLaterThan': 'date_of_sailing', + }, + date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, + }, +} as unknown as JsonSchema + +const uischema = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/date_of_containerization' }, + { type: 'Control', scope: '#/properties/date_of_sailing' }, + ], +} as UISchemaElement + +function renderForm( + seed: Record, + additionalErrors?: ErrorObject[], + validationMode: 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation' = 'ValidateAndShow', +) { + function Harness() { + const [data, setData] = useState(seed) + return ( + + setData(next as Record)} + /> + + ) + } + return render() +} + +describe('DateControl x-notLaterThan', () => { + it('shows the order error and clears it when the sibling moves later', async () => { + renderForm({ date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }) + + expect(await screen.findByText('Date of Containerization cannot be later than Date of Sailing')).toBeTruthy() + + const sailing = document.getElementById('date_of_sailing') as HTMLInputElement + fireEvent.change(sailing, { target: { value: '2026-06-25' } }) + + await waitFor(() => { + expect(screen.queryByText('Date of Containerization cannot be later than Date of Sailing')).toBeNull() + }) + }) + + it('shows the order error only once when additionalErrors is also passed', async () => { + const additionalErrors = collectNotLaterThanErrors(schema, { + date_of_containerization: '2026-06-20', + date_of_sailing: '2026-06-12', + }) as unknown as ErrorObject[] + + renderForm({ date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, additionalErrors) + + const matches = await screen.findAllByText('Date of Containerization cannot be later than Date of Sailing') + expect(matches).toHaveLength(1) + }) + + it('hides the order error when validationMode is ValidateAndHide', () => { + renderForm( + { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, + undefined, + 'ValidateAndHide', + ) + expect(screen.queryByText('Date of Containerization cannot be later than Date of Sailing')).toBeNull() + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.tsx index d5300e5..0359227 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.tsx @@ -48,7 +48,9 @@ const FieldShell = ({ path, label, required, errors, description, children }: Sh ) } -export const DateControl = ({ +type ViewProps = ControlProps & { orderError?: string } + +const DateControlView = ({ data, handleChange, path, @@ -58,11 +60,8 @@ export const DateControl = ({ schema, enabled, visible = true, -}: ControlProps) => { - // Sibling lookup for x-notLaterThan: only root data changes when the limit - // field is edited, not this control's own `data`. - const ctx = useJsonForms() - + orderError, +}: ViewProps) => { useEffect(() => { if (visible === false) { handleChange(path, undefined) @@ -73,8 +72,17 @@ export const DateControl = ({ return null } - const orderError = notLaterThanControlError(ctx.core?.data, path, data, schema, label) - const displayErrors = [errors, orderError].filter((part) => typeof part === 'string' && part.length > 0).join('\n') + // When the host also passes collectNotLaterThanErrors as additionalErrors, + // JSON Forms merges that message into `errors` — skip the duplicate. + const orderAlreadyShown = + Boolean(orderError) && + errors + .split('\n') + .map((line) => line.trim()) + .includes(orderError!) + const displayErrors = [errors, orderAlreadyShown ? undefined : orderError] + .filter((part) => typeof part === 'string' && part.length > 0) + .join('\n') const isValid = displayErrors.length === 0 const value: string = typeof data === 'string' ? data : '' @@ -84,7 +92,8 @@ export const DateControl = ({ ) - // Time-only: native picker is plenty. x-notLaterThan does not apply to time. + // Time-only: native picker is plenty. x-notLaterThan does not apply to time + // (notLaterThanControlError guards on format === date | date-time). if (schema.format === 'time') { return shell( { + const ctx = useJsonForms() + // Match JSON Forms: ValidateAndHide / NoValidation hide other control errors. + const validationMode = ctx.core?.validationMode ?? 'ValidateAndShow' + const orderError = + validationMode === 'ValidateAndShow' + ? notLaterThanControlError( + ctx.core?.data, + props.path, + props.data, + props.schema, + props.label, + ctx.core?.schema, + ) + : undefined + return +} + +export const DateControl = (props: ControlProps) => { + const limitField = (props.schema as { 'x-notLaterThan'?: unknown })['x-notLaterThan'] + if (typeof limitField === 'string') { + return + } + return +} + export const DateControlTester: RankedTester = rankWith(2, or(isDateControl, isDateTimeControl, isTimeControl)) const JsonFormsDateControl = withJsonFormsControlProps(DateControl) diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts index 2959275..478b0c1 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts @@ -2,23 +2,73 @@ import type { JsonSchema } from '@jsonforms/core' import { describe, expect, it } from 'vitest' import { collectNotLaterThanErrors, notLaterThanControlError } from './notLaterThan' -describe('notLaterThanControlError', () => { - const schema = { - type: 'string', - format: 'date', - 'x-notLaterThan': 'date_of_sailing', - } as unknown as JsonSchema +const rootSchema = { + type: 'object', + properties: { + date_of_containerization: { + type: 'string', + format: 'date', + title: 'Date of Containerization', + 'x-notLaterThan': 'date_of_sailing', + }, + date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, + nested: { + type: 'object', + properties: { + start: { + type: 'string', + format: 'date', + title: 'Start', + 'x-notLaterThan': 'end', + }, + end: { type: 'string', format: 'date', title: 'End' }, + }, + }, + legs: { + type: 'array', + items: { + type: 'object', + properties: { + depart: { + type: 'string', + format: 'date', + title: 'Depart', + 'x-notLaterThan': 'arrive', + }, + arrive: { type: 'string', format: 'date', title: 'Arrive' }, + }, + }, + }, + stamped_at: { + type: 'string', + format: 'date-time', + title: 'Stamped at', + 'x-notLaterThan': 'deadline', + }, + deadline: { type: 'string', format: 'date', title: 'Deadline' }, + only_time: { + type: 'string', + format: 'time', + title: 'Only time', + 'x-notLaterThan': 'date_of_sailing', + }, + }, +} as unknown as JsonSchema + +const containerSchema = rootSchema.properties!.date_of_containerization as JsonSchema - it('errors when this date is after the sibling', () => { +describe('notLaterThanControlError', () => { + it('errors when this date is after the sibling, using the sibling title', () => { expect( notLaterThanControlError( { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, 'date_of_containerization', '2026-06-20', - schema, + containerSchema, 'Date of Containerization', + rootSchema, ), - ).toBe('Date of Containerization cannot be later than date_of_sailing') + ).toBe('Date of Containerization cannot be later than Date of Sailing') }) it('is silent when equal, earlier, or sibling missing', () => { @@ -27,8 +77,9 @@ describe('notLaterThanControlError', () => { { date_of_containerization: '2026-06-12', date_of_sailing: '2026-06-12' }, 'date_of_containerization', '2026-06-12', - schema, + containerSchema, 'Date of Containerization', + rootSchema, ), ).toBeUndefined() expect( @@ -36,8 +87,81 @@ describe('notLaterThanControlError', () => { { date_of_containerization: '2026-06-20' }, 'date_of_containerization', '2026-06-20', - schema, + containerSchema, + 'Date of Containerization', + rootSchema, + ), + ).toBeUndefined() + }) + + it('checks nested paths and ignores time format / non-string limits', () => { + expect( + notLaterThanControlError( + { nested: { start: '2026-07-02', end: '2026-07-01' } }, + 'nested.start', + '2026-07-02', + (rootSchema.properties!.nested as JsonSchema).properties!.start as JsonSchema, + 'Start', + rootSchema, + ), + ).toBe('Start cannot be later than End') + + expect( + notLaterThanControlError( + { only_time: '12:30', date_of_sailing: '2026-06-12' }, + 'only_time', + '12:30', + rootSchema.properties!.only_time as JsonSchema, + 'Only time', + rootSchema, + ), + ).toBeUndefined() + + expect( + notLaterThanControlError( + { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, + 'date_of_containerization', + '2026-06-20', + { type: 'string', format: 'date', 'x-notLaterThan': 1 } as unknown as JsonSchema, 'Date of Containerization', + rootSchema, + ), + ).toBeUndefined() + }) + + it('compares mixed date / date-time at day granularity', () => { + expect( + notLaterThanControlError( + { stamped_at: '2026-06-12T10:00:00+05:30', deadline: '2026-06-12' }, + 'stamped_at', + '2026-06-12T10:00:00+05:30', + rootSchema.properties!.stamped_at as JsonSchema, + 'Stamped at', + rootSchema, + ), + ).toBeUndefined() + + expect( + notLaterThanControlError( + { stamped_at: '2026-06-14T12:00:00Z', deadline: '2026-06-12' }, + 'stamped_at', + '2026-06-14T12:00:00Z', + rootSchema.properties!.stamped_at as JsonSchema, + 'Stamped at', + rootSchema, + ), + ).toBe('Stamped at cannot be later than Deadline') + }) + + it('is silent for invalid date strings', () => { + expect( + notLaterThanControlError( + { date_of_containerization: 'not-a-date', date_of_sailing: '2026-06-12' }, + 'date_of_containerization', + 'not-a-date', + containerSchema, + 'Date of Containerization', + rootSchema, ), ).toBeUndefined() }) @@ -45,28 +169,75 @@ describe('notLaterThanControlError', () => { describe('collectNotLaterThanErrors', () => { it('builds one AJV-shaped error with schema titles', () => { - const schema = { + expect( + collectNotLaterThanErrors(rootSchema, { + date_of_containerization: '2026-06-20', + date_of_sailing: '2026-06-12', + }), + ).toEqual([ + { + instancePath: '/date_of_containerization', + schemaPath: '#/properties/date_of_containerization/x-notLaterThan', + keyword: 'x-notLaterThan', + params: { limitField: 'date_of_sailing' }, + message: 'Date of Containerization cannot be later than Date of Sailing', + }, + ]) + }) + + it('recurses into nested objects and arrays; supports dotted limit paths', () => { + expect( + collectNotLaterThanErrors(rootSchema, { + nested: { start: '2026-07-02', end: '2026-07-01' }, + legs: [{ depart: '2026-08-05', arrive: '2026-08-01' }], + }), + ).toEqual([ + { + instancePath: '/nested/start', + schemaPath: '#/properties/start/x-notLaterThan', + keyword: 'x-notLaterThan', + params: { limitField: 'end' }, + message: 'Start cannot be later than End', + }, + { + instancePath: '/legs/0/depart', + schemaPath: '#/properties/depart/x-notLaterThan', + keyword: 'x-notLaterThan', + params: { limitField: 'arrive' }, + message: 'Depart cannot be later than Arrive', + }, + ]) + + const withDotted = { type: 'object', properties: { - date_of_containerization: { + shipment: { + type: 'object', + properties: { + sailing: { type: 'string', format: 'date', title: 'Sailing' }, + }, + }, + containerization: { type: 'string', format: 'date', - title: 'Date of Containerization', - 'x-notLaterThan': 'date_of_sailing', + title: 'Containerization', + 'x-notLaterThan': 'shipment.sailing', }, - date_of_sailing: { type: 'string', format: 'date', title: 'Date of Sailing' }, }, } as unknown as JsonSchema expect( - collectNotLaterThanErrors(schema, { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }), + collectNotLaterThanErrors(withDotted, { + shipment: { sailing: '2026-06-12' }, + containerization: '2026-06-20', + }), ).toEqual([ { - instancePath: '/date_of_containerization', - schemaPath: '#/properties/date_of_containerization/x-notLaterThan', + instancePath: '/containerization', + schemaPath: '#/properties/containerization/x-notLaterThan', keyword: 'x-notLaterThan', - params: { limitField: 'date_of_sailing' }, - message: 'Date of Containerization cannot be later than Date of Sailing', + params: { limitField: 'shipment.sailing' }, + message: 'Containerization cannot be later than Sailing', }, ]) }) diff --git a/packages/jsonforms-renderers/src/utils/notLaterThan.ts b/packages/jsonforms-renderers/src/utils/notLaterThan.ts index 3bef10d..e4075cc 100644 --- a/packages/jsonforms-renderers/src/utils/notLaterThan.ts +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.ts @@ -2,8 +2,15 @@ import type { JsonSchema } from '@jsonforms/core' import { Resolve } from '@jsonforms/core' import dayjs from 'dayjs' -// x-notLaterThan: "" on a date/date-time field — local value must not -// be after the sibling (equal OK). dayjs covers YYYY-MM-DD and RFC 3339. +// x-notLaterThan: path to another date/date-time field relative to this field's +// parent object (usually a same-level sibling key). Value must not be after the +// limit (equal OK). Paths use the same dot-joined form as ControlProps.path / +// Resolve.data. Splitting on "." breaks for property names that contain dots +// (JSON Forms-wide limitation). +// +// collectNotLaterThanErrors walks plain `properties` / `items` only — it does +// not resolve $ref, allOf, oneOf, anyOf, or if/then. DateControl still checks +// inline from the already-resolved control schema. export type NotLaterThanError = { instancePath: string @@ -17,10 +24,26 @@ function message(leftTitle: string, rightTitle: string): string { return `${leftTitle} cannot be later than ${rightTitle}` } +function appliesToFormat(schema: JsonSchema): boolean { + return schema.format === 'date' || schema.format === 'date-time' +} + +function isDateOnly(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value) +} + +/** True when left is strictly after right. Date-only sides compare by calendar day. */ function isLaterThan(left: string, right: string): boolean { const l = dayjs(left) const r = dayjs(right) - return l.isValid() && r.isValid() && l.isAfter(r) + if (!l.isValid() || !r.isValid()) return false + // Bare YYYY-MM-DD is local midnight; mixing with date-time would false-positive + // the same calendar day. Impossible calendar dates (e.g. 2026-02-31) are left + // to AJV format validation — dayjs coerces them. + if (isDateOnly(left) || isDateOnly(right)) { + return l.startOf('day').isAfter(r.startOf('day')) + } + return l.isAfter(r) } function siblingValue(rootData: unknown, controlPath: string, siblingKey: string): unknown { @@ -32,6 +55,30 @@ function titleOf(propSchema: JsonSchema | undefined, key: string): string { return propSchema && typeof propSchema === 'object' && typeof propSchema.title === 'string' ? propSchema.title : key } +/** Schema pointer for a data path (object properties + array items). */ +function dataPathToSchemaPath(dataPath: string): string { + let schemaPath = '#' + for (const seg of dataPath.split('.')) { + if (!seg) continue + schemaPath += /^\d+$/.test(seg) ? '/items' : `/properties/${seg}` + } + return schemaPath +} + +function siblingTitle(rootSchema: JsonSchema | undefined, controlPath: string, siblingKey: string): string { + if (!rootSchema) return siblingKey + const parentPath = controlPath.split('.').slice(0, -1).join('.') + const siblingPath = parentPath ? `${parentPath}.${siblingKey}` : siblingKey + const resolved = Resolve.schema(rootSchema, dataPathToSchemaPath(siblingPath), rootSchema) + const fallback = siblingKey.includes('.') ? (siblingKey.split('.').pop() ?? siblingKey) : siblingKey + return titleOf(resolved, fallback) +} + +function instancePathToDataPath(instancePath: string, key: string): string { + const base = instancePath.replace(/^\//, '').replace(/\//g, '.') + return base ? `${base}.${key}` : key +} + /** Inline error string for DateControl, or undefined when OK / inapplicable. */ export function notLaterThanControlError( rootData: unknown, @@ -39,19 +86,26 @@ export function notLaterThanControlError( data: unknown, schema: JsonSchema, label: string, + rootSchema?: JsonSchema, ): string | undefined { + if (!appliesToFormat(schema)) return undefined const limitField = (schema as { 'x-notLaterThan'?: unknown })['x-notLaterThan'] if (typeof limitField !== 'string' || typeof data !== 'string' || data === '') return undefined const sibling = siblingValue(rootData, controlPath, limitField) if (typeof sibling !== 'string' || sibling === '' || !isLaterThan(data, sibling)) return undefined - return message(label || controlPath, limitField) + return message(label || controlPath, siblingTitle(rootSchema, controlPath, limitField)) } -/** AJV-shaped errors for JsonForms `additionalErrors` (host submit gating). */ +/** + * AJV-shaped errors for host submit gating (and optionally `additionalErrors`). + * Walks plain properties/items only — see file header for $ref / combinator limits. + */ export function collectNotLaterThanErrors( schema: JsonSchema | undefined, data: unknown, instancePath = '', + rootData: unknown = data, + rootSchema: JsonSchema | undefined = schema, ): NotLaterThanError[] { if (!schema || typeof schema !== 'object') return [] const properties = schema.properties @@ -62,28 +116,29 @@ export function collectNotLaterThanErrors( for (const [key, propSchema] of Object.entries(properties)) { if (!propSchema || typeof propSchema !== 'object') continue const limitField = (propSchema as { 'x-notLaterThan'?: unknown })['x-notLaterThan'] - if (typeof limitField === 'string') { + if (typeof limitField === 'string' && appliesToFormat(propSchema)) { + const controlPath = instancePathToDataPath(instancePath, key) const left = obj[key] - const right = obj[limitField] + const right = siblingValue(rootData, controlPath, limitField) if (typeof left === 'string' && typeof right === 'string' && isLaterThan(left, right)) { out.push({ instancePath: `${instancePath}/${key}`, schemaPath: `#/properties/${key}/x-notLaterThan`, keyword: 'x-notLaterThan', params: { limitField }, - message: message(titleOf(propSchema, key), titleOf(properties[limitField], limitField)), + message: message(titleOf(propSchema, key), siblingTitle(rootSchema, controlPath, limitField)), }) } } if (obj[key] === undefined) continue - out.push(...collectNotLaterThanErrors(propSchema, obj[key], `${instancePath}/${key}`)) + out.push(...collectNotLaterThanErrors(propSchema, obj[key], `${instancePath}/${key}`, rootData, rootSchema)) } } const items = schema.items if (items && !Array.isArray(items) && Array.isArray(data)) { data.forEach((item, index) => { - out.push(...collectNotLaterThanErrors(items, item, `${instancePath}/${index}`)) + out.push(...collectNotLaterThanErrors(items, item, `${instancePath}/${index}`, rootData, rootSchema)) }) } From b22d7c2205ee26add5edb3e4c6a7f83cc508c774 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 7 Oct 2026 21:48:32 -0700 Subject: [PATCH 6/7] fix(jsonforms-renderers): drop ErrorObject import in DateControl test @jsonforms/core does not export ErrorObject; use NotLaterThanError so tsc passes in CI. Co-authored-by: Cursor --- .../src/renderers/DateControl.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx index 8d921c6..664ba3d 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx @@ -4,9 +4,9 @@ import { useState } from 'react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { JsonForms } from '@jsonforms/react' import { Theme } from '@radix-ui/themes' -import type { ErrorObject, JsonSchema, UISchemaElement } from '@jsonforms/core' +import type { JsonSchema, UISchemaElement } from '@jsonforms/core' import { radixRenderers } from './index' -import { collectNotLaterThanErrors } from '../utils/notLaterThan' +import { collectNotLaterThanErrors, type NotLaterThanError } from '../utils/notLaterThan' afterEach(() => { cleanup() @@ -35,7 +35,7 @@ const uischema = { function renderForm( seed: Record, - additionalErrors?: ErrorObject[], + additionalErrors?: NotLaterThanError[], validationMode: 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation' = 'ValidateAndShow', ) { function Harness() { @@ -75,7 +75,7 @@ describe('DateControl x-notLaterThan', () => { const additionalErrors = collectNotLaterThanErrors(schema, { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12', - }) as unknown as ErrorObject[] + }) renderForm({ date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, additionalErrors) From 7147f6251553f8f85a8788b24e8a33d80199e974 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 7 Oct 2026 21:57:29 -0700 Subject: [PATCH 7/7] style(jsonforms-renderers): prettier-format DateControl review fixes Co-authored-by: Cursor --- .../src/renderers/DateControl.test.tsx | 6 +----- .../jsonforms-renderers/src/renderers/DateControl.tsx | 9 +-------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx index 664ba3d..f35b3eb 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx @@ -84,11 +84,7 @@ describe('DateControl x-notLaterThan', () => { }) it('hides the order error when validationMode is ValidateAndHide', () => { - renderForm( - { date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, - undefined, - 'ValidateAndHide', - ) + renderForm({ date_of_containerization: '2026-06-20', date_of_sailing: '2026-06-12' }, undefined, 'ValidateAndHide') expect(screen.queryByText('Date of Containerization cannot be later than Date of Sailing')).toBeNull() }) }) diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.tsx index 0359227..a96ebaf 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.tsx @@ -154,14 +154,7 @@ const DateControlWithNotLaterThan = (props: ControlProps) => { const validationMode = ctx.core?.validationMode ?? 'ValidateAndShow' const orderError = validationMode === 'ValidateAndShow' - ? notLaterThanControlError( - ctx.core?.data, - props.path, - props.data, - props.schema, - props.label, - ctx.core?.schema, - ) + ? notLaterThanControlError(ctx.core?.data, props.path, props.data, props.schema, props.label, ctx.core?.schema) : undefined return }