Skip to content

Commit

Permalink
Merge pull request #104 from piercefreeman/feature/layout-controller-…
Browse files Browse the repository at this point in the history
…support

Initial support for layout controllers
  • Loading branch information
piercefreeman authored May 1, 2024
2 parents e89cd74 + dd576c4 commit d648f7d
Show file tree
Hide file tree
Showing 36 changed files with 595 additions and 79 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ test-project
_server
_ssr
_static
_metadata

# Ignore template files that might be used locally for testing
# but shouldn't be added to the downstream templates
Expand Down
2 changes: 2 additions & 0 deletions ci_webapp/ci_webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ci_webapp.controllers.complex import ComplexController
from ci_webapp.controllers.detail import DetailController
from ci_webapp.controllers.home import HomeController
from ci_webapp.controllers.root_layout import RootLayoutController
from ci_webapp.controllers.stream import StreamController

controller = AppController(
Expand All @@ -20,3 +21,4 @@
controller.register(DetailController())
controller.register(ComplexController())
controller.register(StreamController())
controller.register(RootLayoutController())
23 changes: 23 additions & 0 deletions ci_webapp/ci_webapp/controllers/root_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from mountaineer import LayoutControllerBase, RenderBase
from mountaineer.actions import sideeffect


class RootLayoutRender(RenderBase):
layout_value: int


class RootLayoutController(LayoutControllerBase):
view_path = "/app/layout.tsx"

def __init__(self):
super().__init__()
self.layout_value = 0

def render(self) -> RootLayoutRender:
return RootLayoutRender(
layout_value=self.layout_value,
)

@sideeffect
async def increment_layout_value(self) -> None:
self.layout_value += 1
25 changes: 25 additions & 0 deletions ci_webapp/ci_webapp/views/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { ReactNode } from "react";
import { useServer } from "./_server";

const Layout = ({ children }: { children: ReactNode }) => {
const serverState = useServer();

return (
<div className="p-6">
<h1>Layout State: {serverState.layout_value}</h1>
<div>{children}</div>
<div>
<button
className="rounded-md bg-indigo-500 p-2 text-white"
onClick={async () => {
await serverState.increment_layout_value();
}}
>
Layout increment
</button>
</div>
</div>
);
};

export default Layout;
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
set wildignore+=*/_server/*
set wildignore+=*/_ssr/*
set wildignore+=*/_static/*
set wildignore+=*/_metadata/*
set path-=*/_server/**
set path-=*/_ssr/**
set path-=*/_static/**
{% endif %}
set path-=*/_metadata/**
{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"files.exclude": {
"_server/": true,
"_ssr/": true,
"_metadata/": true,
"_static/": true
}
}
{% endif %}
{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ node_modules
_server
_ssr
_static
_metadata
.watchdog.lock

# General Python excludes
Expand Down
4 changes: 4 additions & 0 deletions docs_website/docs/api/controller.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# View Controller

::: mountaineer.controller.ControllerBase

# Layout Controller

::: mountaineer.controller_layout.LayoutControllerBase
1 change: 1 addition & 0 deletions docs_website/docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ First, add the following to your `.dockerignore` file. This will prevent Docker
**/_server
**/_ssr
**/_static
**/_metadata
```

### Image 1: Frontend Dependencies
Expand Down
4 changes: 2 additions & 2 deletions docs_website/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,9 @@ export default Home;

Go ahead and load it in your browser. If you open up your web tools, you can create a new Todo and see POST requests sending data to the backend and receiving the current server state. The actual data updates and merging happens internally by Mountaineer.

![Getting Started Final TODO App](/media/final_todo_list.png){ height="400" }
![Getting Started Final TODO App](media/final_todo_list.png){ height="400" }

![Getting Started Final TODO App](/media/network_debug.png){ height="400" }
![Getting Started Final TODO App](media/network_debug.png){ height="400" }

You can use these serverState variables anywhere you'd use dynamic React state variables (useEffect, useCallback, etc). But unlike React state, these variables are automatically updated when a relevant sideeffect is triggered.

Expand Down
79 changes: 77 additions & 2 deletions docs_website/docs/views.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Frontend Views
# Frontend 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 @@ -84,7 +84,82 @@ This allows you to chain layouts before rendering the final, most specific page:
/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 layouts should only provide styling. Since the use of `useServer` would be ambiguous for layouts that are rendered by multiple components, they should not contain any logic or data fetching.
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

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.

```python title="/controllers/root_layout.py"
from mountaineer import LayoutControllerBase, RenderBase
from mountaineer.actions import sideeffect

class RootLayoutRender(RenderBase):
layout_value: int

class RootLayoutController(LayoutControllerBase):
view_path = "/app/layout.tsx"

def __init__(self):
super().__init__()
self.layout_value = 0

def render(self) -> RootLayoutRender:
return RootLayoutRender(
layout_value=self.layout_value,
)

@sideeffect
async def increment_layout_value(self) -> None:
self.layout_value += 1
```

All these functions are now exposed to the frontend layout, including the link generator, state, and any actions specified.

```typescript title="/views/app/layout.tsx"
import React, { ReactNode } from "react";
import { useServer } from "./_server";

const Layout = ({ children }: { children: ReactNode }) => {
const serverState = useServer();

return (
<div className="p-6">
<h1>Layout State: {serverState.layout_value}</h1>
<div>{children}</div>
<div>
<button
className="rounded-md bg-indigo-500 p-2 text-white"
onClick={async () => {
await serverState.increment_layout_value();
}}
>
Increase Ticker
</button>
</div>
</div>
);
};

export default Layout;
```

Once your controller is declared, you'll need to mount your layout into the AppController like you do for regular pages.

```python title="/app.py"
app_controller = AppController(...)
app_controller.register(RootLayoutController())
```

In general you can implement layout controllers just like you do for pages. But since they're shared across multiple pages there are a few important differences to keep in mind:

- Layout controllers will be rendered in an isolated scope. Sideeffects in one layout controller won't affect the others.
- Dependency injections are similarly isolated. They are run in an isolated, synthetic context and not with the same dependency injection parameters that the page uses.
- Layout controllers don't modify the page signature. Query params on layouts won't be extracted, for instance.

As long as you write your layout controllers without directly referencing the page that they might be wrapping, which is the case for most layouts, you should be good to go.

## Typescript Configuration

Expand Down
1 change: 1 addition & 0 deletions docs_website/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
description = ""
authors = ["Pierce Freeman <[email protected]>"]
readme = "README.md"
package-mode = false

[tool.poetry.dependencies]
python = "^3.11"
Expand Down
1 change: 1 addition & 0 deletions mountaineer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mountaineer.app import AppController as AppController
from mountaineer.config import ConfigBase as ConfigBase
from mountaineer.controller import ControllerBase as ControllerBase
from mountaineer.controller_layout import LayoutControllerBase as LayoutControllerBase
from mountaineer.dependencies import CoreDependencies as CoreDependencies
from mountaineer.exceptions import APIException as APIException
from mountaineer.js_compiler.postcss import PostCSSBundler as PostCSSBundler
Expand Down
23 changes: 19 additions & 4 deletions mountaineer/__tests__/actions/test_fields.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import AsyncIterator, Iterator, Optional, Type
from typing import AsyncIterator, Iterator, Optional, Type, cast

import pytest
from fastapi.responses import JSONResponse
Expand All @@ -15,6 +15,7 @@
get_function_metadata,
)
from mountaineer.actions.sideeffect import sideeffect
from mountaineer.controller import ControllerBase
from mountaineer.render import Metadata, RenderBase


Expand All @@ -27,6 +28,11 @@ class ExamplePassthroughModel(BaseModel):
passthrough_value_a: str


class ExampleController(ControllerBase):
async def render(self) -> None:
pass


def basic_compare_model_fields(
actual: dict[str, FieldInfo],
expected: dict[str, FieldInfo],
Expand Down Expand Up @@ -113,8 +119,16 @@ def test_fuse_metadata_to_response_typehint(
expected_sideeffect_fields: dict[str, FieldInfo],
expected_passthrough_fields: dict[str, FieldInfo],
):
fused_model = fuse_metadata_to_response_typehint(metadata, render_model)
sample_controller = ExampleController()
raw_model = fuse_metadata_to_response_typehint(
metadata, sample_controller, render_model
)

assert "ExampleController" in raw_model.model_fields.keys()

fused_model = cast(
BaseModel, raw_model.model_fields["ExampleController"].annotation
)
if expected_sideeffect_fields:
assert "sideeffect" in fused_model.model_fields.keys()
assert fused_model.model_fields["sideeffect"].annotation
Expand All @@ -131,7 +145,7 @@ def test_fuse_metadata_to_response_typehint(
expected_passthrough_fields,
)

assert fused_model.__name__ == expected_model_name
assert raw_model.__name__ == expected_model_name


class ParentRender(RenderBase):
Expand All @@ -143,7 +157,7 @@ class ChildRender(ParentRender):


def test_fuse_metadata_to_response_typehint_inherit_render():
class ParentController(BaseModel):
class ParentController(ControllerBase):
def render(self) -> ParentRender:
return ParentRender(render_value_a="example")

Expand All @@ -157,6 +171,7 @@ def render(self) -> ChildRender:

model_fused = fuse_metadata_to_response_typehint(
get_function_metadata(ChildController.sideeffect),
ChildController(),
ChildRender,
)
assert set(
Expand Down
8 changes: 5 additions & 3 deletions mountaineer/__tests__/actions/test_passthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ async def call_passthrough_async(

# The response payload should be the same both both sync and async endpoints
expected_response = {
"passthrough": ExamplePassthroughModel(
status="success",
)
"TestController": {
"passthrough": ExamplePassthroughModel(
status="success",
)
}
}

assert return_value_sync == expected_response
Expand Down
18 changes: 11 additions & 7 deletions mountaineer/__tests__/actions/test_sideeffect.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,13 @@ async def mock_get_render_parameters(*args, **kwargs):

# The response payload should be the same both both sync and async endpoints
expected_response = {
"sideeffect": ExampleRenderModel(
value_a="Hello",
value_b="World",
),
"passthrough": None,
controller.__class__.__name__: {
"sideeffect": ExampleRenderModel(
value_a="Hello",
value_b="World",
),
"passthrough": None,
}
}

assert return_value_sync == expected_response
Expand Down Expand Up @@ -277,8 +279,10 @@ def call_sideeffect(self, payload: dict) -> None:
elapsed = (monotonic_ns() - start) / 1e9
assert response.status_code == 200
assert response.json() == {
"sideeffect": {
"value_a": "Hello 1229",
"ExampleController": {
"sideeffect": {
"value_a": "Hello 1229",
}
}
}

Expand Down
Loading

0 comments on commit d648f7d

Please sign in to comment.