wrap_it_ruby 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ce1f71d5f276d68be720746927f8329d7bc67b7a10feead3c3192f12cc4e419
4
- data.tar.gz: c66c7abbdd059a99148798242cc0796694410f98cbd96dec504e7bf1ee1342d8
3
+ metadata.gz: 7f6ed92be744074125808b44a1aa65cf1dafd00be14c8190824904841d275b6d
4
+ data.tar.gz: 4d3efb23f4ffd7406fd786fd41d98f6f4cb0b2ba35c9e1fd5c43d6c7027771a3
5
5
  SHA512:
6
- metadata.gz: d3a604c19cba59629d6714be6f04ee0a5481578be2dd4de4e8c40225521dd6c1408ea4f6ddd6702a957938273db2bc1350fa881ffbdcb568187c87e762977138
7
- data.tar.gz: 5583c57ac4793d85d57d96d04dab4f0e984358485e366bb311b089ac7187b1dee34ded804cacc4f5e15999c214ff0ef97559cfe88689969253b2dad352d7676f
6
+ metadata.gz: c7964a50fad62192b45d0e2a238217a6be39c6142f95b8710f5ad61a3f5b3bf3c4c0346f47d898908cb9ed74282f452c29c9add1c32bbefc9c1f207d91b08b3d
7
+ data.tar.gz: '097445c1d52ed9248c6feff3e1606642c1087e8ae68be7a2d99cf175cecbbc6e0c0f87a70b503496a640c1cc8232a8bb100d0ad82eaac187954a726e89cb9bcb'
@@ -14,6 +14,13 @@
14
14
 
15
15
  console.log('[interception]', 'loaded');
16
16
 
17
+ // Block service worker registration — proxied apps share the proxy
18
+ // origin so a SW registered by one app (e.g. code-server) would
19
+ // intercept requests for all proxied sites with stale cached paths.
20
+ if (navigator.serviceWorker) {
21
+ navigator.serviceWorker.register = () => Promise.resolve()
22
+ }
23
+
17
24
  var _fetch = window.fetch;
18
25
  var _XHR = window.XMLHttpRequest;
19
26
  var _xhrOpen = _XHR.prototype.open;
@@ -92,15 +99,16 @@
92
99
  const url = isReq ? input.url : String(input instanceof URL ? input.href : input)
93
100
 
94
101
  const headers = new Headers(init.headers || (isReq ? input.headers : {}))
95
- headers.set('x-proxy-host', '_proxy')
102
+ headers.set('x-proxy-host', PROXY_HOST)
96
103
 
97
104
  const newInit = {
98
105
  method: init.method || (isReq ? input.method : 'GET'),
99
106
  headers,
100
107
  body: 'body' in init ? init.body : (isReq ? input.body : null),
108
+ referrer: `/_proxy/${PROXY_HOST}/`,
101
109
  }
102
110
 
103
- const passthrough = ['credentials', 'mode', 'cache', 'redirect', 'referrer',
111
+ const passthrough = ['credentials', 'mode', 'cache', 'redirect',
104
112
  'referrerPolicy', 'integrity', 'keepalive', 'signal']
105
113
  for (const key of passthrough) {
106
114
  if (key in init) newInit[key] = init[key]
@@ -134,39 +142,54 @@
134
142
  _xhrOpen.call(this, meta.method, rewritten, ...meta.openArgs)
135
143
  }
136
144
 
137
- _xhrSetHeader.call(this, 'x-proxy-host', '_proxy')
145
+ _xhrSetHeader.call(this, 'x-proxy-host', PROXY_HOST)
138
146
  return _xhrSend.call(this, body)
139
147
  }
140
148
 
141
149
  // ============================= WebSocket ===================================
142
150
 
