Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Don't forget to remove deprecated code on each major release!

- The `navigate` component now accepts integers for `to`, allowing relative navigation in the browser's history stack (e.g. `navigate(-1)` to go back, `navigate(1)` to go forward).
- Support for ReactPy v2.x (beta). The initial URL is now sourced from the ReactPy executor (`use_connection().location`) instead of a JS-side `popstate` effect, removing a redundant network round-trip on first load.
- The `form` component now provides client-side form interception akin to React Router's `<Form>`. Form submissions are serialized via `FormData`, sent as query parameters via `pushState`, and the submitted data is made available through the `use_form_data()` hook.

### Changed

Expand Down
63 changes: 62 additions & 1 deletion src/js/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { React } from "@reactpy/client";
import { createLocationObject, pushState, replaceState } from "./utils";
import { HistoryProps, LinkProps, NavigateProps } from "./types";
import { HistoryProps, LinkProps, NavigateProps, FormProps } from "./types";

/**
* Interface used to bind a ReactPy node to React.
Expand Down Expand Up @@ -118,3 +118,64 @@ export function Navigate({

return null;
}

/**
* Form component that intercepts form submissions and notifies the server
* instead of performing a full page reload.
*
* This component is not the actual `<form>` element. It is just an event
* listener for ReactPy-Router's server-side form component.
*/
export function Form({ onSubmitCallback, formClass }: FormProps): null {
React.useEffect(() => {
const handleSubmit = (event: Event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);

// Serialize FormData to a Record<string, string[]>
const serialized: Record<string, string[]> = {};
for (const [key, value] of formData.entries()) {
if (!serialized[key]) {
serialized[key] = [];
}
serialized[key].push(value.toString());
}

// Build the action URL with form data as query params (GET-style)
let action = form.getAttribute("action") || window.location.pathname;
const params = new URLSearchParams();
for (const [key, values] of Object.entries(serialized)) {
for (const value of values) {
params.append(key, value);
}
}
const queryString = params.toString();
if (queryString) {
action += (action.includes("?") ? "&" : "?") + queryString;
}

pushState(action);
onSubmitCallback({
form_data: serialized,
location: createLocationObject(),
});
};

const forms = document.querySelectorAll(`form.${formClass}`);
if (forms.length === 0) {
console.warn(`Form component with class name ${formClass} not found.`);
} else {
forms.forEach((form) => {
form.addEventListener("submit", handleSubmit);
});
}

return () => {
forms.forEach((form) => {
form.removeEventListener("submit", handleSubmit);
});
};
});
return null;
}
2 changes: 1 addition & 1 deletion src/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { bind, History, Link, Navigate } from "./components";
export { bind, History, Link, Navigate, Form } from "./components";
10 changes: 10 additions & 0 deletions src/js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ export interface NavigateProps {
to: string | number;
replace?: boolean;
}

export interface FormSubmitData {
form_data: Record<string, string[]>;
location: ReactPyLocation;
}

export interface FormProps {
onSubmitCallback: (data: FormSubmitData) => void;
formClass: string;
}
6 changes: 4 additions & 2 deletions src/reactpy_router/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
__version__ = "3.0.0b1"


from reactpy_router.components import link, navigate, route
from reactpy_router.hooks import use_params, use_search_params
from reactpy_router.components import form, link, navigate, route
from reactpy_router.hooks import use_form_data, use_params, use_search_params
from reactpy_router.routers import browser_router, create_router

__all__ = (
"browser_router",
"create_router",
"form",
"link",
"navigate",
"route",
"use_form_data",
"use_params",
"use_search_params",
)
45 changes: 44 additions & 1 deletion src/reactpy_router/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from reactpy.reactjs import component_from_file
from reactpy.types import Location

from reactpy_router.hooks import _use_route_state
from reactpy_router.hooks import _use_form_data_state, _use_route_state
from reactpy_router.types import Route

if TYPE_CHECKING:
Expand All @@ -27,6 +27,9 @@
)
"""Client-side portion of the navigate component"""

FormJs = component_from_file(Path(__file__).parent / "static" / "bundle.js", import_names="Form", name="reactpy-router")
"""Client-side portion of form handling"""


