Skip to content

Commit

Permalink
Merge pull request #106 from piercefreeman/feature/list-render-params
Browse files Browse the repository at this point in the history
Support list-based page query params
  • Loading branch information
piercefreeman authored May 2, 2024
2 parents d648f7d + ade72c2 commit 8415e29
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 25 deletions.
108 changes: 97 additions & 11 deletions docs_website/docs/views.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Frontend Views & Layouts
# Views & Layouts

Your React app should be initialized in the `/views` folder of your Mountaineer project. This is the directory where we look for package.json and tsconfig.json, and where esbuild looks for specific build-time overrides. In other words, the views folder should look just like your frontend application if you were building a Single-Page-App (SPA). It's just embedded within your larger Mountaineer project and rendered separately.

Expand Down Expand Up @@ -42,6 +42,92 @@ const Home = () => {
export default Home;
```

## Controllers

A controller backs a view in a 1:1 relationship. It provides the backend plumbing to render the view, and can also provide sideeffects and passthroughs actions. The main entrypoint into this is the `render` function, which is called on your initial view to serialize all the data that your frontend will need when displaying the initial state of the page. All your data heavy lifting (database queries, manipulation, etc) should go here.

```python title="/controllers/home.py"

from mountaineer import sideeffect, ControllerBase, RenderBase
from mountaineer.database import DatabaseDependencies

from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select

from myapp.models import TodoItem

class HomeRender(RenderBase):
todos: list[TodoItem]

class HomeController(ControllerBase):
url = "/"
view_path = "/app/home/page.tsx"

async def render(
self,
session: AsyncSession = Depends(DatabaseDependencies.get_db_session)
) -> HomeRender:
todos = await session.execute(select(TodoItem))

return HomeRender(
todos=todos.scalars().all()
)
```

### Path Parameters

The `render` function signature is inspected to provide the full URL that can be called. To provide a URL parameter that extracts a given ID identifier and matches the following:

```
/details/a687566b-db3e-42e3-9053-4f679abe8277
/details/4a4c26bc-554a-40dd-aecd-916abd3bc475
```

You can do:

```python
class DetailController(ControllerBase):
url = "/details/{item_id}"
view_path = "/app/details/page.tsx"

async def render(
self,
item_id: UUID,
) -> HomeRender:
...
```

### Query Parameters

Query parameters are also supported. We support both simple types (str, float, UUID, etc) alongside lists of simple types. To provide a query parameter that matches the following:

```
/search?name=Apple&cost=30&cost=50
/search?name=Banana
```

You can do:

```python
from typing import Annotated
from fastapi import Query

class SearchController(ControllerBase):
url = "/search"
view_path = "/app/search/page.tsx"

async def render(
self,
name: str,
cost: Annotated[list[int] | None, Query()] = None,
) -> HomeRender:
...
```

!!! tip

Both path and query parameters are validated by FastAPI, so you can use the same validation techniques. For more details, see the FastAPI [guide](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/).

## Layouts

We also support the Next.js `layout.tsx` convention, which is a special file that will be used to wrap all containing views in a common layout. This is useful for things like headers, footers, and other common elements.
Expand Down Expand Up @@ -73,22 +159,22 @@ export default Layout;
This allows you to chain layouts before rendering the final, most specific page:

```
/views
/app
/dashboard
/layout.tsx
/home
/page.tsx
/settings
/page.tsx
/layout.tsx
views/
└── app/
├── dashboard/
│ ├── layout.tsx
│ ├── home/
│ └── page.tsx
│ └── settings/
└── page.tsx
└── layout.tsx
```

When rendering `dashboard/home/page.tsx`, the view will be wrapped in the `app/dashboard/layout.tsx` layout alongside `app/layout.tsx`. These layout files will be automatically found by Mountaineer during the build process. They don't require any explicit declaration in your Python backend if you're just using them for styling.

If you need more server side power and want to define them in Python, you can add a LayoutController that backs the layout.

## Layout Controllers
### Layout Controllers

Layouts support most of the same controller logic that regular pages do. They can specify their own actions, both sideeffects and passthroughs, which will re-render the layout as required.

Expand Down
2 changes: 1 addition & 1 deletion docs_website/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ nav:
- Quickstart: quickstart.md
- Learn:
- structure.md
- client_actions.md
- views.md
- client_actions.md
- metadata.md
- database.md
- error_handling.md
Expand Down
19 changes: 13 additions & 6 deletions mountaineer/__tests__/client_builder/test_build_links.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from enum import StrEnum
from re import sub as re_sub
from typing import Callable
from typing import Annotated, Callable
from uuid import UUID

import pytest
from fastapi import APIRouter
from fastapi import APIRouter, Query
from fastapi.openapi.utils import get_openapi

from mountaineer.client_builder.build_links import OpenAPIToTypescriptLinkConverter
Expand All @@ -18,7 +18,11 @@ def view_endpoint_path_params(path_a: str, path_b: int):
pass


def view_endpoint_query_params(query_a: str, query_b: int | None = None):
def view_endpoint_query_params(
query_a: str,
query_b: int | None = None,
query_c: Annotated[list[int] | None, Query()] = None,
):
pass


Expand Down Expand Up @@ -94,15 +98,18 @@ def enum_view_url(model_type: RouteType, model_id: UUID):
"""
export const getLink = ({
query_a,
query_b
query_b,
query_c
}:{
query_a: string,
query_b?: null | number
query_b?: null | number,
query_c?: Array<number> | null
}) => {
const url = `/query_params`;
const queryParameters: Record<string,any> = {
query_a,
query_b
query_b,
query_c
};
const pathParameters: Record<string,any> = {};
return __getLink({
Expand Down
18 changes: 18 additions & 0 deletions mountaineer/__tests__/client_builder/test_typescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def test_collapse_repeated_literals(
@pytest.mark.parametrize(
"url_parameter, expected_ts_key, expected_ts_type",
[
# Single typed URL parameter
(
URLParameterDefinition.from_meta(
name="test",
Expand All @@ -117,6 +118,7 @@ def test_collapse_repeated_literals(
"test",
"string",
),
# Multiple types for a single URL parameter
(
URLParameterDefinition.from_meta(
name="test",
Expand All @@ -137,6 +139,22 @@ def test_collapse_repeated_literals(
"test",
"number | string",
),
# Support for list-based values in the URL string
(
URLParameterDefinition.from_meta(
name="test",
required=True,
schema_ref=OpenAPIProperty.from_meta(
variable_type=OpenAPISchemaType.ARRAY,
items=OpenAPIProperty.from_meta(
variable_type=OpenAPISchemaType.STRING
),
),
in_location=ParameterLocationType.PATH,
),
"test",
"Array<string>",
),
],
)
def test_get_typehint_for_parameter(
Expand Down
26 changes: 20 additions & 6 deletions mountaineer/client_builder/typescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ def get_types_from_parameters(
if isinstance(schema, EmptyAPIProperty):
return "any"

# Mutually exclusive definitions
if schema.enum:
yield " | ".join([f"'{enum_value}'" for enum_value in schema.enum])
elif schema.variable_type:
if schema.variable_type == OpenAPISchemaType.ARRAY:
child_typehint = " | ".join(
str(value)
for value in (
get_types_from_parameters(schema.items, base_openapi_spec)
if schema.items
else ["any"]
)
)
yield f"Array<{child_typehint}>"
# This call should completely wrap all of the sub-types, so we don't
# allow ourselves to continue down the tree.
return
else:
yield map_openapi_type_to_ts(schema.variable_type)

# Recursively gather all of the types that might be nested
for property in schema.properties.values():
yield from get_types_from_parameters(property, base_openapi_spec)
Expand All @@ -116,12 +136,6 @@ def get_types_from_parameters(
for one_of in schema.anyOf:
yield from get_types_from_parameters(one_of, base_openapi_spec)

# Mutually exclusive definitions
if schema.enum:
yield " | ".join([f"'{enum_value}'" for enum_value in schema.enum])
elif schema.variable_type:
yield map_openapi_type_to_ts(schema.variable_type)

# If we're able to resolve the ref, do so. Some clients call this to get a limited
# scope of known parameters, so this value is optional.
if schema.ref:
Expand Down
11 changes: 10 additions & 1 deletion mountaineer/static/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,16 @@ export const __getLink = (params: GetLinkParams) => {
// access to the URLSearchParams API.
const parsedParams = Object.entries(params.queryParameters).reduce(
(acc, [key, value]) => {
if (value !== undefined) {
if (value === undefined) {
return acc;
}

// If we've been given an array, we want separate key-value pairs for each element
if (Array.isArray(value)) {
for (const element of value) {
acc.push(`${key}=${element}`);
}
} else {
acc.push(`${key}=${value}`);
}
return acc;
Expand Down

0 comments on commit 8415e29

Please sign in to comment.