143
- //window.WebSocket = function (url, protocols) {
144
- // var req = { type: 'websocket', url: rewriteUrl(url), originalUrl: url,
145
- // method: 'GET', headers: {}, body: null };
146
- // runRequestHooks(req);
147
- // if (protocols !== undefined) return new _WebSocket(req.url, protocols);
148
- // return new _WebSocket(req.url);
149
- //};
150
- //window.WebSocket.prototype = _WebSocket.prototype;
151
- //window.WebSocket.CONNECTING = _WebSocket.CONNECTING;
152
- //window.WebSocket.OPEN = _WebSocket.OPEN;
153
- //window.WebSocket.CLOSING = _WebSocket.CLOSING;
154
- //window.WebSocket.CLOSED = _WebSocket.CLOSED;
155
-
156
- //// ============================ EventSource ==================================
157
-
158
- //if (_EventSource) {
159
- // window.EventSource = function (url, dict) {
160
- // var raw = typeof url === 'string' ? url : url.href;
161
- // var req = { type: 'eventsource', url: rewriteUrl(raw), originalUrl: raw,
162
- // method: 'GET', headers: {}, body: null };
163
- // runRequestHooks(req);
164
- // return new _EventSource(req.url, dict);
165
- // };
166
- // window.EventSource.prototype = _EventSource.prototype;
167
- // window.EventSource.CONNECTING = _EventSource.CONNECTING;
168
- // window.EventSource.OPEN = _EventSource.OPEN;
169
- // window.EventSource.CLOSED = _EventSource.CLOSED;
170
- //}
151
+ // Rewrite WebSocket URLs so they go through the proxy.
152
+ // wss://code.cia.net/ws wss://4000.cia.net/_proxy/code.cia.net/ws
153
+ const rewriteWsUrl = (url) => {
154
+ const raw = typeof url === 'string' ? url : url.toString()
155
+ try {
156
+ const parsed = new URL(raw)
157
+ if (parsed.host === window.__hostingSite) return raw
158
+ // Rewrite wss://upstream/path → wss://proxy/_proxy/upstream/path
159
+ return `${parsed.protocol}//${window.__hostingSite}/_proxy/${parsed.host}${parsed.pathname}${parsed.search}`
160
+ } catch {
161
+ // Relative URL — prefix with proxy host
162
+ if (raw.startsWith('/')) {
163
+ return `wss://${window.__hostingSite}/_proxy/${PROXY_HOST}${raw}`
164
+ }
165
+ return raw
166
+ }
167
+ }
168
+
169
+ window.WebSocket = function (url, protocols) {
170
+ const rewritten = rewriteWsUrl(url)
171
+ console.log('[interception] WebSocket', url, '->', rewritten)
172
+ if (protocols !== undefined) return new _WebSocket(rewritten, protocols)
173
+ return new _WebSocket(rewritten)
174
+ }
175
+ window.WebSocket.prototype = _WebSocket.prototype
176
+ window.WebSocket.CONNECTING = _WebSocket.CONNECTING
177
+ window.WebSocket.OPEN = _WebSocket.OPEN
178
+ window.WebSocket.CLOSING = _WebSocket.CLOSING
179
+ window.WebSocket.CLOSED = _WebSocket.CLOSED
180
+
181
+ // ============================ EventSource ==================================
182
+
183
+ if (_EventSource) {
184
+ window.EventSource = function (url, dict) {
185
+ const raw = typeof url === 'string' ? url : url.href
186
+ const rewritten = raw.startsWith('/') ? `/_proxy/${PROXY_HOST}${raw}` : rewriteUrl(raw)
187
+ return new _EventSource(rewritten, dict)
188
+ }
189
+ window.EventSource.prototype = _EventSource.prototype
190
+ window.EventSource.CONNECTING = _EventSource.CONNECTING
191
+ window.EventSource.OPEN = _EventSource.OPEN
192
+ window.EventSource.CLOSED = _EventSource.CLOSED
193
+ }
171
194
 
172
195
  })(window);
@@ -8,6 +8,32 @@ html, body {
8
8
  background-color: white;
9
9
  }
