Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import CalendarPlusDateInputTest from "@rilldata/web-common/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInputTest.svelte";
import { mockAnimationsForComponentTesting } from "@rilldata/web-common/lib/test/mock-animations";
import { act, fireEvent, render, screen } from "@testing-library/svelte";
import { DateTime, Interval, type DateTimeUnit } from "luxon";
import { describe, expect, it, vi } from "vitest";

const UTC = "UTC";
const NEW_YORK = "America/New_York";

function dateTime(iso: string, zone: string) {
return DateTime.fromISO(iso, { zone }) as DateTime<true>;
}

function intervalOf(startIso: string, endIso: string, zone: string) {
return Interval.fromDateTimes(
dateTime(startIso, zone),
dateTime(endIso, zone),
) as Interval<true>;
}

function renderCalendar({
interval,
maxDate,
minTimeGrain,
zone,
minDate,
}: {
interval: Interval<true>;
maxDate: DateTime<true>;
minTimeGrain: DateTimeUnit;
zone: string;
minDate?: DateTime<true>;
}) {
const updateRange = vi.fn();
const onApply = vi.fn();
const closeMenu = vi.fn();

render(CalendarPlusDateInputTest, {
props: {
interval,
minDate: minDate ?? dateTime("2024-01-01T00:00:00", zone),
maxDate,
minTimeGrain,
zone,
updateRange,
onApply,
closeMenu,
},
});

return { updateRange, onApply, closeMenu };
}

/**
* The out-of-range indicator is a tooltip trigger rendered next to the input.
* It uses a yellow icon for out-of-range dates and a red one for invalid dates.
*/
function getOutOfRangeIndicator(boundary: "start" | "end") {
const indicator = screen
.getByLabelText(`${boundary} date`)
.parentElement!.querySelector("button");
const isOutOfRange = indicator
?.querySelector("svg")
?.classList.contains("text-yellow-500");
return isOutOfRange ? indicator : null;
}

async function enterDate(boundary: "start" | "end", value: string) {
const input = screen.getByLabelText(`${boundary} date`);
await act(() => fireEvent.focus(input));
await act(() => fireEvent.input(input, { target: { value } }));
await act(() => fireEvent.blur(input));
}

