diff --git a/docs/api-reference/var_system.md b/docs/api-reference/var_system.md index 469ee8dca83..224509f5c94 100644 --- a/docs/api-reference/var_system.md +++ b/docs/api-reference/var_system.md @@ -78,3 +78,37 @@ def factorial(value: NumberVar): Use `js_expression` to pass explicit JavaScript expressions; in the `multiply_array_values` example, we pass in a JavaScript expression that calculates the product of all elements in an array called `a` by using the reduce method to multiply each element with the accumulated result, starting from an initial value of 1. Later, we leverage `rx.cond` in the' factorial' function, we instantiate an array using the `range` function, and pass this array to `multiply_array_values`. + +## Hook Vars + +Some values only exist on the frontend and are exposed through React hooks. +`rx.vars.use_hook_var()` binds the return value of a no-argument hook to a unique variable name and returns it as a `Var`. +The hook call and the import of the hook are automatically included in any component that uses the var, so the value reflects the context of the component it is rendered in. + +```py +chart_width = rx.vars.use_hook_var( + library="recharts@3.8.1", + hook="useChartWidth", + _var_type=int | None, +) +``` + +A component using `chart_width` will import `useChartWidth` from `recharts` and render `const = useChartWidth();` in its body, so `chart_width` can be used like any other `Var[int | None]`. + +For the common case of React's built-in [`useId`](https://react.dev/reference/react/useId), `rx.vars.use_id()` returns a `Var[str]` containing a stable unique id for the rendered component. +This is useful for linking SVG elements to `defs` such as gradients or filters: + +```py +def gradient_rect() -> rx.Component: + gradient_id = rx.vars.use_id() + return rx.el.svg( + rx.el.svg.linear_gradient( + rx.el.svg.stop(offset="0%", stop_color="gold"), + rx.el.svg.stop(offset="100%", stop_color="tomato"), + id=gradient_id, + ), + rx.el.svg.rect(fill=f"url(#{gradient_id})", width=64, height=64), + width=64, + height=64, + ) +``` diff --git a/docs/library/graphing/charts/sankeychart.md b/docs/library/graphing/charts/sankeychart.md new file mode 100644 index 00000000000..6d0d9a0e424 --- /dev/null +++ b/docs/library/graphing/charts/sankeychart.md @@ -0,0 +1,221 @@ +--- +components: + - rx.recharts.SankeyChart +title: Sankey Chart +meta_description: "Create Sankey charts in Python with Reflex. Build interactive Recharts Sankey diagrams to visualize weighted flows between stages, categories, or systems." +--- + +# Sankey Chart + +```python exec +import random +from typing import Any + +import reflex as rx +``` + +Sankey charts in Reflex are built on [Recharts](https://recharts.org/), a React charting library, and created in pure Python. A Sankey chart visualizes weighted flows between nodes, making it useful for showing movement through stages, resource allocation, user journeys, and other source-to-target relationships. + +## Simple Example + +An `rx.recharts.sankey_chart()` takes a `data` dictionary with `nodes` and `links`. Links refer to nodes by zero-based index. + +```python demo graphing +sankey_data = { + "nodes": [ + {"name": "Website"}, + {"name": "Landing Page"}, + {"name": "Product Page"}, + {"name": "Checkout"}, + {"name": "Purchase"}, + ], + "links": [ + {"source": 0, "target": 1, "value": 1200}, + {"source": 1, "target": 2, "value": 900}, + {"source": 2, "target": 3, "value": 420}, + {"source": 3, "target": 4, "value": 260}, + ], +} + + +def sankey_simple(): + return rx.recharts.sankey_chart( + rx.recharts.graphing_tooltip(), + data=sankey_data, + node_padding=24, + node_width=12, + link_curvature=0.55, + width="100%", + height=320, + ) +``` + +## Stateful Example + +Chart data can be tied to a State var. This example randomizes the flow values when the button is clicked. + +```python demo exec +class SankeyState(rx.State): + data: dict[str, Any] = { + "nodes": [ + {"name": "Marketing"}, + {"name": "Trial"}, + {"name": "Sales"}, + {"name": "Support"}, + {"name": "Retained"}, + ], + "links": [ + {"source": 0, "target": 1, "value": 600}, + {"source": 1, "target": 2, "value": 320}, + {"source": 2, "target": 4, "value": 210}, + {"source": 1, "target": 3, "value": 180}, + {"source": 3, "target": 4, "value": 130}, + ], + } + + @rx.event + def randomize_flows(self): + for link in self.data["links"]: + link["value"] = random.randint(80, 700) + + +def sankey_stateful(): + return rx.vstack( + rx.recharts.sankey_chart( + rx.recharts.graphing_tooltip(), + data=SankeyState.data, + node={ + "fill": rx.color("accent", 7), + "stroke": rx.color("accent", 10), + "strokeWidth": 2, + }, + link={ + "stroke": rx.color("gray", 7), + "strokeOpacity": 0.35, + }, + node_padding=18, + node_width=14, + width="100%", + height=320, + ), + rx.button("Randomize flows", on_click=SankeyState.randomize_flows), + width="100%", + ) +``` + + +## Full Node / Link Customization + +For complete control over node and link rendering, pass a +`@rx.recharts.sankey_chart.node` or `@rx.recharts.sankey_chart.link` decorated +function that returns an svg-based component. The function receives a +`Var[SankeyNodeProps]` or `Var[SankeyLinkProps]` object with the node or link +data `payload`, as well as the object's position and dimensions. You can use these +properties to construct a custom node or link. + +Because the component renders inside the SVG element of the chart, you can only +use `rx.el.svg` components to construct the custom node or link. + +The example below also uses `rx.recharts.use_chart_width()` to read the +rendered chart width and `rx.vars.use_id()` to generate a unique id that links +each link's gradient definition to the path that references it. + +```python demo graphing +styled_sankey_data = { + "nodes": [ + {"name": "Sources", "type": "source", "fill": rx.color("blue", 8)}, + {"name": "Direct", "type": "channel", "fill": rx.color("green", 8)}, + {"name": "Search", "type": "channel", "fill": rx.color("grass", 8)}, + {"name": "Paid", "type": "channel", "fill": rx.color("amber", 8)}, + {"name": "Revenue", "type": "outcome", "fill": rx.color("purple", 8)}, + ], + "links": [ + {"source": 0, "target": 1, "value": 350}, + {"source": 0, "target": 2, "value": 500}, + {"source": 0, "target": 3, "value": 220}, + {"source": 1, "target": 4, "value": 190}, + {"source": 2, "target": 4, "value": 260}, + {"source": 3, "target": 4, "value": 150}, + ], +} + + +def sankey_custom_render(): + @rx.recharts.sankey_chart.node + def custom_node( + node: rx.Var[rx.recharts.sankey_chart.SankeyNodeProps], + ) -> rx.Component: + # Determine if the node is at the right edge of the chart to adjust the label position accordingly. + is_out = node.x + node.width + 6 > rx.recharts.use_chart_width() + return rx.fragment( + rx.el.svg.text( + node.payload.name, + x=rx.cond(is_out, node.x - 6, node.x + node.width + 6), + y=node.y + node.height / 2, + text_anchor=rx.cond(is_out, "end", "start"), + fill=rx.color("gray", 12), + font_size=10, + ), + rx.el.svg.rect( + x=node.x, + y=node.y, + width=node.width, + height=node.height, + # Accessing custom keys in the payload needs a `dict` cast. + fill=node.payload.to(dict)["fill"], + stroke=rx.color("gray", 12), + stroke_width=1, + ), + ) + + @rx.recharts.sankey_chart.link + def custom_link( + link: rx.Var[rx.recharts.sankey_chart.SankeyLinkProps], + ) -> rx.Component: + link_id = rx.vars.use_id() + source = link.payload.source.to(dict) + target = link.payload.target.to(dict) + return rx.fragment( + rx.el.svg.linear_gradient( + rx.el.svg.stop(offset="0%", stop_color=source["fill"]), + rx.el.svg.stop(offset="100%", stop_color=target["fill"]), + id=link_id, + ), + rx.el.svg.text( + link.payload.value, + x=(link.sourceX + link.targetX) / 2, + y=(link.sourceY + link.targetY) / 2, + text_anchor="middle", + fill=rx.color("gray", 12), + font_size=10, + ), + rx.el.svg.path( + d=( + f"M{link.sourceX},{link.sourceY} " + f"C{link.sourceControlX},{link.sourceY} " + f"{link.targetControlX},{link.targetY} " + f"{link.targetX},{link.targetY}" + ), + fill="none", + stroke=f"url(#{link_id})", + stroke_opacity=0.35, + stroke_width=link.linkWidth, + ), + ) + + return rx.recharts.sankey_chart( + data=styled_sankey_data, + node=custom_node, + link=custom_link, + width="100%", + height=340, + ) +``` + +## Related Charts + +Explore more chart types you can build with Reflex and Recharts in pure Python: + +- [Treemap](/docs/library/graphing/charts/treemap) +- [Funnel Chart](/docs/library/graphing/charts/funnelchart) +- [Pie Chart](/docs/library/graphing/charts/piechart) diff --git a/docs/wrapping-react/custom-code-and-hooks.md b/docs/wrapping-react/custom-code-and-hooks.md index c35f908d678..10c0cfc71c4 100644 --- a/docs/wrapping-react/custom-code-and-hooks.md +++ b/docs/wrapping-react/custom-code-and-hooks.md @@ -114,3 +114,20 @@ export function Div_7178f430b7b371af8a12d8265d65ab9b() { ```md alert info # You can mix custom code and hooks in the same component. Hooks can access a variable defined in the custom code, but custom code cannot access a variable defined in a hook. ``` + +## Using a Hook's Return Value + +`add_hooks` inserts hook statements into the component, but the values they define are not directly accessible from Python. When you need the return value of a no-argument hook, use `rx.vars.use_hook_var()`, which binds the hook call to a unique variable name and returns it as a `Var`. The hook statement and its import are automatically included in any component where the var is used, so it composes with regular props and var operations. + +```python +import reflex as rx + + +def use_chart_width() -> rx.Var[int | None]: + """Get the width of the enclosing recharts chart as a var.""" + return rx.vars.use_hook_var( + library="recharts@3.8.1", hook="useChartWidth", _var_type=int | None + ) +``` + +For React's built-in [`useId`](https://react.dev/reference/react/useId), `rx.vars.use_id()` returns a `Var[str]` with a stable unique id for the component being rendered, e.g. for linking SVG elements to gradient or filter definitions. diff --git a/packages/reflex-base/news/6708.feature.md b/packages/reflex-base/news/6708.feature.md new file mode 100644 index 00000000000..21e05d1f2f9 --- /dev/null +++ b/packages/reflex-base/news/6708.feature.md @@ -0,0 +1 @@ +Add `rx.vars.use_hook_var()` to create a `Var` bound to the value of a no-argument React hook imported from a given library, and `rx.vars.use_id()` to get React's stable `useId` value for the rendered component. diff --git a/packages/reflex-base/src/reflex_base/vars/__init__.py b/packages/reflex-base/src/reflex_base/vars/__init__.py index c986cf1fd36..2c78f8964df 100644 --- a/packages/reflex-base/src/reflex_base/vars/__init__.py +++ b/packages/reflex-base/src/reflex_base/vars/__init__.py @@ -28,6 +28,7 @@ LiteralStringVar, StringVar, ) +from .special import use_hook_var, use_id __all__ = [ "EMPTY_VAR_INT", @@ -66,6 +67,8 @@ "number", "object", "sequence", + "use_hook_var", + "use_id", "var_operation", "var_operation_return", ] diff --git a/packages/reflex-base/src/reflex_base/vars/special.py b/packages/reflex-base/src/reflex_base/vars/special.py new file mode 100644 index 00000000000..97c34b99fb6 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/vars/special.py @@ -0,0 +1,60 @@ +"""Special Vars for rendering values from the environment.""" + +from types import UnionType +from typing import Any, TypeVar, cast, overload + +from typing_extensions import TypeForm + +from reflex_base.utils.imports import ImportVar +from reflex_base.utils.types import GenericType +from reflex_base.vars.base import Var, VarData, get_unique_variable_name + +HOOK_VAR_TYPE = TypeVar("HOOK_VAR_TYPE") + + +@overload +def use_hook_var(library: str, hook: str) -> Var[Any]: ... + + +@overload +def use_hook_var( + library: str, hook: str, _var_type: TypeForm[HOOK_VAR_TYPE] +) -> Var[HOOK_VAR_TYPE]: ... + + +@overload +def use_hook_var(library: str, hook: str, _var_type: UnionType) -> Var: ... + + +def use_hook_var(library: str, hook: str, _var_type: Any = Any) -> Var: + """Get a Var representing a React hook's value. + + The value will depend on the context of the component in which it is used. + + Args: + library: The library to import the hook from. + hook: The name of the hook. + _var_type: The type of the Var. + + Returns: + A Var representing the React hook. + """ + var_name = get_unique_variable_name() + hook_alias = f"{hook}_{var_name}" + return Var( + var_name, + _var_type=cast(GenericType, _var_type), + _var_data=VarData( + imports={library: ImportVar(tag=hook, alias=hook_alias)}, + hooks=(f"const {var_name} = {hook_alias}();",), + ), + ).guess_type() + + +def use_id() -> Var[str]: + """Get the stable React useId hook value for a component. + + Returns: + A Var representing the useId hook value. + """ + return use_hook_var(library="react", hook="useId", _var_type=str) diff --git a/packages/reflex-components-recharts/news/6708.feature.md b/packages/reflex-components-recharts/news/6708.feature.md new file mode 100644 index 00000000000..9627636e7b7 --- /dev/null +++ b/packages/reflex-components-recharts/news/6708.feature.md @@ -0,0 +1 @@ +Added a Recharts Sankey chart wrapper (`rx.recharts.sankey_chart`) with support for custom node and link renderers, and `rx.recharts.use_chart_width()` for reading the rendered chart width as a `Var`. diff --git a/packages/reflex-components-recharts/pyproject.toml b/packages/reflex-components-recharts/pyproject.toml index 7f82ea8baf7..063a67470d1 100644 --- a/packages/reflex-components-recharts/pyproject.toml +++ b/packages/reflex-components-recharts/pyproject.toml @@ -7,7 +7,10 @@ readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" -dependencies = ["reflex-base >= 0.9.0"] +dependencies = [ + "reflex-base >= 0.9.7", + "reflex-components-core >= 0.9.0", +] [tool.hatch.version] source = "uv-dynamic-versioning" @@ -21,7 +24,13 @@ targets.sdist.artifacts = ["*.pyi"] targets.wheel.artifacts = ["*.pyi"] [tool.hatch.build.hooks.reflex-pyi] -dependencies = ["ruff", "reflex-base"] +dependencies = [ + "ruff", + "reflex-base", + "reflex-components-core", + "reflex-components-lucide", + "reflex-components-sonner", +] [build-system] requires = ["hatchling", "uv-dynamic-versioning", "hatch-reflex-pyi"] diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py b/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py index bb327bbb54d..a2ff29dc436 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/__init__.py @@ -56,6 +56,8 @@ "ScatterChart", "funnel_chart", "FunnelChart", + "sankey_chart", + "SankeyChart", "treemap", "Treemap", ], @@ -73,6 +75,7 @@ "LabelList", "cell", "Cell", + "use_chart_width", ], "polar": [ "pie", diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py b/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py index 6bfedd0a451..e80253aedbd 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/charts.py @@ -2,14 +2,18 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any, ClassVar +import inspect +from collections.abc import Callable, Mapping, Sequence +from typing import Any, ClassVar, TypedDict, get_args, get_origin, get_type_hints -from reflex_base.components.component import Component, field +from reflex_base.components.component import Component, ComponentNamespace, field +from reflex_base.components.memo import memo from reflex_base.constants import EventTriggers from reflex_base.constants.colors import Color from reflex_base.event import EventHandler, no_args_event_spec from reflex_base.vars.base import Var +from reflex_base.vars.object import RestProp +from typing_extensions import NotRequired from reflex_components_recharts.general import ResponsiveContainer @@ -516,6 +520,238 @@ class FunnelChart(ChartBase): ] +class SankeyNode(TypedDict): + """A node in a Sankey chart.""" + + name: str + type: NotRequired[str] + fill: NotRequired[str | Color] + stroke: NotRequired[str | Color] + strokeWidth: NotRequired[int | float] + strokeOpacity: NotRequired[int | float] + + +class SankeyLink(TypedDict): + """A weighted link between two Sankey chart nodes.""" + + source: int + target: int + value: int | float + fill: NotRequired[str | Color] + fillOpacity: NotRequired[int | float] + stroke: NotRequired[str | Color] + strokeWidth: NotRequired[int | float] + strokeOpacity: NotRequired[int | float] + + +class SankeyData(TypedDict): + """The source data for a Sankey chart.""" + + nodes: Sequence[SankeyNode] + links: Sequence[SankeyLink] + + +class SankeyNodePayload(TypedDict): + """The payload for a Sankey chart node.""" + + name: str + sourceLinks: list[SankeyLinkPayload] + targetLinks: list[SankeyLinkPayload] + value: int | float + depth: int + x: int | float + dx: int | float + y: int | float + dy: int | float + + +class SankeyNodeProps(TypedDict): + """The props for a custom Sankey chart node.""" + + height: int | float + width: int | float + payload: SankeyNodePayload + index: int + x: int | float + y: int | float + + +class SankeyLinkPayload(TypedDict): + """The payload for a Sankey chart link.""" + + source: SankeyNodePayload + target: SankeyNodePayload + value: int | float + dy: int | float + sy: int | float + ty: int | float + + +class SankeyLinkProps(TypedDict): + """The props for a custom Sankey chart link.""" + + sourceX: int | float + targetX: int | float + sourceY: int | float + targetY: int | float + sourceControlX: int | float + targetControlX: int | float + sourceRelativeY: int | float + targetRelativeY: int | float + linkWidth: int | float + index: int + payload: SankeyLinkPayload + + +def _sankey_renderer( + fn: Callable, + props_type: type, + decorator_name: str, +) -> Callable[..., Component]: + """Create a memoized Sankey renderer with a typed rest-prop parameter. + + Args: + fn: The renderer function to wrap. + props_type: The TypedDict props type expected by the renderer. + decorator_name: The public decorator name used in error messages. + + Returns: + A memoized renderer wrapper. + """ + sig = inspect.signature(fn) + if len(sig.parameters) != 1: + msg = ( + f"@{decorator_name} decorated function must take a single argument " + f"of type {props_type.__name__}, got {sig.parameters}" + ) + raise TypeError(msg) + + first_param = next(iter(sig.parameters.values())) + if first_param.kind not in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + msg = ( + f"@{decorator_name} decorated function parameter must be callable " + f"by keyword, got {first_param.kind.description}" + ) + raise TypeError(msg) + + hints = get_type_hints(fn, include_extras=True) + param_annotation = hints.get(first_param.name, first_param.annotation) + if ( + get_origin(param_annotation) is not Var + or not (args := get_args(param_annotation)) + or args[0] is not props_type + ): + msg = ( + f"@{decorator_name} decorated function must take a single argument " + f"of type {props_type.__name__}, got {sig.parameters}" + ) + raise TypeError(msg) + + def _wrapper(rest: RestProp) -> Component: + return fn(**{first_param.name: rest.to(props_type)}) + + _wrapper.__name__ = fn.__name__ + _wrapper.__module__ = fn.__module__ + return memo(wrapper=None)(_wrapper) + + +def sankey_node( + fn: Callable, +) -> Callable[..., Component]: + """A decorator to create a custom Sankey chart node. + + Args: + fn: A function that takes a SankeyNodeProps and returns a Reflex component. + + Returns: + A function that takes a SankeyNodeProps and returns a Reflex component. + """ + return _sankey_renderer(fn, SankeyNodeProps, "sankey_node") + + +def sankey_link( + fn: Callable, +) -> Callable[..., Component]: + """A decorator to create a custom Sankey chart link. + + Args: + fn: A function that takes a SankeyLinkProps and returns a Reflex component. + + Returns: + A function that takes a SankeyLinkProps and returns a Reflex component. + """ + return _sankey_renderer(fn, SankeyLinkProps, "sankey_link") + + +class SankeyChart(ChartBase): + """A Sankey chart component in Recharts.""" + + tag = "Sankey" + + alias = "RechartsSankeyChart" + + name_key: Var[str] = field(doc='The key of each node name. Default: "name"') + + data_key: Var[str | int] = field(doc='The key of each link value. Default: "value"') + + data: Var[SankeyData | Mapping[str, Any]] = field( + doc="The source data, including nodes and the weighted links between them." + ) + + margin: Var[dict[str, Any]] = field( + doc='The sizes of whitespace around the chart. Default: {"top": 5, "right": 5, "bottom": 5, "left": 5}' + ) + + node: Var[Any] = field( + doc="The configuration object or custom renderer used to draw nodes." + ) + + link: Var[Any] = field( + doc="The configuration object or custom renderer used to draw links." + ) + + sort: Var[bool] = field( + doc="Whether to sort nodes on the y-axis or display them in data order." + ) + + node_padding: Var[int] = field(doc="The padding between nodes.") + + node_width: Var[int] = field(doc="The width of each node.") + + link_curvature: Var[float] = field(doc="The curvature of each link.") + + iterations: Var[int] = field( + doc="The number of layout iterations used to position nodes and links." + ) + + # Valid children components + _valid_children: ClassVar[list[str]] = [ + "Legend", + "GraphingTooltip", + "Defs", + ] + + +class SankeyNamespace(ComponentNamespace): + """A namespace for the Sankey chart components.""" + + node = staticmethod(sankey_node) + link = staticmethod(sankey_link) + __call__ = staticmethod(SankeyChart.create) + + # For type checking + SankeyNode = SankeyNode + SankeyLink = SankeyLink + SankeyData = SankeyData + SankeyNodePayload = SankeyNodePayload + SankeyNodeProps = SankeyNodeProps + SankeyLinkPayload = SankeyLinkPayload + SankeyLinkProps = SankeyLinkProps + + class Treemap(RechartsCharts): """A Treemap chart component in Recharts.""" @@ -598,4 +834,5 @@ def create(cls, *children, **props) -> Component: radial_bar_chart = RadialBarChart.create scatter_chart = ScatterChart.create funnel_chart = FunnelChart.create +sankey_chart = SankeyNamespace() treemap = Treemap.create diff --git a/packages/reflex-components-recharts/src/reflex_components_recharts/general.py b/packages/reflex-components-recharts/src/reflex_components_recharts/general.py index d3dcaa97926..f76089ab17c 100644 --- a/packages/reflex-components-recharts/src/reflex_components_recharts/general.py +++ b/packages/reflex-components-recharts/src/reflex_components_recharts/general.py @@ -9,6 +9,7 @@ from reflex_base.constants.colors import Color from reflex_base.event import EventHandler, no_args_event_spec from reflex_base.vars.base import LiteralVar, Var +from reflex_base.vars.special import use_hook_var from .recharts import ( LiteralAnimationEasing, @@ -66,6 +67,7 @@ class ResponsiveContainer(Recharts, MemoizationLeaf): "RadialBarChart", "ResponsiveContainer", "ScatterChart", + "SankeyChart", "Treemap", "ComposedChart", "FunnelChart", @@ -299,6 +301,19 @@ class Cell(Recharts): ) +def use_chart_width() -> Var[int | None]: + """Get the chart width as a var. + + Outside of a chart context, this will be None/undefined. + + Returns: + The chart width var. + """ + return use_hook_var( + library=Recharts.library or "", hook="useChartWidth", _var_type=int | None + ) + + responsive_container = ResponsiveContainer.create legend = Legend.create graphing_tooltip = tooltip = GraphingTooltip.create diff --git a/pyi_hashes.json b/pyi_hashes.json index 144cc2dfcf4..267364a2f4f 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -111,10 +111,10 @@ "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "39e4144cef066bbff8c27b36a205a54b", "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "86fc106181638c6a0a2a199332be817f", "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "2682dc6e825d25307d8390d01e2bd653", - "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "359e123d9a046557ce05a96ce10313f5", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "de544a2efb54d0d90639933ccfa65b77", "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "3485aaabbfd3ca89908ae19425c7297e", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "280a7cd51298ee676f3d076104133f44", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "f5e3491c4e1f69ba89085682888888a9", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "d410ba40f14146c3491832a2c4f12132", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "be94d0ac11cca0bd0cfb2519b759897b", "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "58521fcd1b514804f534d97624e82c9a", diff --git a/tests/units/components/graphing/test_recharts.py b/tests/units/components/graphing/test_recharts.py index 3e4268eb891..b870849342d 100644 --- a/tests/units/components/graphing/test_recharts.py +++ b/tests/units/components/graphing/test_recharts.py @@ -1,3 +1,10 @@ +from typing import get_type_hints + +import pytest +from reflex_base.components.component import ( + ComponentNamespace, + evaluate_style_namespaces, +) from reflex_components_recharts.charts import ( AreaChart, BarChart, @@ -5,9 +12,18 @@ PieChart, RadarChart, RadialBarChart, + SankeyChart, + SankeyLinkPayload, + SankeyLinkProps, + SankeyNodePayload, + SankeyNodeProps, ScatterChart, + sankey_chart, ) -from reflex_components_recharts.general import ResponsiveContainer +from reflex_components_recharts.general import ResponsiveContainer, use_chart_width +from reflex_components_recharts.recharts import Recharts + +import reflex as rx def test_area_chart(): @@ -50,3 +66,87 @@ def test_scatter_chart(): sc = ScatterChart.create() assert isinstance(sc, ResponsiveContainer) assert isinstance(sc.children[0], ScatterChart) + + +def test_sankey_chart(): + sc = SankeyChart.create() + assert isinstance(sc, ResponsiveContainer) + assert isinstance(sc.children[0], SankeyChart) + assert sc.children[0].render()["name"] == "RechartsSankeyChart" + assert "link_width" not in SankeyChart.get_props() + + +def test_sankey_chart_namespace_can_be_used_as_style_key(): + assert isinstance(sankey_chart, ComponentNamespace) + assert evaluate_style_namespaces({sankey_chart: {"height": "20rem"}}) == { + SankeyChart.create: {"height": "20rem"} + } + + +def test_sankey_chart_accepts_unannotated_state_data(): + class SankeyState(rx.State): + data = { + "nodes": [{"name": "A"}, {"name": "B"}], + "links": [{"source": 0, "target": 1, "value": 1}], + } + + sc = SankeyChart.create(data=SankeyState.data) + assert isinstance(sc, ResponsiveContainer) + + +def test_sankey_link_payload_matches_recharts_runtime_shape(): + link_payload_hints = get_type_hints(SankeyLinkPayload) + assert link_payload_hints["source"] is SankeyNodePayload + assert link_payload_hints["target"] is SankeyNodePayload + assert "dy" in link_payload_hints + assert "width" not in link_payload_hints + assert "index" not in link_payload_hints + + link_props_hints = get_type_hints(SankeyLinkProps) + assert link_props_hints["index"] is int + assert link_props_hints["linkWidth"] == (int | float) + + +def test_sankey_renderer_decorators_accept_deferred_annotations(): + namespace = { + "rx": rx, + "SankeyLinkProps": SankeyLinkProps, + "SankeyNodeProps": SankeyNodeProps, + } + exec( + """ +from __future__ import annotations + + +def custom_node(node: rx.Var[SankeyNodeProps]) -> rx.Component: + return rx.fragment() + + +def custom_link(link: rx.Var[SankeyLinkProps]) -> rx.Component: + return rx.fragment() +""", + namespace, + ) + + assert callable(sankey_chart.node(namespace["custom_node"])) + assert callable(sankey_chart.link(namespace["custom_link"])) + + +def test_sankey_renderer_decorator_rejects_positional_only_parameter(): + def custom_node(node: rx.Var[SankeyNodeProps], /) -> rx.Component: + return rx.fragment() + + with pytest.raises(TypeError, match="keyword"): + sankey_chart.node(custom_node) + + +def test_use_chart_width(): + width = use_chart_width() + assert width._var_type == (int | None) + var_data = width._get_all_var_data() + assert var_data is not None + hook_alias = f"useChartWidth_{width!s}" + assert var_data.hooks == (f"const {width!s} = {hook_alias}();",) + assert dict(var_data.imports)[Recharts.library or ""] == ( + rx.ImportVar(tag="useChartWidth", alias=hook_alias), + ) diff --git a/tests/units/reflex_base/vars/test_special.py b/tests/units/reflex_base/vars/test_special.py new file mode 100644 index 00000000000..11c49c1bd2a --- /dev/null +++ b/tests/units/reflex_base/vars/test_special.py @@ -0,0 +1,86 @@ +"""Tests for reflex_base.vars.special hook-backed vars.""" + +from typing import Any + +from reflex_base.components.component import Component +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.number import NumberVar +from reflex_base.vars.sequence import StringVar +from reflex_base.vars.special import use_hook_var, use_id + + +class HookComponent(Component): + """A minimal component for asserting hook var hoisting.""" + + library = "test-lib" + + tag = "HookComponent" + + +def test_use_hook_var_var_data(): + """use_hook_var carries the hook statement and its import in VarData.""" + v = use_hook_var(library="some-lib", hook="useThing") + assert v._var_type is Any + vd = v._get_all_var_data() + assert vd is not None + hook_alias = f"useThing_{v!s}" + assert vd.hooks == (f"const {v!s} = {hook_alias}();",) + assert vd.imports == (("some-lib", (ImportVar(tag="useThing", alias=hook_alias),)),) + + +def test_use_hook_var_guesses_var_type(): + """The returned Var is downcast to the class matching _var_type.""" + count = use_hook_var(library="some-lib", hook="useCount", _var_type=int) + assert isinstance(count, NumberVar) + assert count._var_type is int + + maybe = use_hook_var(library="some-lib", hook="useMaybe", _var_type=int | None) + assert maybe._var_type == (int | None) + + +def test_use_hook_var_names_are_unique(): + """Each call binds the hook value to a fresh variable name.""" + names = {str(use_hook_var(library="some-lib", hook="useThing")) for _ in range(5)} + assert len(names) == 5 + + +def test_same_named_hook_imports_are_aliased(): + """Same-named hooks from different libraries do not collide in JS imports.""" + first = use_hook_var(library="first-lib", hook="useThing") + second = use_hook_var(library="second-lib", hook="useThing") + first_alias = f"useThing_{first!s}" + second_alias = f"useThing_{second!s}" + first_var_data = first._get_all_var_data() + second_var_data = second._get_all_var_data() + + assert first_alias != second_alias + assert first_var_data is not None + assert second_var_data is not None + assert first_var_data.imports == ( + ("first-lib", (ImportVar(tag="useThing", alias=first_alias),)), + ) + assert second_var_data.imports == ( + ("second-lib", (ImportVar(tag="useThing", alias=second_alias),)), + ) + + +def test_use_id(): + """use_id returns a str Var bound to React's useId hook.""" + v = use_id() + assert isinstance(v, StringVar) + assert v._var_type is str + vd = v._get_all_var_data() + assert vd is not None + hook_alias = f"useId_{v!s}" + assert vd.hooks == (f"const {v!s} = {hook_alias}();",) + assert vd.imports == (("react", (ImportVar(tag="useId", alias=hook_alias),)),) + + +def test_hook_var_hoisted_into_component(): + """A component using a hook var renders the hook and pulls its import.""" + v = use_id() + comp = HookComponent.create(id=v) + hook_alias = f"useId_{v!s}" + assert f"const {v!s} = {hook_alias}();" in comp._get_all_hooks() + assert ImportVar(tag="useId", alias=hook_alias) in comp._get_all_imports()["react"] + assert comp.render()["props"] == [f"id:{v!s}"] diff --git a/uv.lock b/uv.lock index e6efb6377a0..7f66bb55932 100644 --- a/uv.lock +++ b/uv.lock @@ -3940,10 +3940,14 @@ name = "reflex-components-recharts" source = { editable = "packages/reflex-components-recharts" } dependencies = [ { name = "reflex-base" }, + { name = "reflex-components-core" }, ] [package.metadata] -requires-dist = [{ name = "reflex-base", editable = "packages/reflex-base" }] +requires-dist = [ + { name = "reflex-base", editable = "packages/reflex-base" }, + { name = "reflex-components-core", editable = "packages/reflex-components-core" }, +] [[package]] name = "reflex-components-sonner"