10
10
 
11
+ #site-content {
12
+ position: relative;
13
+ }
14
+
15
+ #site-content::after {
16
+ content: "";
17
+ position: absolute;
18
+ inset: 0;
19
+ box-shadow: inset 0px 0px 4px 1px rgb(0 0 0 / 19%);
20
+ pointer-events: none;
21
+ border-radius: inherit;
22
+ border: 1px solid rgb(151 151 151 / 79%);
23
+ }
24
+
25
+ /*
26
+ .iframe-wrapper::after {
27
+ content: "";
28
+ position: absolute;
29
+ inset: 0;
30
+ border: 1px solid rgb(151 151 151 / 79%);
31
+ box-shadow: inset 0px 0px 4px 1px rgb(0 0 0 / 19%);
32
+ pointer-events: none;
33
+ border-radius: inherit;
34
+ }
35
+ */
36
+
11
37
  #site-wrapper {
12
38
  display: flex;
13
39
  flex-direction: column;
@@ -25,11 +51,55 @@ html, body {
25
51
  margin: 7px;
26
52
  margin-top: 0px;
27
53
  border-radius: 15px;
28
- background-color: grey;
54
+ #background-color: grey;
29
55
  overflow: hidden;
30
56
  }
31
57
  }
32
58
 
59
+ /*! Sortable Tree 0.7.6, (c) 2025 Marc Anton Dahmen, MIT license */
60
+ :root {
61
+ --st-label-height: 2.5rem;
62
+ --st-subnodes-padding-left: 1.5rem;
63
+ --st-collapse-icon-height: var(--st-label-height);
64
+ --st-collapse-icon-width: 1.25rem;
65
+ --st-collapse-icon-size: 0.75rem;
66
+ }
67
+ sortable-tree-node {
68
+ position: relative;
69
+ z-index: 1;
70
+ display: flex;
71
+ flex-direction: column;
72
+ }
73
+ sortable-tree-node * {
74
+ user-select: none;
75
+ }
76
+ sortable-tree-node > :first-child {
77
+ display: flex;
78
+ align-items: center;
79
+ height: var(--st-label-height);
80
+ }
81
+ sortable-tree-node > :nth-child(2) {
82
+ display: none;
83
+ flex-direction: column;
84
+ padding-left: var(--st-subnodes-padding-left);
85
+ }
86
+ sortable-tree-node[open] > div:nth-child(2) {
87
+ display: flex;
88
+ }
89
+ sortable-tree-node > :nth-child(3) {
90
+ position: absolute;
91
+ display: flex;
92
+ align-items: center;
93
+ top: 0;
94
+ left: calc(var(--st-collapse-icon-width) * -1);
95
+ height: var(--st-collapse-icon-height);
96
+ font-size: var(--st-collapse-icon-size);
97
+ cursor: pointer;
98
+ }
99
+ sortable-tree-node > :nth-child(2):empty + span {
100
+ display: none;
101
+ }
102
+
33
103
  #proxy-content {
34
104
  height: 100%;
