diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 75b0e8fa7..c11d2183e 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,109 @@ One section per release, newest first, with only the changes that need action from you. The full record of every release, features and fixes included, is on [GitHub releases](https://github.com/raystack/apsara/releases). +## Unreleased: date filters compare whole days + +`DataView`, `DataTable` and `FilterChip` read and compare dates as calendar +days, without dayjs. A date filter's control is +[CalendarPreview](/docs/components/calendar-preview) instead of `DatePicker`. +`Calendar`, `DatePicker` and `RangePicker` are still exported and do not change. + +### 1. Check how your backend reads `stringValue` + +A date filter's `stringValue` is a day key, `'2026-08-15'`. It used to be local +midnight as a UTC instant, `'2026-08-14T18:30:00.000Z'` for a viewer in India. +`value` does not change. + +```ts +// Before +onTableQueryChange={query => { + // '2026-08-14T18:30:00.000Z' + const day = query.filters?.[0]?.stringValue?.slice(0, 10); // '2026-08-14' +}} + +// After +onTableQueryChange={query => { + const day = query.filters?.[0]?.stringValue; // '2026-08-15' +}} +``` + +If your backend took the date part of the old string, it read the day before +for every viewer east of UTC. The new value fixes that. A backend that expects a +full timestamp may read a date-only string differently, so test it. + +Saved filters and URL parameters that hold an old timestamp still load, as the +day that instant falls on in the viewer's time zone. + +DataView and DataTable compare and write the day in the viewer's time zone. If +you set `calendarProps.timeZone` or `filterProps.calendar.timeZone` to another +zone, a picked day can shift by one: 1 October picked in `Asia/Tokyo` is 30 +September for a viewer in New York. Leave `timeZone` unset for filters, or set +it to the viewer's zone. + +### 2. Move `calendarProps` to CalendarPreview props + +`FilterChip`'s `calendarProps` and `DataTable`'s `filterProps.calendar` take +CalendarPreview props now. `slotProps.popover` and `showCalendarIcon` work as +before. `slotProps.input` takes `CalendarPreview.Input` props, which have no +`value` or `defaultValue`. `onErrorChange` still reports `'Invalid date'`, but +the error clears only when the typed text is valid or empty, or a date is +committed. Closing the popup does not clear it. + +| Removed | Replacement | +|---|---| +| `dateFormat` | `formatValue(date, timeZone)`, which returns the label | +| `slotProps.calendar` / `calendarProps` | `minDate`, `maxDate`, `isDateUnavailable`, `defaultMonth`, `yearRange` | +| `inputProps` | `slotProps.input` | +| `popoverProps` | `slotProps.popover` | + +```tsx +// Before + + +// After + + date.toLocaleDateString("en-CA", { timeZone }), + maxDate: new Date() + }} +/> +``` + +`formatValue` changes only the label. The input still reads typed text in the +default formats, such as `15 Aug 2026`. + +### 3. Handle a cleared date + +A date filter can be cleared: click the selected day, or empty the input. The +chip then calls `onValueChange` with `''`. In `DataView` and `DataTable` the +chip stays, and the filter stops matching until a date is picked again. If you +render `FilterChip` yourself, handle `''`. + +### 4. Expect different rows from some filters + +These filters used to match by mistake, and now match differently: + +- A date filter with no value used to filter to today. It is now dropped. +- A date filter passed in `query` used to filter to today. It now filters by its + own date. +- A date filter on a day that does not exist, such as `2026-02-30`, used to roll + over to 2 March. It is now dropped. +- A row with no date used to be read as today, so it could match any operator, + depending on the filter day. It now matches only `neq`. +- A row holding a day that does not exist used to roll over to a real date. It + now matches only `neq`, and the timeline does not draw it. +- A row holding a numeric string or a boolean used to be read as a date, such + as the year 1792 for `'1786752000000'`. It now matches only `neq`, and the + timeline does not draw it. + ## 2.0: `Theme` is rewritten `Theme` used to put its tokens on `` from an effect, so it could not diff --git a/apps/www/src/content/docs/components/filter-chip/demo.ts b/apps/www/src/content/docs/components/filter-chip/demo.ts index 6852e3eb8..3bd964aba 100644 --- a/apps/www/src/content/docs/components/filter-chip/demo.ts +++ b/apps/www/src/content/docs/components/filter-chip/demo.ts @@ -123,18 +123,33 @@ export const autocompleteDemo = { }; export const calendarPropsDemo = { type: 'code', - code: ` + tabs: [ + { + name: 'Max date', + code: ` } columnType="date" calendarProps={{ - dateFormat: "YYYY-MM-DD", - slotProps: { - calendar: { captionLayout: "dropdown" } - } + maxDate: new Date() }} />` + }, + { + name: 'Custom format', + code: ` +} + columnType="date" + calendarProps={{ + formatValue: (date, timeZone) => + date.toLocaleDateString('en-CA', { timeZone }) + }} +/>` + } + ] }; export const iconDemo = { type: 'code', diff --git a/apps/www/src/content/docs/components/filter-chip/index.mdx b/apps/www/src/content/docs/components/filter-chip/index.mdx index e80495c79..0963dfd80 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -38,7 +38,7 @@ Use `selectProps` to enable autocomplete search on select and multiselect filter ### Date with calendarProps -Use `calendarProps` to forward DatePicker options such as `dateFormat`, `timeZone` and `slotProps.calendar` to the chip's date control. `value`, `onSelect`, and `defaultValue` are owned by `FilterChip`, and `children` is excluded so the chip's input trigger isn't replaced. +Use `calendarProps` to forward [CalendarPreview](/docs/components/calendar-preview) props such as `timeZone`, `minDate` and `maxDate` to the chip's date control. `formatValue(date, timeZone)` returns the label for the selected date. The chip is day-only, so the callback gets no scale. `slotProps.input` and `slotProps.popover` go to the date input and its popup. `FilterChip` owns the value and the calendar parts, and closes the popup when a day is picked. diff --git a/apps/www/src/content/docs/components/filter-chip/props.ts b/apps/www/src/content/docs/components/filter-chip/props.ts index a94709b86..8e9e68772 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -1,3 +1,6 @@ +import { CalendarPreviewInputProps } from '../calendar-preview/props'; +import { PopoverContentProps } from '../popover/props'; + export interface FilterChipProps { /** Text label for the filter (required) */ label: string; @@ -46,16 +49,30 @@ export interface FilterChipProps { defaultSearchValue?: string; }; - /** Props forwarded to the underlying DatePicker for `columnType="date"`. Refer to DatePicker for full props list. `dateFormat` defaults to `"DD MMM YYYY"`. */ + /** Props for the date control at `columnType="date"`. `timeZone` through `today` are CalendarPreview props. */ calendarProps?: { - dateFormat?: string; - showCalendarIcon?: boolean; + /** Formats the selected date for the input. The chip calls it with a `Date` and the `timeZone`. */ + formatValue?: (date: Date, timeZone?: string) => string; + /** The zone the calendar reads days in. DataView and DataTable filter in the viewer's zone, so a different zone can shift the filter day. */ timeZone?: string; + minDate?: Date; + maxDate?: Date; + isDateUnavailable?: (date: Date) => boolean; + yearRange?: { from: number; to: number }; + defaultMonth?: Date; + today?: Date; + /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). `input.disabled` and `input.readOnly` also apply to the calendar. */ slotProps?: { - input?: Record; - calendar?: Record; - popover?: Record; + input?: Omit; + popover?: Omit; }; + /** + * Shows the calendar icon in the date input. + * @default false + */ + showCalendarIcon?: boolean; + /** Called with a message when the typed date is invalid, and with `undefined` when it is valid again. */ + onErrorChange?: (error: string | undefined) => void; }; /** Additional CSS class names */ diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index d7acbce8f..da7e0db8d 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -72,6 +72,73 @@ and [Icons](https://apsara.raystack.io/docs/theme/icons). `IconComponent`, `IconProps`, `IconProviderProps`, and `IconProvider` itself. +### Date filters: DataView and DataTable compare whole days (BREAKING) + +DataView, DataTable and FilterChip read, compare and format dates +through CalendarPreview's date-fns adapter instead of dayjs. A date +filter compares whole days, and a date that is missing or does not exist +no longer stands in for another day. `dayjs` stays a dependency, because +`Calendar`, `DatePicker` and `RangePicker` still import it. + +#### Breaking changes + +- **A date filter's `stringValue` is a day key.** It is `'2026-08-15'`, + not `'2026-08-14T18:30:00.000Z'`. The old value was local midnight as + a UTC instant, so a backend that read the date part got the previous + day for any viewer east of UTC. `value` passes through unchanged. If + your backend parses `stringValue`, check it: a date-only string is + valid ISO 8601, but a parser that expects a timestamp may read it + differently. +- **A date filter with no value is dropped.** dayjs read an empty value + as today, so clearing a date filter filtered the rows to today. +- **A date filter restored from `query` filters by its date.** It lost + its type when the query was loaded, so the date comparison got no + date, and dayjs read that as today. +- **A date filter holding a day that does not exist is dropped.** dayjs + rolled `2026-02-30` over to 2 March and filtered on that. +- **A row whose date is missing matches only `neq`.** dayjs read it as + today, so the row matched `eq`, `lte` and `gte` on a filter day of + today, `lt` and `lte` on a later filter day, and `gt` and `gte` on an + earlier one. +- **A row holding a numeric string or a boolean matches only `neq`.** + dayjs read `'1786752000000'` as the year 1792 and `true` as 1970, and + compared those. A timeline row holding one is not drawn. +- **A row holding a day that does not exist matches only `neq`.** dayjs + rolled it to a real date and compared that, so `2026-02-30` matched + `lt` and `lte` against a filter day after 2 March, and `2026-13-01` + matched `gt` and `gte` against a filter day before 1 January 2027. A + timeline row holding one is not drawn. +- **`neq` still matches a row with a missing or unreadable date.** + +Saved filters and URL parameters that hold an ISO timestamp are still +read, as the day the instant falls on in the viewer's zone. There is no +migration: the next query a stored filter produces carries a day key. + +### FilterChip: the date control is CalendarPreview (BREAKING) + +A date FilterChip renders `CalendarPreview` instead of `DatePicker`, so +the date filters in DataView and DataTable do too. No component inside +Apsara renders `Calendar`, `DatePicker` or `RangePicker` now. + +#### Breaking changes + +- **`calendarProps` takes CalendarPreview props.** `FilterChipCalendarProps` + is `formatValue`, `timeZone`, `minDate`, `maxDate`, `isDateUnavailable`, + `yearRange`, `defaultMonth` and `today`, plus `slotProps.input`, + `slotProps.popover`, `showCalendarIcon` and `onErrorChange`. + `formatValue(date, timeZone)` takes the date and returns its label. + `slotProps.input` takes `CalendarPreview.Input` props, which have no + `value` or `defaultValue`. `onErrorChange` still reports + `'Invalid date'`, and its error no longer clears when the popup closes, + only when the typed text is valid or empty, or a date is committed. `dateFormat`, `slotProps.calendar`, `inputProps`, + `calendarProps` and `popoverProps` are gone. Replace `dateFormat` with + `formatValue`, move calendar options such as `disabled` days to + `minDate`, `maxDate` or `isDateUnavailable`, and move `inputProps` and + `popoverProps` to `slotProps.input` and `slotProps.popover`. + DataTable's `filterProps.calendar` has the same type. +- **A date filter can be cleared.** Clicking the selected day or emptying + the input clears it, and `onValueChange` receives `''`. + ## 0.49.0 ### Calendar / DatePicker / RangePicker improvements (PR #819) diff --git a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx index dd9e2aecb..fe6cba67f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -56,6 +56,24 @@ describe('CalendarPreview root', () => { error.mockRestore(); }); + it('does not warn when a controlled value that started empty is set', () => { + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const { rerender } = render( + + + + ); + rerender( + + + + ); + expect(error).not.toHaveBeenCalled(); + error.mockRestore(); + }); + it('commits a clicked day and reports the period and the day acted on', () => { const onValueChange = vi.fn(); const { container } = renderCalendar(undefined, { onValueChange }); diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index 9648b5d96..f45d165d7 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -15,6 +17,8 @@ import { parseKey, shiftMonths, startOfMonthKey, + toDayKey, + toInstant, yearOf } from '../date-adapter'; @@ -243,3 +247,197 @@ describe('monthShortNames', () => { }); }); }); + +/* Expected values were captured from `dayjs(v)` in UTC, Asia/Kolkata and + America/Los_Angeles before the migration. */ +describe('toInstant', () => { + it.each([ + ['an ISO day', '2023-12-01', new Date(2023, 11, 1)], + ['a year and month', '2023-12', new Date(2023, 11, 1)], + ['a bare year', '2023', new Date(2023, 0, 1)], + ['basic ISO', '20231201', new Date(2023, 11, 1)], + ['a local time', '2023-12-01T10:30:00', new Date(2023, 11, 1, 10, 30)], + [ + 'a space before the time', + '2023-12-01 10:30', + new Date(2023, 11, 1, 10, 30) + ], + ['a lowercase t', '2023-12-01t10:30:00', new Date(2023, 11, 1, 10, 30)], + ['slashes', '2023/12/01', new Date(2023, 11, 1)], + ['an unpadded day', '2023-1-5', new Date(2023, 0, 5)], + ['an unpadded slashed day', '2023/1/5', new Date(2023, 0, 5)], + ['a month-first slashed day', '12/01/2023', new Date(2023, 11, 1)], + [ + 'a day-first slashed day, read month-first', + '01/12/2023', + new Date(2023, 0, 12) + ], + ['a long month name', 'December 1, 2023', new Date(2023, 11, 1)], + ['a short month name', '1 Dec 2023', new Date(2023, 11, 1)], + ['a dotted day', '2023.12.01', new Date(2023, 11, 1)], + ['surrounding whitespace', ' 2023-12-01 ', new Date(2023, 11, 1)], + ['a Date', new Date(2023, 11, 1), new Date(2023, 11, 1)], + ['an epoch', Date.UTC(2023, 11, 1), new Date(Date.UTC(2023, 11, 1))], + ['epoch zero', 0, new Date(0)], + [ + 'a UTC instant', + '2023-12-01T10:30:00Z', + new Date(Date.UTC(2023, 11, 1, 10, 30)) + ], + [ + 'microseconds', + '2023-12-01T10:30:00.123456Z', + new Date(Date.UTC(2023, 11, 1, 10, 30, 0, 123)) + ], + [ + 'an offset', + '2023-12-01T10:30:00+05:30', + new Date(Date.UTC(2023, 11, 1, 5, 0)) + ], + [ + 'a negative offset', + '2023-11-30T20:00:00-05:00', + new Date(Date.UTC(2023, 11, 1, 1, 0)) + ], + [ + 'a Date string', + 'Fri Dec 01 2023 00:00:00 GMT+0000', + new Date(Date.UTC(2023, 11, 1)) + ] + ])('reads %s as dayjs did', (_label, input, expected) => { + expect(toInstant(input)?.getTime()).toBe(expected.getTime()); + }); + + it.each([ + ['an empty string', ''], + ['whitespace', ' '], + ['a non-date string', 'not a date'], + ['null', null], + ['an invalid Date', new Date(Number.NaN)], + ['an object', { date: '2023-12-01' }] + ])('rejects %s as dayjs did', (_label, input) => { + expect(toInstant(input)).toBeNull(); + }); + + /* dayjs rolled each of these into a neighbouring day or month. */ + it.each([ + ['month 13', '2023-13-01'], + ['month 0', '2023-00-10'], + ['day 0', '2023-12-00'], + ['day 32', '2023-12-32'], + ['30 February', '2023-02-30'], + ['an epoch as a string', '1701388800000'] + ])('rejects %s where dayjs rolled it over', (_label, input) => { + expect(toInstant(input)).toBeNull(); + }); + + it.each([ + '2023-02-30', + '2026-13-01', + '2023-02-30T00:00:00Z', + '2026-02-30T12:00:00+05:30' + ])('rejects the impossible day in %s', input => { + expect(toInstant(input)).toBeNull(); + }); + + it('reads a real day with a zone suffix', () => { + expect(toInstant('2023-02-28T00:00:00Z')?.getTime()).toBe( + Date.UTC(2023, 1, 28) + ); + }); + + it.each([ + '02/30/2014', + '2/30/2026 10:30', + '2-30-2026', + '2 30 2026', + '2026 2 30', + 'Tue 2/30/2026', + 'February 30, 2026', + 'Feb. 30, 2026', + '30 Feb 2026', + '30-Feb-2026', + '2026 Feb 30', + 'Sep 31, 2026', + 'Sept 31, 2026', + '2023.02.30', + 'Tue Feb 30 2026 10:30:00 GMT+0530', + 'Feb 30 2026 10:00 PST', + 'February 30, 2026 3', + '2/30/2026 3' + ])('rejects the impossible day in %s', input => { + expect(toInstant(input)).toBeNull(); + }); + + it.each([ + ['02/29/2024', new Date(2024, 1, 29)], + ['2-28-2026', new Date(2026, 1, 28)], + ['Sept 30, 2026', new Date(2026, 8, 30)], + ['Monday, March 2, 2026', new Date(2026, 2, 2)], + ['Dec 2023', new Date(2023, 11, 1)], + ['Fri, 01 Dec 2023 00:00:00 GMT', new Date(Date.UTC(2023, 11, 1))], + [ + 'Fri Dec 01 2023 02:00:00 GMT+0530', + new Date(Date.UTC(2023, 10, 30, 20, 30)) + ], + ['Nov 30 2026 23:00 EST', new Date(Date.UTC(2026, 11, 1, 4))], + ['Dec 1 2026 01:00 EST', new Date(Date.UTC(2026, 11, 1, 6))] + ])('reads the real day in %s as dayjs did', (input, expected) => { + expect(toInstant(input)?.getTime()).toBe(expected.getTime()); + }); + + it('keeps milliseconds from a longer fraction in a local time', () => { + expect(toInstant('2023/12/01 10:30:00.123456')?.getTime()).toBe( + new Date(2023, 11, 1, 10, 30, 0, 123).getTime() + ); + }); + + /* dayjs read `undefined` as now, so an unset date filter matched today. */ + it('rejects undefined', () => { + expect(toInstant(undefined)).toBeNull(); + }); + + it('rejects a boolean, which dayjs read as epoch zero', () => { + expect(toInstant(true)).toBeNull(); + }); + + /* dayjs rejected or misread these ISO 8601 forms. */ + it.each([ + ['an ISO week', '2023-W48', new Date(2023, 10, 27)], + ['an ordinal day', '2023-335', new Date(2023, 11, 1)], + [ + 'a one-digit fraction', + '2023-12-01T10:30:00.5', + new Date(2023, 11, 1, 10, 30, 0, 500) + ], + [ + 'a year below 100', + '0050-01-01', + new Date(new Date(0, 0, 1).setFullYear(50)) + ] + ])('reads %s as ISO 8601', (_label, input, expected) => { + expect(toInstant(input)?.getTime()).toBe(expected.getTime()); + }); +}); + +/* Safari before 16.4 throws on a lookbehind when the module loads. */ +it('uses no regex lookbehind', () => { + const source = readFileSync(resolve(__dirname, '../date-adapter.ts'), 'utf8'); + expect(source).not.toMatch(/\(\?<[=!]/); +}); + +describe('toDayKey', () => { + it('reads the local calendar day', () => { + expect(toDayKey('2023-12-01')).toBe('2023-12-01'); + expect(toDayKey(new Date(2023, 11, 1, 23, 59))).toBe('2023-12-01'); + }); + + it('rejects what toInstant rejects', () => { + expect(toDayKey('2023-02-30')).toBeNull(); + expect(toDayKey(undefined)).toBeNull(); + }); + + it('rejects a year outside four digits', () => { + expect(toDayKey(new Date(10000, 0, 1))).toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts index 1c2a55bfe..6dd4eb1f5 100644 --- a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { defaultFormatValue } from '../calendar-preview-root'; import { parseScaleInput } from '../lib/parse'; /* Fixed so the year-inference tests do not change meaning on 1 January. */ @@ -235,3 +236,17 @@ describe('parseScaleInput — rejections', () => { expect(parseScaleInput(input, IN_2026)).toBeNull(); }); }); + +describe('parseScaleInput reads what defaultFormatValue displays', () => { + it.each([ + { date: '2026-08-15', scale: 'day' }, + { date: '2026-08-01', scale: 'month' }, + { date: '2026-07-01', scale: 'quarter' }, + { date: '2026-07-01', scale: 'halfYear' }, + { date: '2026-01-01', scale: 'year' } + ] as const)('round-trips $scale', value => { + expect(parseScaleInput(defaultFormatValue(value, value.scale))).toEqual( + value + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index bfb4147a2..3d246aa3c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { getAllSlots, getSlot } from '~/test-utils/data-slots'; import { Field } from '../../field'; @@ -89,6 +90,88 @@ describe('CalendarPreview picker composition', () => { expect(isOpen()).toBe(true); }); + it('stays shut when a controlled open closes it from a picked day', async () => { + const onOpenChange = vi.fn(); + function Controlled() { + const [open, setOpen] = useState(false); + return ( + { + onOpenChange(next); + setOpen(next); + }} + onValueChange={() => setOpen(false)} + > + + + + + + + + ); + } + const { container } = render(); + const input = getSlot(container, 'calendar-preview-input') as HTMLElement; + act(() => input.focus()); + expect(isOpen()).toBe(true); + const day = getAllSlots(document.body, 'calendar-preview-day').find( + cell => + getSlot(cell, 'calendar-preview-day-number')?.textContent === '12' && + !cell.hasAttribute('data-outside') + ) as HTMLElement; + fireEvent.pointerDown(day); + act(() => day.focus()); + fireEvent.click(day); + await act(() => new Promise(resolve => setTimeout(resolve, 100))); + expect(isOpen()).toBe(false); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('stays shut when a controlled open closes it after ignoring a close', async () => { + let close: () => void = () => undefined; + function Controlled() { + const [open, setOpen] = useState(false); + close = () => setOpen(false); + return ( + { + if (next) setOpen(true); + }} + > + + + + + + + + ); + } + const { container } = render(); + const input = getSlot(container, 'calendar-preview-input') as HTMLElement; + act(() => input.focus()); + const day = getAllSlots(document.body, 'calendar-preview-day').find( + cell => + getSlot(cell, 'calendar-preview-day-number')?.textContent === '12' && + !cell.hasAttribute('data-outside') + ) as HTMLElement; + act(() => day.focus()); + fireEvent.keyDown(day, { key: 'Escape' }); + await act(() => new Promise(resolve => setTimeout(resolve, 10))); + expect(isOpen()).toBe(true); + fireEvent.keyDown(day, { key: 'Shift' }); + act(() => close()); + await act(() => new Promise(resolve => setTimeout(resolve, 100))); + expect(isOpen()).toBe(false); + }); + it('opens on focus again after Escape closed it', () => { const { input } = renderPicker(); /* Real focus, so the guard can see where it is; the event drives it. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 54a327ea7..47ce44988 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -4,7 +4,14 @@ import { mergeProps, Popover, useRender } from '@base-ui/react'; import { REASONS } from '@base-ui/react/internals/reasons'; import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; import styles from './calendar-preview.module.css'; import { type CalendarPreviewChangeDetails, @@ -230,14 +237,19 @@ export function CalendarPreviewRoot({ state: 'value' }); - const [month, setMonthUnwrapped] = useControlled({ - controlled: monthProp, - /* `valueProp` first: `defaultValue` is nulled once `value` is controlled. */ - default: + /* Read once: a controlled `value` that starts empty would otherwise move the default. */ + const [initialMonth] = useState( + () => + /* `valueProp` first: `defaultValue` is nulled once `value` is controlled. */ defaultMonth ?? monthAnchor(valueProp) ?? monthAnchor(defaultValue) ?? - today, + today + ); + + const [month, setMonthUnwrapped] = useControlled({ + controlled: monthProp, + default: initialMonth, name: 'CalendarPreview', state: 'month' }); @@ -249,16 +261,20 @@ export function CalendarPreviewRoot({ return list.length > 0 ? Array.from(new Set(list)) : ['day']; }, [scalesProp]); - const [scale, setScaleUnwrapped] = useControlled({ - controlled: scaleProp, - /* The value's own scale, or a quarter opens on the day grid unmarked. */ - default: + const [initialScale] = useState( + () => + /* The value's own scale, or a quarter opens on the day grid unmarked. */ defaultScale ?? (isScaleValue(valueProp) ? valueProp.scale : isScaleValue(defaultValue) ? defaultValue.scale - : scales[0]), + : scales[0]) + ); + + const [scale, setScaleUnwrapped] = useControlled({ + controlled: scaleProp, + default: initialScale, name: 'CalendarPreview', state: 'scale' }); @@ -374,14 +390,20 @@ export function CalendarPreviewRoot({ const settleScaleRef = useRef<((scale: Scale) => void) | null>(null); const dismissedByOutsidePress = useRef(false); + const closingThroughSetOpen = useRef(false); const setOpen = useCallback( (next: boolean, details: CalendarPreviewOpenChangeDetails) => { + closingThroughSetOpen.current = !next; if (!next) { const outside = details.reason === REASONS.outsidePress; dismissedByOutsidePress.current = outside; armFocusGuard(outside); dropDraftRef.current?.(); + /* A controlled parent can keep `open`, so the mark must not outlive this close. */ + setTimeout(() => { + closingThroughSetOpen.current = false; + }); } setOpenUnwrapped(next); onOpenChange?.(next, details); @@ -389,6 +411,18 @@ export function CalendarPreviewRoot({ [setOpenUnwrapped, onOpenChange, armFocusGuard] ); + /* A controlled `open` can close without `setOpen`, and the focus Base UI gives back would reopen it. */ + const wasOpen = useRef(open); + useLayoutEffect(() => { + if (wasOpen.current && !open && !closingThroughSetOpen.current) { + dismissedByOutsidePress.current = false; + armFocusGuard(false); + dropDraftRef.current?.(); + } + closingThroughSetOpen.current = false; + wasOpen.current = open; + }, [open, armFocusGuard]); + /* Base UI returns focus to the trigger's first tabbable child — the `.Input`. */ const shouldRestoreFinalFocus = useCallback( () => !dismissedByOutsidePress.current, diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 9e5320e27..a37c0b0ae 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -3,11 +3,20 @@ import { TZDate } from '@date-fns/tz'; import { addDays, addMonths, + addQuarters, + addWeeks, + addYears, endOfMonth, format, + getQuarter, isValid, parse, - startOfMonth + parseISO, + startOfDay, + startOfMonth, + startOfQuarter, + startOfWeek, + startOfYear } from 'date-fns'; export type DayKey = string; @@ -26,6 +35,146 @@ export function dayKey(date: Date, timeZone?: string): DayKey { return key; } +export function toDayKey(value: unknown): DayKey | null { + const date = toInstant(value); + if (!date) return null; + try { + return dayKey(date); + } catch { + return null; + } +} + +export function toInstant(value: unknown): Date | null { + if (value instanceof Date) return isValid(value) ? value : null; + if (typeof value === 'number') { + const fromEpoch = new Date(value); + return isValid(fromEpoch) ? fromEpoch : null; + } + if (typeof value !== 'string') return null; + const iso = parseISO(value); + if (isValid(iso)) return iso; + const isoDay = ISO_DAY.exec(value); + if (isoDay && !isDayKey(isoDay[1])) return null; + const parts = LOCAL_SHAPE.exec(value); + if (parts) return fromLocalParts(parts); + const native = new Date(value); + return isValid(native) && writesMonthOf(value, native) ? native : null; +} + +/* `new Date` rolls an impossible day over, whatever suffix follows it. */ +const ISO_DAY = /^(\d{4}-\d{2}-\d{2})/; + +/* A numeric offset counts only after GMT, UTC or a time, so `2-30-2026` + does not read as offset -20:26. The names are the zones V8 reads. The + prefixes are tested in code: Safari before 16.4 has no regex lookbehind. */ +const OFFSET = /([+-])(\d{2}):?(\d{2})(?!\d)/g; +const AFTER_ZONE_NAME = /\b(?:GMT|UTC|UT)\s*$/i; +const AFTER_TIME = /\d:\d{2}(?::\d{2}(?:\.\d+)?)?\s?$/; +const ZONE_NAME = /\b(UTC|UT|GMT|[ECMP][SD]T)\b/gi; +const ZULU = /\d(Z)\b/gi; +const ZONE_OFFSETS: Record = { + UT: 0, + UTC: 0, + GMT: 0, + Z: 0, + EST: -300, + EDT: -240, + CST: -360, + CDT: -300, + MST: -420, + MDT: -360, + PST: -480, + PDT: -420 +}; +const TIME = /\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?/g; +const MONTH_PREFIXES = 'janfebmaraprmayjunjulaugsepoctnovdec'; + +type Zone = { start: number; end: number; offset: number }; + +/* The leftmost zone, and the longest at that position. */ +function findZone(value: string): Zone | null { + const zones: Zone[] = []; + for (const match of value.matchAll(OFFSET)) { + const before = value.slice(0, match.index); + const name = AFTER_ZONE_NAME.exec(before); + if (!name && !AFTER_TIME.test(before)) continue; + const sign = match[1] === '-' ? -1 : 1; + zones.push({ + start: name ? name.index : match.index, + end: match.index + match[0].length, + offset: sign * (Number(match[2]) * 60 + Number(match[3])) + }); + } + for (const match of value.matchAll(ZONE_NAME)) { + zones.push({ + start: match.index, + end: match.index + match[0].length, + offset: ZONE_OFFSETS[match[1].toUpperCase()] + }); + } + for (const match of value.matchAll(ZULU)) { + zones.push({ start: match.index + 1, end: match.index + 2, offset: 0 }); + } + zones.sort((a, b) => a.start - b.start || b.end - a.end); + return zones[0] ?? null; +} + +/* The month a named month word writes, or `null` for none. */ +function monthOfWord(word: string): number | null { + if (word.length < 3) return null; + const at = MONTH_PREFIXES.indexOf(word.slice(0, 3).toLowerCase()); + return at >= 0 && at % 3 === 0 ? at / 3 : null; +} + +/* `new Date` rolls an impossible day into the next month, in any form it + reads. A result in a month the string never writes is that rollover. */ +function writesMonthOf(value: string, date: Date): boolean { + const zone = findZone(value); + const month = zone + ? new Date(date.getTime() + zone.offset * 60_000).getUTCMonth() + : date.getMonth(); + const datePart = ( + zone ? `${value.slice(0, zone.start)} ${value.slice(zone.end)}` : value + ).replace(TIME, ' '); + const named = (datePart.match(/[A-Za-z]+/g) ?? []) + .map(monthOfWord) + .filter(word => word !== null); + if (named.length > 0) return named.includes(month); + /* A number after the year is not a month, so `2/30/2026 3` is not March. */ + const numbers = datePart.match(/\d+/g) ?? []; + const yearAt = numbers.findIndex(number => number.length >= 3); + const candidates = + yearAt === 0 + ? numbers.slice(1, 3) + : yearAt > 0 + ? numbers.slice(0, yearAt) + : numbers; + return candidates.map(Number).includes(month + 1); +} + +/* The shape dayjs parsed as local time. `new Date` reads some of these as UTC + and rolls out-of-range fields over, so they never reach it. */ +const LOCAL_SHAPE = + /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/; + +function fromLocalParts(parts: RegExpExecArray): Date | null { + const year = Number(parts[1]); + const [month = 1, day = 1, hour = 0, minute = 0, second = 0] = parts + .slice(2, 7) + .map(part => (part ? Number(part) : undefined)); + const ms = Number((parts[7] ?? '0').slice(0, 3)); + const date = new Date(year, month - 1, day, hour, minute, second, ms); + const readsBack = + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day && + date.getHours() === hour && + date.getMinutes() === minute && + date.getSeconds() === second; + return readsBack ? date : null; +} + export function isDayKey(value: string): boolean { return DAY_KEY_SHAPE.test(value) && isValid(parseStrict(value)); } @@ -118,6 +267,43 @@ export function formatWeekdayLabel(date: Date, timeZone?: string): string { return format(zoned(date, timeZone), 'EEE'); } +/* `TimelineScale` has `week` and stops at quarter, so it cannot share `lib/scale.ts`. */ +export const startOfUnit = { + day: startOfDay, + week: startOfWeek, + month: startOfMonth, + quarter: startOfQuarter, + year: startOfYear +} as const; + +export const addUnit = { + day: addDays, + week: addWeeks, + month: addMonths, + quarter: addQuarters, + year: addYears +} as const; + +export function formatDayOfMonth(date: Date): string { + return format(date, 'd'); +} + +export function formatMonthShort(date: Date): string { + return format(date, 'MMM'); +} + +export function formatDayMonth(date: Date): string { + return format(date, 'd MMM'); +} + +export function formatYear(date: Date): string { + return format(date, 'yyyy'); +} + +export function formatQuarterShort(date: Date): string { + return `Q${getQuarter(date)}`; +} + /* Same locale as monthFromName, so the column and the parser cannot disagree. */ export function monthShortNames(): string[] { return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMM')); diff --git a/packages/raystack/components/data-table/__tests__/data-table.test.tsx b/packages/raystack/components/data-table/__tests__/data-table.test.tsx index f1b6d83d3..0c3b25a01 100644 --- a/packages/raystack/components/data-table/__tests__/data-table.test.tsx +++ b/packages/raystack/components/data-table/__tests__/data-table.test.tsx @@ -1,9 +1,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; -import { DataTableColumnDef } from '../data-table.types'; +import { DataTableColumnDef, DataTableQuery } from '../data-table.types'; +import { useDataTable } from '../hooks/useDataTable'; beforeAll(() => { global.IntersectionObserver = vi.fn().mockImplementation(() => ({ @@ -667,4 +668,101 @@ describe('DataTable', () => { expect(screen.getByText('John Doe')).toBeInTheDocument(); }); }); + + describe('Restored date filters', () => { + type Dated = { id: string; when?: string }; + const dated: Dated[] = [ + { id: 'd14', when: '2026-08-14' }, + { id: 'd15', when: '2026-08-15' }, + { id: 'd16', when: '2026-08-16' }, + { id: 'dNone' } + ]; + const datedColumns: DataTableColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'when', header: 'When', filterType: 'date' } + ]; + const originalTimeZone = process.env.TZ; + afterEach(() => { + process.env.TZ = originalTimeZone; + }); + + const Rows = () => { + const { table } = useDataTable(); + return ( + + {table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + ); + }; + + const renderRestored = ( + filters: NonNullable, + mode: 'client' | 'server' = 'client', + onTableQueryChange?: (query: DataTableQuery) => void + ) => + render( + + + + ); + + it.each([ + ['eq', 'd15'], + ['neq', 'd14,d16,dNone'], + ['lt', 'd14'], + ['gt', 'd16'] + ] as const)('filters rows by a restored %s date filter', (operator, rows) => { + renderRestored([{ name: 'when', operator, value: '2026-08-15' }]); + expect(screen.getByTestId('rows')).toHaveTextContent(rows); + }); + + it('reads a restored ISO instant as the day the viewer picked', () => { + process.env.TZ = 'Asia/Kolkata'; + renderRestored([ + { name: 'when', operator: 'eq', value: '2026-08-14T18:30:00.000Z' } + ]); + expect(screen.getByTestId('rows')).toHaveTextContent('d15'); + }); + + it('emits a restored date filter as a day key', () => { + const onTableQueryChange = vi.fn(); + renderRestored( + [{ name: 'when', operator: 'eq', value: new Date(2026, 7, 15) }], + 'server', + onTableQueryChange + ); + const last = onTableQueryChange.mock.calls[ + onTableQueryChange.mock.calls.length - 1 + ]?.[0] as DataTableQuery; + expect(last.filters?.[0]?.stringValue).toBe('2026-08-15'); + }); + + it('leaves a restored string filter as it was sent', () => { + const onTableQueryChange = vi.fn(); + renderRestored( + [{ name: 'id', operator: 'eq', value: 'd15' }], + 'server', + onTableQueryChange + ); + const last = onTableQueryChange.mock.calls[ + onTableQueryChange.mock.calls.length - 1 + ]?.[0] as DataTableQuery; + expect(last.filters?.[0]).toEqual({ + name: 'id', + operator: 'eq', + value: 'd15', + stringValue: 'd15' + }); + }); + }); }); diff --git a/packages/raystack/components/data-table/data-table.tsx b/packages/raystack/components/data-table/data-table.tsx index 42369cc62..087203b27 100644 --- a/packages/raystack/components/data-table/data-table.tsx +++ b/packages/raystack/components/data-table/data-table.tsx @@ -53,8 +53,8 @@ function DataTableRoot({ getRowId }: React.PropsWithChildren>) { const defaultTableQuery = useMemo( - () => getDefaultTableQuery(defaultSort, query), - [defaultSort, query] + () => getDefaultTableQuery(defaultSort, query, columns), + [defaultSort, query, columns] ); const initialColumnVisibility = getInitialColumnVisibility(columns); diff --git a/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx b/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx index 79f75cb54..e744f4de2 100644 --- a/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx +++ b/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx @@ -1,5 +1,4 @@ import type { Row } from '@tanstack/table-core'; -import dayjs from 'dayjs'; import { describe, expect, it, vi } from 'vitest'; import { EmptyFilterValue, FilterType } from '~/types/filters'; import { @@ -344,6 +343,28 @@ describe('Filter Operations', () => { expect(result).toBe(true); }); + + it('should match an unreadable row with neq', () => { + const result = filterOperationsMap.date.neq( + createMockRow('not a date'), + 'createdAt', + { date: new Date(2023, 11, 1) }, + addMeta + ); + + expect(result).toBe(true); + }); + + it('should not match an unreadable row with gt', () => { + const result = filterOperationsMap.date.gt( + createMockRow(undefined), + 'createdAt', + { date: new Date(2023, 11, 1) }, + addMeta + ); + + expect(result).toBe(false); + }); }); describe('Select Filter Operations', () => { @@ -614,15 +635,13 @@ describe('Filter Operations', () => { }); it('should handle date filter type', () => { - const date = new Date('2023-12-01'); + const date = new Date(2023, 11, 1); const result = getFilterValue({ value: date, filterType: FilterType.date }); - expect(result).toEqual({ - value: date, - stringValue: date.toISOString() - }); + expect(result.value).toBe(date); + expect(result.stringValue).toBe('2023-12-01'); }); it('should return empty stringValue for invalid date', () => { diff --git a/packages/raystack/components/data-table/utils/__tests__/index.test.tsx b/packages/raystack/components/data-table/utils/__tests__/index.test.tsx index 7fa39325b..f526ab4be 100644 --- a/packages/raystack/components/data-table/utils/__tests__/index.test.tsx +++ b/packages/raystack/components/data-table/utils/__tests__/index.test.tsx @@ -187,6 +187,23 @@ describe('Data Table Utils', () => { expect(result.columnFilters).toHaveLength(0); }); + /* dayjs read `undefined` as now, so this filter was kept and matched today. */ + it('should filter out a date filter with no value', () => { + const query: InternalQuery = { + filters: [ + { + name: 'createdAt', + operator: 'eq', + value: undefined, + _type: FilterType.date + } + ] + }; + const result = queryToTableState(query); + + expect(result.columnFilters).toHaveLength(0); + }); + it('should keep valid date filters', () => { const query: InternalQuery = { filters: [ @@ -691,8 +708,24 @@ describe('Data Table Utils', () => { expect(result.filters).toHaveLength(0); }); + it('should filter out a date filter with no value', () => { + const query: InternalQuery = { + filters: [ + { + name: 'createdAt', + operator: 'eq', + value: undefined, + _type: FilterType.date + } + ] + }; + + const result = transformToDataTableQuery(query); + expect(result.filters).toHaveLength(0); + }); + it('should keep valid date filters', () => { - const date = new Date('2023-12-01'); + const date = new Date(2023, 11, 1); const query: InternalQuery = { filters: [ { @@ -707,7 +740,7 @@ describe('Data Table Utils', () => { const result = transformToDataTableQuery(query); expect(result.filters).toHaveLength(1); expect(result.filters![0].name).toBe('createdAt'); - expect(result.filters![0].stringValue).toBe(date.toISOString()); + expect(result.filters![0].stringValue).toBe('2023-12-01'); }); it('should preserve other query properties', () => { diff --git a/packages/raystack/components/data-table/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index 169e74d9c..9adbf165a 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -1,8 +1,7 @@ import type { FilterFn } from '@tanstack/table-core'; -import dayjs from 'dayjs'; -import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; -import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { notOnDay, onDay } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -19,9 +18,6 @@ import { } from '~/types/filters'; import { DataTableFilterValues } from '../data-table.types'; -dayjs.extend(isSameOrAfter); -dayjs.extend(isSameOrBefore); - export type FilterPrimitive = string | string[] | number | boolean | Date; export type FilterFunctionsMap = { @@ -83,42 +79,12 @@ export const filterOperationsMap: FilterFunctionsMap = { } }, date: { - eq: (row, columnId, filterValue: FilterValue, _addMeta) => { - return dayjs(row.getValue(columnId)).isSame( - dayjs(filterValue.date), - 'day' - ); - }, - neq: (row, columnId, filterValue: FilterValue, _addMeta) => { - return !dayjs(row.getValue(columnId)).isSame( - dayjs(filterValue.date), - 'day' - ); - }, - lt: (row, columnId, filterValue: FilterValue, _addMeta) => { - return dayjs(row.getValue(columnId)).isBefore( - dayjs(filterValue.date), - 'day' - ); - }, - lte: (row, columnId, filterValue: FilterValue, _addMeta) => { - return dayjs(row.getValue(columnId)).isSameOrBefore( - dayjs(filterValue.date), - 'day' - ); - }, - gt: (row, columnId, filterValue: FilterValue, _addMeta) => { - return dayjs(row.getValue(columnId)).isAfter( - dayjs(filterValue.date), - 'day' - ); - }, - gte: (row, columnId, filterValue: FilterValue, _addMeta) => { - return dayjs(row.getValue(columnId)).isSameOrAfter( - dayjs(filterValue.date), - 'day' - ); - } + eq: onDay((day, filterDay) => day === filterDay), + neq: notOnDay, + lt: onDay((day, filterDay) => day < filterDay), + lte: onDay((day, filterDay) => day <= filterDay), + gt: onDay((day, filterDay) => day > filterDay), + gte: onDay((day, filterDay) => day >= filterDay) }, select: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { @@ -169,19 +135,8 @@ const handleStringBasedTypes = ( ): DataTableFilterValues => { switch (filterType) { case FilterType.date: { - const dateValue = dayjs(value as string | Date); - let stringValue = ''; - if (dateValue.isValid()) { - try { - stringValue = dateValue.toISOString(); - } catch { - stringValue = ''; - } - } - return { - value, - stringValue - }; + const day = toDayKey(value); + return { value, stringValue: day ?? '' }; } case FilterType.select: return { diff --git a/packages/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index 299093d42..c1fff47f9 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -1,7 +1,8 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; -import dayjs from 'dayjs'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { withDateFilterTypes } from '~/shared/date-filters'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, @@ -25,7 +26,7 @@ export function queryToTableState(query: InternalQuery): Partial { query.filters ?.filter(data => { if (data._type === FilterType.date) - return dayjs(data.value as string | Date).isValid(); + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -223,7 +224,7 @@ export function transformToDataTableQuery( ?.filter(data => { if (data._type === FilterType.select) return true; if (data._type === FilterType.date) - return dayjs(data.value as string | Date).isValid(); + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -346,7 +347,11 @@ export function hasActiveTableFiltering(table: Table): boolean { export function getDefaultTableQuery( defaultSort?: DataTableSort, - oldQuery: DataTableQuery = {} + oldQuery: DataTableQuery = {}, + columns: Pick< + DataTableColumnDef, + 'accessorKey' | 'filterType' + >[] = [] ): InternalQuery { // Convert DataTableQuery to InternalQuery const internalQuery = dataTableQueryToInternal(oldQuery); @@ -354,6 +359,9 @@ export function getDefaultTableQuery( return { sort: defaultSort ? [defaultSort] : [], group_by: [defaultGroupOption.id], - ...internalQuery + ...internalQuery, + ...(internalQuery.filters && { + filters: withDateFilterTypes(internalQuery.filters, columns) + }) }; } diff --git a/packages/raystack/components/data-view/__tests__/data-view.test.tsx b/packages/raystack/components/data-view/__tests__/data-view.test.tsx index 9e98859d4..7ad5b9a25 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -7,6 +7,7 @@ import { DataView } from '../data-view'; import type { DataViewField, DataViewListColumn, + DataViewQuery, ViewSpec } from '../data-view.types'; import { useDataView as useDataViewForTest } from '../hooks/useDataView'; @@ -1190,4 +1191,103 @@ describe('DataView', () => { expect(receivedRowOriginal).toMatchObject({ id: 3 }); }); }); + + describe('Restored date filters', () => { + type Dated = { id: string; when?: string }; + const dated: Dated[] = [ + { id: 'd14', when: '2026-08-14' }, + { id: 'd15', when: '2026-08-15' }, + { id: 'd16', when: '2026-08-16' }, + { id: 'dNone' } + ]; + const datedFields: DataViewField[] = [ + { accessorKey: 'id', label: 'ID', filterable: true }, + { + accessorKey: 'when', + label: 'When', + filterable: true, + filterType: 'date' + } + ]; + const originalTimeZone = process.env.TZ; + afterEach(() => { + process.env.TZ = originalTimeZone; + }); + + const renderRestored = ( + filters: NonNullable, + mode: 'client' | 'server' = 'client', + onTableQueryChange?: (query: DataViewQuery) => void + ) => + render( + + + {ctx => ( + + {ctx.table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + )} + + + ); + + it.each([ + ['eq', 'd15'], + ['neq', 'd14,d16,dNone'], + ['lt', 'd14'], + ['gt', 'd16'] + ] as const)('filters rows by a restored %s date filter', (operator, rows) => { + renderRestored([{ name: 'when', operator, value: '2026-08-15' }]); + expect(screen.getByTestId('rows')).toHaveTextContent(rows); + }); + + it('reads a restored ISO instant as the day the viewer picked', () => { + process.env.TZ = 'Asia/Kolkata'; + renderRestored([ + { name: 'when', operator: 'eq', value: '2026-08-14T18:30:00.000Z' } + ]); + expect(screen.getByTestId('rows')).toHaveTextContent('d15'); + }); + + it('emits a restored date filter as a day key', () => { + const onTableQueryChange = vi.fn(); + renderRestored( + [{ name: 'when', operator: 'eq', value: new Date(2026, 7, 15) }], + 'server', + onTableQueryChange + ); + const last = onTableQueryChange.mock.calls[ + onTableQueryChange.mock.calls.length - 1 + ]?.[0] as DataViewQuery; + expect(last.filters?.[0]?.stringValue).toBe('2026-08-15'); + }); + + it('leaves a restored string filter as it was sent', () => { + const onTableQueryChange = vi.fn(); + renderRestored( + [{ name: 'id', operator: 'eq', value: 'd15' }], + 'server', + onTableQueryChange + ); + const last = onTableQueryChange.mock.calls[ + onTableQueryChange.mock.calls.length - 1 + ]?.[0] as DataViewQuery; + expect(last.filters?.[0]).toEqual({ + name: 'id', + operator: 'eq', + value: 'd15', + stringValue: 'd15' + }); + }); + }); }); diff --git a/packages/raystack/components/data-view/__tests__/filter-operations.test.ts b/packages/raystack/components/data-view/__tests__/filter-operations.test.ts index 5a0d80647..22778476a 100644 --- a/packages/raystack/components/data-view/__tests__/filter-operations.test.ts +++ b/packages/raystack/components/data-view/__tests__/filter-operations.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { queryToTableState, transformToDataViewQuery } from '../utils'; import { getDataType, getFilterFn, @@ -64,14 +65,11 @@ describe('filter-operations', () => { expect(v.stringValue).toBe('foo%'); }); - it('emits ISO string for valid dates', () => { - const d = new Date('2024-01-15T00:00:00Z'); - const v = getFilterValue({ - value: d, - filterType: 'date', - operator: 'eq' - }); - expect(v.stringValue).toBe(d.toISOString()); + it('emits a day key for valid dates and passes the value through', () => { + const value = new Date(2024, 0, 15, 23, 30); + const v = getFilterValue({ value, filterType: 'date', operator: 'eq' }); + expect(v.value).toBe(value); + expect(v.stringValue).toBe('2024-01-15'); }); it('emits boolValue for boolean dataType', () => { @@ -108,3 +106,126 @@ describe('filter-operations', () => { }); }); }); + +describe('date filters', () => { + const run = ( + operator: 'eq' | 'neq' | 'lt' | 'lte' | 'gt' | 'gte', + filterDate: unknown, + rowValue: unknown + ) => { + const fn = getFilterFn('date', operator); + const row = { getValue: () => rowValue } as never; + return fn(row, 'when', { date: filterDate } as never, vi.fn()); + }; + + const DAY = new Date(2026, 7, 15); + + it.each([ + ['eq', '2026-08-15', true], + ['eq', '2026-08-14', false], + ['neq', '2026-08-15', false], + ['neq', '2026-08-14', true], + ['lt', '2026-08-14', true], + ['lt', '2026-08-15', false], + ['lte', '2026-08-15', true], + ['lte', '2026-08-16', false], + ['gt', '2026-08-16', true], + ['gt', '2026-08-15', false], + ['gte', '2026-08-15', true], + ['gte', '2026-08-14', false] + ] as const)('%s against %s', (operator, row, expected) => { + expect(run(operator, DAY, row)).toBe(expected); + }); + + it.each([ + 'eq', + 'lt', + 'lte', + 'gt', + 'gte' + ] as const)('does not match an unreadable row with %s', operator => { + expect(run(operator, DAY, 'not a date')).toBe(false); + expect(run(operator, DAY, undefined)).toBe(false); + }); + + it('matches an unreadable row with neq', () => { + expect(run('neq', DAY, 'not a date')).toBe(true); + expect(run('neq', DAY, undefined)).toBe(true); + expect(run('neq', DAY, '2026-02-30')).toBe(true); + }); + + it('does not match with neq when the filter date is unreadable', () => { + expect(run('neq', 'not a date', '2026-08-15')).toBe(false); + }); + + /* dayjs read a missing filter date as "now", so an unset filter quietly + matched today's rows. */ + it('does not fall back to today when the filter has no date', () => { + expect(run('eq', undefined, '2026-08-15')).toBe(false); + }); +}); + +/* Filters stored before day keys hold an ISO instant of local midnight. */ +describe('stored date filters', () => { + const originalTimeZone = process.env.TZ; + afterEach(() => { + process.env.TZ = originalTimeZone; + }); + + const matches = (stored: unknown, row: string) => + getFilterFn('date', 'eq')( + { getValue: () => row } as never, + 'when', + { date: stored } as never, + vi.fn() + ); + + it('reads a stored ISO instant as the day the viewer picked', () => { + process.env.TZ = 'Asia/Kolkata'; + expect(matches('2026-08-14T18:30:00.000Z', '2026-08-15')).toBe(true); + expect(matches('2026-08-14T18:30:00.000Z', '2026-08-14')).toBe(false); + }); + + it.each([ + 'UTC', + 'Asia/Kolkata', + 'America/Los_Angeles', + 'Pacific/Kiritimati' + ])('reads a stored day key as the same day in %s', timeZone => { + process.env.TZ = timeZone; + expect(matches('2026-08-15', '2026-08-15')).toBe(true); + expect(matches('2026-08-15', '2026-08-14')).toBe(false); + }); + + it('writes the day the viewer picked, not the UTC day', () => { + process.env.TZ = 'Asia/Kolkata'; + const value = new Date(2026, 7, 15); + const v = getFilterValue({ value, filterType: 'date' }); + expect(v.value).toBe(value); + expect(v.stringValue).toBe('2026-08-15'); + }); + + it('writes a stored ISO instant as its day and keeps the value', () => { + process.env.TZ = 'Asia/Kolkata'; + const value = '2026-08-14T18:30:00.000Z'; + const v = getFilterValue({ value, filterType: 'date' }); + expect(v.value).toBe(value); + expect(v.stringValue).toBe('2026-08-15'); + }); +}); + +/* dayjs read `undefined` as now, so an unset date filter was kept and matched + today. */ +describe('date filter with no value', () => { + const query = { + filters: [{ name: 'when', operator: 'eq', value: undefined, _type: 'date' }] + } as never; + + it('is dropped from the table state', () => { + expect(queryToTableState(query).columnFilters).toEqual([]); + }); + + it('is dropped from the emitted query', () => { + expect(transformToDataViewQuery(query).filters).toEqual([]); + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index ee81c2ef8..1bb0a142d 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -1,6 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import dayjs from 'dayjs'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name @@ -40,7 +39,7 @@ describe('toTimestamp', () => { const date = new Date('2025-01-05T00:00:00'); expect(toTimestamp(date)).toBe(date.getTime()); expect(toTimestamp(1736035200000)).toBe(1736035200000); - expect(toTimestamp('2025-01-05')).toBe(dayjs('2025-01-05').valueOf()); + expect(toTimestamp('2025-01-05')).toBe(new Date(2025, 0, 5).getTime()); }); it('returns null for missing or invalid values', () => { @@ -56,42 +55,42 @@ describe('toTimestamp', () => { describe('createTimeScale', () => { it('snaps the domain to unit boundaries with padding', () => { const ts = createTimeScale({ - minTime: dayjs('2025-01-05T10:30:00').valueOf(), - maxTime: dayjs('2025-01-20T18:00:00').valueOf(), + minTime: new Date(2025, 0, 5, 10, 30).getTime(), + maxTime: new Date(2025, 0, 20, 18, 0).getTime(), scale: 'day', unitWidth: 20, padUnits: 2 }); - expect(ts.t0).toBe(dayjs('2025-01-03').startOf('day').valueOf()); + expect(ts.t0).toBe(new Date(2025, 0, 3).getTime()); // startOf(max) + (pad + 1) days. - expect(ts.t1).toBe(dayjs('2025-01-23').startOf('day').valueOf()); + expect(ts.t1).toBe(new Date(2025, 0, 23).getTime()); }); it('extends the domain end to reach minWidth, in whole units', () => { // Jan 1 → Feb 1 = 31 days × 20px = 620px; filling to 1000px needs 50 days. const ts = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 20, padUnits: 0, minWidth: 1000 }); - expect(ts.t0).toBe(dayjs('2025-01-01').valueOf()); - expect(ts.t1).toBe(dayjs('2025-02-20').valueOf()); + expect(ts.t0).toBe(new Date(2025, 0, 1).getTime()); + expect(ts.t1).toBe(new Date(2025, 1, 20).getTime()); expect(ts.totalWidth).toBe(1000); }); it('never shrinks a domain already wider than minWidth', () => { const ts = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 20, padUnits: 0, minWidth: 100 }); - expect(ts.t1).toBe(dayjs('2025-02-01').valueOf()); + expect(ts.t1).toBe(new Date(2025, 1, 1).getTime()); expect(ts.totalWidth).toBe(620); }); @@ -99,8 +98,8 @@ describe('createTimeScale', () => { // Months render at (actual ms × pxPerMs), not exactly unitWidth, so the // fill must land at or past minWidth despite short months. const ts = createTimeScale({ - minTime: dayjs('2025-01-15').valueOf(), - maxTime: dayjs('2025-02-15').valueOf(), + minTime: new Date(2025, 0, 15).getTime(), + maxTime: new Date(2025, 1, 15).getTime(), scale: 'month', unitWidth: 96, padUnits: 0, @@ -108,20 +107,20 @@ describe('createTimeScale', () => { }); expect(ts.totalWidth).toBeGreaterThanOrEqual(500); // Still snapped to a month boundary. - expect(dayjs(ts.t1).date()).toBe(1); + expect(new Date(ts.t1).getDate()).toBe(1); }); it('maps time to px linearly and inverts with timeAt', () => { const ts = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 20, padUnits: 0 }); expect(ts.x(ts.t0)).toBe(0); - expect(ts.x(dayjs('2025-01-05').valueOf())).toBe(80); - const time = dayjs('2025-01-11').valueOf(); + expect(ts.x(new Date(2025, 0, 5).getTime())).toBe(80); + const time = new Date(2025, 0, 11).getTime(); expect(ts.timeAt(ts.x(time))).toBe(time); // Domain = Jan 1 → Feb 1 = 31 days. expect(ts.totalWidth).toBe(31 * 20); @@ -131,8 +130,8 @@ describe('createTimeScale', () => { // 0 → pxPerMs 0 (NaN geometry); negative → inverted scale whose // viewport-fill loop never terminates. Both clamp to a 1px unit. const zero = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 0, padUnits: 0, @@ -141,8 +140,8 @@ describe('createTimeScale', () => { expect(Number.isFinite(zero.totalWidth)).toBe(true); expect(zero.totalWidth).toBeGreaterThanOrEqual(500); const negative = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: -20, padUnits: 0, @@ -156,8 +155,8 @@ describe('createTimeScale', () => { describe('buildAxis', () => { const januaryScale = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 20, padUnits: 0 @@ -181,8 +180,8 @@ describe('buildAxis', () => { // 40px per tick → all labels show. const roomy = buildAxis( createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 40, padUnits: 0 @@ -195,8 +194,8 @@ describe('buildAxis', () => { it('labels every Nth unit when labelEvery is passed', () => { const roomyScale = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 40, padUnits: 0 @@ -216,8 +215,8 @@ describe('buildAxis', () => { it('collision floor wins over a too-dense labelEvery', () => { // 10px per tick → auto floor is every 3rd; asking for every 2nd degrades. const dense = createTimeScale({ - minTime: dayjs('2025-01-01').valueOf(), - maxTime: dayjs('2025-01-31').valueOf(), + minTime: new Date(2025, 0, 1).getTime(), + maxTime: new Date(2025, 0, 31).getTime(), scale: 'day', unitWidth: 10, padUnits: 0 @@ -231,8 +230,8 @@ describe('buildAxis', () => { it('emits month bands over day ticks, with the year on the first band', () => { const wide = createTimeScale({ - minTime: dayjs('2025-01-10').valueOf(), - maxTime: dayjs('2025-02-20').valueOf(), + minTime: new Date(2025, 0, 10).getTime(), + maxTime: new Date(2025, 1, 20).getTime(), scale: 'day', unitWidth: 20, padUnits: 0 @@ -243,8 +242,8 @@ describe('buildAxis', () => { it('emits year bands over month ticks', () => { const yearly = createTimeScale({ - minTime: dayjs('2024-11-01').valueOf(), - maxTime: dayjs('2025-03-01').valueOf(), + minTime: new Date(2024, 10, 1).getTime(), + maxTime: new Date(2025, 2, 1).getTime(), scale: 'month', unitWidth: 96, padUnits: 0 @@ -254,20 +253,17 @@ describe('buildAxis', () => { expect(ticks[0].label).toBe('Nov'); }); - it('builds quarter ticks and year bands (hand-rolled, non-dayjs path)', () => { - // Quarter snapping/stepping is hand-rolled (dayjs has no quarter unit - // without a plugin): startOfUnit subtracts month % 3, addUnits steps by - // 3 months, and tick labels derive Q1–Q4 from the month index. + it('builds quarter ticks and year bands', () => { const quarterly = createTimeScale({ - minTime: dayjs('2024-11-15').valueOf(), - maxTime: dayjs('2025-05-10').valueOf(), + minTime: new Date(2024, 10, 15).getTime(), + maxTime: new Date(2025, 4, 10).getTime(), scale: 'quarter', unitWidth: 140, padUnits: 0 }); // Nov 15 snaps back to Q4's start; May 10 is in Q2, +1 unit → Jul 1. - expect(dayjs(quarterly.t0).format('YYYY-MM-DD')).toBe('2024-10-01'); - expect(dayjs(quarterly.t1).format('YYYY-MM-DD')).toBe('2025-07-01'); + expect(quarterly.t0).toBe(new Date(2024, 9, 1).getTime()); + expect(quarterly.t1).toBe(new Date(2025, 6, 1).getTime()); const { ticks, bands } = buildAxis(quarterly, 'quarter', 140); expect(ticks.map(t => t.label)).toEqual(['Q4', 'Q1', 'Q2', 'Q3']); expect(ticks[0].x).toBe(0); @@ -276,8 +272,8 @@ describe('buildAxis', () => { it('builds week ticks snapped to week starts, with month bands', () => { const weekly = createTimeScale({ - minTime: dayjs('2025-01-05').valueOf(), // a Sunday (dayjs week start) - maxTime: dayjs('2025-01-20').valueOf(), + minTime: new Date(2025, 0, 5).getTime(), // a Sunday (the week start) + maxTime: new Date(2025, 0, 20).getTime(), scale: 'week', unitWidth: 56, padUnits: 0 @@ -638,6 +634,20 @@ describe('DataView.Timeline', () => { expect(screen.queryByText('6 Jan')).toBeNull(); }); + it.each([ + ['month', 96, 'Jan 2025'], + ['quarter', 140, 'Q1 2025'] + ] as const)('labels the cursor badge with the %s and year', async (scale, unitWidth, label) => { + const { container } = renderTimeline({ scale, unitWidth }); + const root = container.firstElementChild as HTMLElement; + expect(screen.queryByText(label)).toBeNull(); + await act(async () => { + fireEvent.mouseMove(root, { clientX: 10 }); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + it('does not track the cursor when showCursorLine is false', async () => { const { container } = renderTimeline({ showCursorLine: false }); const root = container.firstElementChild as HTMLElement; @@ -794,7 +804,7 @@ describe('DataView.Timeline', () => { expect(from).toBeInstanceOf(Date); expect(to).toBeInstanceOf(Date); // jsdom viewport is 0-wide at scrollLeft 0 → both edges sit at t0 (Jan 1). - expect(from.getTime()).toBe(dayjs('2025-01-01').valueOf()); + expect(from.getTime()).toBe(new Date(2025, 0, 1).getTime()); }); it('does not re-fire onVisibleRangeChange when the window is unchanged', async () => { @@ -846,7 +856,7 @@ describe('DataView.Timeline', () => { expect(onVisibleRangeChange.mock.calls.length).toBe(callsAfterMount + 1); const calls = onVisibleRangeChange.mock.calls; const [from] = calls[calls.length - 1][0]; - expect(from.getTime()).toBe(dayjs('2025-01-06').valueOf()); + expect(from.getTime()).toBe(new Date(2025, 0, 6).getTime()); // Sub-pixel drift (scroll anchoring's float round-trip, device-pixel // quantization of scrollLeft) is noise, not a scroll, so no re-fire. @@ -1767,9 +1777,9 @@ describe('DataView.Timeline actionsRef', () => { const range = actionsRef.current!.getVisibleRange(); expect(range).not.toBeNull(); const [from, to] = range!; - expect(from.getTime()).toBe(dayjs('2025-01-11').valueOf()); + expect(from.getTime()).toBe(new Date(2025, 0, 11).getTime()); // 0-wide jsdom viewport → both edges coincide. - expect(to.getTime()).toBe(dayjs('2025-01-11').valueOf()); + expect(to.getTime()).toBe(new Date(2025, 0, 11).getTime()); }); it('no-ops with a dev warning while hidden, and getVisibleRange is null', () => { diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index 4671ba567..e5b844ac5 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -2,7 +2,6 @@ import type { Row } from '@tanstack/react-table'; import { cx } from 'class-variance-authority'; -import dayjs from 'dayjs'; import { CSSProperties, memo, @@ -15,6 +14,12 @@ import { useRef, useState } from 'react'; +import { + formatDayMonth, + formatMonthLabel, + formatQuarterShort, + formatYear +} from '~/components/calendar-preview/date-adapter'; import { useScaling } from '~/hooks/useScaling'; import { Badge } from '../../badge'; import styles from '../data-view.module.css'; @@ -353,15 +358,15 @@ const MARKER_BADGE_VARIANT: Record< /** Axis-badge label for the hover cursor, formatted per scale granularity. */ function cursorLabel(time: number, scale: TimelineScale): string { - const date = dayjs(time); + const date = new Date(time); switch (scale) { case 'day': case 'week': - return date.format('D MMM'); + return formatDayMonth(date); case 'month': - return date.format('MMM YYYY'); + return formatMonthLabel(date); case 'quarter': - return `Q${Math.floor(date.month() / 3) + 1} ${date.format('YYYY')}`; + return `${formatQuarterShort(date)} ${formatYear(date)}`; } } @@ -925,7 +930,7 @@ export function DataViewTimeline({ key: '__today', time: todayTime, x: timeScale.x(todayTime), - label: dayjs(todayTime).format('D MMM'), + label: formatDayMonth(new Date(todayTime)), variant: 'accent' }); } @@ -936,7 +941,7 @@ export function DataViewTimeline({ key: `__marker-${index}`, time, x: timeScale.x(time), - label: marker.label ?? dayjs(time).format('D MMM'), + label: marker.label ?? formatDayMonth(new Date(time)), variant: marker.variant ?? 'default' }); }); @@ -1009,7 +1014,7 @@ export function DataViewTimeline({ timeScale.t0, Math.min(timeScale.timeAt(canvasX), timeScale.t1) ); - const snapped = startOfUnit(dayjs(time), scale).valueOf(); + const snapped = startOfUnit(new Date(time), scale).getTime(); setCursorTime(prev => (prev === snapped ? prev : snapped)); }, [showCursorLine, timeScale, scale]); diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx index c2e419cbb..f784ab7b2 100644 --- a/packages/raystack/components/data-view/data-view.tsx +++ b/packages/raystack/components/data-view/data-view.tsx @@ -69,8 +69,8 @@ function DataViewRoot({ groupByResolvers }: React.PropsWithChildren>) { const defaultTableQuery = useMemo( - () => getDefaultTableQuery(defaultSort, query), - [defaultSort, query] + () => getDefaultTableQuery(defaultSort, query, fields), + [defaultSort, query, fields] ); // Active view (controlled / uncontrolled). diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index 3268c0deb..9fd8e1897 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -1,8 +1,7 @@ import type { FilterFn } from '@tanstack/table-core'; -import dayjs from 'dayjs'; -import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; -import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { notOnDay, onDay } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -19,9 +18,6 @@ import { } from '~/types/filters'; import { DataViewFilterValues } from '../data-view.types'; -dayjs.extend(isSameOrAfter); -dayjs.extend(isSameOrBefore); - export type FilterFunctionsMap = { number: Record>; string: Record>; @@ -69,24 +65,12 @@ export const filterOperationsMap: FilterFunctionsMap = { } }, date: { - eq: (row, columnId, filterValue: FilterValue) => - dayjs(row.getValue(columnId)).isSame(dayjs(filterValue.date), 'day'), - neq: (row, columnId, filterValue: FilterValue) => - !dayjs(row.getValue(columnId)).isSame(dayjs(filterValue.date), 'day'), - lt: (row, columnId, filterValue: FilterValue) => - dayjs(row.getValue(columnId)).isBefore(dayjs(filterValue.date), 'day'), - lte: (row, columnId, filterValue: FilterValue) => - dayjs(row.getValue(columnId)).isSameOrBefore( - dayjs(filterValue.date), - 'day' - ), - gt: (row, columnId, filterValue: FilterValue) => - dayjs(row.getValue(columnId)).isAfter(dayjs(filterValue.date), 'day'), - gte: (row, columnId, filterValue: FilterValue) => - dayjs(row.getValue(columnId)).isSameOrAfter( - dayjs(filterValue.date), - 'day' - ) + eq: onDay((day, filterDay) => day === filterDay), + neq: notOnDay, + lt: onDay((day, filterDay) => day < filterDay), + lte: onDay((day, filterDay) => day <= filterDay), + gt: onDay((day, filterDay) => day > filterDay), + gte: onDay((day, filterDay) => day >= filterDay) }, select: { eq: (row, columnId, filterValue: FilterValue) => { @@ -131,16 +115,8 @@ const handleStringBasedTypes = ( ): DataViewFilterValues => { switch (filterType) { case FilterType.date: { - const dateValue = dayjs(value); - let stringValue = ''; - if (dateValue.isValid()) { - try { - stringValue = dateValue.toISOString(); - } catch { - stringValue = ''; - } - } - return { value, stringValue }; + const day = toDayKey(value); + return { value, stringValue: day ?? '' }; } case FilterType.select: return { diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index 83dccd5e5..3fdd4f5e4 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -4,8 +4,9 @@ import { type RowModel, TableState } from '@tanstack/table-core'; -import dayjs from 'dayjs'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { withDateFilterTypes } from '~/shared/date-filters'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, @@ -29,7 +30,8 @@ export function queryToTableState(query: InternalQuery): Partial { const columnFilters = query.filters ?.filter(data => { - if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data._type === FilterType.date) + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -262,7 +264,8 @@ export function transformToDataViewQuery(query: InternalQuery): DataViewQuery { filters ?.filter(data => { if (data._type === FilterType.select) return true; - if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data._type === FilterType.date) + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -400,12 +403,16 @@ export function hasActiveTableFiltering(table: Table): boolean { export function getDefaultTableQuery( defaultSort: DataViewSort, - oldQuery: DataViewQuery = {} + oldQuery: DataViewQuery = {}, + fields: Pick[] = [] ): InternalQuery { const internalQuery = dataViewQueryToInternal(oldQuery); return { sort: [defaultSort], group_by: [defaultGroupOption.id], - ...internalQuery + ...internalQuery, + ...(internalQuery.filters && { + filters: withDateFilterTypes(internalQuery.filters, fields) + }) }; } diff --git a/packages/raystack/components/data-view/utils/time-scale.tsx b/packages/raystack/components/data-view/utils/time-scale.tsx index 680fcdef5..b2632d99a 100644 --- a/packages/raystack/components/data-view/utils/time-scale.tsx +++ b/packages/raystack/components/data-view/utils/time-scale.tsx @@ -1,5 +1,13 @@ -import dayjs, { type Dayjs } from 'dayjs'; - +import { + addUnit, + formatDayOfMonth, + formatMonthLabel, + formatMonthShort, + formatQuarterShort, + formatYear, + startOfUnit as startOfCalendarUnit, + toInstant +} from '~/components/calendar-preview/date-adapter'; import type { TimelineScale } from '../data-view.types'; /** @@ -35,24 +43,16 @@ export function toTimestamp(value: unknown): number | null { if (typeof value === 'number') { return Number.isFinite(value) ? value : null; } - if (typeof value === 'string') { - const parsed = dayjs(value); - return parsed.isValid() ? parsed.valueOf() : null; - } + if (typeof value === 'string') return toInstant(value)?.getTime() ?? null; return null; } -/** `startOf` that also understands quarters without a dayjs plugin. */ -export function startOfUnit(date: Dayjs, scale: TimelineScale): Dayjs { - if (scale === 'quarter') { - return date.startOf('month').subtract(date.month() % 3, 'month'); - } - return date.startOf(scale); +export function startOfUnit(date: Date, scale: TimelineScale): Date { + return startOfCalendarUnit[scale](date); } -export function addUnits(date: Dayjs, scale: TimelineScale, n: number): Dayjs { - if (scale === 'quarter') return date.add(3 * n, 'month'); - return date.add(n, scale); +export function addUnits(date: Date, scale: TimelineScale, n: number): Date { + return addUnit[scale](date, n); } export interface TimelineTimeScale { @@ -90,26 +90,26 @@ export function createTimeScale(params: { const unitWidth = Math.max(1, params.unitWidth); const pxPerMs = unitWidth / TIMELINE_UNIT_MS[scale]; const t0 = addUnits( - startOfUnit(dayjs(Math.min(minTime, maxTime)), scale), + startOfUnit(new Date(Math.min(minTime, maxTime)), scale), scale, -padUnits - ).valueOf(); + ).getTime(); // +1 so the max instant's unit is fully inside the domain. let end = addUnits( - startOfUnit(dayjs(Math.max(minTime, maxTime)), scale), + startOfUnit(new Date(Math.max(minTime, maxTime)), scale), scale, padUnits + 1 ); // Viewport fill: bulk-add the estimated deficit in one step, then correct // for calendar drift (short months, DST days), at most a few iterations. - const deficitPx = minWidth - (end.valueOf() - t0) * pxPerMs; + const deficitPx = minWidth - (end.getTime() - t0) * pxPerMs; if (deficitPx > 0) { end = addUnits(end, scale, Math.ceil(deficitPx / unitWidth)); - while ((end.valueOf() - t0) * pxPerMs < minWidth) { + while ((end.getTime() - t0) * pxPerMs < minWidth) { end = addUnits(end, scale, 1); } } - const t1 = end.valueOf(); + const t1 = end.getTime(); return { t0, t1, @@ -137,15 +137,15 @@ export interface TimelineBand { label: string; } -function tickLabel(date: Dayjs, scale: TimelineScale): string { +function tickLabel(date: Date, scale: TimelineScale): string { switch (scale) { case 'day': case 'week': - return date.format('D'); + return formatDayOfMonth(date); case 'month': - return date.format('MMM'); + return formatMonthShort(date); case 'quarter': - return `Q${Math.floor(date.month() / 3) + 1}`; + return formatQuarterShort(date); } } @@ -181,13 +181,13 @@ export function buildAxis( ); const ticks: TimelineTick[] = []; - let cursor = startOfUnit(dayjs(timeScale.t0), scale); - if (cursor.valueOf() < timeScale.t0) cursor = addUnits(cursor, scale, 1); + let cursor = startOfUnit(new Date(timeScale.t0), scale); + if (cursor.getTime() < timeScale.t0) cursor = addUnits(cursor, scale, 1); let index = 0; - while (cursor.valueOf() <= timeScale.t1) { + while (cursor.getTime() <= timeScale.t1) { ticks.push({ - time: cursor.valueOf(), - x: timeScale.x(cursor.valueOf()), + time: cursor.getTime(), + x: timeScale.x(cursor.getTime()), label: tickLabel(cursor, scale), showLabel: index % effectiveLabelEvery === 0, index @@ -198,20 +198,20 @@ export function buildAxis( const bands: TimelineBand[] = []; const bandUnit = scale === 'day' || scale === 'week' ? 'month' : 'year'; - let band = dayjs(timeScale.t0).startOf(bandUnit); + let band = startOfCalendarUnit[bandUnit](new Date(timeScale.t0)); let isFirst = true; - while (band.valueOf() < timeScale.t1) { - const next = band.add(1, bandUnit); - const from = Math.max(band.valueOf(), timeScale.t0); - const to = Math.min(next.valueOf(), timeScale.t1); + while (band.getTime() < timeScale.t1) { + const next = addUnit[bandUnit](band, 1); + const from = Math.max(band.getTime(), timeScale.t0); + const to = Math.min(next.getTime(), timeScale.t1); const label = bandUnit === 'month' - ? isFirst || band.month() === 0 - ? band.format('MMM YYYY') - : band.format('MMM') - : band.format('YYYY'); + ? isFirst || band.getMonth() === 0 + ? formatMonthLabel(band) + : formatMonthShort(band) + : formatYear(band); bands.push({ - time: band.valueOf(), + time: band.getTime(), x: timeScale.x(from), width: (to - from) * timeScale.pxPerMs, label diff --git a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx index 40343f52f..aaf880264 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -1,5 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; import { FilterType } from '~/types/filters'; import { FilterChip } from '../filter-chip'; import styles from '../filter-chip.module.css'; @@ -126,10 +129,7 @@ describe('FilterChip', () => { }); describe('Date Filter Type', () => { - it('renders the date picker without crashing when no value is set', () => { - // Regression: an unset date chip seeds its value with '' and forwarded - // that string to DatePicker, whose controlled-sync effect ran - // `valueProp?.getTime()` → "getTime is not a function". + it('renders the calendar without crashing when no value is set', () => { expect(() => render() ).not.toThrow(); @@ -144,7 +144,11 @@ describe('FilterChip', () => { value='2026-05-27' /> ); - expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument(); + expect( + screen.getByDisplayValue( + defaultFormatValue(new Date(2026, 4, 27), 'day') + ) + ).toBeInTheDocument(); }); it('parses an epoch number value', () => { @@ -156,7 +160,11 @@ describe('FilterChip', () => { value={new Date(2026, 4, 27).getTime()} /> ); - expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument(); + expect( + screen.getByDisplayValue( + defaultFormatValue(new Date(2026, 4, 27), 'day') + ) + ).toBeInTheDocument(); }); it('coerces an unparseable value to unselected instead of crashing', () => { @@ -181,21 +189,211 @@ describe('FilterChip', () => { value={new Date(2026, 4, 27)} /> ); - expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument(); + expect( + screen.getByDisplayValue( + defaultFormatValue(new Date(2026, 4, 27), 'day') + ) + ).toBeInTheDocument(); + }); + + it('forwards calendarProps to the calendar', () => { + render( + + `${date.getDate()} ${timeZone ?? 'local'}` + }} + /> + ); + expect(screen.getByDisplayValue('27 local')).toBeInTheDocument(); + }); + + it('emits the typed date', () => { + const onValueChange = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText('Select date'); + fireEvent.change(input, { target: { value: '27 May 2026' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onValueChange).toHaveBeenCalledWith( + new Date(2026, 4, 27), + expect.any(String) + ); + }); + + it('emits an empty value when the date is cleared', () => { + const onValueChange = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText('Select date'); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + expect(onValueChange).toHaveBeenCalledWith('', expect.any(String)); + expect(input).toHaveValue(''); }); - it('forwards calendarProps to the underlying DatePicker', () => { - // dateFormat is the easiest forwarded prop to observe, since the formatted - // string in the input changes when it lands on DatePicker. + const isOpen = () => + getSlot(document.body, 'calendar-preview-content') !== null; + const clickDay = (day: string) => { + const cell = getAllSlots(document.body, 'calendar-preview-day').find( + one => + getSlot(one, 'calendar-preview-day-number')?.textContent === day && + !one.hasAttribute('data-outside') + ) as HTMLElement; + fireEvent.pointerDown(cell); + act(() => cell.focus()); + fireEvent.click(cell); + }; + + it('closes the calendar when a day is picked', async () => { + const onValueChange = vi.fn(); render( ); - expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument(); + const input = screen.getByPlaceholderText('Select date'); + act(() => input.focus()); + expect(isOpen()).toBe(true); + clickDay('12'); + expect(onValueChange).toHaveBeenCalledWith( + new Date(2026, 4, 12), + expect.any(String) + ); + await act(() => new Promise(resolve => setTimeout(resolve, 100))); + expect(isOpen()).toBe(false); + }); + + it('keeps the calendar open when the picked day is cleared', () => { + render( + + ); + fireEvent.focus(screen.getByPlaceholderText('Select date')); + clickDay('27'); + expect(isOpen()).toBe(true); + }); + + it('forwards slotProps to the input and the popup', () => { + const { container } = render( + + ); + const input = screen.getByPlaceholderText('Pick a day'); + expect(container.querySelector('.custom-input')).toHaveClass( + styles.dateField + ); + fireEvent.focus(input); + expect( + getSlot(document.body, 'calendar-preview-content')?.querySelector( + '.custom-popup' + ) + ).toBeInTheDocument(); + }); + + it('shows the calendar icon only with showCalendarIcon', () => { + const icons = (showCalendarIcon?: boolean) => + render( + + ).container.querySelectorAll('[data-slot="filter-chip-value"] svg') + .length; + expect(icons()).toBe(0); + expect(icons(true)).toBe(1); + }); + + it('reports a typed error through onErrorChange', () => { + const onErrorChange = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText('Select date'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'not a date' } }); + expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date'); + fireEvent.change(input, { target: { value: '27 May 2026' } }); + expect(onErrorChange).toHaveBeenLastCalledWith(undefined); + }); + + it('keeps a typed error when a string value rerenders the chip', () => { + function Parent() { + const [error, setError] = useState(); + return ( + <> + {error} + + + ); + } + render(); + const input = screen.getByDisplayValue( + defaultFormatValue(new Date(2026, 4, 27), 'day') + ); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'not a date' } }); + expect(input).toHaveValue('not a date'); + expect(screen.getByTestId('error')).toHaveTextContent('Invalid date'); + }); + + it('does not select a day when slotProps.input is disabled', () => { + const onValueChange = vi.fn(); + render( + + ); + fireEvent.click( + getSlot(document.body, 'calendar-preview-trigger') as HTMLElement + ); + if (isOpen()) clickDay('12'); + expect(onValueChange).not.toHaveBeenCalled(); }); }); diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index ef210df74..8f243721f 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -43,7 +43,7 @@ * resize pressure so `text-overflow` ellipsizes with padding intact; * `min-width` keeps an empty value clickable, `max-width` caps a runaway one. */ .chip [data-slot="input"], -.chip [data-slot="date-picker-input"] { +.chip [data-slot="calendar-preview-input"] { border: none; box-shadow: none; background: transparent; @@ -212,13 +212,13 @@ button.selectValue:hover { box-shadow: none; } -/* match height of the DatePicker when FilterChip variant is text */ +/* match height of the date input when FilterChip variant is text */ .chip[data-variant="text"] .dateField { height: var(--rs-space-7); min-height: var(--rs-space-7); } -.dateField [data-slot="date-picker-input"] { +.dateField [data-slot="calendar-preview-input"] { text-align: left; padding-left: var(--rs-space-3); padding-right: var(--rs-space-3); diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 7ee97b483..28c65bf93 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -1,8 +1,23 @@ 'use client'; -import { cva, VariantProps } from 'class-variance-authority'; -import dayjs from 'dayjs'; -import { ComponentProps, ReactElement, useCallback, useState } from 'react'; +import { cva, cx, VariantProps } from 'class-variance-authority'; +import { + ComponentProps, + ReactElement, + useCallback, + useMemo, + useState +} from 'react'; +import { + CalendarPreview, + type CalendarPreviewContentProps, + type CalendarPreviewInputProps, + type CalendarPreviewProps +} from '~/components/calendar-preview'; +import { + parseKey, + toInstant +} from '~/components/calendar-preview/date-adapter'; import { XIcon } from '~/icons'; import { FilterOperation, @@ -12,7 +27,6 @@ import { FilterTypes, filterOperators } from '~/types/filters'; -import { DatePicker, type DatePickerProps } from '../calendar'; import { Flex } from '../flex'; import { Input } from '../input'; import { Select } from '../select'; @@ -35,30 +49,51 @@ const chip = cva(styles.chip, { export type FilterChipValue = string | string[] | number | Date; +/* The message `DatePicker` reports, so `onErrorChange` reads the same. */ +const INVALID_DATE = 'Invalid date'; + /** - * Coerce a `FilterChipValue` to the `Date` the DatePicker expects, since filter + * Coerce a `FilterChipValue` to the `Date` the calendar expects, since filter * state hydrated from a serialized query arrives as a string or epoch number. * Unparseable values leave the field unselected. */ const toDateValue = (value: unknown): Date | undefined => { if (value instanceof Date) return value; if (typeof value === 'string' || typeof value === 'number') { - const parsed = dayjs(value); - return parsed.isValid() ? parsed.toDate() : undefined; + return toInstant(value) ?? undefined; } return undefined; }; /** - * Subset of `DatePickerProps` that consumers may forward to the chip's - * built-in DatePicker via `calendarProps`. `value`/`onSelect`/`defaultValue` - * are owned by `FilterChip`; `children` would replace the input trigger and - * break the chip layout. + * The `CalendarPreview` props that consumers may forward to the chip's + * calendar via `calendarProps`. `FilterChip` owns the value and the parts. */ -export type FilterChipCalendarProps = Omit< - DatePickerProps, - 'value' | 'onSelect' | 'defaultValue' | 'children' ->; +export type FilterChipCalendarProps = Pick< + CalendarPreviewProps, + | 'timeZone' + | 'minDate' + | 'maxDate' + | 'isDateUnavailable' + | 'yearRange' + | 'defaultMonth' + | 'today' +> & { + /** Formats the selected date for the input. */ + formatValue?: (date: Date, timeZone?: string) => string; + /** Props for the chip's date input and its popup. */ + slotProps?: { + input?: Omit; + popover?: Omit; + }; + /** + * Shows the calendar icon in the date input. + * @default false + */ + showCalendarIcon?: boolean; + /** Called with a message when the typed date is invalid, and with `undefined` when it is valid again. */ + onErrorChange?: (error: string | undefined) => void; +}; export interface FilterChipProps extends ComponentProps<'div'>, @@ -73,18 +108,14 @@ export interface FilterChipProps leadingIcon?: ReactElement; operations?: FilterOperator[]; selectProps?: BaseSelectProps; - /** - * Props forwarded to the underlying `DatePicker` for `columnType="date"`. - * `value`/`onSelect`/`defaultValue` are owned by `FilterChip` and excluded; - * `children` is excluded so the chip's input trigger isn't replaced. - */ + /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ calendarProps?: FilterChipCalendarProps; } /** * A compact, removable filter pill that pairs a label and operator with a * value control chosen by `columnType`: a `Select` (`select`/`multiselect`), - * a `DatePicker` (`date`), or a text `Input` (`string`/`number`). The value + * a `CalendarPreview` (`date`), or a text `Input` (`string`/`number`). The value * control sizes to its content so the chip hugs the active filter. Emits * `onValueChange`/`onOperationChange` and renders a remove button when * `onRemove` is provided. @@ -116,6 +147,22 @@ export const FilterChip = ({ // `??` not `||`, since a falsy option value like `0` is a real selection. const [filterValue, setFilterValue] = useState(value ?? ''); + const [dateOpen, setDateOpen] = useState(false); + const { + formatValue: formatDate, + slotProps: dateSlotProps, + showCalendarIcon = false, + onErrorChange, + ...calendarRest + } = calendarProps ?? {}; + const { classNames: inputClassNames, ...inputProps } = + dateSlotProps?.input ?? {}; + /* A new `Date` each render reads as a new value, and the input drops its typed text. */ + const dateValue = useMemo( + () => toDateValue(filterValue) ?? null, + [filterValue] + ); + const showOnRemove = typeof onRemove === 'function'; const isMultiSelectColumn = columnType === FilterType.multiselect; @@ -180,19 +227,52 @@ export const FilterChip = ({ className={styles.dateFieldWrapper} data-slot='filter-chip-value' > - handleFilterValueChange(date)} - slotProps={{ - ...calendarProps?.slotProps, - input: { - classNames: { container: styles.dateField }, - ...calendarProps?.slotProps?.input - } + + formatDate( + date instanceof Date ? date : parseKey(date.date), + timeZone + )) + } + disabled={inputProps.disabled} + readOnly={inputProps.readOnly} + value={dateValue} + onValueChange={date => { + handleFilterValueChange(date ?? ''); + if (date) setDateOpen(false); }} - /> + open={dateOpen} + onOpenChange={setDateOpen} + > + + { + inputProps.onValidityChange?.(validity); + onErrorChange?.( + validity.valid ? undefined : validity.message + ); + }} + /> + + + + + ); default: diff --git a/packages/raystack/shared/date-filters.ts b/packages/raystack/shared/date-filters.ts new file mode 100644 index 000000000..5885c6820 --- /dev/null +++ b/packages/raystack/shared/date-filters.ts @@ -0,0 +1,54 @@ +import type { FilterFn } from '@tanstack/table-core'; +import { + type DayKey, + toDayKey +} from '~/components/calendar-preview/date-adapter'; +import type { + FilterTypes, + FilterValue, + FilterValueType +} from '~/types/filters'; +import { FilterType } from '~/types/filters'; + +/* An unreadable row cannot be placed before or after a day, so it matches none + of these. */ +export function onDay( + test: (day: DayKey, filterDay: DayKey) => boolean +): FilterFn { + return (row, columnId, filterValue: FilterValue) => { + const day = toDayKey(row.getValue(columnId)); + const filterDay = toDayKey(filterValue.date); + return day !== null && filterDay !== null && test(day, filterDay); + }; +} + +/* A row with no readable date is still not the filter's day. */ +export const notOnDay: FilterFn = ( + row, + columnId, + filterValue: FilterValue +) => { + const filterDay = toDayKey(filterValue.date); + return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay; +}; + +type TypedFilter = { + name: string; + _type?: FilterTypes; + _dataType?: FilterValueType; +}; + +/* A query from the consumer carries no filter types, and a date filter without + one never reaches the date comparisons. Only dates are typed here, because a + type changes how string and number filters are sent. A date filter sends + its day key as a string. */ +export function withDateFilterTypes( + filters: Filter[], + fields: { accessorKey?: unknown; filterType?: FilterTypes }[] +): Filter[] { + return filters.map(filter => { + const field = fields.find(f => f.accessorKey === filter.name); + if (filter._type || field?.filterType !== FilterType.date) return filter; + return { ...filter, _type: FilterType.date, _dataType: 'string' }; + }); +}