diff --git a/docs/library/data-display/moment.md b/docs/library/data-display/moment.md index 3e2ca89ca44..94e7ee26ef2 100644 --- a/docs/library/data-display/moment.md +++ b/docs/library/data-display/moment.md @@ -185,6 +185,40 @@ rx.vstack( ) ``` +### Shared defaults + +Wrap components in `rx.moment_provider` to give them a shared `locale`, +`format`, `timezone`, `local`, `parse`, `element`, `filters`, or `renderers` +default. A prop set on a component overrides the provider. Components outside +the provider keep English. Set `pool=True` to update all descendants from one +timer that ticks every `interval` milliseconds. + +```python demo exec +def moment_provider_example(): + return rx.moment_provider( + rx.moment("2024-03-14"), + rx.moment("2024-03-14", locale="en"), + locale="fr", + format="D MMM YYYY", + timezone="Europe/Paris", + ) +``` + +### Additional react-moment 2.x options + +`rx.moment` also supports the `utc`, `calendar`, `from_`, `to`, `ago`, `element`, +`fallback`, `filters`, and `renderers` options from `react-moment` 2.x. In Python, +`from_` maps to the JavaScript `from` prop. `filters` and `renderers` accept +JavaScript function values: + +```python +rx.moment( + "2024-03-14", + format="MMMM", + filters=[rx.Var("(content) => content.toUpperCase()")], +) +``` + ### Timezones You can also set dates to display in a specific timezone: diff --git a/news/+moment-provider.feature.md b/news/+moment-provider.feature.md new file mode 100644 index 00000000000..63fad102ef3 --- /dev/null +++ b/news/+moment-provider.feature.md @@ -0,0 +1 @@ +Expose `rx.moment_provider` for sharing locale, timezone, formatting, and timer defaults across Moment components. diff --git a/packages/reflex-components-moment/news/+7100.bugfix.md b/packages/reflex-components-moment/news/+7100.bugfix.md new file mode 100644 index 00000000000..9f27b68ca6e --- /dev/null +++ b/packages/reflex-components-moment/news/+7100.bugfix.md @@ -0,0 +1 @@ +Expose the `utc`, `calendar`, `from_`, `to`, `ago`, `element`, `fallback`, `filters`, and `renderers` options of `react-moment` 2.x, which previously rendered as CSS, and add `moment_provider` for shared defaults and pooled timers. diff --git a/packages/reflex-components-moment/src/reflex_components_moment/__init__.py b/packages/reflex-components-moment/src/reflex_components_moment/__init__.py index 9f4987c56b5..4297187f803 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/__init__.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/__init__.py @@ -1,5 +1,7 @@ """Moment.js component.""" -from .moment import Moment, MomentDelta +from .moment import Moment, MomentProvider +from .moment import MomentDelta as MomentDelta moment = Moment.create +moment_provider = MomentProvider.create diff --git a/packages/reflex-components-moment/src/reflex_components_moment/moment.py b/packages/reflex-components-moment/src/reflex_components_moment/moment.py index bd1519193d4..a1ae5069277 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -4,8 +4,14 @@ import dataclasses import datetime - -from reflex_base.components.component import MemoizationLeaf, NoSSRComponent, field +from typing import Any + +from reflex_base.components.component import ( + Component, + MemoizationLeaf, + NoSSRComponent, + field, +) from reflex_base.event import EventHandler, passthrough_event_spec from reflex_base.utils.imports import ImportDict, ImportVar from reflex_base.vars.base import LiteralVar, Var @@ -26,6 +32,100 @@ class MomentDelta: milliseconds: int | None = dataclasses.field(default=None) +def _locale_module(locale: Var[str] | None) -> str | None: + """Return the moment locale module that a locale prop requires. + + Args: + locale: The locale prop, if set. + + Returns: + The locale module, or None when the built-in English locale suffices. + """ + if isinstance(locale, LiteralVar): + if locale._var_value == "en": + return None + return f"moment/locale/{locale._var_value}" + return None if locale is None else "moment/min/locales" + + +def _locale_timezone_imports( + locale: Var[str] | None, timezone: Var[str] | None +) -> ImportDict: + """Return the locale and timezone imports required by Moment props. + + Args: + locale: The locale prop, if set. + timezone: The timezone prop, if set. + + Returns: + The locale and timezone module imports, plus moment itself for + _restore_global_locale when a locale module is imported. + """ + imports: ImportDict = {} + if (module := _locale_module(locale)) is not None: + imports[""] = module + imports["moment@2.31.0"] = ImportVar(tag="moment", is_default=True) + if timezone is not None: + imports["moment-timezone@0.6.5"] = "" + return imports + + +def _restore_global_locale(locale: Var[str] | None) -> list[str]: + """Return code that resets moment's global locale after a locale import. + + Importing a locale module also makes it moment's global locale, which + Moment components without a locale or provider use. + + Args: + locale: The locale prop, if set. + + Returns: + The reset code, or nothing when no locale module is imported. + """ + return [] if _locale_module(locale) is None else ['moment.locale("en");'] + + +class MomentProvider(Component): + """Provide shared defaults and timer pooling for descendant Moment components.""" + + tag: str | None = "MomentProvider" + library: str | None = "react-moment@2.0.2" + lib_dependencies: list[str] = ["moment@2.31.0"] + + pool: Var[bool] = field(doc="Use one timer for all descendant Moment components.") + interval: Var[int] = field(doc="The interval in milliseconds for the pooled timer.") + locale: Var[str] = field(doc="The default locale for descendant Moment components.") + local: Var[bool] = field(doc="Display descendant dates in local time by default.") + format: Var[str] = field(doc="The default date format for descendants.") + parse: Var[str | list[str]] = field( + doc="The default date parse format for descendants." + ) + filters: Var[list[Var[Any]]] = field( + doc="JavaScript content filters applied to descendant Moment components." + ) + renderers: Var[list[Var[Any]]] = field( + doc="JavaScript content renderers tried for descendant Moment components." + ) + element: Var[str] = field(doc="The default HTML element for descendant components.") + timezone: Var[str] = field(doc="The default timezone for descendant components.") + + def add_imports(self) -> ImportDict: + """Import the locale and timezone modules required by the defaults. + + Returns: + The locale and timezone module imports. + """ + return _locale_timezone_imports(self.locale, self.timezone) + + def add_custom_code(self) -> list[str]: + """Keep moment's global locale English after the locale import. + + Returns: + The global locale reset, if a locale module is imported. + """ + return _restore_global_locale(self.locale) + + class Moment(NoSSRComponent, MemoizationLeaf): """The Moment component.""" @@ -50,6 +150,40 @@ class Moment(NoSSRComponent, MemoizationLeaf): doc=" Use the parse attribute to tell moment how to parse the given date when non-standard. Accepts a single format string or a list of formats to try." ) + utc: Var[bool] = field(doc="Parses and displays the date in UTC.") + + calendar: Var[bool | dict[str, str]] = field( + doc="Displays the date using Moment's calendar format, optionally with custom calendar strings." + ) + + from_: Var[ + str | datetime.datetime | datetime.date | datetime.time | datetime.timedelta + ] = field(doc="Displays the interval from this date to the component's date.") + + to: Var[ + str | datetime.datetime | datetime.date | datetime.time | datetime.timedelta + ] = field(doc="Displays the interval from the component's date to this date.") + + ago: Var[bool] = field( + doc='Omits the "ago" or "in" suffix from relative-time output.' + ) + + element: Var[str] = field( + doc="The HTML element used to render the formatted date (defaults to time)." + ) + + fallback: Var[str | int] = field( + doc="Content to render when the date input is invalid." + ) + + filters: Var[list[Var[Any]]] = field( + doc="JavaScript functions applied in order to the rendered content." + ) + + renderers: Var[list[Var[Any]]] = field( + doc="JavaScript renderer functions tried before the built-in output modes." + ) + add: Var[MomentDelta] = field( doc='Add a delta to the base date (keys are "years", "quarters", "months", "weeks", "days", "hours", "minutes", "seconds")' ) @@ -111,8 +245,7 @@ class Moment(NoSSRComponent, MemoizationLeaf): tz: Var[str] = field(doc="Display the date in the given timezone.") locale: Var[str] = field( - default=Var.create("en"), - doc="The locale for this component. Defaults to English independently of other Moment components.", + doc="The locale for this component. Defaults to the nearest rx.moment_provider locale, or English.", ) on_change: EventHandler[passthrough_event_spec(str)] = field( @@ -125,19 +258,16 @@ def add_imports(self) -> ImportDict: Returns: The import dict for the component. """ - imports = {} - - if isinstance(self.locale, LiteralVar): - # English is built into Moment and has no separate locale module. - if self.locale._var_value != "en": - imports[""] = f"moment/locale/{self.locale._var_value}" - elif self.locale is not None: - # If the user is using a variable for the locale, we can't know the - # value at compile time so import all locales available. - imports[""] = "moment/min/locales" - if self.tz is not None: - imports["moment-timezone@0.6.5"] = "" + imports = _locale_timezone_imports(self.locale, self.tz) if self.duration is not None or self.duration_from_now is not None: imports["moment-duration-format@2.3.2"] = ImportVar(tag=None) return imports + + def add_custom_code(self) -> list[str]: + """Keep moment's global locale English after the locale import. + + Returns: + The global locale reset, if a locale module is imported. + """ + return _restore_global_locale(self.locale) diff --git a/pyi_hashes.json b/pyi_hashes.json index ea93850b764..81e75bf7e19 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -41,7 +41,7 @@ "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "2ce1c076ecf5c2fa4945b4abdbf2f91d", "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "1e331a3d6420b97e5b1ce7f63ad53de8", "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "6759daf6e242e209d9298045c7b4456a", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "13e39f8d2b46bfd76c50cb04062da8ff", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "21c3396cb28b1ef6442c5a9588dc708b", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "f411341e59a3065fee964747584f9fcf", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "a77352f60fb6f4135b5d08a6e56efa6d", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bbd4d1a4fa73275a882c33ba485d0165", @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "f2a9cf6db58d169289fbc76de6813300", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "1c841ad90d789507cd56d966b474d11c", + "reflex/__init__.pyi": "23c090b0294d3a91e59f447f36a7a37f", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "87e485038dc79f66b2ab15144845ef1e" } diff --git a/pyproject.toml b/pyproject.toml index 9520867e570..5bccb9bc28b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "reflex-components-gridjs >= 0.10.0", "reflex-components-lucide >= 1.1.0", "reflex-components-markdown >= 0.10.0", - "reflex-components-moment >= 0.10.0", + "reflex-components-moment >= 0.10.1.dev0", "reflex-components-plotly >= 0.10.0", "reflex-components-radix >= 0.10.1.dev0", "reflex-components-react-player >= 0.10.0", diff --git a/reflex/__init__.py b/reflex/__init__.py index ddcfec42123..ea1775c275f 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -151,7 +151,7 @@ "reflex_base.components.props": ["PropsBase"], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], - "reflex_components_moment": ["MomentDelta", "moment"], + "reflex_components_moment": ["MomentDelta", "moment", "moment_provider"], } _COMPONENT_NAME_TO_PATH: dict[str, str] = { diff --git a/tests/integration/test_moment.py b/tests/integration/test_moment.py index 6dc5655a9e4..c289e25e360 100644 --- a/tests/integration/test_moment.py +++ b/tests/integration/test_moment.py @@ -18,6 +18,14 @@ def MomentApp(): class State(rx.State): """State used to wait for the browser connection before asserting output.""" + @rx.memo + def memoized_provider_moment(): + return rx.moment( + "2024-03-14T15:09:26", + format="dddd D MMMM YYYY", + id="moment-provider-memo-locale", + ) + @app.add_page def index(): return rx.vstack( @@ -50,6 +58,17 @@ def index(): locale="fr", id="moment-french", ), + rx.moment_provider( + rx.box( + rx.moment( + "2024-03-14T15:09:26", + format="dddd D MMMM YYYY", + id="moment-provider-locale", + ) + ), + memoized_provider_moment(), + locale="fr", + ), rx.link("Plain page", href="/plain", id="plain-link"), ) @@ -67,9 +86,22 @@ def plain(): ), rx.moment("2020-01-01", from_now=True, id="plain-relative"), rx.link("Home", href="/", id="home-link"), + rx.link("Provider page", href="/provider", id="provider-link"), + ) + + def provider(): + """Render a provider locale whose moments compile outside the page module. + + Returns: + The page that loads a foreign locale only through a provider. + """ + return rx.vstack( + rx.moment_provider(memoized_provider_moment(), locale="fr"), + rx.link("Plain page", href="/plain", id="provider-plain-link"), ) app.add_page(plain, route="/plain") + app.add_page(provider, route="/provider") @pytest.fixture(scope="module") @@ -133,6 +165,18 @@ def test_moment_locales_are_isolated(driver: WebDriver) -> None: AppHarness.expect( lambda: driver.find_element(By.ID, "moment-french").text == "jeudi 14 mars 2024" ) + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "moment-provider-locale").text + == "jeudi 14 mars 2024" + ) + ) + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "moment-provider-memo-locale").text + == "jeudi 14 mars 2024" + ) + ) driver.find_element(By.ID, "plain-link").click() AppHarness.expect( lambda: ( @@ -156,3 +200,39 @@ def test_moment_locales_are_isolated(driver: WebDriver) -> None: == "Thursday 14 March 2024" ) ) + + +def test_moment_provider_locale_does_not_leak( + moment_app: AppHarness, driver: WebDriver +) -> None: + """A provider locale stays inside the provider across client-side navigation. + + The page load starts on a route that has not loaded the French locale module. + + Args: + moment_app: The running Moment app harness. + driver: The browser connected to the Moment app. + """ + assert moment_app.frontend_url is not None + driver.get(moment_app.frontend_url.rstrip("/") + "/plain") + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "plain-date").text == "Thursday 14 March 2024" + ) + ) + driver.find_element(By.ID, "provider-link").click() + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "moment-provider-memo-locale").text + == "jeudi 14 mars 2024" + ) + ) + driver.find_element(By.ID, "provider-plain-link").click() + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "plain-date").text == "Thursday 14 March 2024" + ) + ) + AppHarness.expect( + lambda: driver.find_element(By.ID, "plain-relative").text.endswith("ago") + ) diff --git a/tests/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index 0ca16a9ee82..ffeb464f020 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -1,32 +1,133 @@ """Tests for per-component Moment locale configuration.""" import pytest +from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import Var -from reflex_components_moment.moment import Moment +from reflex_components_moment.moment import Moment, MomentProvider +MOMENT_IMPORT = {"moment@2.31.0": ImportVar(tag="moment", is_default=True)} +RESET = ['moment.locale("en");'] -@pytest.mark.parametrize("props", [{}, {"locale": None}, {"locale": "en"}]) -def test_default_locale_is_explicit_english(props): - """Omitted and English locales do not inherit another component's locale. - Args: - props: The omitted or explicitly English locale configuration. - """ - moment = Moment.create("2024-03-14", **props) +def test_default_locale_keeps_moment_lazy(): + """An omitted locale adds no eager moment import, reset, or app wrap.""" + moment = Moment.create("2024-03-14") + assert not any(prop.startswith("locale:") for prop in moment.render()["props"]) + assert moment.add_imports() == {} + assert moment.add_custom_code() == [] + assert moment._get_all_app_wrap_components() == {} + + +@pytest.mark.parametrize( + ("component", "locale", "module"), + [ + (Moment, "fr", "moment/locale/fr"), + (MomentProvider, "fr", "moment/locale/fr"), + (Moment, Var(_js_expr="selectedLocale", _var_type=str), "moment/min/locales"), + ( + MomentProvider, + Var(_js_expr="selectedLocale", _var_type=str), + "moment/min/locales", + ), + ], +) +def test_locale_import_restores_global_locale(component, locale, module): + """Every locale import resets the global locale that it changes to English.""" + instance = component.create("2024-03-14", locale=locale) + assert instance.add_imports() == {"": module, **MOMENT_IMPORT} + assert instance.add_custom_code() == RESET + + +def test_explicit_english_locale_is_preserved(): + """An explicit English locale overrides a provider locale.""" + moment = Moment.create("2024-03-14", locale="en") assert 'locale:"en"' in moment.render()["props"] assert "moment/locale/en" not in moment.add_imports().values() -def test_literal_locale_import_is_preserved(): - """A component that requests French still imports and renders that locale.""" +def test_explicit_child_locale_overrides_provider(): + """An explicit child locale remains a per-instance provider override.""" + provider = MomentProvider.create( + Moment.create("2024-03-14", locale="en"), locale="fr" + ) + + assert 'locale:"en"' in provider.children[0].render()["props"] + + +def test_literal_locale_is_rendered(): + """A component that requests French renders that locale.""" moment = Moment.create("2024-03-14", locale="fr") assert 'locale:"fr"' in moment.render()["props"] - assert moment.add_imports()[""] == "moment/locale/fr" -def test_reactive_locale_imports_all_locales(): - """Reactive locale selection remains available independently of the default.""" +def test_reactive_locale_is_rendered(): + """Reactive locale selection is forwarded to React.""" locale = Var(_js_expr="selectedLocale", _var_type=str) moment = Moment.create("2024-03-14", locale=locale) assert "locale:selectedLocale" in moment.render()["props"] - assert moment.add_imports()[""] == "moment/min/locales" + + +def test_react_moment_2_props_are_forwarded(): + """Moment 2.x props are passed to React instead of becoming CSS.""" + moment = Moment.create( + "2024-03-14", + utc=True, + calendar={"sameDay": "[Today]"}, + from_="2024-03-01", + to="2024-03-31", + ago=True, + element="span", + fallback="Unknown date", + filters=Var(_js_expr="[uppercase]", _var_type=list), + renderers=Var(_js_expr="[customRenderer]", _var_type=list), + ) + + props = moment.render()["props"] + assert "utc:true" in props + assert any(prop.startswith("calendar:") and '"sameDay"' in prop for prop in props) + assert 'from:"2024-03-01"' in props + assert 'to:"2024-03-31"' in props + assert "ago:true" in props + assert 'element:"span"' in props + assert 'fallback:"Unknown date"' in props + assert "filters:[uppercase]" in props + assert "renderers:[customRenderer]" in props + assert "css:" not in props + + +def test_undeclared_props_stay_css(): + """Props that react-moment does not accept keep the generic CSS behavior.""" + moment = Moment.create("2024-03-14", filter="blur(2px)") + assert 'css:({ ["filter"] : "blur(2px)" })' in moment.render()["props"] + + +def test_moment_provider_exports_named_component_and_shared_props(): + """Provider props are forwarded and use the named react-moment export.""" + import reflex as rx + + assert rx.moment_provider is not None + provider = MomentProvider.create( + Moment.create("2024-03-14"), + pool=True, + interval=30000, + locale="fr", + format="D MMM YYYY", + timezone="America/Los_Angeles", + ) + + assert provider.render()["name"] == "MomentProvider" + props = provider.render()["props"] + assert "pool:true" in props + assert "interval:30000" in props + assert 'locale:"fr"' in props + assert 'format:"D MMM YYYY"' in props + assert 'timezone:"America/Los_Angeles"' in props + assert provider.add_imports() == { + "": "moment/locale/fr", + **MOMENT_IMPORT, + "moment-timezone@0.6.5": "", + } + assert ( + ImportVar(tag="MomentProvider") + in provider._get_all_imports()["react-moment@2.0.2"] + )