Skip to content

feat(jsonforms-renderers): honor x-notLaterThan on DateControl - #94

Closed
ginaxu1 wants to merge 7 commits into
mainfrom
feat/date-x-not-later-than
Closed

ginaxu1 wants to merge 7 commits into
mainfrom
feat/date-x-not-later-than

Conversation

@ginaxu1

@ginaxu1 ginaxu1 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Tested locally SEE SCREENRECORDING
https://github.com/user-attachments/assets/b3d03545-773f-484b-ae49-44f71946bae0

Summary

Minimum date-order fix for LSFLK/lsf-govtech-tnsw#175 (and replacement for OpenNSW/nsw-srilanka#572): DateControl honors x-notLaterThan; export collectNotLaterThanErrors for host submit gating.

Close nsw-srilanka#572. Follow-up there: bump package + call collectNotLaterThanErrors in FormRenderer (do not keep the local form walk).

Changes

  • DateControl inline error when this date is after the sibling
  • collectNotLaterThanErrors for additionalErrors / isValid
  • Helper unit tests

Test plan

  • Later containerization than sailing → inline error on DateControl
  • Equal/earlier → no error
  • After host wiring: primary submit blocked; draft still allowed

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 <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6dc5c2d7-6f06-4a67-9856-03942fe7eece
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…face

Keep DateControl inline check + collectNotLaterThanErrors for submit
gating; drop unused exported helpers and extra tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
ginaxu1 and others added 2 commits October 6, 2026 10:17
JsonSchema7 rejects custom keywords on object literals; match the
package's existing as-unknown-as-JsonSchema test pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ginaxu1 ginaxu1 self-assigned this Oct 7, 2026
@ginaxu1
ginaxu1 requested a review from sthanikan2000 October 7, 2026 16:10

@mushrafmim mushrafmim left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall approach looks good: inline check plus an exported helper for blocking submit. Tests and tsc pass locally. Main concerns are inline below: the error shows twice when additionalErrors is used, the inline and submit checks disagree (message wording, nested schemas, dotted paths), and a same-day date-time vs date false positive.

One design point with no single line to attach it to: the error only appears on the earlier field. If the user makes the later field ("Date of Sailing") earlier, the error shows on the other field, which may be off-screen. Consider showing a hint on the later field too.

}
const isValid = errors.length === 0

const orderError = notLaterThanControlError(ctx.core?.data, path, data, schema, label)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the error shows twice when a host follows the README. JSON Forms core merges additionalErrors into each control's errors prop (getErrorsAt in @jsonforms/core). So if the host passes collectNotLaterThanErrors(...) as additionalErrors, as the README recommends, errors already contains the order error and we append orderError again. The field ends up showing:

Date of Containerization cannot be later than Date of Sailing
Date of Containerization cannot be later than date_of_sailing

Options: skip orderError when errors already contains the message, or keep one source of truth (inline only, with the host using collectNotLaterThanErrors just to gate submit and not passing it as additionalErrors).

Also: this ignores validationMode. With ValidateAndHide or NoValidation, every other error is hidden but this one still shows right away. If that's intended, please call it out; otherwise read ctx.core.validationMode here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b6c2af0. Inline skips orderError when that message is already in errors (so additionalErrors no longer doubles it). README notes the host can still pass collect for submit gating and/or additionalErrors. Also reads validationMode and only shows the order error under ValidateAndShow, matching other control errors.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf: calling useJsonForms() inside the component wrapped with withJsonFormsControlProps subscribes every DateControl to the whole context. They all re-render on any keystroke anywhere in the form, even without x-notLaterThan. Fine for small forms, but consider a narrower subscription, or at least short-circuit the lookup when the schema has no x-notLaterThan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b6c2af0. DateControl only mounts the useJsonForms subscriber when the schema has a string x-notLaterThan; other date/time fields skip the context subscription.

)

// Time-only: native picker is plenty.
// Time-only: native picker is plenty. x-notLaterThan does not apply to time.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says x-notLaterThan doesn't apply to time, but orderError is computed above for every format. It only does nothing for time because dayjs('12:30') happens to be invalid. Please add an explicit format guard (date / date-time only) in notLaterThanControlError and collectNotLaterThanErrors so this doesn't depend on parser behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b6c2af0. appliesToFormat gates both notLaterThanControlError and collectNotLaterThanErrors to date / date-time only.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline message names the other field by its raw key (date_of_sailing), while collectNotLaterThanErrors uses its schema title ("Date of Sailing"). Users will see the raw key. The sibling's title can be looked up via Resolve.schema on the root schema (ctx.core.schema); that means passing it in, or the resolved title, from DateControl.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b6c2af0. Inline now resolves the sibling title via Resolve.schema on the root schema (same wording as collect).

