Skip to content

Commit

Permalink
feat: allow multipage/routing in single script
Browse files Browse the repository at this point in the history
  • Loading branch information
maartenbreddels committed May 26, 2023
1 parent 662f1bd commit 9d81cba
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 37 deletions.
20 changes: 12 additions & 8 deletions solara/autorouting.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ def RenderPage():
layouts = []
else:
layouts = [DefaultLayout]
if route_current.data is None and route_current.module is None:
return solara.Error(f"Page not found: {router.path}, route does not link to a path or module")
if route_current.data is None and route_current.module is None and route_current.component is None:
return solara.Error(f"Page not found: {router.path}, route does not link to a path or module or component")

def wrap_in_layouts(element: reacton.core.Element, layouts):
for Layout in reversed(layouts):
Expand Down Expand Up @@ -217,14 +217,18 @@ def get_args(f):
else:
main = solara.Error(f"Suffix {path.suffix} not supported")
else:
assert route_current.module is not None
title = route_current.label or "No title"
title_element = solara.Title(title)
module = route_current.module
namespace = module.__dict__
Page = namespace.get("Page", None)
# app is for backwards compatibility
page = namespace.get("page", namespace.get("app"))
if route_current.module is not None:
assert route_current.module is not None
module = route_current.module
namespace = module.__dict__
Page = namespace.get("Page", None)
# app is for backwards compatibility
page = namespace.get("page", namespace.get("app"))
else:
Page = route_current.component
page = None
if page is not None:
if isinstance(page, reacton.core.Element):
pass # we are good
Expand Down
22 changes: 12 additions & 10 deletions solara/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,13 @@ def add_path():
exec(ast, local_scope)
app = nested_get(local_scope, self.app_name)
routes = cast(Optional[List[solara.Route]], local_scope.get("routes"))
layout_class = local_scope.get("Layout", solara.AppLayout)
if isinstance(app, reacton.core.Component):
app = cast(reacton.core.Component, layout_class)(children=[app()])
if app is None and routes is not None:
app = solara.autorouting.RenderPage()
# does this make sense?
# else:
# layout_class = local_scope.get("Layout")
# if layout_class and isinstance(app, reacton.core.Component):
# app = cast(reacton.core.Component, layout_class)(children=[app()])
elif self.name.endswith(".ipynb"):
self.type = AppType.NOTEBOOK
add_path()
Expand All @@ -150,12 +154,10 @@ def add_path():
cell_path = f"{self.path} input cell {cell_index}"
ast = compile(source, cell_path, "exec")
exec(ast, local_scope)
app = nested_get(local_scope, self.app_name)
routes = cast(Optional[List[solara.Route]], local_scope.get("routes"))
if isinstance(app, Element):
app = solara.AppLayout(children=[app])
if isinstance(app, reacton.core.Component):
app = solara.AppLayout(children=[app()])
app = nested_get(local_scope, self.app_name)
routes = cast(Optional[List[solara.Route]], local_scope.get("routes"))
if app is None and routes is not None:
app = solara.autorouting.RenderPage()
else:
# the module itself will be added by reloader
# automatically
Expand Down Expand Up @@ -222,7 +224,7 @@ def add_path():

options = [k for k in list(local_scope) if k not in ignore and not k.startswith("_")]
matches = difflib.get_close_matches(self.app_name, options)
msg = f"No object with name {self.app_name} found for {self.name} at {self.path}."
msg = f"No object with name {self.app_name} found for {self.name} at {self.path} and no routes defined."
if matches:
msg += " Did you mean: " + " or ".join(map(repr, matches))
else:
Expand Down
26 changes: 26 additions & 0 deletions solara/website/pages/docs/content/10-howto/20-multipage.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ Solara server is starting at http://localhost:8765
Go to http://localhost:8765 ([or click here](http://localhost:8765)), explore the source code, edit it, save it, and watch the web app reload instantly.


## In a single script

If you want to setup a multipage app in a single script, you do not need to define a `Page` component, but you can define a list of routes.

```python
import solara


@solara.component
def Home():
solara.Markdown("Home")


@solara.component
def About():
solara.Markdown("About")


routes = [
solara.Route(path="/", component=Home, label="home"),
solara.Route(path="about", component=About, label="about"),
]
```

See more details in the [Route section](/docs/understanding/routing).

## Dynamic pages

