[REF-1993] link: respect is_external
prop and other attributes on A tag (#2651)
* link: respect `is_external` prop and other attributes on A tag Instead of passing all props to NextLink by default, only pass props that NextLink understands, placing the remaining props on the Radix link Add a test case to avoid regression of `is_external` behavior. * Link is a MemoizationLeaf Because Link is often rendered with NextLink as_child, and NextLink breaks if the href is stateful outside of a Link, ensure that any stateful child of Link gets memoized together.
This commit is contained in:
parent
99a566f43e
commit
279e9bfa28
89
integration/test_navigation.py
Normal file
89
integration/test_navigation.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""Integration tests for links and related components."""
|
||||||
|
from typing import Generator
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from reflex.testing import AppHarness
|
||||||
|
|
||||||
|
from .utils import poll_for_navigation
|
||||||
|
|
||||||
|
|
||||||
|
def NavigationApp():
|
||||||
|
"""Reflex app with links for navigation."""
|
||||||
|
import reflex as rx
|
||||||
|
|
||||||
|
class State(rx.State):
|
||||||
|
is_external: bool = True
|
||||||
|
|
||||||
|
app = rx.App()
|
||||||
|
|
||||||
|
@app.add_page
|
||||||
|
def index():
|
||||||
|
return rx.fragment(
|
||||||
|
rx.link("Internal", href="/internal", id="internal"),
|
||||||
|
rx.link(
|
||||||
|
"External",
|
||||||
|
href="/internal",
|
||||||
|
is_external=State.is_external,
|
||||||
|
id="external",
|
||||||
|
),
|
||||||
|
rx.link(
|
||||||
|
"External Target", href="/internal", target="_blank", id="external2"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@rx.page(route="/internal")
|
||||||
|
def internal():
|
||||||
|
return rx.text("Internal")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def navigation_app(tmp_path) -> Generator[AppHarness, None, None]:
|
||||||
|
"""Start NavigationApp app at tmp_path via AppHarness.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmp_path: pytest tmp_path fixture
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
running AppHarness instance
|
||||||
|
"""
|
||||||
|
with AppHarness.create(
|
||||||
|
root=tmp_path,
|
||||||
|
app_source=NavigationApp, # type: ignore
|
||||||
|
) as harness:
|
||||||
|
yield harness
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_navigation_app(navigation_app: AppHarness):
|
||||||
|
"""Type text after moving cursor. Update text on backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
navigation_app: harness for NavigationApp app
|
||||||
|
"""
|
||||||
|
assert navigation_app.app_instance is not None, "app is not running"
|
||||||
|
driver = navigation_app.frontend()
|
||||||
|
|
||||||
|
internal_link = driver.find_element(By.ID, "internal")
|
||||||
|
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
internal_link.click()
|
||||||
|
assert urlsplit(driver.current_url).path == f"/internal/"
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
driver.back()
|
||||||
|
|
||||||
|
external_link = driver.find_element(By.ID, "external")
|
||||||
|
external2_link = driver.find_element(By.ID, "external2")
|
||||||
|
|
||||||
|
external_link.click()
|
||||||
|
# Expect a new tab to open
|
||||||
|
assert AppHarness._poll_for(lambda: len(driver.window_handles) == 2)
|
||||||
|
|
||||||
|
# Switch back to the main tab
|
||||||
|
driver.switch_to.window(driver.window_handles[0])
|
||||||
|
|
||||||
|
external2_link.click()
|
||||||
|
# Expect another new tab to open
|
||||||
|
assert AppHarness._poll_for(lambda: len(driver.window_handles) == 3)
|
@ -6,7 +6,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from reflex.components.component import Component
|
from reflex.components.component import Component, MemoizationLeaf
|
||||||
|
from reflex.components.core.cond import cond
|
||||||
from reflex.components.el.elements.inline import A
|
from reflex.components.el.elements.inline import A
|
||||||
from reflex.components.next.link import NextLink
|
from reflex.components.next.link import NextLink
|
||||||
from reflex.utils import imports
|
from reflex.utils import imports
|
||||||
@ -27,7 +28,7 @@ LiteralLinkUnderline = Literal["auto", "hover", "always"]
|
|||||||
next_link = NextLink.create()
|
next_link = NextLink.create()
|
||||||
|
|
||||||
|
|
||||||
class Link(RadixThemesComponent, A):
|
class Link(RadixThemesComponent, A, MemoizationLeaf):
|
||||||
"""A semantic element for navigation between pages."""
|
"""A semantic element for navigation between pages."""
|
||||||
|
|
||||||
tag = "Link"
|
tag = "Link"
|
||||||
@ -53,6 +54,9 @@ class Link(RadixThemesComponent, A):
|
|||||||
# Whether to render the text with higher contrast color
|
# Whether to render the text with higher contrast color
|
||||||
high_contrast: Var[bool]
|
high_contrast: Var[bool]
|
||||||
|
|
||||||
|
# If True, the link will open in a new tab
|
||||||
|
is_external: Var[bool]
|
||||||
|
|
||||||
def _get_imports(self) -> imports.ImportDict:
|
def _get_imports(self) -> imports.ImportDict:
|
||||||
return {**super()._get_imports(), **next_link._get_imports()}
|
return {**super()._get_imports(), **next_link._get_imports()}
|
||||||
|
|
||||||
@ -70,12 +74,23 @@ class Link(RadixThemesComponent, A):
|
|||||||
Returns:
|
Returns:
|
||||||
Component: The link component
|
Component: The link component
|
||||||
"""
|
"""
|
||||||
|
is_external = props.pop("is_external", None)
|
||||||
|
if is_external is not None:
|
||||||
|
props["target"] = cond(is_external, "_blank", "")
|
||||||
if props.get("href") is not None:
|
if props.get("href") is not None:
|
||||||
if not len(children):
|
if not len(children):
|
||||||
raise ValueError("Link without a child will not display")
|
raise ValueError("Link without a child will not display")
|
||||||
if "as_child" not in props:
|
if "as_child" not in props:
|
||||||
|
# Extract props for the NextLink, the rest go to the Link/A element.
|
||||||
|
known_next_link_props = NextLink.get_props()
|
||||||
|
next_link_props = {}
|
||||||
|
for prop in props.copy():
|
||||||
|
if prop in known_next_link_props:
|
||||||
|
next_link_props[prop] = props.pop(prop)
|
||||||
# If user does not use `as_child`, by default we render using next_link to avoid page refresh during internal navigation
|
# If user does not use `as_child`, by default we render using next_link to avoid page refresh during internal navigation
|
||||||
return super().create(
|
return super().create(
|
||||||
NextLink.create(*children, **props), as_child=True
|
NextLink.create(*children, **next_link_props),
|
||||||
|
as_child=True,
|
||||||
|
**props,
|
||||||
)
|
)
|
||||||
return super().create(*children, **props)
|
return super().create(*children, **props)
|
||||||
|
@ -8,7 +8,8 @@ from reflex.vars import Var, BaseVar, ComputedVar
|
|||||||
from reflex.event import EventChain, EventHandler, EventSpec
|
from reflex.event import EventChain, EventHandler, EventSpec
|
||||||
from reflex.style import Style
|
from reflex.style import Style
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from reflex.components.component import Component
|
from reflex.components.component import Component, MemoizationLeaf
|
||||||
|
from reflex.components.core.cond import cond
|
||||||
from reflex.components.el.elements.inline import A
|
from reflex.components.el.elements.inline import A
|
||||||
from reflex.components.next.link import NextLink
|
from reflex.components.next.link import NextLink
|
||||||
from reflex.utils import imports
|
from reflex.utils import imports
|
||||||
@ -19,7 +20,7 @@ from .base import LiteralTextSize, LiteralTextTrim, LiteralTextWeight
|
|||||||
LiteralLinkUnderline = Literal["auto", "hover", "always"]
|
LiteralLinkUnderline = Literal["auto", "hover", "always"]
|
||||||
next_link = NextLink.create()
|
next_link = NextLink.create()
|
||||||
|
|
||||||
class Link(RadixThemesComponent, A):
|
class Link(RadixThemesComponent, A, MemoizationLeaf):
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
def create( # type: ignore
|
def create( # type: ignore
|
||||||
@ -113,6 +114,7 @@ class Link(RadixThemesComponent, A):
|
|||||||
]
|
]
|
||||||
] = None,
|
] = None,
|
||||||
high_contrast: Optional[Union[Var[bool], bool]] = None,
|
high_contrast: Optional[Union[Var[bool], bool]] = None,
|
||||||
|
is_external: Optional[Union[Var[bool], bool]] = None,
|
||||||
download: Optional[
|
download: Optional[
|
||||||
Union[Var[Union[str, int, bool]], Union[str, int, bool]]
|
Union[Var[Union[str, int, bool]], Union[str, int, bool]]
|
||||||
] = None,
|
] = None,
|
||||||
@ -238,6 +240,7 @@ class Link(RadixThemesComponent, A):
|
|||||||
underline: Sets the visibility of the underline affordance: "auto" | "hover" | "always"
|
underline: Sets the visibility of the underline affordance: "auto" | "hover" | "always"
|
||||||
color_scheme: Overrides the accent color inherited from the Theme.
|
color_scheme: Overrides the accent color inherited from the Theme.
|
||||||
high_contrast: Whether to render the text with higher contrast color
|
high_contrast: Whether to render the text with higher contrast color
|
||||||
|
is_external: If True, the link will open in a new tab
|
||||||
download: Specifies that the target (the file specified in the href attribute) will be downloaded when a user clicks on the hyperlink.
|
download: Specifies that the target (the file specified in the href attribute) will be downloaded when a user clicks on the hyperlink.
|
||||||
href: Specifies the URL of the page the link goes to
|
href: Specifies the URL of the page the link goes to
|
||||||
href_lang: Specifies the language of the linked document
|
href_lang: Specifies the language of the linked document
|
||||||
|
Loading…
Reference in New Issue
Block a user