Current section

Files

Jump to
zotonic_mod_admin src models m_admin_menu.erl
Raw

src/models/m_admin_menu.erl

%% @author Arjan Scherpenisse <arjan@scherpenisse.net>
%% @copyright 2012-2026 Arjan Scherpenisse <arjan@scherpenisse.net>
%% @doc Zotonic: admin menu
%% @end
%% Copyright 2012-2026 Arjan Scherpenisse
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
-module(m_admin_menu).
-moduledoc("
This model holds the admin menu, which is built up by calling each module to add items to the menu.
You can extend the admin menu by observing the [admin_menu](/id/doc_notification_admin_menu#admin-menu) notification.
Available Model API Paths
-------------------------
| Method | Path pattern | Description |
| --- | --- | --- |
| `get` | `/` | Return the full admin menu tree for the current context (`menu/1`), including nested items, resolved URLs, sorting, and visibility filtering. No further lookups. |
| `get` | `/menu/...` | Return the admin menu tree built from `#admin_menu` notifications, filtered by ACL/visible checks and organized into parent-child hierarchy. |
`/+name` marks a variable path segment. A trailing `/...` means extra path segments are accepted for further lookups.
").
-author("Arjan Scherpenisse <arjan@scherpenisse.net>").
-behaviour(zotonic_model).
-include_lib("zotonic_core/include/zotonic.hrl").
-include_lib("../../include/admin_menu.hrl").
%% interface functions
-export([
m_get/3,
menu/1
]).
-export([test/0]).
%% @doc Fetch the value for the key from a model source
-spec m_get( list(), zotonic_model:opt_msg(), z:context() ) -> zotonic_model:return().
m_get([], _Msg, Context) ->
{ok, {menu(Context), []}};
m_get([ <<"menu">> | Rest ], _Msg, Context) ->
{ok, {menu(Context), Rest}};
m_get(_Vs, _Msg, _Context) ->
{error, unknown_path}.
menu(Context) ->
case z_acl:is_allowed(use, mod_admin, Context) of
false ->
[];
true ->
Menu = z_notifier:foldl(#admin_menu{}, [], Context),
hierarchize(Menu, Context)
end.
hierarchize(Items, Context) ->
hierarchize(undefined, Items, Context).
hierarchize(Id, All, Context) ->
{Matches, Rest} = partition(Id, All),
Matches1 = sort_items(Matches),
Matches2 = [ mixin(C, Rest, Context) || C <- Matches1 ],
lists:filter(fun(I) -> item_visible(I, Context) end, Matches2).
sort_items(Ts) ->
{_, Ts1} = lists:foldl(
fun
(#menu_item{ sort = S } = M, {N, Acc}) ->
{N+1, [{S, N, M} | Acc]};
(#menu_separator{ sort = S } = M, {N, Acc}) ->
{N+1, [{S, N, M} | Acc]}
end,
{1, []},
Ts),
Ts2 = lists:sort( lists:reverse(Ts1) ),
lists:map(fun({_, _, M}) -> M end, Ts2).
partition(Key, Items) ->
lists:partition(
fun
(#menu_item{parent=K}) when K =:= Key ->
true;
(#menu_separator{parent=K}) when K =:= Key ->
true;
(_) ->
false
end,
Items).
mixin(#menu_item{ id = Id, url = UrlDef } = Item, All, Context) ->
Url = item_url(UrlDef, Context),
Props = [
{url, Url},
{items, hierarchize(Id, All, Context)}
| proplists:delete(url, lists:zip(record_info(fields, menu_item), tl(tuple_to_list(Item))))
],
{Id, Props};
mixin(#menu_separator{ visiblecheck = C }, _All, _Context) ->
{undefined, [ {separator, true}, {visiblecheck, C} ]}.
item_url(undefined, _Context) ->
undefined;
item_url(Rule, Context) when is_atom(Rule) ->
z_dispatcher:url_for(Rule, Context);
item_url({Rule}, Context) ->
z_dispatcher:url_for(Rule, Context);
item_url({Rule, Args}, Context) ->
z_dispatcher:url_for(Rule, Args, Context);
item_url(URL, _Context) when is_list(URL); is_binary(URL) ->
URL.
item_visible({_Key, ItemProps}, Context) ->
case proplists:get_value(visiblecheck, ItemProps) of
undefined ->
%% Always show separators
proplists:get_value(separator, ItemProps) =:= true orelse
%% Always show menu items with an external URL
proplists:get_value(url, ItemProps) =/= undefined orelse
%% Show a menu item only if it contains non-separator sub items
lists:filter(
fun({_SubItemKey, SubItemProps}) ->
proplists:get_value(separator, SubItemProps) =/= true
end,
proplists:get_value(items, ItemProps, [])
) =/= [];
C ->
visiblecheck(C, Context)
end.
visiblecheck(F, _Context) when is_function(F, 0) ->
F();
visiblecheck(F, Context) when is_function(F, 1) ->
F(Context);
visiblecheck({acl, Action, Object}, Context) ->
z_acl:is_allowed(Action, Object, Context);
visiblecheck({allof, L}, Context) ->
lists:all(fun(C) -> visiblecheck(C, Context) end, L);
visiblecheck({anyof, L}, Context) ->
lists:any(fun(C) -> visiblecheck(C, Context) end, L);
visiblecheck(L, Context) when is_list(L) ->
lists:all(fun(C) -> visiblecheck(C, Context) end, L);
visiblecheck(true, _Context) ->
true;
visiblecheck(false, _Context) ->
false.
test() ->
C = z:c(zotonic_site_status),
%% simple test
Items1 = [
#menu_item{id=top1, label="Label", url="/"}
],
[{top1, [{url, "/"},
{items, []},
{id, top1},
{parent, undefined},
{label, "Label"},
{icon, undefined},
{visiblecheck, undefined}]}] = hierarchize(Items1, C),
%% simple test with empty children
Items1a = [
{top1, {undefined, "Label", "/"}},
{top2, {undefined, "Label2", undefined}}
],
[{top1, [{url, "/"},
{label, "Label"},
{items, []}]}] = hierarchize(Items1a, C),
%% test w/ 1 child
Items2 = [
{top1, {undefined, "Label", "/"}},
{sub1, {top1, "Label1", "/xx"}}
],
[{top1, [{url, "/"},
{label, "Label"},
{items, [
{sub1, [{url, "/xx"}, {label, "Label1"}, {items, []}]}
]}]}] = hierarchize(Items2, C),
%% test w/ 1 child
Items2a = [
{top1, {undefined, "Label", "/"}},
{sub1, {top1, "Label1", "/xx"}},
{sub2, {top1, "Label2", "/yy"}}
],
[{top1, [{url, "/"},
{label, "Label"},
{items, [
{sub1, [{url, "/xx"}, {label, "Label1"}, {items, []}]},
{sub2, [{url, "/yy"}, {label, "Label2"}, {items, []}]}
]}]}] = hierarchize(Items2a, C),
%% test w/ callback
Items3 = [
{top1, {undefined, "Label", "/"}},
{sub1, {top1, "Label1", "/xx", fun() -> false end}}
],
[{top1, [{url, "/"},
{label, "Label"},
{items, []}]}] = hierarchize(Items3, C),
ok.