In the previous section we created the example portal app. Taking a look at
Expand Down
67 changes: 50 additions & 17 deletions solara/website/pages/docs/content/20-understanding/40-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,40 @@ Similar to the previous section [`generate_routes(module: ModuleType) -> List[so
## Manually defining routes

In Solara, we set up routing by defining a list of `solara.Route` objects, where each route can have another list of routes, its children, forming
a tree of routes. We assign this list to the `routes` variable in your main app script or module, so Solara can find it and it should be in the same namespace as your `Page` component.
a tree of routes. We assign this list to the `routes` variable in your main app script or module.

Routes are matched by splitting the pathname around the slash ("/") and matching each part to the routes.
### Defining route components

If no `Page` component is found in your main script or module, Solara will assume you have either set the `component` or the `module` argument of the `solara.Route` object.

For example

```python
import solara
from solara.website.pages.examples.utilities import calculator


@solara.component
def Home():
solara.Markdown("Home")


@solara.component
def About():
solara.Markdown("About")


routes = [
solara.Route(path="/", component=Home, label="Home"),
# the calculator module should have a Page component
solara.Route(path="calculator", module=calculator, label="Calculator"),
solara.Route(path="about", component=About, label="About"),
]
```

### Defining route components

If you do define a `Page` component, you are fully responsible for how routing is done, but we recommend using [use_route](/api/use_route).

An example route definition could be something like this:

Expand Down Expand Up @@ -64,22 +95,9 @@ routes = [
solara.Route(path="contact") # matches '/contact'
]

# Lets assume our pathname is `/docs/basics/react`,
@solara.component
def Page():
...
```

The level of the depth into the tree is what we call the `route_level`, which starts at 0, the top level.
Each call to `use_route` will return the current route (if there is a match to the current path) and the list of siblings including itself.

## Rendering based on routes

For instance, when our pathname is `/docs/basics/react`, the following code shows what
`solara.use_route_level` and `solara.use_route` will return:

```python
@solara.component
def MyRootComponent():
level = solara.use_route_level() # returns 0
route_current, routes_current_level = solara.use_routes()
# route_current is routes[1], i.e. solara.Route(path="docs", children=[...])
Expand All @@ -91,8 +109,15 @@ def MyRootComponent():
else:
# we could render some top level navigation here based on route_current_level and route_current
return MyFirstLevelChildComponent()
```

Routes are matched by splitting the pathname around the slash ("/") and matching each part to the routes. The level of the depth into the tree is what we call the `route_level`, which starts at 0, the top level.
Each call to `use_route` will return the current route (if there is a match to the current path) and the list of siblings including itself.


Now the `MyFirstLevelChildComponent` component is responsible for rendering the second level navigation:

```python
@solara.component
def MyFirstLevelChildComponent():
level = solara.use_route_level() # returns 1
Expand All @@ -106,6 +131,11 @@ def MyFirstLevelChildComponent():
# we could render some mid level navigation here based on route_current_level and route_current
return MySecondLevelChildComponent()

```

And the `MySecondLevelChildComponent` component is responsible for rendering the third level navigation:

```python
@solara.component
def MySecondLevelChildComponent():
level = solara.use_route_level() # returns 2
Expand Down Expand Up @@ -135,7 +165,8 @@ From this code, we can see we are free how we transform the routes into the stat
Often, your render logic needs some extra data on what to display. For instance, you may want to dynamically render tabs based on the routes,
which requires you to have a label, and know which component to add.
For this purposed we added `label: str` and the `component' attributes, so you can defines routes likes:
```

```python
routes = [
solara.Route("/", component=Home, label="What is Solara ☀️?"),
solara.Route("docs", component=docs.App, label="Docs", children=docs.routes),
Expand All @@ -149,6 +180,8 @@ routes = [
]
```

In the case where you did not specify a `Page` component, label is used for the [Title](/api/title) component.

If you need to store more data in the route, you are free to put whatever you want in the `data` attribute, see also [Route](/api/route).


Expand Down
4 changes: 2 additions & 2 deletions tests/unit/app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ def test_notebook_component(app_context, no_app_context):
app = AppScript(name)
try:
with app_context:
el = app.run().kwargs["children"][0].component
el = app.run()
assert isinstance(el, reacton.core.Component)
el2 = app.run().kwargs["children"][0].component
el2 = app.run()
assert el is el2
finally:
app.close()
Expand Down

0 comments on commit 9d81cba

Please sign in to comment.