Multi page app documentation

Hi,

I am pretty new to Panel, and while so far I love it, I am having trouble finding documentation or examples about building multi-page apps. My goal would be to have a sidebar with a list of pages, each containing some interactive plots.

With Dash I used to do that with Dash-bootstrap-components.

I suspect that Panel’s templates could be the solution but none of the examples shows a multi-page app.

The closest thing is this one here but with a card visualization rather than a sidebar with apps.

Any document I can read something about this?

Thanks

1 Like

Hi @micdonato ! I’m sure there are multiple ways of achieving this so I hope more people will chime in to bring their own experience.

I could suggest something like the following, where I use one of the templates provided by Panel to add buttons in the sidebar, clicking on a button updates the content of the main area. Well, too be more accurate, it updates the content of a Column layout that is the only component added here in the main area, since the main area should not be updated directly (this is mentioned multiple times in the documentation).

import panel as pn
import holobiews as hv
import nunpy as np

pn.extension()

vanilla = pn.template.VanillaTemplate(title='Vanilla Template')

xs = np.linspace(0,np.pi)
freq = pn.widgets.FloatSlider(name="Frequency", start=0, end=10, value=2)
phase = pn.widgets.FloatSlider(name="Phase", start=0, end=np.pi)

@pn.depends(freq=freq, phase=phase)
def sine(freq, phase):
    return hv.Curve((xs, np.sin(xs*freq+phase))).opts(
        responsive=True, min_height=400)

@pn.depends(freq=freq, phase=phase)
def cosine(freq, phase):
    return hv.Curve((xs, np.cos(xs*freq+phase))).opts(
        responsive=True, min_height=400)

page = pn.Column(sizing_mode='stretch_width')

content1 = [
    pn.Row(freq, phase),
    hv.DynamicMap(sine),
]
content2 = [
    pn.Row(freq, phase),
    hv.DynamicMap(cosine),
]

link1 = pn.widgets.Button(name='Sine')
link2 = pn.widgets.Button(name='Cosine')

vanilla.sidebar.append(link1)
vanilla.sidebar.append(link2)

vanilla.main.append(page)

def load_content1(event):
    vanilla.main[0].objects = content1


def load_content2(event):
    vanilla.main[0].objects = content2
    
link1.on_click(load_content1)
link2.on_click(load_content2)

vanilla.show()

So this ends up not being a multipage app per say, it’s just a rather dynamic single-page app.

A simpler solution, without sidebar though, would be to simply use Tabs.

2 Likes

Hi @micdonato

Welcome to the community. The answer to your question depends on your use case and requirements.

Option 1 (preferred): panel serve multiple files

The simplest is to serve a multi page app using multiple files.

panel serve page1.py page2.ipynb page3.py ...

Add the --autoreload flag while developing. This will also give you the index page out of the box.