describe("CalendarPlusDateInput", () => {
mockAnimationsForComponentTesting();

it("allows the day containing the max date when the min grain is smaller than a day", () => {
renderCalendar({
// The selected range ends at the end of the day that contains the max date.
interval: intervalOf("2024-03-15T00:00:00", "2024-03-16T00:00:00", UTC),
maxDate: dateTime("2024-03-15T10:30:00", UTC),
minTimeGrain: "hour",
zone: UTC,
});

expect(getOutOfRangeIndicator("start")).toBeNull();
expect(getOutOfRangeIndicator("end")).toBeNull();
});

it("flags dates past the day containing the max date when the min grain is smaller than a day", async () => {
renderCalendar({
interval: intervalOf("2024-03-15T00:00:00", "2024-03-16T00:00:00", UTC),
maxDate: dateTime("2024-03-15T10:30:00", UTC),
minTimeGrain: "hour",
zone: UTC,
});

await enterDate("end", "Mar 20, 2024");

expect(getOutOfRangeIndicator("end")).not.toBeNull();
});

it("resets an out-of-range end date to the day after the max date", async () => {
const { updateRange } = renderCalendar({
interval: intervalOf("2024-03-15T00:00:00", "2024-03-16T00:00:00", UTC),
maxDate: dateTime("2024-03-15T10:30:00", UTC),
minTimeGrain: "hour",
zone: UTC,
});

await enterDate("end", "Mar 20, 2024");
await act(() => fireEvent.click(getOutOfRangeIndicator("end")!));

expect(updateRange).toHaveBeenLastCalledWith("2024-03-15 to 2024-03-16");
expect(getOutOfRangeIndicator("end")).toBeNull();
});

it("snaps the max date to the min grain when the grain is larger than a day", async () => {
renderCalendar({
// Later in the same month as the max date, which is allowed for a month grain.
interval: intervalOf("2024-03-20T00:00:00", "2024-03-21T00:00:00", UTC),
maxDate: dateTime("2024-03-15T10:30:00", UTC),
minTimeGrain: "month",
zone: UTC,
});

expect(getOutOfRangeIndicator("end")).toBeNull();

// The next month is past the snapped max date.
await enterDate("end", "Apr 5, 2024");

expect(getOutOfRangeIndicator("end")).not.toBeNull();
});

it("snaps the max date in the dashboard time zone", async () => {
renderCalendar({
// In New York the max date is Mar 14 at 22:00, so Mar 14 is the last selectable day.
interval: intervalOf(
"2024-03-14T00:00:00",
"2024-03-15T00:00:00",
NEW_YORK,
),
maxDate: dateTime("2024-03-15T02:00:00", UTC),
minTimeGrain: "hour",
zone: NEW_YORK,
});

expect(getOutOfRangeIndicator("end")).toBeNull();

await enterDate("end", "Mar 15, 2024");

expect(getOutOfRangeIndicator("end")).not.toBeNull();
});

it("keeps the min date snapped to the start of the day for larger grains", () => {
renderCalendar({
interval: intervalOf("2024-02-01T00:00:00", "2024-02-02T00:00:00", UTC),
minDate: dateTime("2024-02-01T14:00:00", UTC),
maxDate: dateTime("2024-03-15T10:30:00", UTC),
minTimeGrain: "month",
zone: UTC,
});

expect(getOutOfRangeIndicator("start")).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import Calendar from "@rilldata/web-common/components/date-picker/Calendar.svelte";
import DateInput from "@rilldata/web-common/components/date-picker/DateInput.svelte";
import { DateTime, Duration, Interval, type DateTimeUnit } from "luxon";
import { snapToDayOrLargerGrain } from "@rilldata/web-common/lib/time/new-grains.ts";

export let interval: Interval<true> | undefined;
export let minDate: DateTime | undefined = undefined;
Expand Down Expand Up @@ -32,10 +33,13 @@
? inputInterval?.end
: inputInterval?.end.minus({ millisecond: 1 });

// Calender picker is for selecting days. So always snap to day.
$: adjustedMinDate = minDate?.startOf("day");
// The exception is end date and the min grain is larger than day.
// For grains like week, month, year, etc. we need to snap to that instead.
$: adjustedMaxDate = maxDate
?.plus({ [minTimeGrain]: 1 })
.startOf(minTimeGrain);
? snapToDayOrLargerGrain(maxDate, minTimeGrain, zone)
: undefined;

$: capMs = maxQueryTimeRange?.as("milliseconds") ?? 0;
$: exceedsCap =
Expand Down Expand Up @@ -115,10 +119,10 @@
<DateInput
boundary="start"
{zone}
date={startDate}
minDate={adjustedMinDate}
maxDate={adjustedMaxDate}
currentYear={firstVisibleMonth.year}
date={startDate}
{onValidDateInput}
onFocus={() => {
firstVisibleMonth = inputInterval.start;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import * as Tooltip from "@rilldata/web-common/components/tooltip-v2";
import CalendarPlusDateInput from "@rilldata/web-common/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.svelte";
import type { DateTime, Duration, Interval, DateTimeUnit } from "luxon";

/**
* Test-only wrapper that supplies the Tooltip.Provider that the app layout
* normally provides. Date inputs render a tooltip when a date is out of range.
*/

export let interval: Interval<true> | undefined;
export let minDate: DateTime | undefined = undefined;
export let maxDate: DateTime | undefined = undefined;
export let minTimeGrain: DateTimeUnit;
export let zone: string;
export let maxQueryTimeRange: Duration | undefined = undefined;
export let updateRange: (range: string) => void = () => {};
export let onApply: (interval: Interval<true>) => void;
export let closeMenu: () => void;
</script>

<Tooltip.Provider>
<CalendarPlusDateInput
{interval}
{minDate}
{maxDate}
{minTimeGrain}
{zone}
{maxQueryTimeRange}
{updateRange}
{onApply}
{closeMenu}
/>
</Tooltip.Provider>
15 changes: 14 additions & 1 deletion web-common/src/lib/time/new-grains.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { reverseMap } from "@rilldata/web-common/lib/map-utils.ts";
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas";
import type { DateTimeUnit, Interval } from "luxon";
import type { DateTime, DateTimeUnit, Interval } from "luxon";

const MAX_BUCKETS = 1500;

Expand Down Expand Up @@ -287,6 +287,19 @@ export function getAllowedGrains(
return getAllowedGrainsFromOrder(order);
}

export function snapToDayOrLargerGrain(
date: DateTime<true>,
grain: DateTimeUnit,
zone: string,
) {
const snapToGrain = GrainToOrder[grain] > GrainToOrder.day ? grain : "day";
return date
.setZone(zone)
.plus({ [snapToGrain]: 1 })
.startOf(snapToGrain)
.toUTC();
}

export function getLowerOrderGrain(grain: V1TimeGrain): V1TimeGrain {
switch (grain) {
case V1TimeGrain.TIME_GRAIN_MILLISECOND:
Expand Down
Loading