return `${leftTitle} cannot be later than ${rightTitle}`
}

function isLaterThan(left: string, right: string): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge cases in the comparison:

  • Mixed date and date-time: isLaterThan('2026-06-12T10:00:00+05:30', '2026-06-12') returns true, because the bare date is treated as local midnight, so the same day gets flagged. When either side is date-only, compare at day level (e.g. isAfter(r, 'day') after normalizing).
  • Impossible dates: dayjs accepts 2026-02-31 as 2026-03-03. Native inputs won't produce that, but prefilled or imported data could. Consider strict parsing with the customParseFormat plugin, or leave it to AJV format validation.
  • Different UTC offsets compare correctly. 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the mixed date / date-time case in b6c2af0: if either side is YYYY-MM-DD, compare at day granularity. Leaving impossible calendar dates to AJV format validation (called out in a comment). Offsets unchanged.

}

function siblingValue(rootData: unknown, controlPath: string, siblingKey: string): unknown {
const parentPath = controlPath.split('.').slice(0, -1).join('.')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: splitting on . breaks for property names that contain dots. That's a JSON Forms-wide limitation, so it's fine to leave, but worth a note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Noted in the file header that dot-splitting is the same JSON Forms-wide path limitation as ControlProps.path / Resolve.data.

const limitField = (propSchema as { 'x-notLaterThan'?: unknown })['x-notLaterThan']
if (typeof limitField === 'string') {
const left = obj[key]
const right = obj[limitField]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This disagrees with the inline check. Inline, Resolve.data follows a dotted x-notLaterThan such as "shipment.date_of_sailing", but here obj[limitField] gets undefined and silently passes. So the inline error shows while submit isn't blocked. Either support the same path form in both, or document and enforce "same-level sibling key only" in both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b6c2af0. collect now uses the same siblingValue / Resolve.data path as the inline check, so dotted limits like shipment.sailing block submit too.

}
}
if (obj[key] === undefined) continue
out.push(...collectNotLaterThanErrors(propSchema, obj[key], `${instancePath}/${key}`))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The walk only follows plain properties and items. It doesn't resolve $ref, allOf, oneOf, anyOf or if/then. The inline check uses the control's already-resolved schema, so a date field under a $ref or a combinator shows the inline error, but collectNotLaterThanErrors returns [] and submit isn't blocked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in the util header and README in b6c2af0: collect walks plain properties/items only. Inline still works from the control's already-resolved schema under $ref/combinators; expanding the walker is follow-up if we need submit gating there too.

schema,
'Date of Containerization',
),
).toBe('Date of Containerization cannot be later than date_of_sailing')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion locks in the raw key (date_of_sailing) in a user-facing message. It should become "Date of Sailing" once the title lookup is fixed.

Missing coverage:

  • nested objects and arrays, in both functions
  • date-time values, and mixed date/date-time
  • invalid date strings, and a non-string x-notLaterThan
  • the recursion in collectNotLaterThanErrors
  • a rendered DateControl test: the error appears and clears when the sibling changes, and shows only once when additionalErrors is also passed

@ginaxu1 ginaxu1 Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in b6c2af0

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 <cursoragent@cursor.com>
@ginaxu1

ginaxu1 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@mushrafmim On the design point in your review: keeping the error only on the constrained field for this PR (matches the schema annotation site). Agree a hint on the limit field would help when that control is off-screen; happy to do that as a follow-up if you want it in scope.

Addressed the inline threads in b6c2af0 (dedupe + validationMode, context short-circuit, format guard, sibling titles, day-level mixed compare, shared Resolve.data paths, docs for $ref walk limits, expanded tests).

@jsonforms/core does not export ErrorObject; use NotLaterThanError so tsc passes in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ginaxu1
ginaxu1 requested a review from mushrafmim October 8, 2026 04:48
Co-authored-by: Cursor <cursoragent@cursor.com>
@mushrafmim

Copy link
Copy Markdown
Collaborator

@ginaxu1 thanks for working through the earlier comments. Stepping back, though, I think we can drop most of this PR: AJV already supports this validation, and it works with the AJV setup JSON Forms already uses.

Why the current approach is fragile

