Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,18 @@ Service code should not call `session.commit()` directly. Flush for intermediate

- `auth.user`, `auth.isAuthenticated`, `auth.permissions` (expanded from roles).
- `menus` — grouped by `MenuSection` (sidebar, adminSidebar, navbar, userDropdown), role-filtered.

Sidebar `order` follows banded ranges so installed modules sort into coherent groups:

| Band | Purpose | Built-in modules |
|-----------|---------------------|-------------------------------------------------------------|
| `10–99` | Content / domain | Dashboard (10), Products (20), Datasets (30), Files (40) |
| `100–199` | Administration | Users (100), Feature Flags (110), Background Tasks (120) |
| `200+` | System | Settings (200) |

Pick a band by audience, leave gaps of ~10 between siblings, and put module-specific user-dropdown items in the `900+` range (Profile=990, Logout=999).

Sidebar items can also set `group="<Label>"` on the `MenuItem` to render under a group header. The frontend clusters consecutive items with the same group label and prints the label as a section heading; the group's position is set by the lowest-`order` item that joins it. Built-in groups are `Content`, `Administration`, and `System`. Items with no `group` (the default) render flat — Dashboard intentionally stays ungrouped above the headed groups.
- `i18n` — active locale and translation bundle.

The framework does not know the shape of `auth.user`. A module (typically `users`) registers a `principal_serializer: Callable[[user], dict]` on `app.state` during `register_settings(app)`; the middleware calls it with `request.state.user` to build the `auth.user` payload. Without a registered serializer, `auth.user` is `None` even when a user is authenticated.
Expand Down
6 changes: 6 additions & 0 deletions framework/core/simple_module_core/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class MenuItem:
method: MenuItemMethod = "get"
"""HTTP method used when the item is activated. ``"post"`` renders as an
Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
group: str = ""
"""Sidebar group label. Empty = ungrouped (renders flat, no header).
Items in the same section that share a group are visually clustered under a
header in the order they already sort by ``order``; the group's own position
is set by the lowest-ordered item that belongs to it."""


class MenuRegistry:
Expand Down Expand Up @@ -83,6 +88,7 @@ def get_for_user(
"url": item.url,
"icon": item.icon,
"method": item.method,
"group": item.group,
}
)

Expand Down
14 changes: 14 additions & 0 deletions framework/core/tests/test_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,17 @@ async def test_icon_preserved(self):
reg.add(MenuItem(label="Home", url="/", icon="home"))
result = reg.get_for_user(is_authenticated=True)
assert result["sidebar"][0]["icon"] == "home"

async def test_group_default_empty(self):
reg = MenuRegistry()
reg.add(MenuItem(label="Home", url="/"))
result = reg.get_for_user(is_authenticated=True)
assert result["sidebar"][0]["group"] == ""

async def test_group_serialized(self):
reg = MenuRegistry()
reg.add(MenuItem(label="Users", url="/users", group="Administration"))
reg.add(MenuItem(label="Settings", url="/settings", group="System"))
result = reg.get_for_user(is_authenticated=True)
groups = [i["group"] for i in result["sidebar"]]
assert groups == ["Administration", "System"]
2 changes: 1 addition & 1 deletion modules/background_tasks/background_tasks/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# ── Menu ────────────────────────────────────────────────────────
MENU_LABEL = "Background Tasks"
MENU_ICON = "activity"
MENU_ORDER = 80
MENU_ORDER = 120

# ── Page identifiers ────────────────────────────────────────────
# Kept as literals at the call site (see endpoints/views.py) so
Expand Down
1 change: 1 addition & 0 deletions modules/background_tasks/background_tasks/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
order=MENU_ORDER,
section=MenuSection.SIDEBAR,
roles=["admin"],
group="Administration",
)
)

Expand Down
2 changes: 1 addition & 1 deletion modules/dashboard/dashboard/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
label="Dashboard",
url=_URL_DASHBOARD,
icon=_ICON_DASHBOARD,
order=1,
order=10,
section=MenuSection.SIDEBAR,
)
)
Expand Down
2 changes: 1 addition & 1 deletion modules/feature_flags/feature_flags/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
MENU_LABEL = "Feature Flags"
MENU_URL = "/feature_flags"
MENU_ICON = "flag"
MENU_ORDER = 45
MENU_ORDER = 110

PAGE_BROWSE = "FeatureFlags/Browse"

Expand Down
1 change: 1 addition & 0 deletions modules/feature_flags/feature_flags/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
icon=MENU_ICON,
order=MENU_ORDER,
section=MenuSection.SIDEBAR,
group="Administration",
)
)

Expand Down
1 change: 1 addition & 0 deletions modules/file_storage/file_storage/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
order=constants.MENU_ORDER,
section=MenuSection.SIDEBAR,
roles=list(constants.MENU_ROLES),
group="Content",
)
)

Expand Down
2 changes: 1 addition & 1 deletion modules/settings/settings/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
MENU_LABEL: Final = MODULE_NAME
MENU_URL: Final = VIEW_PREFIX
MENU_ICON: Final = "settings"
MENU_ORDER: Final = 30
MENU_ORDER: Final = 200

# ── Permissions ──────────────────────────────────────────────────────
PERM_GROUP: Final = MODULE_NAME
Expand Down
1 change: 1 addition & 0 deletions modules/settings/settings/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
icon=MENU_ICON,
order=MENU_ORDER,
section=MenuSection.SIDEBAR,
group="System",
)
)

Expand Down
3 changes: 2 additions & 1 deletion modules/users/users/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
label="Users",
url=_URL_USERS_ADMIN,
icon=_ICON_USERS,
order=30,
order=100,
section=MenuSection.SIDEBAR,
roles=[ADMIN_ROLE_NAME],
group="Administration",
)
)
# Self-service: profile + logout live in the user dropdown.
Expand Down
5 changes: 1 addition & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 55 additions & 25 deletions packages/ui/src/layouts/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,23 @@ import { ChevronsUpDown } from 'lucide-react';
import type React from 'react';
import { useState } from 'react';
import { NavIcon } from '../components/NavIcon';
import type { SharedProps } from '../types';
import type { MenuItem, SharedProps } from '../types';

function groupMenuItems(items: MenuItem[]): { group: string; items: MenuItem[] }[] {
const groups: { group: string; items: MenuItem[] }[] = [];
const indexByGroup = new Map<string, number>();
for (const item of items) {
const key = item.group ?? '';
let idx = indexByGroup.get(key);
if (idx === undefined) {
idx = groups.length;
indexByGroup.set(key, idx);
groups.push({ group: key, items: [] });
}
groups[idx].items.push(item);
}
return groups;
}

interface SidebarTheme {
sidebarBg: string;
Expand Down Expand Up @@ -143,30 +159,44 @@ export function SidebarLayout({
{headerSlot}

{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{menuItems.map((item, index) => {
const isActive = currentUrl.startsWith(item.url);
return (
<Tooltip key={item.url}>
<TooltipTrigger asChild>
<Link
href={item.url}
onClick={closeSidebar}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive ? theme.activeClass : theme.inactiveClass
}`}
style={{ animationDelay: `${index * 50}ms` }}
>
<NavIcon name={item.icon} />
{item.label}
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="lg:hidden">
{item.label}
</TooltipContent>
</Tooltip>
);
})}
<nav className="flex-1 px-3 py-4 overflow-y-auto">
{groupMenuItems(menuItems).map((group, groupIndex) => (
<div
key={group.group || `__ungrouped_${groupIndex}`}
className={groupIndex === 0 ? 'space-y-0.5' : 'mt-4 space-y-0.5'}
>
{group.group && (
<div
className={`px-3 pb-1 text-xs font-semibold uppercase tracking-wider ${theme.mutedTextClass}`}
>
{group.group}
</div>
)}
{group.items.map((item, index) => {
const isActive = currentUrl.startsWith(item.url);
return (
<Tooltip key={item.url}>
<TooltipTrigger asChild>
<Link
href={item.url}
onClick={closeSidebar}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive ? theme.activeClass : theme.inactiveClass
}`}
style={{ animationDelay: `${index * 50}ms` }}
>
<NavIcon name={item.icon} />
{item.label}
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="lg:hidden">
{item.label}
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
{footerNavSlot}
</nav>

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface MenuItem {
url: string;
icon: string;
method?: 'get' | 'post';
group?: string;
}

export interface SharedProps {
Expand Down
1 change: 1 addition & 0 deletions scripts/_templates_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
icon="box",
order=30,
section=MenuSection.SIDEBAR,
group="Content",
)
)

Expand Down
Loading