Add DateRangePicker component with segmented entry and calendar popup
Django CI/CD / test (push) Successful in 2m33s
Django CI/CD / build-and-push (push) Successful in 1m17s

Implements the DateRangePicker design: a DateRangeField that looks like a
single input but splits each date into DD/MM/YYYY part inputs (ordered by
the new common.time.dateformat_hyphenated), and a DateRangeCalendar popup
with a preset column (today, yesterday, last 7/30 days, this/last month,
this year), anchor-style range picking with an outlined/filled/muted range
track, and a Cancel / Clear / Select footer.

Typing fills each part's placeholder from the right (YYYY -> YY19 -> 1987),
auto-advances between parts, and Backspace/Delete reverts the active part.
The committed value lives in hidden ISO {prefix}-min/{prefix}-max inputs --
the same contract as DateRangeFilter, so filter_bar.js needs no changes.

As a tryout, the Purchased filter in PurchaseFilterBar now uses the
DateRangePicker; Refunded keeps the native-date DateRangeFilter, and the
native-path e2e tests were repointed at it.

Includes unit tests for the component family and the filter-bar
integration, plus Playwright e2e tests for segment entry, calendar
picking, presets, and footer actions.

https://claude.ai/code/session_017b75KJAu4kNNpZPu9NAPBM
This commit is contained in:
Claude
2026-06-11 17:49:22 +00:00
committed by Lukáš Kucharczyk
parent 15a97dee9a
commit 0fa860c237
10 changed files with 1552 additions and 11 deletions
+8
View File
@@ -11,6 +11,11 @@ from common.components.core import (
_render_element, _render_element,
randomid, randomid,
) )
from common.components.date_range_picker import (
DateRangeCalendar,
DateRangeField,
DateRangePicker,
)
from common.components.domain import ( from common.components.domain import (
GameLink, GameLink,
GameStatus, GameStatus,
@@ -123,6 +128,9 @@ __all__ = [
"PurchasePrice", "PurchasePrice",
"SessionDeviceSelector", "SessionDeviceSelector",
"_resolve_name_with_icon", "_resolve_name_with_icon",
"DateRangeCalendar",
"DateRangeField",
"DateRangePicker",
"FilterBar", "FilterBar",
"PurchaseFilterBar", "PurchaseFilterBar",
"SessionFilterBar", "SessionFilterBar",
+355
View File
@@ -0,0 +1,355 @@
"""DateRangePicker: a segmented date-range input with a calendar popup.
``DateRangePicker`` composes two parts:
- ``DateRangeField`` — the visible widget, styled as a single input. Each
date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
``common.time.dateformat_hyphenated``) that the user fills digit by digit,
plus a calendar icon that opens the popup.
- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
last 7 days, …), a month grid rendered client-side, and a
Cancel / Clear / Select footer.
The committed value lives in two hidden ISO-date inputs named
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
as the older ``DateRangeFilter``, so ``filter_bar.js`` serializes either
widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``games/static/js/date_range_picker.js``.
"""
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLAttribute
from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts
_FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
"focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
)
# The segments must not stand out from the container: transparent background,
# no border, and only a subtle highlight when active (focused).
_SEGMENT_INPUT_CLASS = (
"bg-transparent border-0 p-0 text-center text-sm text-heading "
"placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
"focus:bg-brand/30 caret-transparent"
)
_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
_CALENDAR_ICON_SVG = (
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
'stroke="currentColor" aria-hidden="true">'
'<path stroke-linecap="round" stroke-linejoin="round" '
'd="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5'
"A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5"
"A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5"
'A2.25 2.25 0 0 1 21 11.25v7.5"/>'
"</svg>"
)
_PRESET_OPTIONS: list[tuple[str, str]] = [
("today", "Today"),
("yesterday", "Yesterday"),
("last_7_days", "Last 7 days"),
("last_30_days", "Last 30 days"),
("this_month", "This month"),
("last_month", "Last month"),
("this_year", "This year"),
]
_PRESET_BUTTON_CLASS = (
"px-3 py-1.5 text-sm text-start text-body hover:text-heading "
"hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
)
_NAV_BUTTON_CLASS = (
"p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
"rounded-base cursor-pointer"
)
_FOOTER_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-heading bg-neutral-secondary-medium border border-default-medium "
"hover:bg-neutral-tertiary-medium"
)
_FOOTER_SELECT_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-white bg-brand border border-transparent hover:bg-brand-strong"
)
def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
"""Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
Returns an empty mapping for empty/malformed input so a bad stored filter
renders as empty segments instead of crashing."""
if not iso_value:
return {}
pieces = iso_value.split("-")
if len(pieces) != 3:
return {}
year, month, day = pieces
values = {"year": year, "month": month, "day": day}
if any(not values[part.name].isdigit() for part in parts):
return {}
return values
def _segment_input(
*, part: DatePartSpec, side: str, label: str, value: str
) -> SafeText:
side_label = "from" if side == "min" else "to"
return Input(
attributes=[
("inputmode", "numeric"),
("autocomplete", "off"),
("maxlength", str(part.length)),
("placeholder", part.placeholder),
("value", value),
("data-date-part", part.name),
("data-date-side", side),
("aria-label", f"{label} {side_label} {part.name}"),
(
"class",
f"{_SEGMENT_INPUT_CLASS} "
f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
),
],
)
def _segment_group(*, side: str, label: str, iso_value: str) -> SafeText:
"""One date's worth of segments (``DD - MM - YYYY``) for a range side."""
parts = date_parts()
initial_values = _iso_part_values(iso_value, parts)
children: list[SafeText] = []
for index, part in enumerate(parts):
if index > 0:
children.append(
Span(
attributes=[("class", "text-body select-none")],
children=["-"],
)
)
children.append(
_segment_input(
part=part,
side=side,
label=label,
value=initial_values.get(part.name, ""),
)
)
return Span(
attributes=[
("class", "flex items-center gap-0.5"),
("data-date-range-side", side),
],
children=children,
)
def DateRangeField(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> SafeText:
"""The visible half of the DateRangePicker: a single-input-looking
container holding two segmented dates, a calendar toggle, and the two
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
committed value to ``filter_bar.js``."""
min_input_id = f"{input_name_prefix}-min"
max_input_id = f"{input_name_prefix}-max"
return Div(
attributes=[
("class", _FIELD_CONTAINER_CLASS),
("data-date-range-field", ""),
],
children=[
Input(
type="hidden",
attributes=[
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("data-date-range-hidden", "min"),
],
),
Input(
type="hidden",
attributes=[
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("data-date-range-hidden", "max"),
],
),
_segment_group(side="min", label=label, iso_value=min_value),
Span(
attributes=[("class", "text-body select-none px-0.5")],
children=[""],
),
_segment_group(side="max", label=label, iso_value=max_value),
Component(
tag_name="button",
attributes=[
("type", "button"),
("data-date-range-calendar-toggle", ""),
("aria-label", f"Open {label} calendar"),
(
"class",
"ms-auto p-1 text-body hover:text-heading rounded "
"cursor-pointer shrink-0",
),
],
children=[mark_safe(_CALENDAR_ICON_SVG)],
),
],
)
def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
return Component(
tag_name="button",
attributes=[
("type", "button"),
(f"data-date-range-{direction}", ""),
("aria-label", label),
("class", _NAV_BUTTON_CLASS),
],
children=[arrow],
)
def _footer_button(action: str, label: str, button_class: str) -> SafeText:
return Component(
tag_name="button",
attributes=[
("type", "button"),
(f"data-date-range-{action}", ""),
("class", button_class),
],
children=[label],
)
def DateRangeCalendar(*, input_name_prefix: str) -> SafeText:
"""The popup half of the DateRangePicker: preset column, month grid
(filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [
Component(
tag_name="button",
attributes=[
("type", "button"),
("data-date-range-preset", preset_value),
("class", _PRESET_BUTTON_CLASS),
],
children=[preset_label],
)
for preset_value, preset_label in _PRESET_OPTIONS
]
return Div(
attributes=[
(
"class",
"hidden absolute z-20 top-full start-0 mt-1 flex "
"rounded-base border border-default-medium "
"bg-neutral-secondary-medium shadow-lg",
),
("data-date-range-calendar", ""),
("data-input-name-prefix", input_name_prefix),
],
children=[
Div(
attributes=[
(
"class",
"flex flex-col gap-0.5 p-2 border-e border-default-medium",
),
("data-date-range-presets", ""),
],
children=preset_buttons,
),
Div(
attributes=[("class", "p-2")],
children=[
Div(
attributes=[
("class", "flex items-center justify-between gap-2"),
],
children=[
_calendar_nav_button("prev", "", "Previous month"),
Span(
attributes=[
("class", "text-sm font-medium text-heading"),
("data-date-range-month-label", ""),
],
),
_calendar_nav_button("next", "", "Next month"),
],
),
Div(
attributes=[
("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
("data-date-range-grid", ""),
],
),
Div(
attributes=[
(
"class",
"flex justify-end gap-2 mt-2 pt-2 border-t "
"border-default-medium",
),
],
children=[
_footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
_footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
_footer_button(
"select", "Select", _FOOTER_SELECT_BUTTON_CLASS
),
],
),
],
),
],
)
def DateRangePicker(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> SafeText:
"""A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
inputs."""
attributes: list[HTMLAttribute] = [
("class", "date-range-picker relative"),
("data-date-range-picker", ""),
("data-input-name-prefix", input_name_prefix),
]
return Div(
attributes=attributes,
children=[
DateRangeField(
label=label,
input_name_prefix=input_name_prefix,
min_value=min_value,
max_value=max_value,
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
],
)
+2 -1
View File
@@ -6,6 +6,7 @@ from django.db import models
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component from common.components.core import Component
from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import ( from common.components.search_select import (
DEFAULT_PREFETCH, DEFAULT_PREFETCH,
@@ -1289,7 +1290,7 @@ def PurchaseFilterBar(
), ),
_filter_field( _filter_field(
"Purchased", "Purchased",
DateRangeFilter( DateRangePicker(
label="Purchased", label="Purchased",
input_name_prefix="filter-date-purchased", input_name_prefix="filter-date-purchased",
min_value=date_purchased_min, min_value=date_purchased_min,
+26
View File
@@ -1,17 +1,43 @@
import re import re
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import NamedTuple
from django.utils import timezone from django.utils import timezone
from common.utils import generate_split_ranges from common.utils import generate_split_ranges
dateformat: str = "%d/%m/%Y" dateformat: str = "%d/%m/%Y"
dateformat_hyphenated: str = "%d-%m-%Y"
datetimeformat: str = "%d/%m/%Y %H:%M" datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M" timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours" durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours" durationformat_manual: str = "%H hours"
class DatePartSpec(NamedTuple):
"""One date part (day/month/year) of a hyphenated date format."""
name: str
placeholder: str
length: int
_DATE_PART_SPECS: dict[str, DatePartSpec] = {
"%d": DatePartSpec("day", "DD", 2),
"%m": DatePartSpec("month", "MM", 2),
"%Y": DatePartSpec("year", "YYYY", 4),
}
def date_parts(format_string: str = dateformat_hyphenated) -> list[DatePartSpec]:
"""Split a hyphenated strftime date format into its ordered parts.
``"%d-%m-%Y"`` becomes ``[day, month, year]`` specs, each carrying the
placeholder text (``DD``/``MM``/``YYYY``) and digit length shown by the
DateRangeField segments."""
return [_DATE_PART_SPECS[directive] for directive in format_string.split("-")]
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
if duration is None: if duration is None:
return timedelta(0) return timedelta(0)
+14 -10
View File
@@ -5,6 +5,10 @@ cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
elements, building a ``DateCriterion`` JSON object, and navigating the elements, building a ``DateCriterion`` JSON object, and navigating the
browser to ``?filter=<encoded>``. browser to ``?filter=<encoded>``.
The native ``<input type="date">`` path is exercised through the Refunded
field — the Purchased field now uses the DateRangePicker component, covered
by ``test_date_range_picker_e2e.py``.
Renders the bar at its own custom URL so the test doesn't need to auth Renders the bar at its own custom URL so the test doesn't need to auth
against the real app — the bar's JS doesn't care what route serves it. against the real app — the bar's JS doesn't care what route serves it.
""" """
@@ -42,7 +46,7 @@ def empty_bar_view(request):
def prefilled_bar_view(request): def prefilled_bar_view(request):
filter_json = json.dumps( filter_json = json.dumps(
{ {
"date_purchased": { "date_refunded": {
"value": "2024-03-15", "value": "2024-03-15",
"value2": "2024-09-20", "value2": "2024-09-20",
"modifier": "BETWEEN", "modifier": "BETWEEN",
@@ -70,8 +74,8 @@ def _filter_from_url(url: str) -> dict:
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") @override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_both_dates_serializes_as_between(live_server, page): def test_both_dates_serializes_as_between(live_server, page):
page.goto(live_server.url + "/test-date-filter/") page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-purchased-min"]').fill("2024-01-01") page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
page.locator('input[name="filter-date-purchased-max"]').fill("2024-12-31") page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
with page.expect_navigation(): with page.expect_navigation():
page.evaluate( page.evaluate(
"document.getElementById('filter-bar-form')" "document.getElementById('filter-bar-form')"
@@ -79,7 +83,7 @@ def test_both_dates_serializes_as_between(live_server, page):
) )
parsed = _filter_from_url(page.url) parsed = _filter_from_url(page.url)
assert parsed == { assert parsed == {
"date_purchased": { "date_refunded": {
"value": "2024-01-01", "value": "2024-01-01",
"value2": "2024-12-31", "value2": "2024-12-31",
"modifier": "BETWEEN", "modifier": "BETWEEN",
@@ -91,7 +95,7 @@ def test_both_dates_serializes_as_between(live_server, page):
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") @override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_min_only_serializes_as_greater_than(live_server, page): def test_min_only_serializes_as_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-filter/") page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-purchased-min"]').fill("2024-06-15") page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
with page.expect_navigation(): with page.expect_navigation():
page.evaluate( page.evaluate(
"document.getElementById('filter-bar-form')" "document.getElementById('filter-bar-form')"
@@ -99,10 +103,10 @@ def test_min_only_serializes_as_greater_than(live_server, page):
) )
parsed = _filter_from_url(page.url) parsed = _filter_from_url(page.url)
assert parsed == { assert parsed == {
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"} "date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
} }
# value2 must not be present when there's no upper bound. # value2 must not be present when there's no upper bound.
assert "value2" not in parsed["date_purchased"] assert "value2" not in parsed["date_refunded"]
@pytest.mark.django_db @pytest.mark.django_db
@@ -144,11 +148,11 @@ def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
re-submits the same bounds unchanged.""" re-submits the same bounds unchanged."""
page.goto(live_server.url + "/test-date-filter-prefilled/") page.goto(live_server.url + "/test-date-filter-prefilled/")
assert ( assert (
page.locator('input[name="filter-date-purchased-min"]').input_value() page.locator('input[name="filter-date-refunded-min"]').input_value()
== "2024-03-15" == "2024-03-15"
) )
assert ( assert (
page.locator('input[name="filter-date-purchased-max"]').input_value() page.locator('input[name="filter-date-refunded-max"]').input_value()
== "2024-09-20" == "2024-09-20"
) )
with page.expect_navigation(): with page.expect_navigation():
@@ -157,7 +161,7 @@ def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
".dispatchEvent(new Event('submit', {cancelable: true}))" ".dispatchEvent(new Event('submit', {cancelable: true}))"
) )
parsed = _filter_from_url(page.url) parsed = _filter_from_url(page.url)
assert parsed["date_purchased"] == { assert parsed["date_refunded"] == {
"value": "2024-03-15", "value": "2024-03-15",
"value2": "2024-09-20", "value2": "2024-09-20",
"modifier": "BETWEEN", "modifier": "BETWEEN",
+325
View File
@@ -0,0 +1,325 @@
"""End-to-end Playwright tests for the DateRangePicker component.
Exercises the behaviour layers the rendering tests cannot reach
(``date_range_picker.js``): segmented digit entry with right-to-left
placeholder fill and auto-advance, Backspace reverting a part, the calendar
popup's anchor-style range picking, presets, the Cancel / Clear / Select
footer, and the ``filter_bar.js`` serialization of the hidden ISO inputs
into a ``DateCriterion``.
Like the other filter-bar e2e modules, the bar is served from its own
minimal URLconf (no auth, no CSS) — the JS only cares about the DOM.
"""
import datetime
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from common.components import PurchaseFilterBar
from django.urls import path
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Date range picker E2E</title>
<script src="/static/js/range_slider.js" defer></script>
<script src="/static/js/search_select.js" defer></script>
<script src="/static/js/date_range_picker.js" defer></script>
<script src="/static/js/filter_bar.js" defer></script>
</head>
<body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
return HttpResponse(_bar_page(filter_json))
urlpatterns = [
path("test-date-range-picker/", empty_bar_view),
path("test-date-range-picker-prefilled/", prefilled_bar_view),
]
PICKER = '[data-date-range-picker][data-input-name-prefix="filter-date-purchased"]'
POPUP = PICKER + " [data-date-range-calendar]"
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
def _segment(page, side: str, part: str):
return page.locator(
f'{PICKER} input[data-date-side="{side}"][data-date-part="{part}"]'
)
def _day_cell(page, iso_date: str):
return page.locator(
f'{PICKER} [data-date-range-grid] button[data-date="{iso_date}"]'
)
def _popup_is_open(page) -> bool:
return "hidden" not in (page.locator(POPUP).get_attribute("class") or "")
def _submit_filter_bar(page):
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
def _filter_from_url(url: str) -> dict:
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
# ── Segmented manual entry ──────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_typing_fills_parts_and_serializes_between(live_server, page):
"""Digits flow through the parts (DD → MM → YYYY → DD …) with
auto-advance, ending in a BETWEEN criterion on submit."""
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("1503202420092024")
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_placeholder_fills_from_the_right(live_server, page):
"""Typing 19 into the YYYY part shows YYY1 then YY19."""
page.goto(live_server.url + "/test-date-range-picker/")
year_segment = _segment(page, "min", "year")
year_segment.click()
page.keyboard.press("1")
assert year_segment.input_value() == "YYY1"
page.keyboard.press("9")
assert year_segment.input_value() == "YY19"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_min_side_only_serializes_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("15062024")
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_backspace_reverts_part_to_placeholder(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("15032024")
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
month_segment = _segment(page, "min", "month")
month_segment.click()
page.keyboard.press("Backspace")
assert month_segment.input_value() == ""
# An incomplete date no longer commits to the hidden input.
assert page.locator(HIDDEN_MIN).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_only_numbers_can_be_typed(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
day_segment = _segment(page, "min", "day")
day_segment.click()
page.keyboard.type("ab-/")
assert day_segment.input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_invalid_calendar_date_does_not_commit(live_server, page):
"""31-02-2024 fills all parts but is not a real date — no hidden value."""
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("31022024")
assert page.locator(HIDDEN_MIN).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_clicking_container_activates_first_part(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
page.locator(PICKER + " [data-date-range-field]").click(position={"x": 5, "y": 5})
focused = page.evaluate(
"document.activeElement.getAttribute('data-date-part') + ':' +"
"document.activeElement.getAttribute('data-date-side')"
)
assert focused == "day:min"
# ── Calendar popup ──────────────────────────────────────────────────────────
def _open_calendar(page):
page.locator(PICKER + " [data-date-range-calendar-toggle]").click()
def _current_month_iso(day_of_month: int) -> str:
today = datetime.date.today()
return today.replace(day=day_of_month).isoformat()
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_calendar_pick_range_then_select(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
assert _popup_is_open(page)
first_pick = _current_month_iso(10)
second_pick = _current_month_iso(20)
_day_cell(page, first_pick).click()
assert page.locator(HIDDEN_MIN).input_value() == first_pick
assert page.locator(HIDDEN_MAX).input_value() == ""
_day_cell(page, second_pick).click()
assert page.locator(HIDDEN_MAX).input_value() == second_pick
page.locator(PICKER + " [data-date-range-select]").click()
assert not _popup_is_open(page)
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {
"value": first_pick,
"value2": second_pick,
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_picking_before_start_restarts_the_range(live_server, page):
"""With the StartDate anchored, picking an earlier date clears the range
and the clicked date becomes the new StartDate."""
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(20)).click()
_day_cell(page, _current_month_iso(10)).click()
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(10)
assert page.locator(HIDDEN_MAX).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_completed_range_anchor_moves_to_end(live_server, page):
"""After both dates are picked the EndDate becomes the anchor, so a
further pick inside the range moves the StartDate."""
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
_day_cell(page, _current_month_iso(15)).click()
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(15)
assert page.locator(HIDDEN_MAX).input_value() == _current_month_iso(20)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_preset_fills_both_dates(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
page.locator(PICKER + ' [data-date-range-preset="last_7_days"]').click()
today = datetime.date.today()
assert (
page.locator(HIDDEN_MIN).input_value()
== (today - datetime.timedelta(days=6)).isoformat()
)
assert page.locator(HIDDEN_MAX).input_value() == today.isoformat()
# Presets keep the popup open; Select commits and closes.
assert _popup_is_open(page)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_clear_clears_dates_but_keeps_popup_open(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
page.locator(PICKER + " [data-date-range-clear]").click()
assert page.locator(HIDDEN_MIN).input_value() == ""
assert page.locator(HIDDEN_MAX).input_value() == ""
assert _popup_is_open(page)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_cancel_clears_dates_and_closes_popup(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
page.locator(PICKER + " [data-date-range-cancel]").click()
assert page.locator(HIDDEN_MIN).input_value() == ""
assert page.locator(HIDDEN_MAX).input_value() == ""
assert not _popup_is_open(page)
# ── Prefill round-trip ──────────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_prefilled_picker_round_trips_unchanged(live_server, page):
page.goto(live_server.url + "/test-date-range-picker-prefilled/")
assert _segment(page, "min", "day").input_value() == "15"
assert _segment(page, "min", "month").input_value() == "03"
assert _segment(page, "min", "year").input_value() == "2024"
assert _segment(page, "max", "day").input_value() == "20"
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed["date_purchased"] == {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
+95
View File
@@ -918,6 +918,9 @@
.ms-2\.5 { .ms-2\.5 {
margin-inline-start: calc(var(--spacing) * 2.5); margin-inline-start: calc(var(--spacing) * 2.5);
} }
.ms-auto {
margin-inline-start: auto;
}
.me-2 { .me-2 {
margin-inline-end: calc(var(--spacing) * 2); margin-inline-end: calc(var(--spacing) * 2);
} }
@@ -1582,6 +1585,9 @@
.w-5\/6 { .w-5\/6 {
width: calc(5 / 6 * 100%); width: calc(5 / 6 * 100%);
} }
.w-8 {
width: calc(var(--spacing) * 8);
}
.w-10 { .w-10 {
width: calc(var(--spacing) * 10); width: calc(var(--spacing) * 10);
} }
@@ -1597,6 +1603,12 @@
.w-72 { .w-72 {
width: calc(var(--spacing) * 72); width: calc(var(--spacing) * 72);
} }
.w-\[2\.5ch\] {
width: 2.5ch;
}
.w-\[4\.5ch\] {
width: 4.5ch;
}
.w-\[300px\] { .w-\[300px\] {
width: 300px; width: 300px;
} }
@@ -1736,6 +1748,9 @@
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.cursor-text {
cursor: text;
}
.resize { .resize {
resize: both; resize: both;
} }
@@ -1787,6 +1802,9 @@
.justify-start { .justify-start {
justify-content: flex-start; justify-content: flex-start;
} }
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 { .gap-1 {
gap: calc(var(--spacing) * 1); gap: calc(var(--spacing) * 1);
} }
@@ -1836,6 +1854,9 @@
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
} }
} }
.gap-y-0\.5 {
row-gap: calc(var(--spacing) * 0.5);
}
.gap-y-4 { .gap-y-4 {
row-gap: calc(var(--spacing) * 4); row-gap: calc(var(--spacing) * 4);
} }
@@ -1904,6 +1925,9 @@
.rounded-xl { .rounded-xl {
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
} }
.rounded-xs {
border-radius: var(--radius-xs);
}
.rounded-s-base { .rounded-s-base {
border-start-start-radius: var(--radius-base); border-start-start-radius: var(--radius-base);
border-end-start-radius: var(--radius-base); border-end-start-radius: var(--radius-base);
@@ -1958,6 +1982,10 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; border-width: 2px;
} }
.border-y {
border-block-style: var(--tw-border-style);
border-block-width: 1px;
}
.border-e { .border-e {
border-inline-end-style: var(--tw-border-style); border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px; border-inline-end-width: 1px;
@@ -2030,6 +2058,12 @@
.border-brand { .border-brand {
border-color: var(--color-brand); border-color: var(--color-brand);
} }
.border-brand\/70 {
border-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-brand) 70%, transparent);
}
}
.border-default { .border-default {
border-color: var(--color-default); border-color: var(--color-default);
} }
@@ -2121,12 +2155,24 @@
.bg-brand { .bg-brand {
background-color: var(--color-brand); background-color: var(--color-brand);
} }
.bg-brand\/10 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 10%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 10%, transparent);
}
}
.bg-brand\/15 { .bg-brand\/15 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent); background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
} }
} }
.bg-brand\/30 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
}
}
.bg-dark-backdrop\/70 { .bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2296,6 +2342,9 @@
padding: 0 !important; padding: 0 !important;
} }
} }
.p-0 {
padding: calc(var(--spacing) * 0);
}
.p-1 { .p-1 {
padding: calc(var(--spacing) * 1); padding: calc(var(--spacing) * 1);
} }
@@ -2320,6 +2369,9 @@
.p-6 { .p-6 {
padding: calc(var(--spacing) * 6); padding: calc(var(--spacing) * 6);
} }
.px-0\.5 {
padding-inline: calc(var(--spacing) * 0.5);
}
.px-2 { .px-2 {
padding-inline: calc(var(--spacing) * 2); padding-inline: calc(var(--spacing) * 2);
} }
@@ -2419,6 +2471,9 @@
.text-right { .text-right {
text-align: right; text-align: right;
} }
.text-start {
text-align: start;
}
.align-middle { .align-middle {
vertical-align: middle; vertical-align: middle;
} }
@@ -2714,9 +2769,15 @@
.decoration-dotted { .decoration-dotted {
text-decoration-style: dotted; text-decoration-style: dotted;
} }
.caret-transparent {
caret-color: transparent;
}
.opacity-0 { .opacity-0 {
opacity: 0%; opacity: 0%;
} }
.opacity-40 {
opacity: 40%;
}
.opacity-50 { .opacity-50 {
opacity: 50%; opacity: 50%;
} }
@@ -2752,6 +2813,13 @@
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.ring-2 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-brand-strong {
--tw-ring-color: var(--color-brand-strong);
}
.outline { .outline {
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;
@@ -2817,6 +2885,9 @@
.\[program\:qcluster\] { .\[program\:qcluster\] {
program: qcluster; program: qcluster;
} }
.ring-inset {
--tw-ring-inset: inset;
}
.group-hover\:absolute { .group-hover\:absolute {
&:is(:where(.group):hover *) { &:is(:where(.group):hover *) {
@media (hover: hover) { @media (hover: hover) {
@@ -2958,6 +3029,22 @@
padding-top: calc(var(--spacing) * 0); padding-top: calc(var(--spacing) * 0);
} }
} }
.focus-within\:border-brand {
&:focus-within {
border-color: var(--color-brand);
}
}
.focus-within\:ring-1 {
&:focus-within {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus-within\:ring-brand {
&:focus-within {
--tw-ring-color: var(--color-brand);
}
}
.hover\:scale-110 { .hover\:scale-110 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3223,6 +3310,14 @@
border-color: var(--color-brand); border-color: var(--color-brand);
} }
} }
.focus\:bg-brand\/30 {
&:focus {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
}
}
}
.focus\:text-blue-700 { .focus\:text-blue-700 {
&:focus { &:focus {
color: var(--color-blue-700); color: var(--color-blue-700);
+530
View File
@@ -0,0 +1,530 @@
/**
* DateRangePicker — vanilla JavaScript implementation.
*
* Drives the DateRangePicker component (common/components/date_range_picker.py):
*
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
* own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
* → Y198 → 1987), full parts auto-advance to the next one, and
* Backspace/Delete reverts the active part to its placeholder.
* - DateRangeCalendar: popup month grid with a preset column and a
* Cancel / Clear / Select footer. Picking works anchor-style: the first
* pick becomes the StartDate anchor, the second pick sets the EndDate and
* moves the anchor there so further picks adjust the StartDate. Picking on
* the wrong side of the anchor clears the range and restarts from the
* clicked date.
*
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
* {prefix}-max) that filter_bar.js serializes into a DateCriterion.
*
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
* them up — keep them as plain literals.
*/
(function () {
"use strict";
var WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
var WEEKDAY_CLASS =
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
var DAY_BASE_CLASS =
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
var DAY_ROUNDED_CLASS = "rounded-base";
var DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
var DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
var DAY_ANCHOR_CLASS =
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
// The three visual states of the date range track (the days between the
// two endpoints): outlined while picking the second date, filled once both
// are picked, muted when showing an already-committed range read-only.
var TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
var TRACK_FILLED_CLASS = "bg-brand/30";
var TRACK_MUTED_CLASS = "bg-brand/15";
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
function padNumber(value, width) {
var text = String(value);
while (text.length < width) text = "0" + text;
return text;
}
function isoFromDate(dateObject) {
return (
padNumber(dateObject.getFullYear(), 4) +
"-" +
padNumber(dateObject.getMonth() + 1, 2) +
"-" +
padNumber(dateObject.getDate(), 2)
);
}
function dateFromIso(isoString) {
var pieces = isoString.split("-");
return new Date(
parseInt(pieces[0], 10),
parseInt(pieces[1], 10) - 1,
parseInt(pieces[2], 10)
);
}
function addDays(dateObject, dayCount) {
var copy = new Date(dateObject.getTime());
copy.setDate(copy.getDate() + dayCount);
return copy;
}
/** Validate a (year, month, day) triple as a real calendar date. */
function isoFromParts(year, month, day) {
var candidate = new Date(year, month - 1, day);
if (
candidate.getFullYear() !== year ||
candidate.getMonth() !== month - 1 ||
candidate.getDate() !== day
) {
return "";
}
return isoFromDate(candidate);
}
function presetRange(presetName) {
var today = new Date();
today.setHours(0, 0, 0, 0);
var yesterday = addDays(today, -1);
var year = today.getFullYear();
var month = today.getMonth();
switch (presetName) {
case "today":
return [today, today];
case "yesterday":
return [yesterday, yesterday];
case "last_7_days":
return [addDays(today, -6), today];
case "last_30_days":
return [addDays(today, -29), today];
case "this_month":
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
case "last_month":
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
case "this_year":
return [new Date(year, 0, 1), new Date(year, 11, 31)];
default:
return null;
}
}
// ── DateRangeField: segmented manual entry ──────────────────────────────
function segmentBuffer(segment) {
return segment.dataset.typedDigits || "";
}
function setSegmentBuffer(segment, buffer) {
segment.dataset.typedDigits = buffer;
if (buffer === "") {
segment.value = "";
return;
}
var placeholder = segment.getAttribute("placeholder");
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
}
function segmentsForSide(picker, side) {
return Array.prototype.slice.call(
picker.querySelectorAll('input[data-date-side="' + side + '"]')
);
}
/** Recompute one hidden ISO input from its side's segment buffers. */
function syncHiddenFromSegments(picker, side) {
var hidden = picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
);
var partValues = {};
var complete = true;
segmentsForSide(picker, side).forEach(function (segment) {
var buffer = segmentBuffer(segment);
if (buffer.length !== parseInt(segment.getAttribute("maxlength"), 10)) {
complete = false;
}
partValues[segment.dataset.datePart] = buffer;
});
var previousValue = hidden.value;
if (complete) {
hidden.value = isoFromParts(
parseInt(partValues.year, 10),
parseInt(partValues.month, 10),
parseInt(partValues.day, 10)
);
} else {
hidden.value = "";
}
return hidden.value !== previousValue;
}
/** Push an ISO value (or "") into a side's segments and hidden input. */
function setSideValue(picker, side, isoString) {
var hidden = picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
);
hidden.value = isoString;
var partValues = { year: "", month: "", day: "" };
if (isoString) {
var pieces = isoString.split("-");
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
}
segmentsForSide(picker, side).forEach(function (segment) {
setSegmentBuffer(segment, partValues[segment.dataset.datePart]);
});
}
function initField(picker, calendarState) {
var field = picker.querySelector("[data-date-range-field]");
var segments = Array.prototype.slice.call(
picker.querySelectorAll("input[data-date-part]")
);
// Adopt server-rendered values (prefilled filter) as typed buffers.
segments.forEach(function (segment) {
if (segment.value) setSegmentBuffer(segment, segment.value);
});
// Clicking anywhere in the container that is not a date part activates
// the first date part.
field.addEventListener("mousedown", function (event) {
if (event.target.closest("input[data-date-part]")) return;
if (event.target.closest("[data-date-range-calendar-toggle]")) return;
event.preventDefault();
segments[0].focus();
});
segments.forEach(function (segment, segmentIndex) {
segment.addEventListener("keydown", function (event) {
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
if (event.key === "Enter") return; // let the filter form submit
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
setSegmentBuffer(segment, "");
syncHiddenFromSegments(picker, segment.dataset.dateSide);
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault();
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
var maximumLength = parseInt(segment.getAttribute("maxlength"), 10);
var buffer = segmentBuffer(segment);
// Typing into an already-full part starts it over.
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
setSegmentBuffer(segment, buffer);
syncHiddenFromSegments(picker, segment.dataset.dateSide);
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
segments[segmentIndex + 1].focus();
}
});
// Swallow any input that bypassed keydown (e.g. IME/paste).
segment.addEventListener("input", function () {
setSegmentBuffer(segment, segmentBuffer(segment));
});
segment.addEventListener("focus", function () {
if (calendarState) calendarState.refreshFromField();
});
});
}
// ── DateRangeCalendar: popup month grid ────────────────────────────────
function createCalendarState(picker) {
var popup = picker.querySelector("[data-date-range-calendar]");
var grid = popup.querySelector("[data-date-range-grid]");
var monthLabel = popup.querySelector("[data-date-range-month-label]");
var today = new Date();
var state = {
open: false,
viewYear: today.getFullYear(),
viewMonth: today.getMonth(),
startIso: "",
endIso: "",
// The anchor is the fixed endpoint: "start" while picking the EndDate,
// "end" once the range is complete (further picks move the StartDate).
anchor: "",
hoverIso: "",
// True while showing a committed range the user has not edited yet —
// the track renders muted until the first pick.
readOnly: false,
};
function hiddenValue(side) {
return picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
).value;
}
state.refreshFromField = function () {
if (state.open) return;
state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max");
};
function syncSelectionToField() {
setSideValue(picker, "min", state.startIso);
setSideValue(picker, "max", state.endIso);
}
function openPopup() {
state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max");
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
state.readOnly = Boolean(state.startIso && state.endIso);
state.hoverIso = "";
var focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
state.viewYear = focusDate.getFullYear();
state.viewMonth = focusDate.getMonth();
state.open = true;
popup.classList.remove("hidden");
render();
}
function closePopup() {
state.open = false;
state.hoverIso = "";
popup.classList.add("hidden");
}
function clearSelection() {
state.startIso = "";
state.endIso = "";
state.anchor = "";
state.hoverIso = "";
state.readOnly = false;
syncSelectionToField();
}
/**
* Anchor-style picking:
* - no selection: the pick becomes the StartDate anchor
* - anchor=start (picking EndDate): a pick on/after the StartDate
* completes the range and moves the anchor to the EndDate; a pick
* before it clears the range and restarts
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
* moves the StartDate (extend/shorten); a pick after it clears the
* range and restarts from the clicked date
*/
function pickDate(isoString) {
state.readOnly = false;
if (!state.startIso) {
state.startIso = isoString;
state.anchor = "start";
} else if (state.anchor === "start" && !state.endIso) {
if (isoString >= state.startIso) {
state.endIso = isoString;
state.anchor = "end";
} else {
state.startIso = isoString;
state.endIso = "";
state.anchor = "start";
}
} else {
if (isoString <= state.endIso) {
state.startIso = isoString;
} else {
state.startIso = isoString;
state.endIso = "";
state.anchor = "start";
}
}
syncSelectionToField();
render();
}
function applyPreset(presetName) {
var range = presetRange(presetName);
if (!range) return;
state.startIso = isoFromDate(range[0]);
state.endIso = isoFromDate(range[1]);
state.anchor = "end";
state.readOnly = false;
state.viewYear = range[0].getFullYear();
state.viewMonth = range[0].getMonth();
syncSelectionToField();
render();
}
/** The (inclusive-exclusive of endpoints) track between the two range
* ends; while picking the second date the hovered day acts as the
* provisional other end. */
function trackBounds() {
if (state.startIso && state.endIso) {
return [state.startIso, state.endIso, state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS];
}
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
var lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
var upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
return [lower, upper, TRACK_OUTLINED_CLASS];
}
return null;
}
function dayCellClass(isoString, inViewMonth) {
var classes = [DAY_BASE_CLASS];
var isStart = isoString === state.startIso;
var isEnd = isoString === state.endIso;
var isAnchor =
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
var track = trackBounds();
var inTrack = track && isoString > track[0] && isoString < track[1];
if (inTrack) {
classes.push(track[2]);
} else {
classes.push(DAY_ROUNDED_CLASS);
}
if (isAnchor && !state.readOnly) {
classes.push(DAY_ANCHOR_CLASS);
} else if (isStart || isEnd) {
classes.push(DAY_SELECTED_CLASS);
} else if (!inViewMonth) {
classes.push(DAY_OUTSIDE_MONTH_CLASS);
}
return classes.join(" ");
}
function render() {
monthLabel.textContent = new Date(
state.viewYear,
state.viewMonth,
1
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
grid.textContent = "";
WEEKDAY_LABELS.forEach(function (weekdayLabel) {
var headerCell = document.createElement("span");
headerCell.className = WEEKDAY_CLASS;
headerCell.textContent = weekdayLabel;
grid.appendChild(headerCell);
});
var firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
// Monday-first offset of the leading overflow days.
var leadingDays = (firstOfMonth.getDay() + 6) % 7;
var cellDate = addDays(firstOfMonth, -leadingDays);
for (var cellIndex = 0; cellIndex < 42; cellIndex++) {
var isoString = isoFromDate(cellDate);
var dayButton = document.createElement("button");
dayButton.type = "button";
dayButton.setAttribute("data-date", isoString);
dayButton.className = dayCellClass(
isoString,
cellDate.getMonth() === state.viewMonth
);
dayButton.textContent = String(cellDate.getDate());
grid.appendChild(dayButton);
cellDate = addDays(cellDate, 1);
}
}
// ── Wiring ──
picker
.querySelector("[data-date-range-calendar-toggle]")
.addEventListener("click", function () {
if (state.open) closePopup();
else openPopup();
});
grid.addEventListener("click", function (event) {
var dayButton = event.target.closest("button[data-date]");
if (dayButton) pickDate(dayButton.getAttribute("data-date"));
});
grid.addEventListener("mouseover", function (event) {
if (!state.startIso || state.endIso) return;
var dayButton = event.target.closest("button[data-date]");
if (!dayButton) return;
var hoveredIso = dayButton.getAttribute("data-date");
if (hoveredIso === state.hoverIso) return;
state.hoverIso = hoveredIso;
render();
});
popup
.querySelector("[data-date-range-prev]")
.addEventListener("click", function () {
state.viewMonth -= 1;
if (state.viewMonth < 0) {
state.viewMonth = 11;
state.viewYear -= 1;
}
render();
});
popup
.querySelector("[data-date-range-next]")
.addEventListener("click", function () {
state.viewMonth += 1;
if (state.viewMonth > 11) {
state.viewMonth = 0;
state.viewYear += 1;
}
render();
});
popup.querySelectorAll("[data-date-range-preset]").forEach(function (button) {
button.addEventListener("click", function () {
applyPreset(button.getAttribute("data-date-range-preset"));
});
});
// Cancel: close the popup and clear the selected dates.
popup
.querySelector("[data-date-range-cancel]")
.addEventListener("click", function () {
clearSelection();
closePopup();
});
// Clear: clear the selected dates but keep the popup open.
popup
.querySelector("[data-date-range-clear]")
.addEventListener("click", function () {
clearSelection();
render();
});
// Select: close the popup, keeping the selected dates.
popup
.querySelector("[data-date-range-select]")
.addEventListener("click", function () {
closePopup();
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && state.open) closePopup();
});
document.addEventListener("mousedown", function (event) {
if (state.open && !picker.contains(event.target)) closePopup();
});
return state;
}
function initPicker(picker) {
if (picker.dataset.dateRangePickerInitialized) return;
picker.dataset.dateRangePickerInitialized = "true";
var calendarState = createCalendarState(picker);
initField(picker, calendarState);
}
function initAllPickers() {
document.querySelectorAll("[data-date-range-picker]").forEach(initPicker);
}
window.initDateRangePickers = initAllPickers;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAllPickers);
} else {
initAllPickers();
}
})();
+1
View File
@@ -143,6 +143,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js") + ModuleScript("search_select.js")
+ ModuleScript("date_range_picker.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+196
View File
@@ -0,0 +1,196 @@
"""Unit tests for the DateRangePicker component family.
Pins the structural contract of DateRangeField / DateRangeCalendar /
DateRangePicker segment inputs ordered by ``dateformat_hyphenated``, the
hidden ISO ``{prefix}-min`` / ``{prefix}-max`` inputs that ``filter_bar.js``
serializes, the calendar's preset/footer hooks — and the PurchaseFilterBar
integration that replaced the native-date DateRangeFilter for the Purchased
field.
"""
import json
import re
from django.test import SimpleTestCase, TestCase
from common.components import (
DateRangeCalendar,
DateRangeField,
DateRangePicker,
PurchaseFilterBar,
)
from common.time import date_parts, dateformat_hyphenated
_ESCAPED_TAG_MARKERS = ["&lt;div", "&lt;span", "&lt;button", "&lt;input"]
class DatePartsTest(SimpleTestCase):
def test_default_format_yields_day_month_year(self):
parts = date_parts()
self.assertEqual([part.name for part in parts], ["day", "month", "year"])
self.assertEqual([part.placeholder for part in parts], ["DD", "MM", "YYYY"])
self.assertEqual([part.length for part in parts], [2, 2, 4])
def test_parts_follow_format_order(self):
parts = date_parts("%Y-%d-%m")
self.assertEqual([part.name for part in parts], ["year", "day", "month"])
def test_dateformat_hyphenated_is_parseable(self):
self.assertEqual(len(date_parts(dateformat_hyphenated)), 3)
class DateRangeFieldTest(SimpleTestCase):
def render(self, **kwargs):
defaults = {"label": "Purchased", "input_name_prefix": "filter-date-purchased"}
defaults.update(kwargs)
return str(DateRangeField(**defaults))
def test_renders_hidden_iso_inputs(self):
html = self.render(min_value="2024-03-15", max_value="2024-09-20")
self.assertIn('name="filter-date-purchased-min"', html)
self.assertIn('name="filter-date-purchased-max"', html)
self.assertIn('data-date-range-hidden="min"', html)
self.assertIn('data-date-range-hidden="max"', html)
self.assertIn('value="2024-03-15"', html)
self.assertIn('value="2024-09-20"', html)
def test_renders_segments_in_dateformat_order_for_both_sides(self):
html = self.render()
for side in ("min", "max"):
side_segments = re.findall(
rf'data-date-part="(\w+)" data-date-side="{side}"', html
)
self.assertEqual(side_segments, ["day", "month", "year"])
def test_segment_placeholders_and_lengths(self):
html = self.render()
self.assertEqual(html.count('placeholder="DD"'), 2)
self.assertEqual(html.count('placeholder="MM"'), 2)
self.assertEqual(html.count('placeholder="YYYY"'), 2)
self.assertEqual(html.count('maxlength="2"'), 4)
self.assertEqual(html.count('maxlength="4"'), 2)
self.assertEqual(html.count('inputmode="numeric"'), 6)
def test_prefills_segments_from_iso_values(self):
html = self.render(min_value="2024-03-15")
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
self.assertIn('value="03" data-date-part="month" data-date-side="min"', html)
self.assertIn('value="2024" data-date-part="year" data-date-side="min"', html)
# The max side stays empty.
self.assertIn('value="" data-date-part="day" data-date-side="max"', html)
def test_malformed_iso_value_renders_empty_segments(self):
html = self.render(min_value="not-a-date")
self.assertIn('value="" data-date-part="day" data-date-side="min"', html)
def test_renders_calendar_toggle(self):
html = self.render()
self.assertIn("data-date-range-calendar-toggle", html)
self.assertIn('aria-label="Open Purchased calendar"', html)
def test_no_native_date_inputs(self):
self.assertNotIn('type="date"', self.render())
class DateRangeCalendarTest(SimpleTestCase):
def render(self):
return str(DateRangeCalendar(input_name_prefix="filter-date-purchased"))
def test_renders_all_presets(self):
html = self.render()
for preset in (
"today",
"yesterday",
"last_7_days",
"last_30_days",
"this_month",
"last_month",
"this_year",
):
self.assertIn(f'data-date-range-preset="{preset}"', html)
def test_renders_footer_buttons(self):
html = self.render()
self.assertIn("data-date-range-cancel", html)
self.assertIn("data-date-range-clear", html)
self.assertIn("data-date-range-select", html)
self.assertIn(">Cancel<", html)
self.assertIn(">Clear<", html)
self.assertIn(">Select<", html)
def test_renders_grid_and_navigation_hooks(self):
html = self.render()
self.assertIn("data-date-range-grid", html)
self.assertIn("data-date-range-month-label", html)
self.assertIn("data-date-range-prev", html)
self.assertIn("data-date-range-next", html)
def test_starts_hidden(self):
self.assertIn('class="hidden absolute', self.render())
def test_all_buttons_are_type_button(self):
"""No button inside the calendar may submit the surrounding filter form."""
html = self.render()
button_count = html.count("<button")
self.assertEqual(html.count('<button type="button"'), button_count)
class DateRangePickerTest(SimpleTestCase):
def test_composes_field_and_calendar(self):
html = str(
DateRangePicker(
label="Purchased",
input_name_prefix="filter-date-purchased",
min_value="2024-01-01",
max_value="2024-12-31",
)
)
self.assertIn("data-date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
self.assertIn("data-date-range-field", html)
self.assertIn("data-date-range-calendar", html)
for marker in _ESCAPED_TAG_MARKERS:
self.assertNotIn(marker, html)
class PurchaseFilterBarDateRangePickerTest(TestCase):
"""The Purchased filter uses the DateRangePicker; Refunded keeps the
native-date DateRangeFilter (the picker is a tryout on one field)."""
def render(self, filter_json=""):
return str(
PurchaseFilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
def test_purchased_uses_date_range_picker(self):
html = self.render()
self.assertIn("data-date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
# The hidden ISO inputs keep the names filter_bar.js serializes.
self.assertIn('name="filter-date-purchased-min"', html)
self.assertIn('name="filter-date-purchased-max"', html)
def test_refunded_keeps_native_date_inputs(self):
html = self.render()
refunded_min = html.find('name="filter-date-refunded-min"')
self.assertGreater(refunded_min, 0)
self.assertIn('type="date"', html)
self.assertNotIn('data-input-name-prefix="filter-date-refunded"', html)
def test_prefilled_between_filter_round_trips_into_picker(self):
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
html = self.render(filter_json)
self.assertIn('value="2024-03-15"', html)
self.assertIn('value="2024-09-20"', html)
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
self.assertIn('value="20" data-date-part="day" data-date-side="max"', html)