For example for panel-chemistry I just panel serve examples/reference/*.ipynb ( See Link). You can explore the multi-page application on binder here.

Option 2: pn.serve multiple functions or panels

For some use case you would need to use pn.serve instead and serve the application using python app.py. For example if you want to use other urls than the file names.

Run the script via python app.py

app.py

import panel as pn

def page1():
    return "page 1"

def page2():
    return pn.Column(
        "# Page 2", "Welcome to the second page"
    )
ROUTES = {
    "1": page1, "2": page2
}
pn.serve(ROUTES, port=5006)

Please note that if you don’t pn.serve functions but instances, then they will be shared across users/ sessions. The latter can be confusing. But it also makes it really simple interact in real time with the same app instance for example for demo/ testing purposes. Another use case for instances is a live updating dashboard that should look the same to all users.

Option 3 Use a Widget to navigate between pages in a Single Page Application.

panel serve or pn.serve one page and use a widget and/ or url arguments to navigate between “pages”.

import panel as pn

pn.extension(sizing_mode="stretch_width")

pages = {
    "Page 1": pn.Column("# Page 1", "...bla bla bla"),
    "Page 2": pn.Column("# Page 2", "...more bla"),
}


def show(page):
    return pages[page]


starting_page = pn.state.session_args.get("page", [b"Page 1"])[0].decode()
page = pn.widgets.RadioButtonGroup(
    value=starting_page,
    options=list(pages.keys()),
    name="Page",
    sizing_mode="fixed",
    button_type="success",
)
ishow = pn.bind(show, page=page)
pn.state.location.sync(page, {"value": "page"})

ACCENT_COLOR = "#0072B5"
DEFAULT_PARAMS = {
    "site": "Panel Multi Page App",
    "accent_base_color": ACCENT_COLOR,
    "header_background": ACCENT_COLOR,
}
pn.template.FastListTemplate(
    title="As Single Page App",
    sidebar=[page],
    main=[ishow],
    **DEFAULT_PARAMS,
).servable()

Please note the downside of this approach is that when Panel/ Bokeh replaces one page with another it can be slow if your layout is complex. This should hopefully be fixed when bokeh 3.0 is released.

If you want to have multiple pages in one “single page app” consider using Tabs with argument dynamic=True or a custom template. That will make you app more snappy.

Share State Across Pages

If you need to share state across your pages you can use pn.state.cache for that. It’s just normal python dictionary. You can share Parameterized Classes, DataFrames, Widgets etc if you need to.

Lets take an example show casing some of the possibilities

You can run the below via python name_of_script.py. Alternatively you can refactor out into seperate files and panel serve them.

import panel as pn
import param
import datetime
from threading import Thread
import time

pn.extension(sizing_mode="stretch_width")
class StreamClass(param.Parameterized):
    value = param.Integer()
class MessageQueue(param.Parameterized):
    value = param.List()

    def append(self, asof, user, message):
        if message:
            self.value = [*self.value, (asof, user, message)]

ACCENT_COLOR = "#0072B5"
DEFAULT_PARAMS = {
    "site": "Panel Multi Page App",
    "accent_base_color": ACCENT_COLOR,
    "header_background": ACCENT_COLOR,
}

def fastlisttemplate(title, *objects):
    """Returns a Panel-AI version of the FastListTemplate

    Returns:
        [FastListTemplate]: A FastListTemplate
    """
    return pn.template.FastListTemplate(**DEFAULT_PARAMS, title=title, main=[pn.Column(*objects)])


def get_shared_state():
    if not "stream" in pn.state.cache:
        state=pn.state.cache["stream"]=StreamClass()
        pn.state.cache["messages"]=MessageQueue()

        def update_state():
            while True:
                if state.value==100:
                    state.value=0
                else:
                    state.value+=1
                time.sleep(1)

        Thread(target=update_state).start()

    return pn.state.cache["stream"], pn.state.cache["messages"]

def show_messages(messages):
    result = ""
    for message in messages:
        result = f"- {message[0]} | {message[1]}: {message[2]}\n" + result
    if not result:
        result = "No Messages yet!"
    return result

def page1():
    _, messages = get_shared_state()

    user_input = pn.widgets.TextInput(value="Guest", name="User")
    message_input = pn.widgets.TextInput(value="Hello", name="Message")
    add_message_button = pn.widgets.Button(name="Add")

    def add_message(event):
        messages.append(datetime.datetime.utcnow(), user_input.value, message_input.value)
    add_message_button.on_click(add_message)

    return fastlisttemplate("Add Message", user_input, message_input, add_message_button)

def page2():
    _, messages = get_shared_state()

    ishow_messages = pn.bind(show_messages, messages=messages.param.value)
    return fastlisttemplate("Show Messages",pn.panel(ishow_messages, height=600),)

def page3():
    stream, _ = get_shared_state()

    return fastlisttemplate("Show Streaming Value",stream.param.value,)

ROUTES = {
    "1": page1, "2": page2, "3": page3
}
pn.serve(ROUTES, port=5006, autoreload=True)
11 Likes

Hi @Marc,

Thanks for all the great Panel content! I really like your Option 3 here (Use a widget to navigate between pages in a single page application), but when I try it with panel 1.0.0rc6, I’m seeing the following unexpected behavior:

After I click the “Page 2” button and then click back to the “Page 1” button:

  • the URL updates back to “http://localhost:5007/app?page=Page+1” (expected), but
  • the main content stays “Page 2…more bla”, and does not change back to “Page 1…bla bla bla” (unexpected)

Is this some sort of regression, or should I be trying to achieve this functionality a different way in Panel1.0?

The code I’m using is below for completeness, though it’s just a copy and paste of your Option 3 above:

import panel as pn

pn.extension(sizing_mode="stretch_width")

pages = {
    "Page 1": pn.Column("# Page 1", "...bla bla bla"),
    "Page 2": pn.Column("# Page 2", "...more bla"),
}


def show(page):
    return pages[page]


starting_page = pn.state.session_args.get("page", [b"Page 1"])[0].decode()
page = pn.widgets.RadioButtonGroup(
    value=starting_page,
    options=list(pages.keys()),
    name="Page",
    sizing_mode="fixed",
    button_type="success",
)
ishow = pn.bind(show, page=page)
pn.state.location.sync(page, {"value": "page"})

ACCENT_COLOR = "#0072B5"
DEFAULT_PARAMS = {
    "site": "Panel Multi Page App",
    "accent_base_color": ACCENT_COLOR,
    "header_background": ACCENT_COLOR,
}
pn.template.FastListTemplate(
    title="As Single Page App",
    sidebar=[page],
    main=[ishow],
    **DEFAULT_PARAMS,
).servable()

…weirdly, I get the expected behavior if I copy the pages dictionary to be defined inside the show function itself. But I wouldn’t consider this an acceptable solution, because the code would get really messy/unmaintainable. One of the things I love about Panel from my brief experience is that the right way of doing something always seems very elegant and modular.

Still trying to figure out the right way to get this to work as expected in panel1.0.0rc6…

1 Like

ok, this slightly different approach works as expected for me on v1.0.0rc6:

I also think this code looks very clean and modular, so I’m happy to proceed from here. The one thing this approach doesn’t do is update the URL to reflect what ‘page’ the app is on with pn.state.location.sync. But that’s not a priority for me now, and I’m sure it could be incorporated into this approach if needed.

2 Likes

Replacing the Column objects seems to work:

import panel as pn

pn.extension(sizing_mode="stretch_width")

pages = {
    "Page 1": pn.Column("# Page 1", "...bla bla bla"),
    "Page 2": pn.Column("# Page 2", "...more bla"),
}


def show(page):
    main_column.objects = [pages[page]]


starting_page = pn.state.session_args.get("page", [b"Page 1"])[0].decode()
page = pn.widgets.RadioBoxGroup(
    value=starting_page,
    options=list(pages.keys()),
    name="Page",
    sizing_mode="fixed",
    button_type="success",
)
main_column = pn.Column()
pn.bind(show, page, watch=True)
page.param.trigger("value")
pn.state.location.sync(page, {"value": "page"})

ACCENT_COLOR = "#0072B5"
DEFAULT_PARAMS = {
    "site": "Panel Multi Page App",
    "accent_base_color": ACCENT_COLOR,
    "header_background": ACCENT_COLOR,
}
pn.template.FastListTemplate(
    title="As Single Page App",
    sidebar=[page],
    main=[main_column],
    **DEFAULT_PARAMS,
).servable()