Repository navigation
Conversation
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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
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. Comment |
…face Keep DateControl inline check + collectNotLaterThanErrors for submit gating; drop unused exported helpers and extra tests. Co-authored-by: Cursor <cursoragent@cursor.com>
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>
mushrafmim
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Edge cases in the comparison:
- Mixed
dateanddate-time:isLaterThan('2026-06-12T10:00:00+05:30', '2026-06-12')returnstrue, 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-31as2026-03-03. Native inputs won't produce that, but prefilled or imported data could. Consider strict parsing with thecustomParseFormatplugin, or leave it to AJVformatvalidation. - Different UTC offsets compare correctly. 👍
There was a problem hiding this comment.
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('.') |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}`)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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-timevalues, and mixeddate/date-time- invalid date strings, and a non-string
x-notLaterThan - the recursion in
collectNotLaterThanErrors - a rendered
DateControltest: the error appears and clears when the sibling changes, and shows only once whenadditionalErrorsis also passed
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>
|
@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>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
@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
Because the two produce their messages independently, we needed the text-matching de-duplication. That still breaks when the field has no Built-in alternative:
|
| 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
errorsprop. - Submit is blocked automatically. The error is in
onChange'serrors, so hosts don't needcollectNotLaterThanErrors. - No de-duplication is needed, since there's a single source of the error.
- Validation modes behave like every other error.
ValidateAndShow,ValidateAndHideandNoValidationneed no special handling. - Off-screen fields are still validated, because AJV checks the whole data object.
$refand combinators are handled by AJV's own schema resolution.DateControlgoes back to what's onmain. No context subscription or component split is needed.
Gaps to handle
- The default message isn't user-friendly (
should be <= 2026-06-12). We can map it through JSON Forms' i18ntranslateError. Rough sketch:To name the other field ("Date of Sailing") instead of its value, read the last segment of theconst 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 }} ... />
$datapointer fromparentSchema.formatMaximum.$dataand look up that property'stitle. This (or ajv-errors'errorMessage) is the only custom code we'd need. - 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-timefield pointing at adatefield), the check silently passes. If we really need mixeddate/date-timecomparisons, 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. $dataandformatMaximumare AJV extensions, not standard JSON Schema. A backend validator in another language may not understand them.x-notLaterThanhas 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
DateControlchanges and removeutils/notLaterThan.ts, its tests, and the export fromindex.ts. - Document the
formatMaximum+$datapattern in the README, including thecreateAjv({ $data: true })requirement. - Optionally export a small
translateErrorhelper (or anaddNotLaterThanMessageswrapper) from this package so hosts get the friendly message. - Update the schemas that use
x-notLaterThanto useformatMaximum: { "$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.
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; exportcollectNotLaterThanErrorsfor host submit gating.Close nsw-srilanka#572. Follow-up there: bump package + call
collectNotLaterThanErrorsin FormRenderer (do not keep the local form walk).Changes
collectNotLaterThanErrorsforadditionalErrors/isValidTest plan