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
+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 = ["<div", "<span", "<button", "<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)