Skip to content

Commit 44f1873

Browse files
committed
Implement Menus context and API
1 parent 5fa79a3 commit 44f1873

File tree

13 files changed

+1099
-4
lines changed

13 files changed

+1099
-4
lines changed

.formatter.exs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
[
2-
import_deps: [:ecto, :ecto_sql, :phoenix],
2+
inputs: [
3+
"config/*.{ex,exs}",
4+
"lib/*.{ex,exs}",
5+
"lib/**/*.{ex,exs}",
6+
"test/*.{ex,exs}",
7+
"test/**/*.{ex,exs}",
8+
"priv/**/*.{ex,exs}",
9+
"mix.exs",
10+
".formatter.exs"
11+
],
312
plugins: [Phoenix.LiveView.HTMLFormatter],
4-
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
5-
subdirectories: ["priv/*/migrations"]
13+
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
614
]

lib/sanbase/menu/menus.ex

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
defmodule Sanbase.Menus do
2+
@moduledoc ~s"""
3+
Boundary module for working with menus.
4+
5+
A menu is an ordered list of menu items. Each menu item can be:
6+
- Query;
7+
- Dashboard;
8+
- Menu A sub-menu can also have a list of menu items, in order to build
9+
nesting and hierarchies.
10+
11+
When the menu is returned by the GraphQL API, the menu_to_simple_map/1 function
12+
is used in order to transform the menu struct to a structure that can be directly
13+
translated to JSON. This menu representation contains only the type, id, name and
14+
description of each menu item, as well as the position in the menu.
15+
"""
16+
alias Sanbase.Menus.Menu
17+
alias Sanbase.Menus.MenuItem
18+
alias Sanbase.Repo
19+
20+
import Sanbase.Utils.ErrorHandling, only: [changeset_errors_string: 1]
21+
22+
@type parent_menu_id :: non_neg_integer()
23+
@type user_id :: Sanbase.Accounts.User.user_id()
24+
@type menu_id :: Menu.menu_id()
25+
@type menu_item_id :: MenuItem.menu_item_id()
26+
27+
@type create_menu_params :: %{
28+
required(:name) => String.t(),
29+
optional(:description) => String.t(),
30+
optional(:parent_id) => integer(),
31+
optional(:position) => integer()
32+
}
33+
34+
@type update_menu_params :: %{
35+
optional(:name) => String.t(),
36+
optional(:description) => String.t()
37+
}
38+
39+
@type create_menu_item_params :: %{
40+
required(:parent_id) => menu_id,
41+
optional(:position) => integer() | nil,
42+
optional(:query_id) => Sanbase.Queries.Query.query_id(),
43+
optional(:dashboard_id) => Sanbase.Queries.Dashboard.dashboard_id(),
44+
optional(:menu_id) => menu_id
45+
}
46+
47+
@type update_menu_item_params :: %{
48+
optional(:parent_id) => menu_id,
49+
optional(:position) => integer() | nil
50+
}
51+
52+
@doc ~s"""
53+
Get a menu by its id and preloaded 2 levels of nesting.
54+
"""
55+
def get_menu(menu_id, user_id) do
56+
query = Menu.by_id(menu_id, user_id)
57+
58+
case Repo.one(query) do
59+
nil -> {:error, "Menu with id #{menu_id} not found"}
60+
menu -> {:ok, menu}
61+
end
62+
end
63+
64+
@doc ~s"""
65+
Convert a menu with preloaded menu items to a map in the format. This format
66+
can directly be returned by the GraphQL API if the return type is `:json`
67+
68+
%{
69+
entity: :menu, id: 1, name: "N", description: "D", menu_items: [
70+
%{entity_type: :query, id: 2, name: "Q", description: "D", position: 1},
71+
%{entity_type: :dashboard, id: 21, name: "D", description: "D", position: 2}
72+
]
73+
}
74+
"""
75+
def menu_to_simple_map(%Menu{} = menu) do
76+
%{
77+
# If this menu is a sub-menu, then the caller from get_menu_items/1 will
78+
# additionally set the menu_item_id. If this is the top-level menu, then
79+
# this is not a sub-menu and it does not have a menu_item_id
80+
menu_item_id: nil,
81+
type: :menu,
82+
id: menu.id,
83+
name: menu.name,
84+
description: menu.description,
85+
menu_items: get_menu_items(menu)
86+
}
87+
|> recursively_order_menu_items()
88+
end
89+
90+
@doc ~s"""
91+
Create a new menu.
92+
93+
A menu has a name and a description. It holds a list of MenuItems that have a given
94+
order. The menu params can also have a `parent_id` and `position` which indicates that this menu
95+
is created as a sub-menu of that parent.
96+
"""
97+
@spec create_menu(create_menu_params, user_id) :: {:ok, Menu.t()} | {:error, String.t()}
98+
def create_menu(params, user_id) do
99+
params =
100+
params
101+
|> Map.merge(%{user_id: user_id})
102+
103+
Ecto.Multi.new()
104+
|> Ecto.Multi.run(:create_menu, fn _repo, _changes ->
105+
query = Menu.create(params)
106+
Repo.insert(query)
107+
end)
108+
|> Ecto.Multi.run(:maybe_create_menu_item, fn _repo, %{create_menu: menu} ->
109+
# If the params have `:parent_id`, then this menu is a sub-menu,
110+
# which is done by adding a record to the menu_items table.
111+
case Map.get(params, :parent_id) do
112+
nil ->
113+
{:ok, nil}
114+
115+
parent_id ->
116+
# Add this new menu as a menu item to the parent
117+
create_menu_item(
118+
%{
119+
parent_id: parent_id,
120+
menu_id: menu.id,
121+
position: Map.get(params, :position)
122+
},
123+
user_id
124+
)
125+
end
126+
end)
127+
|> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{create_menu: menu} ->
128+
# There would be no menu items, but this will help to set the menu items to []
129+
# instead of getting an error when trying to iterate them because they're set to <not preloaded>
130+
get_menu(menu.id, user_id)
131+
end)
132+
|> Repo.transaction()
133+
|> process_transaction_result(:get_menu_with_preloads)
134+
end
135+
136+
@doc ~s"""
137+
Update an existing menu.
138+
139+
The name, description, parent_id and position can be updated.
140+
"""
141+
@spec update_menu(menu_id, update_menu_params, user_id) ::
142+
{:ok, Menu.t()} | {:error, String.t()}
143+
def update_menu(menu_id, params, user_id) do
144+
Ecto.Multi.new()
145+
|> Ecto.Multi.run(:get_menu_for_update, fn _repo, _changes ->
146+
get_menu_for_update(menu_id, user_id)
147+
end)
148+
|> Ecto.Multi.run(:update_menu, fn _repo, %{get_menu_for_update: menu} ->
149+
query = Menu.update(menu, params)
150+
Repo.update(query)
151+
end)
152+
|> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{update_menu: menu} ->
153+
get_menu(menu.id, user_id)
154+
end)
155+
|> Repo.transaction()
156+
|> process_transaction_result(:get_menu_with_preloads)
157+
end
158+
159+
@doc ~s"""
160+
Delete a menu
161+
"""
162+
@spec delete_menu(menu_id, user_id) :: {:ok, Menu.t()} | {:error, String.t()}
163+
def delete_menu(menu_id, user_id) do
164+
Ecto.Multi.new()
165+
|> Ecto.Multi.run(:get_menu_for_update, fn _repo, _changes ->
166+
get_menu_for_update(menu_id, user_id)
167+
end)
168+
|> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, _changes ->
169+
# Call this so we can return the menu with its menu items after it is
170+
# successfully deleted
171+
get_menu(menu_id, user_id)
172+
end)
173+
|> Ecto.Multi.run(:delete_menu, fn _repo, %{get_menu_for_update: menu} ->
174+
Repo.delete(menu)
175+
end)
176+
|> Repo.transaction()
177+
# Purposefully do not return the result of the last Ecto.Multi.run call,
178+
# but from the get_menu_with_preloads call, so we can return the menu with
179+
# its items.
180+
|> process_transaction_result(:get_menu_with_preloads)
181+
end
182+
183+
@doc ~s"""
184+
Create a new menu item.
185+
186+
The menu item can be:
187+
- Query
188+
- Dashboard
189+
- Menu (to build hierarchies)
190+
191+
Each item has a `position`. If no position is specified, it will be appended at the end.
192+
If a position is specified, all the positions bigger than it will be bumped by 1 in order
193+
to accomodate the new item.
194+
"""
195+
@spec create_menu_item(create_menu_item_params, user_id) ::
196+
{:ok, Menu.t()} | {:error, String.t()}
197+
def create_menu_item(params, user_id) do
198+
Ecto.Multi.new()
199+
|> Ecto.Multi.run(:get_menu_for_update, fn _repo, _changes ->
200+
case Map.get(params, :parent_id) do
201+
nil ->
202+
# Early error handling as we need the parent_id before calling the MenuItem.create/1
203+
# which does the required fields validation
204+
{:error, "Cannot create a menu item without providing parent_id"}
205+
206+
parent_id ->
207+
# Just check that the current user can update the parent menu
208+
get_menu_for_update(parent_id, user_id)
209+
end
210+
end)
211+
|> Ecto.Multi.run(:get_and_adjust_position, fn _repo, _changes ->
212+
case Map.get(params, :position) do
213+
nil ->
214+
# If `position` is not specified, add it at the end by getting the last position + 1
215+
{:ok, get_next_position(params.parent_id)}
216+
217+
position when is_integer(position) ->
218+
# If `position` is specified, bump all the positions bigger than it by 1 in
219+
# order to avoid having multiple items with the same position.
220+
{:ok, {_, nil}} = inc_all_positions_after(params.parent_id, position)
221+
222+
{:ok, position}
223+
end
224+
end)
225+
|> Ecto.Multi.run(
226+
:create_menu_item,
227+
fn _repo, %{get_and_adjust_position: position} ->
228+
params = params |> Map.merge(%{position: position, parent_id: params.parent_id})
229+
query = MenuItem.create(params)
230+
Repo.insert(query)
231+
end
232+
)
233+
|> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{get_menu_for_update: menu} ->
234+
get_menu(menu.id, user_id)
235+
end)
236+
|> Repo.transaction()
237+
|> process_transaction_result(:get_menu_with_preloads)
238+
end
239+
240+
@doc ~s"""
241+
Update an existing menu item.
242+
243+
A menu item can have the follwing fields updated:
244+
- position - change the position of the item in the menu
245+
- parent_id - change the parent menu of the item. On the frontend this is done
246+
by dragging and dropping the item in the menu tree (this can also update the position)
247+
248+
The entity (query, dashboard, etc.) cannot be changed. Delete a menu item and insert a new
249+
one instead.
250+
"""
251+
@spec update_menu_item(menu_item_id, update_menu_item_params, user_id) ::
252+
{:ok, Menu.t()} | {:error, String.t()}
253+
def update_menu_item(menu_item_id, params, user_id) do
254+
Ecto.Multi.new()
255+
|> Ecto.Multi.run(:get_menu_item_for_update, fn _repo, _changes ->
256+
get_menu_item_for_update(menu_item_id, user_id)
257+
end)
258+
|> Ecto.Multi.run(
259+
:maybe_update_items_positions,
260+
fn _repo, %{get_menu_item_for_update: menu_item} ->
261+
case Map.get(params, :position) do
262+
nil ->
263+
{:ok, nil}
264+
265+
position when is_integer(position) ->
266+
# If `position` is specified, bump all the positions bigger than it by 1 in
267+
# order to avoid having multiple items with the same position.
268+
{:ok, {_, nil}} = inc_all_positions_after(menu_item.parent_id, position)
269+
{:ok, position}
270+
end
271+
end
272+
)
273+
|> Ecto.Multi.run(:update_menu_item, fn _repo, %{get_menu_item_for_update: menu_item} ->
274+
query = MenuItem.update(menu_item, params)
275+
Repo.update(query)
276+
end)
277+
|> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{update_menu_item: menu_item} ->
278+
get_menu(menu_item.parent_id, user_id)
279+
end)
280+
|> Repo.transaction()
281+
|> process_transaction_result(:get_menu_with_preloads)
282+
end
283+
284+
@doc ~s"""
285+
Delete a menu item.
286+
"""
287+
@spec delete_menu_item(menu_item_id, user_id) ::
288+
{:ok, Menu.t()} | {:error, String.t()}
289+
def delete_menu_item(menu_item_id, user_id) do
290+
Ecto.Multi.new()
291+
|> Ecto.Multi.run(:get_menu_item, fn _repo, _changes ->
292+
get_menu_item_for_update(menu_item_id, user_id)
293+
end)
294+
|> Ecto.Multi.run(:delete_menu_item, fn _repo, %{get_menu_item: menu_item} ->
295+
Repo.delete(menu_item)
296+
end)
297+
|> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{delete_menu_item: menu_item} ->
298+
get_menu(menu_item.parent_id, user_id)
299+
end)
300+
|> Repo.transaction()
301+
|> process_transaction_result(:get_menu_with_preloads)
302+
end
303+
304+
# Private functions
305+
306+
defp get_menu_for_update(menu_id, user_id) do
307+
query = Menu.get_for_update(menu_id, user_id)
308+
309+
case Repo.one(query) do
310+
nil -> {:error, "Menu item does not exist"}
311+
menu -> {:ok, menu}
312+
end
313+
end
314+
315+
defp get_menu_item_for_update(menu_item_id, user_id) do
316+
query = MenuItem.get_for_update(menu_item_id, user_id)
317+
318+
case Repo.one(query) do
319+
nil -> {:error, "Menu item does not exist"}
320+
menu -> {:ok, menu}
321+
end
322+
end
323+
324+
defp get_next_position(menu_id) do
325+
query = MenuItem.get_next_position(menu_id)
326+
{:ok, Repo.one(query)}
327+
end
328+
329+
defp inc_all_positions_after(menu_id, position) do
330+
query = MenuItem.inc_all_positions_after(menu_id, position)
331+
{:ok, Repo.update_all(query, [])}
332+
end
333+
334+
defp process_transaction_result({:ok, map}, ok_field),
335+
do: {:ok, map[ok_field]}
336+
337+
defp process_transaction_result({:error, _, %Ecto.Changeset{} = changeset, _}, _ok_field),
338+
do: {:error, changeset_errors_string(changeset)}
339+
340+
defp process_transaction_result({:error, _, error, _}, _ok_field),
341+
do: {:error, error}
342+
343+
# Helpers for transforming a menu struct to a simple map
344+
defp recursively_order_menu_items(%{menu_items: menu_items} = map) do
345+
sorted_menu_items =
346+
Enum.sort_by(menu_items, & &1.position, :asc)
347+
|> Enum.map(fn
348+
%{menu_items: [_ | _]} = elem -> recursively_order_menu_items(elem)
349+
x -> x
350+
end)
351+
352+
%{map | menu_items: sorted_menu_items}
353+
end
354+
355+
defp recursively_order_menu_items(data), do: data
356+
357+
defp get_menu_items(%Menu{menu_items: []}), do: []
358+
359+
defp get_menu_items(%Menu{menu_items: list}) when is_list(list) do
360+
list
361+
|> Enum.map(fn
362+
%{id: menu_item_id, query: %{id: _} = map, position: position} ->
363+
Map.take(map, [:id, :name, :description])
364+
|> Map.merge(%{type: :query, position: position, menu_item_id: menu_item_id})
365+
366+
%{id: menu_item_id, dashboard: %{id: _} = map, position: position} ->
367+
Map.take(map, [:id, :name, :description])
368+
|> Map.merge(%{type: :dashboard, position: position, menu_item_id: menu_item_id})
369+
370+
%{id: menu_item_id, menu: %{id: _} = map, position: position} ->
371+
menu_to_simple_map(map)
372+
|> Map.merge(%{type: :menu, position: position, menu_item_id: menu_item_id})
373+
end)
374+
end
375+
end

0 commit comments

Comments
 (0)