The admin sidebar as data — collected from plugins at runtime, filtered by what the viewer may see, and ordered by what the admin arranged.
The nav used to be a hardcoded literal in MishkaCmsCore.Layouts: every extension's links were
spelled out in the core, and every admin who reached the console saw all of them, including
/admin/dashboard/account/permissions. This module replaces both halves. An extension declares
its own menu and who may see it; nothing in the core names the extension.
The event
"sidebar:menus", a MishkaInstaller.Event.Hook — the sibling of "dashboard:widgets". The
payload is %{groups: [Group.t()], items: [Item.t()]} and every plugin appends to either list:
defmodule MishkaBlogWeb.SidebarMenu do
use MishkaInstaller.Event.Hook, event: "sidebar:menus", initial: %{priority: 40}
use Gettext, backend: MishkaCmsCore.Gettext
@items [
MishkaCmsCore.Sidebar.Item.new!(%{
key: "blog",
label: gettext_noop("Blog"),
icon: "hero-book-open",
path: "/admin/dashboard/blog",
module: MishkaBlogWeb.Live.Admin.Dashboard.Home.Index,
group: "plugins",
order: 10,
permissions: [
"admin:dashboard:blog:post",
"admin:dashboard:blog:comment"
],
credit_name: "Mishka Blog",
credit_url: "https://mishka.tools/cms/extensions/blog"
})
]
@impl true
def call(%{items: items} = data), do: {:reply, %{data | items: items ++ @items}}
endA label is marked with gettext_noop/1 and translated when the sidebar is drawn, in the language
its admin reads the dashboard in: a descriptor is built at compile time, when nobody's language is
known yet.
Groups and items are two flat lists rather than a nested tree so that a group and its contents can come from different extensions — Blog and Documents each contribute one item to the Plugins group the core declares, without either knowing about the other.
Unlike widgets, the payload is seeded with core_menu/0 instead of an empty list. A widget board
with no plugins registered is merely empty; a nav with no plugins registered is a console you
cannot navigate — so Home, Runtime, Account and Settings are always present, whether or not
mishka_installer has finished starting. Enum.uniq_by/2 keeps the FIRST declaration of a key, so
a plugin can add sections but never silently redefine a core one.
Permissions
permissions on an item is a list where holding ANY entry grants; menu/1 compares it against
the viewer and drops the link when nothing matches. Nothing here reads a URL: the question is
"may this user see this link", and the answer needs the user and the item, nothing else.
An entry names the area its data asks for, as a plain string:
- a screen of one area — Sites, Users, Permissions, Activity, an extension's section — lists
that area's name:
["admin:dashboard:runtime:site"]; - a content list every dashboard admin may read — Pages, Layouts, Components, Error Pages,
Stylesheets, JS Hooks, Media, Translations — lists its area and
{:under, "admin:dashboard"}; - the builder lists every name that opens a builder — the page, layout, component and error
page areas and
admin:dashboard:code— and the builder asks for the type it opens on; - Home, Notifications, Settings and the Plugins section list
{:under, "admin:dashboard"}.
An API admin holds nothing under admin:dashboard, so it sees no entry and opens no page.
This hides links; it does not guard pages. A hidden link is still reachable by typing its URL. Guarding the page is the page's own job — its LiveView declares who may open it — because that is a server-side check on a known module rather than a string the client controls.
Arrangement
Each group and item exposes two settings, registered by MishkaCmsCore.Sidebar.MenuSettings:
sidebar.<key>.order— an integer; overrides the declaredordersidebar.<key>.visible— a boolean;falsehides the entry from everyone
Both are scope: :both, so a global default can be overridden per site: one CMS can put Blog
above Runtime for one tenant without touching another. Values are read through
MishkaCmsCore.Settings.get/3 (RAM cache), and a non-integer order or non-boolean visibility is
ignored in favour of the declaration rather than crashing the chrome.
Open pages
changed/1 tells this node's open admin LiveViews that the entries' settings changed, and
MishkaCmsCore.Layouts.Nav redraws their sidebar. MishkaCmsCore.Runtime.States.SettingsState
calls it after it holds a sidebar.* value or an extension's <app>.active switch
(MishkaCmsCore.Install.Extensions.active?/2), so a view that reads the menu again reads the new
value. A site's change reaches the viewers whose menu reads that site, and the global one reaches
every viewer.
Event: runtime:state:sidebar:changed — %{key: site_id}, nil for the global value.
Like the widget board, nothing here is cached: the hook is called at each mount so enabling or disabling a plugin shows up immediately. Everything else is compile-time data and ETS reads, and the viewer's permissions are loaded once per mount rather than once per item.
What is in this file
The whole concern, in one place: Group and Item, the two descriptors a plugin declares. They
are small, they are useless apart, and reading the contract next to the code that applies it is
the point.
The order is load-bearing: both come FIRST because @core_groups/@core_items call their
new!/1 at compile time — a descriptor is built once when this module is compiled, not on every
admin mount. The settings registrar stays in its own file (MishkaCmsCore.Sidebar.MenuSettings)
because it is a supervised plugin, like its widget twin.
Summary
Functions
Tells this node's open admin LiveViews that the sidebar settings of site_id changed; nil for
the global ones, which every viewer reads.
Every declared group and item — core plus every "sidebar:menus" plugin — with anything that is
not a valid descriptor discarded and the first declaration of a key winning.
The core's own menu — Home, the Runtime and Account sections, Plugins (the section extensions hang
their entry point in), and Settings. This is the payload collect/0 seeds the event with, so the
console is navigable even with no plugin registered.
The name and address of the extension that owns the admin page module mounts, for the "Powered
by" line under it: %{name: name, url: url} from the credit_name and credit_url one of that
extension's entries declares. nil for a core page, a page no entry declares, and an extension
that declares both of them on no entry.
The mishka_installer event extensions register their menus on.
The console's Home entry — where someone refused a page is sent back to.
Where user should be sent when a page will not have them — Home if they may see the console, the
site root otherwise.
What user may see, ready to render: %{main: [...], footer: [...]}, each a list of
Group.t() (with items filled in) and top-level Item.t() interleaved in display order.
Whether user may open the admin page at path, as its sidebar entry declares: true for a path
no entry declares, since a link to it is not the sidebar's to judge.
The settings key holding key's position override.
The permissions declared for one of the CORE's pages. See permissions/2.
The permissions declared for key, raising when nothing declares it.
The permissions declared for the LiveView module, or [] when nothing declares it.
The order/visible setting definitions for every declared entry — what
MishkaCmsCore.Sidebar.MenuSettings registers with the Settings page.
Subscribes the calling process to the changes changed/1 announces for the menu user is shown:
the global ones and those of the site the menu reads. It receives {:sidebar_changed, site_id}.
The settings key holding key's on/off switch.
visible_to?/3 against the site the USER is on — their own, or the master site for a master.
Whether user on site_id sees entry — the per-entry setting, the site's switch over the
extension that owns it, and the permissions it declares.
Functions
@spec changed(String.t() | nil) :: :ok
Tells this node's open admin LiveViews that the sidebar settings of site_id changed; nil for
the global ones, which every viewer reads.
Sidebar.changed(site.id)
@spec collect() :: %{ groups: [MishkaCmsCore.Sidebar.Group.t()], items: [MishkaCmsCore.Sidebar.Item.t()] }
Every declared group and item — core plus every "sidebar:menus" plugin — with anything that is
not a valid descriptor discarded and the first declaration of a key winning.
Unfiltered: this is the registry, not what a given admin sees. Use menu/1 to render.
The name and address of the extension that owns the admin page module mounts, for the "Powered
by" line under it: %{name: name, url: url} from the credit_name and credit_url one of that
extension's entries declares. nil for a core page, a page no entry declares, and an extension
that declares both of them on no entry.
@spec event() :: String.t()
The mishka_installer event extensions register their menus on.
@spec home() :: MishkaCmsCore.Sidebar.Item.t()
The console's Home entry — where someone refused a page is sent back to.
Read rather than hardcoded so the landing page and its gate cannot drift from the declaration everything else uses.
Where user should be sent when a page will not have them — Home if they may see the console, the
site root otherwise.
Bouncing someone to a page they would also be refused is how a redirect loop starts, so the same
Home declaration that gates the console decides whether the console is a valid destination. Read
through home/0 rather than hardcoded, so the landing and its gate cannot drift.
Used by MishkaCmsCore.Sidebar.PageGuard for a refusal and by the sign-out page for its "stay
signed in" way out — both are the same question.
Whether user may open the admin page at path, as its sidebar entry declares: true for a path
no entry declares, since a link to it is not the sidebar's to judge.
The settings key holding key's position override.
@spec permissions(String.t()) :: [ MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement() ]
The permissions declared for one of the CORE's pages. See permissions/2.
@spec permissions([MishkaCmsCore.Sidebar.Item.t()], String.t()) :: [ MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement() ]
The permissions declared for key, raising when nothing declares it.
This is the single place a page's gate is written down. A page reads its own:
on_mount {MishkaCmsCore.LiveUserAuth,
{:require_permission, MishkaBlogWeb.SidebarMenu.permissions("blog.posts")}}It raises rather than returning [] on a miss, and it takes a STATIC list rather than calling
collect/0: both are about the same failure. A page reads this at COMPILE time, when no plugin
has registered on the hook yet — so collect/0 would answer [], and a silent [] would mean
a page that gates nothing. A typo has to break the build, not the gate.
@spec permissions_for_module(module() | nil) :: [ MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement() ]
The permissions declared for the LiveView module, or [] when nothing declares it.
[] means "no declaration", and MishkaCmsCore.Sidebar.PageGuard treats that as REFUSE, not as
"no requirement" — an admin page nobody declared is a page nobody vouched for. Forgetting a
declaration therefore locks a page rather than opening it, and the per-extension tests fail the
build long before anyone meets that in a browser.
Looked up by module rather than by path on purpose: a path is a string the client spells (and can spell several ways), a module is what the router already resolved.
@spec settings_definitions() :: [MishkaCmsCore.Settings.definition()]
The order/visible setting definitions for every declared entry — what
MishkaCmsCore.Sidebar.MenuSettings registers with the Settings page.
Each definition inherits the entry's own permissions, so an admin is never offered a switch for
a menu they cannot see, and items are grouped under their section's heading. An entry every
dashboard admin sees ({:under, "admin:dashboard"}) is arranged by the holders of
admin:dashboard:setting alone, even when it also names an area.
@spec subscribe(map() | nil) :: :ok
Subscribes the calling process to the changes changed/1 announces for the menu user is shown:
the global ones and those of the site the menu reads. It receives {:sidebar_changed, site_id}.
The settings key holding key's on/off switch.
visible_to?/3 against the site the USER is on — their own, or the master site for a master.
This is what menu/1 asks, so a caller checking one entry gets the same answer the menu would
rather than the global one.
Whether user on site_id sees entry — the per-entry setting, the site's switch over the
extension that owns it, and the permissions it declares.
Public because it is the whole of "may this be drawn", and a caller asking about one entry should not have to rebuild the menu to find out.