35
105
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ class MenuSettingsController < ::ApplicationController
5
+ def index
6
+ @menu_items = ::MenuItem.roots.includes(children: :children)
7
+ end
8
+
9
+ def create
10
+ ::MenuItem.create!(menu_item_params)
11
+ respond_with_tree_refresh
12
+ end
13
+
14
+ def update
15
+ item = ::MenuItem.find(params[:id])
16
+ item.update!(menu_item_params)
17
+ respond_with_tree_refresh
18
+ end
19
+
20
+ def destroy
21
+ item = ::MenuItem.find(params[:id])
22
+ item.destroy!
23
+ respond_with_tree_refresh
24
+ end
25
+
26
+ def sort
27
+ ordering = params.require(:ordering)
28
+
29
+ ::MenuItem.transaction do
30
+ ordering.each do |entry|
31
+ item = ::MenuItem.find(entry[:id])
32
+ new_parent_id = entry[:parent_id].presence
33
+
34
+ if item.parent_id.to_s != new_parent_id.to_s
35
+ item.remove_from_list
36
+ item.update!(parent_id: new_parent_id)
37
+ end
38
+
39
+ item.insert_at(entry[:position].to_i)
40
+ end
41
+ end
42
+
43
+ respond_with_tree_refresh
44
+ end
45
+
46
+ private
47
+
48
+ def menu_item_params
49
+ params.permit(:label, :icon, :route, :url, :item_type, :parent_id)
50
+ end
51
+
52
+ def respond_with_tree_refresh
53
+ respond_to do |format|
54
+ format.turbo_stream do
55
+ render turbo_stream: turbo_stream.replace(
56
+ 'menu-tree-container',
57
+ partial: 'wrap_it_ruby/menu_settings/tree'
58
+ )
59
+ end
60
+ format.html { head :no_content }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -11,7 +11,8 @@ module WrapItRuby
11
11
  target_domain = menu_item["url"]
12
12
 
13
13
  target_url = "#{target_domain}/#{target_path}"
14
- @iframe_src = "/_proxy/#{target_url}"
14
+ proxy_host = ENV["WRAP_IT_PROXY_HOST"]
15
+ @iframe_src = proxy_host ? "//#{proxy_host}/_proxy/#{target_url}" : "/_proxy/#{target_url}"
15
16
  end
16
17
  end
17
18
 
@@ -1,8 +1,9 @@
1
- require "yaml"
1
+ require 'yaml'
2
2
 
3
3
  module WrapItRuby
4
4
  # Loads and queries the menu configuration from the host app's
5
- # config/menu.yml file.
5
+ # config/menu.yml file, or from the database when menu_item_class
6
+ # is configured.
6
7
  #
7
8
  # Can be used as a module (extend self) or included in controllers/helpers.
8
9
  #
@@ -13,46 +14,321 @@ module WrapItRuby
13
14
  module MenuHelper
14
15
  def menu_config = load_menu
15
16
 
16
- # Renders the sidebar menu using rails-active-ui component helpers.
17
- # Must be called from a view context where ComponentHelper is included.
17
+ # Renders the menu entries using rails-active-ui component helpers.
18
+ # Must be called from inside a Menu { } block in the layout.
19
+ #
20
+ # Supports arbitrary nesting depth:
21
+ # - Top-level entries with "items" render as Fomantic-UI simple
22
+ # dropdown menu items (hover to open).
23
+ # - Nested entries with "items" render as flyout sub-dropdowns
24
+ # (dropdown icon + nested .menu inside an .item).
25
+ # - Leaf entries render as plain linked menu items.
18
26
  def render_menu
