Skip to content
Closed
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
8 changes: 8 additions & 0 deletions docs/api/commands/navigate.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ To navigate to another page, you can use `me.navigate`. This is particularly use
--8<-- "mesop/examples/navigate.py"
```

## Open in New Tab

You can open a URL in a new tab by setting the `open_in_new_tab` parameter to `True`:

```python
--8<-- "mesop/examples/testing/navigate_new_tab.py"
```

## API

::: mesop.commands.navigate.navigate
11 changes: 7 additions & 4 deletions mesop/commands/navigate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ def navigate(
url: str,
*,
query_params: dict[str, str | Sequence[str]] | QueryParams | None = None,
open_in_new_tab: bool = False,
) -> None:
"""
Navigates to the given URL.

Args:
url: The URL to navigate to.
query_params: A dictionary of query parameters to include in the URL, or `me.query_params`. If not provided, all current query parameters will be removed.
open_in_new_tab: Whether to open the URL in a new tab. Defaults to False.
"""
cleaned_url = remove_url_query_param(url)
if url != cleaned_url:
Expand All @@ -30,7 +32,8 @@ def navigate(
if isinstance(query_params, QueryParams):
query_params = {key: query_params.get_all(key) for key in query_params}

# Clear the query params because the query params will
# either be replaced with the new query_params or emptied (in server.py).
runtime().context().query_params().clear()
runtime().context().navigate(cleaned_url, query_params)
# Clear query params only for same-tab navigation to prepare for new params.
# For new-tab navigation, preserve current page state to avoid affecting it.
if not open_in_new_tab:
runtime().context().query_params().clear()
runtime().context().navigate(cleaned_url, query_params, open_in_new_tab)
1 change: 1 addition & 0 deletions mesop/examples/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from mesop.examples import integrations as integrations
from mesop.examples import many_checkboxes as many_checkboxes
from mesop.examples import named_nested_slot as named_nested_slot
from mesop.examples import navigate as navigate
from mesop.examples import navigate_absolute as navigate_absolute
from mesop.examples import navigate_advanced as navigate_advanced
from mesop.examples import nested as nested
Expand Down
3 changes: 3 additions & 0 deletions mesop/examples/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
from mesop.examples.testing import (
minimal_chat as minimal_chat,
)
from mesop.examples.testing import (
navigate_new_tab as navigate_new_tab,
)
from mesop.examples.testing import (
set_serialize as set_serialize,
)
Expand Down
50 changes: 50 additions & 0 deletions mesop/examples/testing/navigate_new_tab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import mesop as me


@me.page(path="/testing/navigate_new_tab")
def page():
me.text("Navigate to New Tab Example", type="headline-5")
me.text("Click the buttons below to test navigation in new tabs:")

with me.box(style=me.Style(margin=me.Margin.all(15))):
me.button("Open about page in new tab", on_click=navigate_to_about_new_tab)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I try to test this, a new tab opens to the right page. However the current page also changes to the same page as the new tab.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85d8365 - the issue was that query_params().clear() was being called even when opening in a new tab, which caused the current page to update. Now query params are only cleared when navigating in the same tab (open_in_new_tab=False).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it out and it still shows the same behavior.


with me.box(style=me.Style(margin=me.Margin.all(15))):
me.button("Open about page in same tab", on_click=navigate_to_about_same_tab)

with me.box(style=me.Style(margin=me.Margin.all(15))):
me.button(
"Open external URL in new tab", on_click=navigate_to_external_new_tab
)

with me.box(style=me.Style(margin=me.Margin.all(15))):
me.button(
"Open with query params in new tab",
on_click=navigate_with_query_params_new_tab,
)


def navigate_to_about_new_tab(e: me.ClickEvent):
me.navigate("/testing/navigate_new_tab_target", open_in_new_tab=True)


def navigate_to_about_same_tab(e: me.ClickEvent):
me.navigate("/testing/navigate_new_tab_target", open_in_new_tab=False)


def navigate_to_external_new_tab(e: me.ClickEvent):
me.navigate("https://example.com", open_in_new_tab=True)


def navigate_with_query_params_new_tab(e: me.ClickEvent):
me.navigate(
"/testing/navigate_new_tab_target",
query_params={"search": "test", "page": "1"},
open_in_new_tab=True,
)


@me.page(path="/testing/navigate_new_tab_target")
def navigate_new_tab_target():
me.text("Navigate New Tab Target Page", type="headline-4")
me.text("This is the target page opened from the navigation example.")
2 changes: 2 additions & 0 deletions mesop/protos/ui.proto
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ message NavigateCommand {
optional string url = 1;

repeated QueryParam query_params = 2;

optional bool open_in_new_tab = 3;
}

message UpdateQueryParam {
Expand Down
9 changes: 7 additions & 2 deletions mesop/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ def clear_commands(self) -> None:
self._commands = []

def navigate(
self, url: str, query_params: dict[str, str | Sequence[str]] | None = None
self,
url: str,
query_params: dict[str, str | Sequence[str]] | None = None,
open_in_new_tab: bool = False,
) -> None:
query_param_protos = None
if query_params is not None:
Expand All @@ -203,7 +206,9 @@ def navigate(
self._commands.append(
pb.Command(
navigate=pb.NavigateCommand(
url=full_url, query_params=query_param_protos
url=full_url,
query_params=query_param_protos,
open_in_new_tab=open_in_new_tab,
)
)
)
Expand Down
112 changes: 112 additions & 0 deletions mesop/tests/e2e/navigate_new_tab_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {test, expect} from '@playwright/test';

test('navigate with open_in_new_tab - relative URL', async ({page, context}) => {
await page.goto('/testing/navigate_new_tab');

// Get the current pages before clicking
const pagesBefore = context.pages().length;

// Click button to open about page in new tab
await page.getByRole('button', {name: 'Open about page in new tab'}).click();

// Wait for new tab to open
await context.waitForEvent('page');

// Check that a new page was opened
const pagesAfter = context.pages().length;
expect(pagesAfter).toBe(pagesBefore + 1);

// Get the new page
const newPage = context.pages()[pagesAfter - 1];

// Wait for navigation in new tab
await newPage.waitForURL('**/testing/navigate_new_tab_target');

// Verify the content in the new page
expect(await newPage.getByText('Navigate New Tab Target Page').textContent()).toContain(
'Navigate New Tab Target Page',
);

// Verify original page is still on the same URL
expect(page.url()).toContain('/testing/navigate_new_tab');
});

test('navigate with open_in_new_tab - external URL', async ({
page,
context,
}) => {
await page.goto('/testing/navigate_new_tab');

// Get the current pages before clicking
const pagesBefore = context.pages().length;

// Click button to open external URL in new tab
await page
.getByRole('button', {name: 'Open external URL in new tab'})
.click();

// Wait for new tab to open
await context.waitForEvent('page');

// Check that a new page was opened
const pagesAfter = context.pages().length;
expect(pagesAfter).toBe(pagesBefore + 1);

// Get the new page
const newPage = context.pages()[pagesAfter - 1];

// Wait for navigation in new tab to example.com
await newPage.waitForURL('https://example.com/**', {timeout: 5000});

// Verify original page is still on the same URL
expect(page.url()).toContain('/testing/navigate_new_tab');
});

test('navigate with open_in_new_tab false - same tab', async ({page}) => {
await page.goto('/testing/navigate_new_tab');

// Click button to open about page in same tab
await page
.getByRole('button', {name: 'Open about page in same tab'})
.click();

// Wait for navigation in same tab
await page.waitForURL('**/testing/navigate_new_tab_target');

// Verify the content changed in the same tab
expect(await page.getByText('Navigate New Tab Target Page').textContent()).toContain(
'Navigate New Tab Target Page',
);
});

test('navigate with query params in new tab', async ({page, context}) => {
await page.goto('/testing/navigate_new_tab');

// Get the current pages before clicking
const pagesBefore = context.pages().length;

// Click button to open with query params in new tab
await page
.getByRole('button', {name: 'Open with query params in new tab'})
.click();

// Wait for new tab to open
await context.waitForEvent('page');

// Check that a new page was opened
const pagesAfter = context.pages().length;
expect(pagesAfter).toBe(pagesBefore + 1);

// Get the new page
const newPage = context.pages()[pagesAfter - 1];

// Wait for navigation in new tab
await newPage.waitForURL('**/testing/navigate_new_tab_target?search=test&page=1');

// Verify query params are in the URL
expect(newPage.url()).toContain('search=test');
expect(newPage.url()).toContain('page=1');

// Verify original page is still on the same URL
expect(page.url()).toContain('/testing/navigate_new_tab');
});
15 changes: 12 additions & 3 deletions mesop/web/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,20 @@ export class Shell {
},
onCommand: async (command) => {
if (command.hasNavigate()) {
const url = command.getNavigate()!.getUrl()!;
if (url.startsWith('http://') || url.startsWith('https://')) {
const navigateCommand = command.getNavigate()!;
const url = navigateCommand.getUrl()!;
const openInNewTab = navigateCommand.getOpenInNewTab();

if (openInNewTab) {
// For relative URLs, resolve to absolute URL before opening in new tab
const absoluteUrl = url.startsWith('http://') || url.startsWith('https://')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's potentially other url formats that are absolute. Is there a more robust way to distinguish this? This is ok to ignore since we use similar pattern for the other case.

? url
: new URL(url, window.location.href).href;
window.open(absoluteUrl, '_blank');
} else if (url.startsWith('http://') || url.startsWith('https://')) {
window.location.href = url;
} else {
await this.router.navigateByUrl(command.getNavigate()!.getUrl()!);
await this.router.navigateByUrl(url);
this.channel.resetOverridedTitle();
}
} else if (command.hasScrollIntoView()) {
Expand Down