Add DateRangePicker component with segmented entry and calendar popup
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:
@@ -11,6 +11,11 @@ from common.components.core import (
|
||||
_render_element,
|
||||
randomid,
|
||||
)
|
||||
from common.components.date_range_picker import (
|
||||
DateRangeCalendar,
|
||||
DateRangeField,
|
||||
DateRangePicker,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
@@ -123,6 +128,9 @@ __all__ = [
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"DateRangeCalendar",
|
||||
"DateRangeField",
|
||||
"DateRangePicker",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from django.db import models
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
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.search_select import (
|
||||
DEFAULT_PREFETCH,
|
||||
@@ -1289,7 +1290,7 @@ def PurchaseFilterBar(
|
||||
),
|
||||
_filter_field(
|
||||
"Purchased",
|
||||
DateRangeFilter(
|
||||
DateRangePicker(
|
||||
label="Purchased",
|
||||
input_name_prefix="filter-date-purchased",
|
||||
min_value=date_purchased_min,
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
dateformat: str = "%d/%m/%Y"
|
||||
dateformat_hyphenated: str = "%d-%m-%Y"
|
||||
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||
timeformat: str = "%H:%M"
|
||||
durationformat: str = "%2.1H 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):
|
||||
if duration is None:
|
||||
return timedelta(0)
|
||||
|
||||
+14
-10
@@ -5,6 +5,10 @@ cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
|
||||
elements, building a ``DateCriterion`` JSON object, and navigating the
|
||||
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
|
||||
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):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_purchased": {
|
||||
"date_refunded": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
@@ -70,8 +74,8 @@ def _filter_from_url(url: str) -> dict:
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_both_dates_serializes_as_between(live_server, page):
|
||||
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-purchased-max"]').fill("2024-12-31")
|
||||
page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
|
||||
page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"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)
|
||||
assert parsed == {
|
||||
"date_purchased": {
|
||||
"date_refunded": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"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")
|
||||
def test_min_only_serializes_as_greater_than(live_server, page):
|
||||
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():
|
||||
page.evaluate(
|
||||
"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)
|
||||
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.
|
||||
assert "value2" not in parsed["date_purchased"]
|
||||
assert "value2" not in parsed["date_refunded"]
|
||||
|
||||
|
||||
@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."""
|
||||
page.goto(live_server.url + "/test-date-filter-prefilled/")
|
||||
assert (
|
||||
page.locator('input[name="filter-date-purchased-min"]').input_value()
|
||||
page.locator('input[name="filter-date-refunded-min"]').input_value()
|
||||
== "2024-03-15"
|
||||
)
|
||||
assert (
|
||||
page.locator('input[name="filter-date-purchased-max"]').input_value()
|
||||
page.locator('input[name="filter-date-refunded-max"]').input_value()
|
||||
== "2024-09-20"
|
||||
)
|
||||
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}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["date_purchased"] == {
|
||||
assert parsed["date_refunded"] == {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -918,6 +918,9 @@
|
||||
.ms-2\.5 {
|
||||
margin-inline-start: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.ms-auto {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.me-2 {
|
||||
margin-inline-end: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -1582,6 +1585,9 @@
|
||||
.w-5\/6 {
|
||||
width: calc(5 / 6 * 100%);
|
||||
}
|
||||
.w-8 {
|
||||
width: calc(var(--spacing) * 8);
|
||||
}
|
||||
.w-10 {
|
||||
width: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -1597,6 +1603,12 @@
|
||||
.w-72 {
|
||||
width: calc(var(--spacing) * 72);
|
||||
}
|
||||
.w-\[2\.5ch\] {
|
||||
width: 2.5ch;
|
||||
}
|
||||
.w-\[4\.5ch\] {
|
||||
width: 4.5ch;
|
||||
}
|
||||
.w-\[300px\] {
|
||||
width: 300px;
|
||||
}
|
||||
@@ -1736,6 +1748,9 @@
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cursor-text {
|
||||
cursor: text;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
@@ -1787,6 +1802,9 @@
|
||||
.justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.gap-0\.5 {
|
||||
gap: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.gap-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)));
|
||||
}
|
||||
}
|
||||
.gap-y-0\.5 {
|
||||
row-gap: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.gap-y-4 {
|
||||
row-gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -1904,6 +1925,9 @@
|
||||
.rounded-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.rounded-xs {
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.rounded-s-base {
|
||||
border-start-start-radius: var(--radius-base);
|
||||
border-end-start-radius: var(--radius-base);
|
||||
@@ -1958,6 +1982,10 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-y {
|
||||
border-block-style: var(--tw-border-style);
|
||||
border-block-width: 1px;
|
||||
}
|
||||
.border-e {
|
||||
border-inline-end-style: var(--tw-border-style);
|
||||
border-inline-end-width: 1px;
|
||||
@@ -2030,6 +2058,12 @@
|
||||
.border-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-color: var(--color-default);
|
||||
}
|
||||
@@ -2121,12 +2155,24 @@
|
||||
.bg-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 {
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
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 {
|
||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -2296,6 +2342,9 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
.p-0 {
|
||||
padding: calc(var(--spacing) * 0);
|
||||
}
|
||||
.p-1 {
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
@@ -2320,6 +2369,9 @@
|
||||
.p-6 {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
}
|
||||
.px-0\.5 {
|
||||
padding-inline: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -2419,6 +2471,9 @@
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.text-start {
|
||||
text-align: start;
|
||||
}
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -2714,9 +2769,15 @@
|
||||
.decoration-dotted {
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
.caret-transparent {
|
||||
caret-color: transparent;
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0%;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 40%;
|
||||
}
|
||||
.opacity-50 {
|
||||
opacity: 50%;
|
||||
}
|
||||
@@ -2752,6 +2813,13 @@
|
||||
--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);
|
||||
}
|
||||
.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-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
@@ -2817,6 +2885,9 @@
|
||||
.\[program\:qcluster\] {
|
||||
program: qcluster;
|
||||
}
|
||||
.ring-inset {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
.group-hover\:absolute {
|
||||
&:is(:where(.group):hover *) {
|
||||
@media (hover: hover) {
|
||||
@@ -2958,6 +3029,22 @@
|
||||
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 {
|
||||
@media (hover: hover) {
|
||||
@@ -3223,6 +3310,14 @@
|
||||
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 {
|
||||
color: var(--color-blue-700);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -143,6 +143,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
title="Manage purchases",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("date_range_picker.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user