19
- Menu(attached: true) {
20
- WrapItRuby::MenuHelper.menu_config.each do |group|
21
- if group["items"]
22
- group["items"].each do |item|
23
- MenuItem(href: item["route"]) { text item["label"] }
24
- end
25
- else
26
- MenuItem(href: group["route"]) { text group["label"] }
27
- end
28
- end
27
+ WrapItRuby::MenuHelper.menu_config.each do |entry|
28
+ render_menu_entry(entry, top_level: true)
29
+ end
30
+ end
31
+
32
+ # Renders the menu-settings, edit, and add modals.
33
+ # Call this outside the Menu { } block so modals are not
34
+ # nested inside the menu bar.
35
+ def render_menu_modals
36
+ # Settings modal — sortable tree + add button
37
+ concat tag.div(id: 'menu-settings-modal', class: 'ui large modal') {
38
+ safe_join([
39
+ tag.i(class: 'close icon'),
40
+ tag.div(class: 'header') { tag.i(class: 'bars icon') + ' Menu Settings' },
41
+ tag.div(class: 'scrolling content') {
42
+ tag.div(id: 'menu-tree-container') { sortable_menu_tree }
43
+ },
44
+ tag.div(class: 'actions') {
45
+ safe_join([
46
+ tag.button(class: 'ui green button', onclick: 'menuSettingsShowAdd()') {
47
+ tag.i(class: 'plus icon') + ' Add Item'
48
+ }
49
+ ])
50
+ }
51
+ ])
52
+ }
53
+
54
+ # Edit modal — stacked on top of settings modal
55
+ concat tag.div(id: 'menu-edit-modal', class: 'ui small modal') {
56
+ safe_join([
57
+ tag.i(class: 'close icon'),
58
+ tag.div(class: 'header') { 'Edit Menu Item' },
59
+ tag.div(class: 'content') {
60
+ tag.form(class: 'ui form', id: 'menu-edit-form') {
61
+ safe_join([
62
+ tag.input(type: 'hidden', name: 'id', id: 'menu-edit-id'),
63
+ tag.div(class: 'two fields') {
64
+ safe_join([
65
+ tag.div(class: 'field') {
66
+ tag.label {
67
+ 'Label'
68
+ } + tag.input(type: 'text', name: 'label', id: 'menu-edit-label')
69
+ },
70
+ tag.div(class: 'field') {
71
+ tag.label {
72
+ 'Icon'
73
+ } + tag.input(type: 'text', name: 'icon', id: 'menu-edit-icon',
74
+ placeholder: 'e.g. server')
75
+ }
76
+ ])
77
+ },
78
+ tag.div(class: 'two fields', id: 'menu-edit-proxy-fields') {
79
+ safe_join([
80
+ tag.div(class: 'field') {
81
+ tag.label {
82
+ 'Route'
83
+ } + tag.input(type: 'text', name: 'route', id: 'menu-edit-route',
84
+ placeholder: '/path')
85
+ },
86
+ tag.div(class: 'field') {
87
+ tag.label {
88
+ 'URL'
89
+ } + tag.input(type: 'text', name: 'url', id: 'menu-edit-url',
90
+ placeholder: 'upstream.example.com')
91
+ }
92
+ ])
93
+ },
94
+ tag.div(class: 'field') {
95
+ tag.label { 'Type' } +
96
+ tag.select(name: 'item_type', id: 'menu-edit-type', class: 'ui dropdown',
97
+ onchange: "menuSettingsToggleProxyFields('menu-edit')") {
98
+ safe_join([
99
+ tag.option(value: 'group') { 'Group' },
100
+ tag.option(value: 'proxy') { 'Proxy' }
101
+ ])
102
+ }
103
+ }
104
+ ])
105
+ }
106
+ },
107
+ tag.div(class: 'actions') {
108
+ safe_join([
109
+ tag.button(class: 'ui red left floated button', onclick: 'menuSettingsDelete()') {
110
+ tag.i(class: 'trash icon') + ' Delete'
111
+ },
112
+ tag.button(class: 'ui button', onclick: "$('#menu-edit-modal').modal('hide')") {
113
+ 'Cancel'
114
+ },
115
+ tag.button(class: 'ui green button', onclick: 'menuSettingsSave()') {
116
+ tag.i(class: 'save icon') + ' Save'
117
+ }
118
+ ])
119
+ }
120
+ ])
121
+ }
122
+
123
+ # Add modal — stacked on top of settings modal
124
+ concat tag.div(id: 'menu-add-modal', class: 'ui small modal') {
125
+ safe_join([
126
+ tag.i(class: 'close icon'),
127
+ tag.div(class: 'header') { 'Add Menu Item' },
128
+ tag.div(class: 'content') {
129
+ tag.form(class: 'ui form', id: 'menu-add-form') {
130
+ safe_join([
131
+ tag.div(class: 'field') {
132
+ tag.label { 'Type' } +
133
+ tag.select(name: 'item_type', id: 'menu-add-type', class: 'ui dropdown',
134
+ onchange: "menuSettingsToggleProxyFields('menu-add')") {
135
+ safe_join([
136
+ tag.option(value: 'group') { 'Group' },
137
+ tag.option(value: 'proxy') { 'Proxy' }
138
+ ])
139
+ }
140
+ },
141
+ tag.div(class: 'two fields') {
142
+ safe_join([
143
+ tag.div(class: 'field') {
144
+ tag.label {
145
+ 'Label'
146
+ } + tag.input(type: 'text', name: 'label', id: 'menu-add-label')
147
+ },
148
+ tag.div(class: 'field') {
149
+ tag.label {
150
+ 'Icon'
151
+ } + tag.input(type: 'text', name: 'icon', id: 'menu-add-icon',
152
+ placeholder: 'e.g. server')
153
+ }
154
+ ])
155
+ },
156
+ tag.div(class: 'two fields', id: 'menu-add-proxy-fields', style: 'display:none') {
157
+ safe_join([
158
+ tag.div(class: 'field') {
159
+ tag.label {
160
+ 'Route'
161
+ } + tag.input(type: 'text', name: 'route', id: 'menu-add-route',
162
+ placeholder: '/path')
163
+ },
164
+ tag.div(class: 'field') {
165
+ tag.label {
166
+ 'URL'
167
+ } + tag.input(type: 'text', name: 'url', id: 'menu-add-url',
168
+ placeholder: 'upstream.example.com')
169
+ }
170
+ ])
171
+ },
172
+ tag.div(class: 'field') {
173
+ tag.label { 'Parent' } +
174
+ tag.select(name: 'parent_id', id: 'menu-add-parent', class: 'ui dropdown') {
175
+ safe_join([
176
+ tag.option(value: '') { 'Root (top level)' },
177
+ *menu_group_options
178
+ ])
179
+ }
180
+ }
181
+ ])
182
+ }
183
+ },
184
+ tag.div(class: 'actions') {
185
+ safe_join([
186
+ tag.button(class: 'ui button', onclick: "$('#menu-add-modal').modal('hide')") {
187
+ 'Cancel'
188
+ },
189
+ tag.button(class: 'ui green button', onclick: 'menuSettingsCreate()') {
190
+ tag.i(class: 'plus icon') + ' Add'
191
+ }
192
+ ])
193
+ }
194
+ ])
29
195
  }
30
196
  end
31
197
 
32
198
  def all_menu_items
33
- menu_config.flat_map { |item| [item, *item.fetch("items", [])] }
199
+ flatten_items(menu_config)
34
200
  end
35
201
 
36
202
  def all_proxy_menu_items
37
- all_menu_items.select { |item| item["type"] == "proxy" }
203
+ all_menu_items.select { |item| item['type'] == 'proxy' }
38
204
  end
39
205
 
40
206
  def proxy_paths
41
207
  all_menu_items
42
- .select { |item| item["type"] == "proxy" }
43
- .map { |item| item["route"] }
208
+ .select { |item| item['type'] == 'proxy' }
209
+ .map { |item| item['route'] }
210
+ end
211
+
212
+ def reset_menu_cache!
213
+ @menu_config = nil
44
214
  end
45
215
 
46
216
  extend self
47
217
 
48
218
  private
49
219
 
50
- def menu_file
51
- Rails.root.join("config/menu.yml")
220
+ def render_menu_entry(entry, top_level: false)
221
+ if entry['items']
222
+ if top_level
223
+ MenuItem(dropdown: true, icon: entry['icon']) do
224
+ text entry['label']
225
+ concat tag.i(class: 'dropdown icon')
226
+ SubMenu do
227
+ entry['items'].each { |child| render_menu_entry(child) }
228
+ end
229
+ end
230
+ else
231
+ MenuItem do
232
+ concat tag.i(class: 'dropdown icon')
233
+ text entry['label']
234
+ SubMenu do
235
+ entry['items'].each { |child| render_menu_entry(child) }
236
+ end
237
+ end
238
+ end
239
+ else
240
+ MenuItem(href: entry['route'], icon: entry['icon']) { text entry['label'] }
241
+ end
242
+ end
243
+
244
+ # Renders a sortable-tree container.
245
+ # Pass an array of MenuItem records (roots with children eager-loaded).
246
+ # The Stimulus controller reads the JSON and initializes sortable-tree.
247
+ def sortable_menu_tree
248
+ items = ::MenuItem.roots.includes(children: :children)
249
+ nodes_json = menu_items_to_nodes(items).to_json
250
+ sort_url = wrap_it_ruby.sort_menu_setting_path(id: 'bulk')
251
+
252
+ tag.div(
253
+ data: {
254
+ controller: 'wrap-it-ruby--sortable-tree',
255
+ "wrap-it-ruby--sortable-tree-nodes-value": nodes_json,
256
+ "wrap-it-ruby--sortable-tree-sort-url-value": sort_url,
257
+ "wrap-it-ruby--sortable-tree-lock-root-value": false,
258
+ "wrap-it-ruby--sortable-tree-collapse-level-value": 3
259
+ }
260
+ )
261
+ end
262
+
263
+ # Converts MenuItem records to the sortable-tree nodes format:
264
+ # [{ data: { id:, title:, icon:, route:, url: }, nodes: [...] }]
265
+ def menu_items_to_nodes(items)
266
+ items.map do |item|
267
+ {
268
+ data: {
269
+ id: item.id,
270
+ title: item.label,
271
+ icon: item.icon,
272
+ route: item.route,
273
+ url: item.url,
274
+ item_type: item.item_type
275
+ },
276
+ nodes: item.children.any? ? menu_items_to_nodes(item.children) : []
277
+ }
278
+ end
279
+ end
280
+
281
+ # Builds <option> tags for all group items (for parent dropdown in add modal).
282
+ # Indents sub-groups with dashes to show hierarchy.
283
+ def menu_group_options(items = nil, depth = 0)
284
+ items ||= ::MenuItem.groups.where(parent_id: nil).order(:position).includes(children: :children)
285
+ items.flat_map do |item|
286
+ prefix = "\u00A0\u00A0" * depth + (depth > 0 ? "\u2514 " : '')
287
+ opts = [tag.option(value: item.id) { "#{prefix}#{item.label}" }]
288
+ sub_groups = item.children.select(&:group?)
289
+ opts += menu_group_options(sub_groups, depth + 1) if sub_groups.any?
290
+ opts
52
291
  end
292
+ end
293
+
294
+ def flatten_items(items)
295
+ items.flat_map { |item| [item, *flatten_items(item.fetch('items', []))] }
296
+ end
297
+
298
+ def menu_file
299
+ Rails.root.join('config/menu.yml')
300
+ end
301
+
302
+ def load_menu
303
+ @menu_config ||= if database_menu_available?
304
+ load_menu_from_database
305
+ else
306
+ YAML.load_file(menu_file)
307
+ end
308
+ end
309
+
310
+ def database_menu_available?
311
+ defined?(::MenuItem) && ::MenuItem.table_exists? && ::MenuItem.exists?
312
+ rescue StandardError
313
+ false
314
+ end
53
315
 
54
- def load_menu
55
- @menu_config ||= YAML.load_file(menu_file)
316
+ def load_menu_from_database
317
+ ::MenuItem.roots.includes(children: :children).map { |item| item_to_hash(item) }
318
+ end
319
+
320
+ def item_to_hash(item)
321
+ hash = { 'label' => item.label, 'icon' => item.icon }
322
+
323
+ if item.children.any?
324
+ hash['items'] = item.children.map { |child| item_to_hash(child) }
325
+ else
326
+ hash['route'] = item.route
327
+ hash['url'] = item.url
328
+ hash['type'] = item.item_type
56
329
  end
330
+
331
+ hash
332
+ end
57
333
  end
58
334
  end