Skip to content

Commit

Permalink
New Menu abstraction (#1744)
Browse files Browse the repository at this point in the history
* WIP menu abstraction

* Spellcheck menu
  • Loading branch information
rafalp committed Mar 16, 2024
1 parent a494b8d commit abad4f0
Show file tree
Hide file tree
Showing 4 changed files with 390 additions and 7 deletions.
20 changes: 13 additions & 7 deletions dev-docs/index.md
Expand Up @@ -5,13 +5,6 @@ This directory contains reference documents for Misago developers.
> **Note:** This documentation is a temporary solution. In future I aim to generate Misago's dev documentation from it's codebase, and use Docusaurus to both version it and make it more attractive to browse.

## Notifications

Misago's notifications feature is implemented in the `misago.notifications` package.

- [Notifications reference](./notifications.md)


## Markup parser

Misago's markup syntax, parser and renderers that convert parsed markup into an HTML or other representations are implemented in the `misago.parser` package.
Expand All @@ -20,6 +13,19 @@ Misago's markup syntax, parser and renderers that convert parsed markup into an
- [Markup AST](./parser/ast.md)


## Menus

Some of Misago menus can be extended with additional items from code.

- [Menus reference](./menus.md)


## Notifications

Misago's notifications feature is implemented in the `misago.notifications` package.

- [Notifications reference](./notifications.md)

## Plugins

Misago implements a plugin system that extends [Django's existing application mechanism](https://docs.djangoproject.com/en/4.2/ref/applications/), allowing developers to customize and extend standard features.
Expand Down
101 changes: 101 additions & 0 deletions dev-docs/menus.md
@@ -0,0 +1,101 @@
Menus
=====

`Menu` based menus
------------------

Some menus in Misago are using the `Menu` class importable from the `misago.menus.menu` module. These menus include:

- Account settings menu
- Profile sections menu
- Users lists menu


### Creating custom menu with the `Menu`

To create custom menu with the `Menu` class, instantiate it somewhere in your package:

```python
# my_plugin/menu.py
from django.utils.translation import pgettext_lazy
from misago.menus.menu import Menu

plugin_menu = Menu()
```

Next, add new menu items to it:

```python
# my_plugin/menu.py
from misago.menus.menu import Menu

plugin_menu = Menu()

plugin_menu.add_item(
key="scores",
url_name="my-plugin:scores",
label=pgettext_lazy("my plugin menu", "Scores"),
)
plugin_menu.add_item(
key="totals",
url_name="my-plugin:totals",
label=pgettext_lazy("my plugin menu", "Totals"),
)
```

To get list of menu items to display in template, call the `get_items` method with Django's `HttpRequest` instance:

```python
# my_plugin.views.py
from django.shortcuts import render

from .menu import plugin_menu

def my_view(request):
render(request, "my_plugin/template.html", {
"menu": plugin_menu.get_items(request)
})
```


### `Menu.add_item` method

`Menu.add_item` method adds new item to the menu. It requires following named arguments:

- `key`: a `str` identifying this menu item. Must be unique in the whole menu.
- `url_name`: a `str` with URL name to `reverse` into final URL.
- `label`: a `str` or lazy string object with menu item's label.

`add_item` method also accepts following optional named arguments:

- `icon`: a `str` with icon to use for this menu item.
- `visible`: a `callable` accepting single argument (Django's `HttpRequest` instance) that should return `True` if this item should be displayed.

By default, menu items are added at the end of menu. To insert item before or after the other one, pass it's key to the `after` and `before` optional named argument:

```python
plugin_menu.add_item(
key="scores",
url_name="my-plugin:scores",
label=pgettext_lazy("my plugin menu", "Scores"),
)

# Totals will be inserted before Scores
plugin_menu.add_item(
key="totals",
url_name="my-plugin:totals",
label=pgettext_lazy("my plugin menu", "Totals"),
before="scores",
)
```


### `Menu.get_items` method

`Menu.get_items` requires single argument, an instance of Django's `HttpRequest`, and returns a Python `list` of all visible menu items with their URL names reversed to URLs and `label`s casted to `str`. Each list item is an instance of frozen dataclass with following attributes:

- `active`: a `bool` specifying if this item is currently active.
- `key`: a `str` with item's key.
- `url`: a `str` with reversed URL.
- `label`: a `str` with item's label.
- `icon`: a `str` with item's icon or `None`.
98 changes: 98 additions & 0 deletions misago/menus/menu.py
@@ -0,0 +1,98 @@
from dataclasses import dataclass
from typing import Callable, Optional

from django.http import HttpRequest
from django.urls import reverse


class Menu:
__slots__ = ("items",)

def __init__(self):
self.items: list["MenuItem"] = []

def add_item(
self,
*,
key: str,
url_name: str,
label: str,
icon: str | None = None,
after: str | None = None,
before: str | None = None,
visible: Callable[[HttpRequest], bool] | None = None,
) -> "MenuItem":
if after and before:
raise ValueError("'after' and 'before' can't be used together.")

item = MenuItem(
key=key,
url_name=url_name,
label=label,
icon=icon,
visible=visible,
)

if after or before:
new_items: list["MenuItem"] = []
for other_item in self.items:
if other_item.key == after:
new_items.append(other_item)
new_items.append(item)
elif other_item.key == before:
new_items.append(item)
new_items.append(other_item)
else:
new_items.append(other_item)
self.items = new_items

if item not in self.items:
other_key = after or before
raise ValueError(f"Item with key '{other_key}' doesn't exist.")
else:
self.items.append(item)

return item

def get_items(self, request: HttpRequest) -> list["BoundMenuItem"]:
final_items: list["BoundMenuItem"] = []
for item in self.items:
if bound_item := item.bind_to_request(request):
final_items.append(bound_item)
return final_items


@dataclass(frozen=True)
class MenuItem:
__slots__ = ("key", "url_name", "label", "icon", "visible")

key: str
url_name: str
label: str
icon: str | None
visible: Callable[[HttpRequest], bool] | None

def bind_to_request(self, request: HttpRequest) -> Optional["BoundMenuItem"] | None:
if self.visible and not self.visible(request):
return None

reversed_url = reverse(self.url_name)

return BoundMenuItem(
active=request.path_info.startswith(reversed_url),
key=self.key,
url=reversed_url,
label=str(self.label),
icon=self.icon,
)


@dataclass(frozen=True)
class BoundMenuItem:
__slots__ = ("active", "key", "url", "label", "icon")

active: bool
key: str
url: str
label: str
icon: str | None

0 comments on commit abad4f0

Please sign in to comment.