diff --git a/packages/jsonforms-renderers/README.md b/packages/jsonforms-renderers/README.md index 03df5a5..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. +- **[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/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.test.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx new file mode 100644 index 0000000..f35b3eb --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx @@ -0,0 +1,90 @@ +// @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 { JsonSchema, UISchemaElement } from '@jsonforms/core' +import { radixRenderers } from './index' +import { collectNotLaterThanErrors, type NotLaterThanError } 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?: NotLaterThanError[], + 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', + }) + + 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 4210cae..a96ebaf 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. @@ -47,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, @@ -57,7 +60,8 @@ export const DateControl = ({ schema, enabled, visible = true, -}: ControlProps) => { + orderError, +}: ViewProps) => { useEffect(() => { if (visible === false) { handleChange(path, undefined) @@ -67,16 +71,29 @@ export const DateControl = ({ if (visible === false) { return null } - const isValid = errors.length === 0 + + // 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 : '' const shell = (children: ReactNode) => ( - + {children} ) - // Time-only: native picker is plenty. + // 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 new file mode 100644 index 0000000..478b0c1 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.test.ts @@ -0,0 +1,244 @@ +import type { JsonSchema } from '@jsonforms/core' +import { describe, expect, it } from 'vitest' +import { collectNotLaterThanErrors, notLaterThanControlError } from './notLaterThan' + +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 + +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', + containerSchema, + 'Date of Containerization', + rootSchema, + ), + ).toBe('Date of Containerization cannot be later than Date of Sailing') + }) + + it('is silent when equal, earlier, or sibling missing', () => { + expect( + notLaterThanControlError( + { date_of_containerization: '2026-06-12', date_of_sailing: '2026-06-12' }, + 'date_of_containerization', + '2026-06-12', + containerSchema, + 'Date of Containerization', + rootSchema, + ), + ).toBeUndefined() + expect( + notLaterThanControlError( + { date_of_containerization: '2026-06-20' }, + 'date_of_containerization', + '2026-06-20', + 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() + }) +}) + +describe('collectNotLaterThanErrors', () => { + it('builds one AJV-shaped error with schema titles', () => { + 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: { + shipment: { + type: 'object', + properties: { + sailing: { type: 'string', format: 'date', title: 'Sailing' }, + }, + }, + containerization: { + type: 'string', + format: 'date', + title: 'Containerization', + 'x-notLaterThan': 'shipment.sailing', + }, + }, + } as unknown as JsonSchema + + expect( + collectNotLaterThanErrors(withDotted, { + shipment: { sailing: '2026-06-12' }, + containerization: '2026-06-20', + }), + ).toEqual([ + { + instancePath: '/containerization', + schemaPath: '#/properties/containerization/x-notLaterThan', + keyword: 'x-notLaterThan', + 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 new file mode 100644 index 0000000..e4075cc --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/notLaterThan.ts @@ -0,0 +1,146 @@ +import type { JsonSchema } from '@jsonforms/core' +import { Resolve } from '@jsonforms/core' +import dayjs from 'dayjs' + +// 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 + schemaPath: string + keyword: 'x-notLaterThan' + params: { limitField: string } + message: string +} + +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) + 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 { + const parentPath = controlPath.split('.').slice(0, -1).join('.') + return Resolve.data(rootData, parentPath ? `${parentPath}.${siblingKey}` : siblingKey) +} + +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, + controlPath: string, + 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, siblingTitle(rootSchema, controlPath, limitField)) +} + +/** + * 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 + 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' && appliesToFormat(propSchema)) { + const controlPath = instancePathToDataPath(instancePath, key) + const left = obj[key] + 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), siblingTitle(rootSchema, controlPath, limitField)), + }) + } + } + if (obj[key] === undefined) continue + 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}`, rootData, rootSchema)) + }) + } + + return out +}