def link(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> Component:
"""
Expand Down Expand Up @@ -68,6 +71,46 @@ def on_click_callback(_event: dict[str, Any]) -> None:
return html(Link({"onClickCallback": on_click_callback, "linkClass": class_name}), html.a(attrs, *children))


def form(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> Component:
"""
Create a form element that intercepts submissions and navigates to the
action URL with form data serialized as query parameters.

Args:
attributes: A dictionary of attributes for the form element.
*children: Child elements to be included within the form.

Returns:
A form component with the specified attributes and children.
"""
return _form(attributes, *children, key=key)


@component
def _form(attributes: dict[str, Any], *children: Any) -> VdomDict:
attributes = attributes.copy()
class_name = use_ref(f"form-{uuid4().hex}").current
route_state = _use_route_state()
form_data_state = _use_form_data_state()
if "className" in attributes:
class_name = " ".join([attributes.pop("className"), class_name])

attrs = {
**attributes,
"className": class_name,
}

def on_submit_callback(event: dict[str, Any]) -> None:
form_data_state.set_form_data(event.get("form_data", {}))
location_data = event.get("location", {})
route_state.set_location(Location(**location_data))

return html(
FormJs({"onSubmitCallback": on_submit_callback, "formClass": class_name}),
html.form(attrs, *children),
)


def route(path: str, element: Any | None, *routes: Route) -> Route:
"""
Create a route with the given path, element, and child routes.
Expand Down
45 changes: 45 additions & 0 deletions src/reactpy_router/hooks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs

Expand All @@ -8,12 +9,31 @@
from reactpy_router.types import RouteState # noqa: TC001

if TYPE_CHECKING:
from collections.abc import Callable

from reactpy.types import Context


_route_state_context: Context[RouteState | None] = create_context(None)


@dataclass
class FormDataState:
"""
Holds form data state for the current route.

Attributes:
form_data: A dictionary of form field names to lists of values.
set_form_data: A callable to update the form data.
"""

form_data: dict[str, list[str]]
set_form_data: Callable[[dict[str, list[str]]], None]


_form_data_state_context: Context[FormDataState | None] = create_context(None)


def _use_route_state() -> RouteState:
route_state = use_context(_route_state_context)
if route_state is None: # pragma: no cover
Expand All @@ -26,6 +46,18 @@ def _use_route_state() -> RouteState:
return route_state


def _use_form_data_state() -> FormDataState:
form_data_state = use_context(_form_data_state_context)
if form_data_state is None: # pragma: no cover
msg = (
"ReactPy-Router was unable to find a form data context. Are you "
"sure this hook/component is being called within a router?"
)
raise RuntimeError(msg)

return form_data_state


def use_params() -> dict[str, Any]:
"""This hook returns an object of key/value pairs of the dynamic parameters \
from the current URL that were matched by the `Route`. Child routes inherit all parameters \
Expand All @@ -41,6 +73,19 @@ def use_params() -> dict[str, Any]:
return _use_route_state().params


def use_form_data() -> dict[str, list[str]]:
"""
This hook returns the form data from the most recent `Form` component
submission. The returned value is a dictionary of field names to lists of values.

If no form has been submitted yet, an empty dictionary is returned.

Returns:
A dictionary of form field names to lists of values.
"""
return _use_form_data_state().form_data


def use_search_params(
keep_blank_values: bool = False,
strict_parsing: bool = False,
Expand Down
11 changes: 9 additions & 2 deletions src/reactpy_router/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from reactpy.types import Component, Connection, Location, VdomDict

from reactpy_router.components import History
from reactpy_router.hooks import RouteState, _route_state_context
from reactpy_router.hooks import FormDataState, RouteState, _form_data_state_context, _route_state_context
from reactpy_router.resolvers import ReactPyResolver

if TYPE_CHECKING:
Expand Down Expand Up @@ -61,6 +61,7 @@ def router(

initial = use_connection()
location, set_location = use_state(initial.location)
form_data, set_form_data = use_state({})
resolvers = use_memo(
lambda: tuple(map(resolver, _iter_routes(routes))),
dependencies=(resolver, hash(routes)),
Expand All @@ -84,7 +85,13 @@ def on_history_previous(event: dict[str, Any]) -> None:

return ConnectionContext(
History({"onHistoryPreviousCallback": on_history_previous}), # type: ignore[return-value]
_route_state_context(match.element, value=RouteState(set_location, match.params)),
_route_state_context(
_form_data_state_context(
match.element,
value=FormDataState(form_data, set_form_data),
),
value=RouteState(set_location, match.params),
),
value=Connection(initial.scope, location or initial.location, initial.carrier),
)

Expand Down
Loading
Loading