x-notLaterThan is checked in two separate places that have to agree:

  • The inline check in DateControl is display-only. It isn't part of JSON Forms' validation state, so it never shows up in onChange's errors and can't block submit. It also doesn't run for fields that aren't rendered (another tab or step, a collapsed array item).
  • collectNotLaterThanErrors exists to cover those gaps. It means maintaining a second schema walker, which doesn't handle $ref or combinators.

Because the two produce their messages independently, we needed the text-matching de-duplication. That still breaks when the field has no title, or when the UI schema sets its own label. The inline message uses the control's label ("Date Of Containerization") and the collector uses the raw key, so both messages render:

date_of_containerization cannot be later than Date of Sailing
Date Of Containerization cannot be later than Date of Sailing

Built-in alternative: formatMaximum with $data

ajv-formats provides formatMaximum, formatMinimum, formatExclusiveMaximum and formatExclusiveMinimum. With AJV's $data option, the limit can point at another field's value.

Schema:

"date_of_containerization": {
  "type": "string",
  "format": "date",
  "formatMaximum": { "$data": "1/date_of_sailing" }
}

"1/date_of_sailing" is a relative JSON pointer: go up one level to the parent object, then read date_of_sailing. Use "2/..." to reach a grandparent, and so on.

Host:

import { createAjv } from '@jsonforms/core'

const ajv = createAjv({ $data: true })
<JsonForms ajv={ajv} ... />

JSON Forms' createAjv already calls addFormats(ajv), and that registers the formatMaximum keywords by default. The only change needed is $data: true.

What I checked locally

These results use createAjv({ $data: true }) from this repo's @jsonforms/core, with ajv-formats 2.1.1:

Case Result
Later than the other date ❌ /date_of_containerization should be <= 2026-06-12
Equal, or earlier ✅ passes
Other date missing ✅ passes (rule is skipped)
Other date in a parent object ("2/s") ❌ caught at /nested/a

Because this is ordinary AJV validation:

  • Inline display works as-is. The error reaches each control through its existing errors prop.
  • Submit is blocked automatically. The error is in onChange's errors, so hosts don't need collectNotLaterThanErrors.
  • No de-duplication is needed, since there's a single source of the error.
  • Validation modes behave like every other error. ValidateAndShow, ValidateAndHide and NoValidation need no special handling.
  • Off-screen fields are still validated, because AJV checks the whole data object.
  • $ref and combinators are handled by AJV's own schema resolution.
  • DateControl goes back to what's on main. No context subscription or component split is needed.

Gaps to handle

  1. The default message isn't user-friendly (should be <= 2026-06-12). We can map it through JSON Forms' i18n translateError. Rough sketch:
    const translateError: ErrorTranslator = (error, translate, uischema) => {
      if (error.keyword === 'formatMaximum') {
        const self = (error.parentSchema as JsonSchema)?.title ?? 'This date'
        return `${self} cannot be later than ${String(error.params.limit)}`
      }
      return defaultErrorTranslator(error, translate, uischema)
    }
    <JsonForms i18n={{ translateError }} ... />
    To name the other field ("Date of Sailing") instead of its value, read the last segment of the $data pointer from parentSchema.formatMaximum.$data and look up that property's title. This (or ajv-errors' errorMessage) is the only custom code we'd need.
  2. Both fields must use the same format. ajv-formats compares using the field's own format. If the other value can't be parsed as that format (a date-time field pointing at a date field), the check silently passes. If we really need mixed date / date-time comparisons, that's the one case where a small custom AJV keyword (ajv.addKeyword) is justified. Even then it should be an AJV keyword, not renderer-side logic.
  3. $data and formatMaximum are AJV extensions, not standard JSON Schema. A backend validator in another language may not understand them. x-notLaterThan has the same limitation, so this isn't a regression, but the backend should enforce the rule on its own either way.

Suggested direction

  • Revert the DateControl changes and remove utils/notLaterThan.ts, its tests, and the export from index.ts.
  • Document the formatMaximum + $data pattern in the README, including the createAjv({ $data: true }) requirement.
  • Optionally export a small translateError helper (or an addNotLaterThanMessages wrapper) from this package so hosts get the friendly message.
  • Update the schemas that use x-notLaterThan to use formatMaximum: { "$data": "1/<field>" }.

Happy to pair on this if useful. Sorry for the churn after the previous round. This only became clear once the de-duplication edge case showed the two code paths fighting each other.

@ginaxu1 ginaxu1 closed this Oct 8, 2026
@ginaxu1
ginaxu1 deleted the feat/date-x-not-later-than branch October 8, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants