From 8a7c1a25db03a32793620145f3cd497c07a63559 Mon Sep 17 00:00:00 2001 From: Mozafar Haider Date: Tue, 7 Jul 2026 12:32:13 +0100 Subject: [PATCH 1/3] fix(calendar): bump multi-calendar-dates to fix CalendarInput typing lag/CI timeouts Bumps @dhis2/multi-calendar-dates from 2.1.2 to 3.0.0-alpha.1, which fixes a broken-memoization bug in useDatePicker causing the year/month navigation lists and day-cell grid to be fully rebuilt (through Intl) on every render, including every keystroke while typing (LIBS-763). Also un-skips the previously-disabled Ethiopic validation test, now that the CLDR48 era-code bug blocking it is fixed in the same release. Adds Jest coverage for gaps documented in the new CLAUDE.md: widget-click day selection for Ethiopic/Nepali, and the format/strictValidation/ pastOnly/dir/numberingSystem/weekDayFormat props, none of which had any test before. Co-Authored-By: Claude Sonnet 5 --- components/calendar/package.json | 2 +- .../calendar/src/calendar-input/CLAUDE.md | 62 +++ .../__tests__/calendar-input.test.js | 358 +++++++++++++++++- yarn.lock | 107 ++++-- 4 files changed, 500 insertions(+), 29 deletions(-) create mode 100644 components/calendar/src/calendar-input/CLAUDE.md diff --git a/components/calendar/package.json b/components/calendar/package.json index 54128c44e..4b1d2bfe5 100644 --- a/components/calendar/package.json +++ b/components/calendar/package.json @@ -38,7 +38,7 @@ "@dhis2-ui/input": "10.16.4", "@dhis2-ui/layer": "10.16.4", "@dhis2-ui/popper": "10.16.4", - "@dhis2/multi-calendar-dates": "2.1.2", + "@dhis2/multi-calendar-dates": "3.0.0-alpha.1", "@dhis2/prop-types": "^3.1.2", "@dhis2/ui-constants": "10.16.4", "@dhis2/ui-icons": "10.16.4", diff --git a/components/calendar/src/calendar-input/CLAUDE.md b/components/calendar/src/calendar-input/CLAUDE.md new file mode 100644 index 000000000..55cb48cfd --- /dev/null +++ b/components/calendar/src/calendar-input/CLAUDE.md @@ -0,0 +1,62 @@ +# CalendarInput + +`CalendarInput` wraps the `Calendar` widget (`../calendar/`) with a text input, validation, and a clear button. It renders dates via `@dhis2/multi-calendar-dates`, which wraps `@js-temporal/polyfill` (vendored as of `2.2.0-alpha.x`, see that package's `src/vendor/README.md`). + +## Test coverage + +Two separate test surfaces exist, covering different things. Scope below is limited to the three calendars this project cares about: **Gregorian, Ethiopic, Nepali** (Islamic and others are exercised too but are out of scope for coverage decisions here). + +| | Jest (`__tests__/calendar-input.test.js`) | Cypress + Cucumber e2e (`../features/*.feature`, driven off `../__e2e__/calendar-input.e2e.stories.js`) | +|---|---|---| +| Run by | `yarn test` in `components/calendar` | separate `yarn cy:test` at repo root (spins up Storybook + a real browser) | +| Drives | `CalendarInput` in isolation / inside a form | fully rendered stories, real DOM + i18n | + +### Coverage matrix + +| Capability | Gregorian | Ethiopic | Nepali | +|---|---|---|---| +| Render + localized weekday/month names (native + English) | e2e | e2e | e2e | +| Month navigation (prev/next) | e2e | e2e | e2e | +| Select a day via calendar widget click | Jest + e2e | Jest + e2e | Jest + e2e | +| Enter a date by typing into the input | Jest | Jest | Jest (validation test) | +| minDate validation message | Jest | Jest | not exercised | +| maxDate validation message | Jest | not exercised | Jest | +| Invalid-date-in-calendar message | not exercised | Jest | Jest | +| Clear button + "today" becomes selected | e2e | e2e | e2e | +| Day-cell text shows the bare day number, not a full date string | e2e (`highlight today as the selected date` step) + Jest (`getByText('17')`) | e2e only | e2e only | +| Ethiopic: era not shown in year select | n/a | e2e | n/a | +| Manual clear (deleting text, not clicking Clear) | Jest | not exercised | not exercised | +| Bare `Calendar` widget without the `CalendarInput` wrapper | no direct unit test for any calendar — everything goes through `CalendarInput` | | | + +Also covered (Gregorian only, calendar-agnostic behavior — not worth tripling up per calendar): `strictValidation` (warning instead of error), `format`/`DD-MM-YYYY` detection and mismatch rejection, `pastOnly` (year selector upper bound), `dir` (RTL wrapper attribute), `numberingSystem` (day-cell digits), `weekDayFormat` (weekday header length). + +### Known gaps + +1. **Ethiopic has no e2e-equivalent test for validation error *text*** — only the Jest suite checks min/max-date and invalid-date messages for it (now un-skipped and passing, see below); there's no Cypress scenario for validation text in any calendar. +2. `inputWidth` is untested — it's a passthrough to a `styled-jsx` CSS width, purely cosmetic, and `styled-jsx` output is fragile to assert on in jsdom. Deliberately skipped rather than an oversight. +3. Only two assertions in the whole suite check that a day cell's *visible text* is the bare day number rather than something else: Jest's `getByText('17')` (Gregorian) and the e2e `highlight today as the selected date` step (all three calendars, via `.isSelected` text). The e2e "select a day" scenarios target cells by `data-test` attribute, not visible text, so they would **not** catch a day-label rendering regression on their own (this is exactly what broke during the `multi-calendar-dates` `2.2.0-alpha.1` upgrade — see git history around that dependency bump). The new Ethiopic/Nepali widget-click Jest tests added below use the same `data-test`-based click, for the same reason (parity with the existing Gregorian test) — they don't independently close this gap for those two calendars. + +The previously-skipped Ethiopic validation test and the previously-flaky-on-CI Nepali test are now both active, unconditionally (see the performance fix below — the timeout root cause is gone). Also added: Jest-level widget-click selection for Ethiopic and Nepali (closing gap 3 above, as it stood before this update), and coverage for every previously-untested prop except `inputWidth`. + +## Performance: typing lag / CI timeouts (LIBS-763) + +The skipped Ethiopic test and the Nepali validation test (flagged in a `ToDo` comment referencing [LIBS-763](https://dhis2.atlassian.net/browse/LIBS-763)) were timing out on CI. Root-caused and fixed upstream in `multi-calendar-dates`; the fix shipped in `@dhis2/multi-calendar-dates@3.0.0-alpha.1` and this package is pinned to it. + +Two separate memoization bugs in `useDatePicker`: + +1. **`useNavigation` args unstable**: `useDatePicker` called `useNavigation` with two arguments freshly constructed on every render (deriving the visible month's date, and an options object, both recreated inline each call). `useNavigation`'s own `useMemo` compares dependencies by reference, so it never actually cached anything — it rebuilt the year dropdown (up to 126 entries) and month list, each entry formatted through `Intl`, on **every single render**, including every keystroke while typing (`useDatePicker` runs on every render regardless of whether the dropdown is open). +2. **`calendarWeekDays` never memoized at all**: the day-grid (~35-42 cells), each formatted through the same Intl-backed `localiseWeekLabel`, was built inline in the returned object on every render — not wrapped in `useMemo` in the first place. The selected-date value driving it had the same instability problem. + +Fix: memoized all of the above against their real dependencies, routing the day-click handler through a ref so it still always calls the latest `onDateSelect` without needing to be a memo dependency. In `3.0.0-alpha.1` this landed as part of a broader refactor that also stopped leaking raw `Temporal` objects from the hook's public surface (hence the major version bump). + +**Impact measured**, 10-keystroke typing benchmark with the calendar dropdown open, against the real published `3.0.0-alpha.1`: + +| Calendar | Before either fix | After | +|---|---|---| +| Gregorian | ~2000ms | ~22ms | +| Ethiopic | crashed outright (separate CLDR48 era-code bug, also fixed in this line of work) | ~9ms | +| Nepali | ~760ms | ~13ms | + +Root cause of the pre-fix *difference* between calendars: Gregorian/Ethiopic are native-ICU calendars, so `localiseWeekLabel`/`localiseYear`/`localiseMonth` go through `Temporal.*.toLocaleString()`, which constructs a brand-new native `Intl.DateTimeFormat` per call (confirmed by instrumenting `Intl.DateTimeFormat.prototype.resolvedOptions`: ~500 constructions triggered by a single keystroke before the fix). Nepali is a custom (non-ICU) calendar, so those helpers skip Intl entirely via a lookup table — cheaper, but still wasted work from the same broken memoization. + +This was entirely a `multi-calendar-dates` bug — nothing in this directory (`CalendarInput`/`Calendar`) needed to change. diff --git a/components/calendar/src/calendar-input/__tests__/calendar-input.test.js b/components/calendar/src/calendar-input/__tests__/calendar-input.test.js index d4d06469b..52e1fe185 100644 --- a/components/calendar/src/calendar-input/__tests__/calendar-input.test.js +++ b/components/calendar/src/calendar-input/__tests__/calendar-input.test.js @@ -40,6 +40,76 @@ describe('Calendar Input', () => { jest.useRealTimers() }) + it('allow selection of a date through the calendar widget (ethiopic)', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const onDateSelectMock = jest.fn() + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.focus(dateInput) + + const calendar = await screen.findByTestId('calendar') + expect(calendar).toBeInTheDocument() + + const todayString = '2017-02-12' + const today = within(calendar).getByTestId(todayString) + + fireEvent.click(today) + + await waitFor(() => { + expect(calendar).not.toBeInTheDocument() + }) + expect(onDateSelectMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarDateString: todayString, + }) + ) + + jest.useRealTimers() + }) + + it('allow selection of a date through the calendar widget (nepali)', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const onDateSelectMock = jest.fn() + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.focus(dateInput) + + const calendar = await screen.findByTestId('calendar') + expect(calendar).toBeInTheDocument() + + const todayString = '2081-07-06' + const today = within(calendar).getByTestId(todayString) + + fireEvent.click(today) + + await waitFor(() => { + expect(calendar).not.toBeInTheDocument() + }) + expect(onDateSelectMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarDateString: todayString, + }) + ) + + jest.useRealTimers() + }) + it('allow selection of a date through the input', async () => { const onDateSelectMock = jest.fn() const screen = render( @@ -61,7 +131,291 @@ describe('Calendar Input', () => { ) }) + it('rejects input whose detected shape does not match the format prop', () => { + // "2024-12-25" is unambiguously YYYY-MM-DD shaped (4-digit group + // first). With format="DD-MM-YYYY" that mismatch is rejected + // rather than reinterpreted. + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.change(dateInput, { target: { value: '2024-12-25' } }) + fireEvent.blur(dateInput) + + expect( + screen.getByText( + 'Date string format does not match the specified format. Expected DD-MM-YYYY but got YYYY-MM-DD.' + ) + ).toBeInTheDocument() + }) + + it('accepts the same typed input without the format prop', () => { + const screen = render() + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.change(dateInput, { target: { value: '2024-12-25' } }) + fireEvent.blur(dateInput) + + expect( + screen.queryByText(/does not match the specified format/) + ).not.toBeInTheDocument() + }) + + it('limits the year selector to the current year when pastOnly is set', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + + const yearSelect = await screen.findByTestId('calendar-year-select') + const years = within(yearSelect) + .getAllByRole('option') + .map((option) => Number(option.value)) + + expect(Math.max(...years)).toBe(2024) + + jest.useRealTimers() + }) + + it('includes future years in the year selector without pastOnly', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + + const yearSelect = await screen.findByTestId('calendar-year-select') + const years = within(yearSelect) + .getAllByRole('option') + .map((option) => Number(option.value)) + + expect(Math.max(...years)).toBeGreaterThan(2024) + + jest.useRealTimers() + }) + + it('sets the calendar wrapper direction from the dir prop', async () => { + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + + const calendar = await screen.findByTestId('calendar') + expect(calendar).toHaveAttribute('dir', 'rtl') + }) + + it('renders day labels using the numberingSystem prop', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + + const calendar = await screen.findByTestId('calendar') + const today = within(calendar).getByTestId('2024-10-22') + + expect(today).toHaveTextContent('٢٢') + + jest.useRealTimers() + }) + + it('renders weekday headers using the weekDayFormat prop', async () => { + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + + const calendar = await screen.findByTestId('calendar') + expect(within(calendar).getByText('Monday')).toBeInTheDocument() + }) + + // Regression coverage for LIBS-763: useDatePicker (in @dhis2/multi-calendar-dates) + // used to rebuild the year/month navigation lists and the day-cell grid on every + // render - including every keystroke while typing here, with the calendar widget + // open - regardless of whether the visible month/calendar/locale had actually + // changed. Fixed upstream in @dhis2/multi-calendar-dates@3.0.0-alpha.1. + describe('performance (LIBS-763)', () => { + // For gregory/ethiopic (native-ICU calendars) the broken memoization showed + // up as ~500 redundant `new Intl.DateTimeFormat(...)` constructions for a + // single keystroke; count is a deterministic, machine-speed-independent + // signal, unlike timing it directly. + const countDateTimeFormatConstructions = (fn) => { + const realResolvedOptions = + Intl.DateTimeFormat.prototype.resolvedOptions + let count = 0 + Intl.DateTimeFormat.prototype.resolvedOptions = function ( + ...args + ) { + count++ + return realResolvedOptions.apply(this, args) + } + try { + fn() + } finally { + Intl.DateTimeFormat.prototype.resolvedOptions = + realResolvedOptions + } + return count + } + + it('does not reconstruct Intl.DateTimeFormat instances while typing (gregory)', async () => { + const screen = render( + + ) + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + await screen.findByTestId('calendar') + + const constructions = countDateTimeFormatConstructions(() => { + fireEvent.change(dateInput, { + target: { value: '2024-10-1' }, + }) + }) + + expect(constructions).toBeLessThan(10) + }) + + it('does not reconstruct Intl.DateTimeFormat instances while typing (ethiopic)', async () => { + const screen = render( + + ) + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + await screen.findByTestId('calendar') + + const constructions = countDateTimeFormatConstructions(() => { + fireEvent.change(dateInput, { + target: { value: '2017-02-0' }, + }) + }) + + expect(constructions).toBeLessThan(10) + }) + + // Nepali is a custom (non-ICU) calendar, so it never goes through + // Intl.DateTimeFormat - the same broken memoization instead showed up as + // redundant custom-calendar date-conversion work, so time it directly. + // 300ms for 10 keystrokes is ~25x the fixed baseline (~13ms) and ~2.5x + // below the broken one (~760ms), leaving headroom in both directions. + // Skipped in CI: unlike the count-based tests above, this asserts on + // wall-clock time, which can be flaky on slower/shared CI runners. + // Uncomment to run locally when touching useDatePicker/useNavigation. + it.skip('keeps typing fast (nepali)', async () => { + const screen = render( + + ) + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + fireEvent.focus(dateInput) + await screen.findByTestId('calendar') + + const chars = '2081-07-01'.split('') + const progressive = chars.map((_, i) => + chars.slice(0, i + 1).join('') + ) + + const start = process.hrtime.bigint() + progressive.forEach((value) => + fireEvent.change(dateInput, { target: { value } }) + ) + const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6 + + expect(elapsedMs).toBeLessThan(300) + }) + }) + describe('validation', () => { + it('should show a warning instead of an error when strictValidation is false', () => { + const onDateSelectMock = jest.fn() + const screen = render( + + ) + + const dateInputString = '2023-10-12' + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.change(dateInput, { target: { value: dateInputString } }) + fireEvent.blur(dateInput) + + expect( + screen.getByText( + 'Date 2023-10-12 is less than the minimum allowed date 2024-01-01.' + ) + ).toBeInTheDocument() + expect(onDateSelectMock).toHaveBeenCalledWith( + expect.objectContaining({ + validation: expect.objectContaining({ + warning: true, + valid: true, + }), + }) + ) + }) it('should validate minimum date', () => { const onDateSelectMock = jest.fn() const screen = render( @@ -109,8 +463,7 @@ describe('Calendar Input', () => { ) ).toBeInTheDocument() }) - // Temporarily disabled - it.skip('should validate date in ethiopic calendar', () => { + it('should validate date in ethiopic calendar', () => { const onDateSelectMock = jest.fn() const { getByTestId, getByText, queryByText } = render( { getByText('Invalid date in specified calendar') ).toBeInTheDocument() }) - // ToDo: these scenarios seem to work but they timeout on CI sporadically - ticket: https://dhis2.atlassian.net/browse/LIBS-763 it('should validate date in nepali calendar', () => { const onDateSelectMock = jest.fn() const { getByTestId, getByText, queryByText } = render( diff --git a/yarn.lock b/yarn.lock index 2ba6210c5..79d487c40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -188,7 +188,16 @@ dependencies: "@babel/highlight" "^7.8.3" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.29.7", "@babel/code-frame@^7.8.3": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.8.3": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/code-frame@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== @@ -254,7 +263,18 @@ eslint-visitor-keys "^2.1.0" semver "^6.3.1" -"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.7", "@babel/generator@^7.29.7", "@babel/generator@^7.7.2": +"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.7", "@babel/generator@^7.7.2": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.2.tgz#87b75813bec87916210e5e01939a4c823d6bb74f" + integrity sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw== + dependencies: + "@babel/parser" "^7.26.2" + "@babel/types" "^7.26.0" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + +"@babel/generator@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== @@ -394,12 +414,22 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== -"@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.24.7", "@babel/helper-validator-identifier@^7.29.7": +"@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.24.7": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/helper-validator-identifier@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== -"@babel/helper-validator-option@^7.24.7", "@babel/helper-validator-option@^7.29.7": +"@babel/helper-validator-option@^7.24.7": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" + integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== + +"@babel/helper-validator-option@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== @@ -413,7 +443,15 @@ "@babel/traverse" "^7.29.7" "@babel/types" "^7.29.7" -"@babel/helpers@^7.12.5", "@babel/helpers@^7.29.7": +"@babel/helpers@^7.12.5": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.0.tgz#30e621f1eba5aa45fe6f4868d2e9154d884119a4" + integrity sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw== + dependencies: + "@babel/template" "^7.25.9" + "@babel/types" "^7.26.0" + +"@babel/helpers@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== @@ -431,7 +469,14 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.17.0", "@babel/parser@^7.18.8", "@babel/parser@^7.20.7", "@babel/parser@^7.29.7", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": +"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.17.0", "@babel/parser@^7.18.8", "@babel/parser@^7.20.7", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" + integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== + dependencies: + "@babel/types" "^7.26.0" + +"@babel/parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== @@ -1347,7 +1392,7 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.12.7", "@babel/template@^7.29.7", "@babel/template@^7.3.3": +"@babel/template@^7.12.7", "@babel/template@^7.25.9", "@babel/template@^7.29.7", "@babel/template@^7.3.3": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== @@ -1356,7 +1401,20 @@ "@babel/parser" "^7.29.7" "@babel/types" "^7.29.7" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.29.7", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" + integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== + dependencies: + "@babel/code-frame" "^7.25.9" + "@babel/generator" "^7.25.9" + "@babel/parser" "^7.25.9" + "@babel/template" "^7.25.9" + "@babel/types" "^7.25.9" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/traverse@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== @@ -1377,7 +1435,7 @@ "@babel/helper-validator-identifier" "^7.14.9" to-fast-properties "^2.0.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.25.2", "@babel/types@^7.29.7", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": +"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.25.2", "@babel/types@^7.26.0", "@babel/types@^7.29.7", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== @@ -2132,13 +2190,13 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/multi-calendar-dates@2.1.2": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@dhis2/multi-calendar-dates/-/multi-calendar-dates-2.1.2.tgz#c3dd01e358c3dda4354c6722bb72e077b3ddd9b8" - integrity sha512-7/EuYZFi266QwyRpK+s79iEiwdOUVBwM+QjKwNUHN3zdYMDmQcWLTo5TujJFg1XnnQ8UhdiRqESq5rgoJkM2fQ== +"@dhis2/multi-calendar-dates@3.0.0-alpha.1": + version "3.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@dhis2/multi-calendar-dates/-/multi-calendar-dates-3.0.0-alpha.1.tgz#2e43e2d00e59ce8b3b7a1d93e5ad8077bea6267b" + integrity sha512-Q2L9UnB04jZLRKH8uBjUG8gDi36orixvgaGZJzAIbFXhZ2FVW98yT1lJ05IKbQDZR0rYT+MY6bSo1vE7ARg+yg== dependencies: "@dhis2/d2-i18n" "^1.1.3" - "@js-temporal/polyfill" "0.4.3" + "@js-temporal/polyfill" "0.5.1" classnames "^2.3.2" "@dhis2/prop-types@^3.1.2": @@ -3338,13 +3396,12 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@js-temporal/polyfill@0.4.3": - version "0.4.3" - resolved "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.3.tgz" - integrity sha512-6Fmjo/HlkyVCmJzAPnvtEWlcbQUSRhi8qlN9EtJA/wP7FqXsevLLrlojR44kzNzrRkpf7eDJ+z7b4xQD/Ycypw== +"@js-temporal/polyfill@0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@js-temporal/polyfill/-/polyfill-0.5.1.tgz#c9726fdb7fbdbc6419292ba94294b10a08852c32" + integrity sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ== dependencies: - jsbi "^4.1.0" - tslib "^2.3.1" + jsbi "^4.3.0" "@juggle/resize-observer@^3.3.1": version "3.4.0" @@ -12613,10 +12670,10 @@ js2xmlparser@^4.0.2: dependencies: xmlcreate "^2.0.4" -jsbi@^4.1.0: - version "4.3.0" - resolved "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz" - integrity sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g== +jsbi@^4.3.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/jsbi/-/jsbi-4.3.2.tgz#8a4d05d4e09907d73042135b6aa55a6d161ee955" + integrity sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew== jsbn@~0.1.0: version "0.1.1" @@ -18467,7 +18524,7 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0: version "2.6.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== From 1feec0d344aea1a7642b90e2e95bbafcc22fac12 Mon Sep 17 00:00:00 2001 From: Mozafar Haider Date: Thu, 9 Jul 2026 18:39:13 +0100 Subject: [PATCH 2/3] fix(calendar-input): pass useDatePicker its own minDate/maxDate keys useDatePicker was receiving validationOptions spread as-is, which is keyed minDateString/maxDateString for validateDateString's benefit - useDatePicker itself destructures minDate/maxDate, so it never actually got the bounds. In strict mode this meant the input correctly rejected an out-of-range date while the calendar widget still navigated to and selected it, instead of falling back to today like useDatePicker's own getInvalidDateResult is meant to for a date it recognizes as invalid. Warning mode was unaffected since it never triggers that fallback path regardless. Adds regression coverage for both the fixed strict-mode case and the (correct, unaffected) warning-mode case, plus a CLAUDE.md gotcha and a trim of that file down to durable, agent-facing guidance rather than a narrative of specific bugs found this session. Co-Authored-By: Claude Sonnet 5 --- .../calendar/src/calendar-input/CLAUDE.md | 66 +++----------- .../__tests__/calendar-input.test.js | 87 +++++++++++++++++++ .../src/calendar-input/calendar-input.js | 5 +- 3 files changed, 105 insertions(+), 53 deletions(-) diff --git a/components/calendar/src/calendar-input/CLAUDE.md b/components/calendar/src/calendar-input/CLAUDE.md index 55cb48cfd..bf430fd96 100644 --- a/components/calendar/src/calendar-input/CLAUDE.md +++ b/components/calendar/src/calendar-input/CLAUDE.md @@ -1,62 +1,24 @@ # CalendarInput -`CalendarInput` wraps the `Calendar` widget (`../calendar/`) with a text input, validation, and a clear button. It renders dates via `@dhis2/multi-calendar-dates`, which wraps `@js-temporal/polyfill` (vendored as of `2.2.0-alpha.x`, see that package's `src/vendor/README.md`). +`CalendarInput` wraps the `Calendar` widget (`../calendar/`) with a text input, validation, and a clear button. Both get their date logic — parsing, localization, navigation — from `@dhis2/multi-calendar-dates`'s `useDatePicker`/`useResolvedDirection`/`validateDateString`; this directory has no date-math of its own. -## Test coverage +## Data flow -Two separate test surfaces exist, covering different things. Scope below is limited to the three calendars this project cares about: **Gregorian, Ethiopic, Nepali** (Islamic and others are exercised too but are out of scope for coverage decisions here). +Dates cross the public API as calendar-native `YYYY-MM-DD` strings (in whatever `calendar` prop is set — not necessarily Gregorian ISO), never as `Temporal` objects. Treat any change that would leak a `Temporal`/date-engine object through `onDateSelect`, `date`, or similar props as a breaking change. `CalendarInput`'s `onDateSelect` payload also carries a `validation` object that `CalendarInput` computes itself via `validateDateString` — it is *not* part of `multi-calendar-dates`'s types, so it's declared separately in `../../types/index.d.ts` (`CalendarInputValidation` / `CalendarInputOnDateSelectPayload`). If you change how validation is computed or shaped, update that file too. -| | Jest (`__tests__/calendar-input.test.js`) | Cypress + Cucumber e2e (`../features/*.feature`, driven off `../__e2e__/calendar-input.e2e.stories.js`) | -|---|---|---| -| Run by | `yarn test` in `components/calendar` | separate `yarn cy:test` at repo root (spins up Storybook + a real browser) | -| Drives | `CalendarInput` in isolation / inside a form | fully rendered stories, real DOM + i18n | +## Testing -### Coverage matrix +Two surfaces, used for different things: +- **Jest** (`__tests__/calendar-input.test.js`, run via `yarn test`) — component behavior and logic: prop handling, validation, callbacks. +- **Cypress + Cucumber e2e** (`../features/*.feature` + step defs, driven off `../__e2e__/calendar-input.e2e.stories.js`, run via `yarn cy:test` at the repo root) — real rendered output and localization across calendars. Heavier; not run by `yarn test`. -| Capability | Gregorian | Ethiopic | Nepali | -|---|---|---|---| -| Render + localized weekday/month names (native + English) | e2e | e2e | e2e | -| Month navigation (prev/next) | e2e | e2e | e2e | -| Select a day via calendar widget click | Jest + e2e | Jest + e2e | Jest + e2e | -| Enter a date by typing into the input | Jest | Jest | Jest (validation test) | -| minDate validation message | Jest | Jest | not exercised | -| maxDate validation message | Jest | not exercised | Jest | -| Invalid-date-in-calendar message | not exercised | Jest | Jest | -| Clear button + "today" becomes selected | e2e | e2e | e2e | -| Day-cell text shows the bare day number, not a full date string | e2e (`highlight today as the selected date` step) + Jest (`getByText('17')`) | e2e only | e2e only | -| Ethiopic: era not shown in year select | n/a | e2e | n/a | -| Manual clear (deleting text, not clicking Clear) | Jest | not exercised | not exercised | -| Bare `Calendar` widget without the `CalendarInput` wrapper | no direct unit test for any calendar — everything goes through `CalendarInput` | | | +When adding a prop or behavior, add a Jest test. If it's about rendering or localized text for a specific calendar/locale, check whether a `.feature` scenario should exist too. -Also covered (Gregorian only, calendar-agnostic behavior — not worth tripling up per calendar): `strictValidation` (warning instead of error), `format`/`DD-MM-YYYY` detection and mismatch rejection, `pastOnly` (year selector upper bound), `dir` (RTL wrapper attribute), `numberingSystem` (day-cell digits), `weekDayFormat` (weekday header length). +Coverage decisions in this component are scoped to the three calendars the project actually supports in practice: **Gregorian, Ethiopic, Nepali**. Ethiopic and Nepali behave differently enough from Gregorian (Ethiopic is a native-ICU calendar with its own era handling; Nepali is a custom, non-ICU calendar implemented in `multi-calendar-dates` via a lookup table, not `Intl`) that "tested for Gregorian" should not be assumed to mean "tested for the other two" — verify explicitly rather than assuming parity. -### Known gaps +## Gotchas -1. **Ethiopic has no e2e-equivalent test for validation error *text*** — only the Jest suite checks min/max-date and invalid-date messages for it (now un-skipped and passing, see below); there's no Cypress scenario for validation text in any calendar. -2. `inputWidth` is untested — it's a passthrough to a `styled-jsx` CSS width, purely cosmetic, and `styled-jsx` output is fragile to assert on in jsdom. Deliberately skipped rather than an oversight. -3. Only two assertions in the whole suite check that a day cell's *visible text* is the bare day number rather than something else: Jest's `getByText('17')` (Gregorian) and the e2e `highlight today as the selected date` step (all three calendars, via `.isSelected` text). The e2e "select a day" scenarios target cells by `data-test` attribute, not visible text, so they would **not** catch a day-label rendering regression on their own (this is exactly what broke during the `multi-calendar-dates` `2.2.0-alpha.1` upgrade — see git history around that dependency bump). The new Ethiopic/Nepali widget-click Jest tests added below use the same `data-test`-based click, for the same reason (parity with the existing Gregorian test) — they don't independently close this gap for those two calendars. - -The previously-skipped Ethiopic validation test and the previously-flaky-on-CI Nepali test are now both active, unconditionally (see the performance fix below — the timeout root cause is gone). Also added: Jest-level widget-click selection for Ethiopic and Nepali (closing gap 3 above, as it stood before this update), and coverage for every previously-untested prop except `inputWidth`. - -## Performance: typing lag / CI timeouts (LIBS-763) - -The skipped Ethiopic test and the Nepali validation test (flagged in a `ToDo` comment referencing [LIBS-763](https://dhis2.atlassian.net/browse/LIBS-763)) were timing out on CI. Root-caused and fixed upstream in `multi-calendar-dates`; the fix shipped in `@dhis2/multi-calendar-dates@3.0.0-alpha.1` and this package is pinned to it. - -Two separate memoization bugs in `useDatePicker`: - -1. **`useNavigation` args unstable**: `useDatePicker` called `useNavigation` with two arguments freshly constructed on every render (deriving the visible month's date, and an options object, both recreated inline each call). `useNavigation`'s own `useMemo` compares dependencies by reference, so it never actually cached anything — it rebuilt the year dropdown (up to 126 entries) and month list, each entry formatted through `Intl`, on **every single render**, including every keystroke while typing (`useDatePicker` runs on every render regardless of whether the dropdown is open). -2. **`calendarWeekDays` never memoized at all**: the day-grid (~35-42 cells), each formatted through the same Intl-backed `localiseWeekLabel`, was built inline in the returned object on every render — not wrapped in `useMemo` in the first place. The selected-date value driving it had the same instability problem. - -Fix: memoized all of the above against their real dependencies, routing the day-click handler through a ref so it still always calls the latest `onDateSelect` without needing to be a memo dependency. In `3.0.0-alpha.1` this landed as part of a broader refactor that also stopped leaking raw `Temporal` objects from the hook's public surface (hence the major version bump). - -**Impact measured**, 10-keystroke typing benchmark with the calendar dropdown open, against the real published `3.0.0-alpha.1`: - -| Calendar | Before either fix | After | -|---|---|---| -| Gregorian | ~2000ms | ~22ms | -| Ethiopic | crashed outright (separate CLDR48 era-code bug, also fixed in this line of work) | ~9ms | -| Nepali | ~760ms | ~13ms | - -Root cause of the pre-fix *difference* between calendars: Gregorian/Ethiopic are native-ICU calendars, so `localiseWeekLabel`/`localiseYear`/`localiseMonth` go through `Temporal.*.toLocaleString()`, which constructs a brand-new native `Intl.DateTimeFormat` per call (confirmed by instrumenting `Intl.DateTimeFormat.prototype.resolvedOptions`: ~500 constructions triggered by a single keystroke before the fix). Nepali is a custom (non-ICU) calendar, so those helpers skip Intl entirely via a lookup table — cheaper, but still wasted work from the same broken memoization. - -This was entirely a `multi-calendar-dates` bug — nothing in this directory (`CalendarInput`/`Calendar`) needed to change. +- **Day-cell tests must assert on visible text, not just the `data-test` attribute.** `data-test` on a day cell is always the full date string (e.g. `2024-10-17`); the *rendered label* is a separate value from `multi-calendar-dates` and can silently regress into something else (it has, historically) without any `data-test`-based assertion catching it. Assert `within(cell).getByText('17')` (or the calendar-appropriate bare day number) in addition to clicking by `data-test`. +- **`useDatePicker` recomputes on every render of `CalendarInput`, regardless of whether the calendar dropdown is open.** Its output must stay properly memoized inside `multi-calendar-dates` — if you find yourself adding expensive work anywhere in this render path (here or upstream), verify it doesn't run on every keystroke. The `performance (LIBS-763)` describe block in the test file guards against this regressing again; keep it green. +- **`inputWidth` is deliberately untested.** It's a passthrough to a `styled-jsx` CSS width — purely cosmetic, and `styled-jsx` output is fragile to assert on in jsdom. Don't assume this is an oversight. +- **`minDate`/`maxDate`/`format`/`strictValidation` get passed to two different functions with different expectations**: `validateDateString` (via `validationOptions`, keyed `minDateString`/`maxDateString`) drives the visible error text, while `useDatePicker` takes the same values under its own key names (`minDate`/`maxDate`) to decide whether to fall back to today internally in strict mode. These used to silently disagree (the `validationOptions` object, keyed for `validateDateString`, was spread straight into `useDatePicker` too) — the widget would keep an out-of-range date selected in strict mode even though the input correctly rejected it. Pass `useDatePicker` its own correctly-named fields explicitly; don't assume an options object built for one of these functions is safe to reuse for the other. diff --git a/components/calendar/src/calendar-input/__tests__/calendar-input.test.js b/components/calendar/src/calendar-input/__tests__/calendar-input.test.js index 52e1fe185..b63046684 100644 --- a/components/calendar/src/calendar-input/__tests__/calendar-input.test.js +++ b/components/calendar/src/calendar-input/__tests__/calendar-input.test.js @@ -61,6 +61,11 @@ describe('Calendar Input', () => { const todayString = '2017-02-12' const today = within(calendar).getByTestId(todayString) + // the visible day-cell text must be the bare day number, not the + // full date string used for data-test - regressed silently during + // the multi-calendar-dates 2.2.0-alpha.1 upgrade (see CLAUDE.md) + expect(within(today).getByText('12')).toBeInTheDocument() + fireEvent.click(today) await waitFor(() => { @@ -96,6 +101,11 @@ describe('Calendar Input', () => { const todayString = '2081-07-06' const today = within(calendar).getByTestId(todayString) + // the visible day-cell text must be the bare day number, not the + // full date string used for data-test - regressed silently during + // the multi-calendar-dates 2.2.0-alpha.1 upgrade (see CLAUDE.md) + expect(within(today).getByText('6')).toBeInTheDocument() + fireEvent.click(today) await waitFor(() => { @@ -441,6 +451,83 @@ describe('Calendar Input', () => { ).toBeInTheDocument() expect(onDateSelectMock).toHaveBeenCalledTimes(1) }) + + // Regression test: minDate/maxDate used to reach validateDateString + // (as minDateString/maxDateString) but not useDatePicker itself, due to a + // key mismatch - so in strict mode the input rejected an out-of-range + // date while the widget still selected it. Warning mode is unaffected + // (see the test below) since useDatePicker only falls back to today when + // strictValidation produces an `error`. + it('calendar widget falls back to today for a date the input has flagged as below the minimum (strict mode)', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.change(dateInput, { target: { value: '2020-01-15' } }) + fireEvent.blur(dateInput) + + expect( + screen.getByText( + 'Date 2020-01-15 is less than the minimum allowed date 2024-01-01.' + ) + ).toBeInTheDocument() + + fireEvent.focus(dateInput) + const calendar = await screen.findByTestId('calendar') + + // expected: the widget falls back to today, like useDatePicker's own + // getInvalidDateResult does for a date it recognizes as invalid + expect( + within(calendar).getByTestId('2024-10-22').querySelector('button') + ).toHaveClass('isToday') + + jest.useRealTimers() + }) + + it('calendar widget correctly still selects an out-of-range date in warning mode', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z')) + + const screen = render( + + ) + + const dateInput = within( + screen.getByTestId('dhis2-uicore-input') + ).getByRole('textbox') + + fireEvent.change(dateInput, { target: { value: '2020-01-15' } }) + fireEvent.blur(dateInput) + + expect( + screen.getByText( + 'Date 2020-01-15 is less than the minimum allowed date 2024-01-01.' + ) + ).toBeInTheDocument() + + fireEvent.focus(dateInput) + const calendar = await screen.findByTestId('calendar') + + // warning mode means "flag it, but still let them pick it" - the + // widget showing the out-of-range date as selected is intentional + expect( + within(calendar).getByTestId('2020-01-15').querySelector('button') + ).toHaveClass('isSelected') + + jest.useRealTimers() + }) + it('should validate maximum date', () => { const { getByTestId, getByText } = render( Date: Thu, 9 Jul 2026 18:39:35 +0100 Subject: [PATCH 3/3] types(calendar): type CalendarInput's onDateSelect validation payload CalendarInput's onDateSelect payload carries a validation object that CalendarInput computes itself via multi-calendar-dates' validateDateString - it was never reflected in this package's own types, since CalendarProps (and by extension CalendarInputProps) derived onDateSelect structurally from useDatePicker's bare signature, which knows nothing about it. Adds CalendarInputValidation (derived from validateDateString's own return type rather than duplicated by hand) and CalendarInputOnDateSelectPayload, and overrides onDateSelect on CalendarInputProps specifically, since the bare Calendar widget never produces this field. Co-Authored-By: Claude Sonnet 5 --- components/calendar/types/index.d.ts | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/components/calendar/types/index.d.ts b/components/calendar/types/index.d.ts index 9fc7c65d7..8f505cbac 100644 --- a/components/calendar/types/index.d.ts +++ b/components/calendar/types/index.d.ts @@ -1,5 +1,5 @@ import * as React from 'react' -import { useDatePicker } from '@dhis2/multi-calendar-dates' +import { useDatePicker, validateDateString } from '@dhis2/multi-calendar-dates' import { InputFieldProps } from '@dhis2-ui/input' export type CalendarDir = 'ltr' | 'rtl' @@ -7,6 +7,25 @@ export type CalendarDir = 'ltr' | 'rtl' type CalendarPickerParam = Parameters[0] type CalendarPickerOptions = CalendarPickerParam['options'] +/** + * The `validation` object CalendarInput attaches to its `onDateSelect` + * payload. Not produced by the bare `Calendar` widget - CalendarInput + * computes it itself via `validateDateString` (whose `error`/`warning`/ + * `valid` combination depends on the `strictValidation` prop), except + * when the date is cleared, in which case it's just `{ valid: true }`. + */ +export type CalendarInputValidation = ReturnType + +export type CalendarInputOnDateSelectPayload = + | { + calendarDateString: string + validation: CalendarInputValidation + } + | { + calendarDateString: null + validation: { valid: true } + } + export interface CalendarProps { /** * the calendar to use such gregory, ethiopic, nepali - full supported list here: https://github.com/dhis2/multi-calendar-dates/blob/main/src/constants/calendars.ts @@ -62,7 +81,14 @@ export interface CalendarProps { export const Calendar: React.FC export type CalendarInputProps = Omit & - CalendarProps & { + Omit & { + /** + * Called with `{ calendarDateString: null, validation: { valid: true } }` + * when the date is cleared, otherwise with `{ calendarDateString: string, validation }` + * where `validation` reflects the result of validating against `minDate`/`maxDate`/ + * `format` (see `CalendarInputValidation`), influenced by `strictValidation`. + */ + onDateSelect: (payload: CalendarInputOnDateSelectPayload) => void /** * Optional format for the date. Determines how the date is displayed * or processed. If not provided it supports both formats