mat_views 0.2.0 → 0.3.0

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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/app/assets/images/mat_views/android-chrome-192x192.png +0 -0
  4. data/app/assets/images/mat_views/android-chrome-512x512.png +0 -0
  5. data/app/assets/images/mat_views/apple-touch-icon.png +0 -0
  6. data/app/assets/images/mat_views/favicon-16x16.png +0 -0
  7. data/app/assets/images/mat_views/favicon-32x32.png +0 -0
  8. data/app/assets/images/mat_views/favicon-48x48.png +0 -0
  9. data/app/assets/images/mat_views/favicon.ico +0 -0
  10. data/app/assets/images/mat_views/favicon.svg +18 -0
  11. data/app/assets/images/mat_views/logo.svg +18 -0
  12. data/app/assets/images/mat_views/mask-icon.svg +5 -0
  13. data/app/assets/stylesheets/mat_views/application.css +323 -12
  14. data/app/controllers/mat_views/admin/application_controller.rb +135 -0
  15. data/app/controllers/mat_views/admin/dashboard_controller.rb +32 -0
  16. data/app/controllers/mat_views/admin/mat_view_definitions_controller.rb +248 -0
  17. data/app/controllers/mat_views/admin/preferences_controller.rb +91 -0
  18. data/app/controllers/mat_views/admin/runs_controller.rb +74 -0
  19. data/app/helpers/mat_views/admin/ui_helper.rb +385 -0
  20. data/app/javascript/mat_views/application.js +8 -0
  21. data/app/javascript/mat_views/controllers/application.js +10 -0
  22. data/app/javascript/mat_views/controllers/details_controller.js +122 -0
  23. data/app/javascript/mat_views/controllers/drawer_controller.js +252 -0
  24. data/app/javascript/mat_views/controllers/filter_controller.js +90 -0
  25. data/app/javascript/mat_views/controllers/flash_controller.js +13 -0
  26. data/app/javascript/mat_views/controllers/index.js +10 -0
  27. data/app/javascript/mat_views/controllers/mv_confirm_controller.js +281 -0
  28. data/app/javascript/mat_views/controllers/submitter_controller.js +15 -0
  29. data/app/javascript/mat_views/controllers/tabs_controller.js +67 -0
  30. data/app/javascript/mat_views/controllers/timezone_controller.js +16 -0
  31. data/app/javascript/mat_views/controllers/tooltip_controller.js +328 -0
  32. data/app/javascript/mat_views/controllers/turbo_frame_lifecycle_controller.js +49 -0
  33. data/app/jobs/mat_views/application_job.rb +2 -2
  34. data/app/jobs/mat_views/create_view_job.rb +9 -8
  35. data/app/jobs/mat_views/delete_view_job.rb +8 -8
  36. data/app/jobs/mat_views/refresh_view_job.rb +8 -9
  37. data/app/models/concerns/mat_views_i18n.rb +139 -0
  38. data/app/models/mat_views/application_record.rb +1 -0
  39. data/app/models/mat_views/mat_view_definition.rb +12 -7
  40. data/app/models/mat_views/mat_view_run.rb +11 -13
  41. data/app/views/layouts/mat_views/_footer.html.erb +41 -0
  42. data/app/views/layouts/mat_views/_header.html.erb +25 -0
  43. data/app/views/layouts/mat_views/admin.html.erb +47 -0
  44. data/app/views/layouts/mat_views/turbo_frame.html.erb +3 -0
  45. data/app/views/mat_views/admin/dashboard/index.html.erb +33 -0
  46. data/app/views/mat_views/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  47. data/app/views/mat_views/admin/mat_view_definitions/_table.html.erb +48 -0
  48. data/app/views/mat_views/admin/mat_view_definitions/empty.html.erb +1 -0
  49. data/app/views/mat_views/admin/mat_view_definitions/form.html.erb +79 -0
  50. data/app/views/mat_views/admin/mat_view_definitions/index.html.erb +10 -0
  51. data/app/views/mat_views/admin/mat_view_definitions/show.html.erb +40 -0
  52. data/app/views/mat_views/admin/preferences/show.html.erb +50 -0
  53. data/app/views/mat_views/admin/runs/_table.html.erb +61 -0
  54. data/app/views/mat_views/admin/runs/index.html.erb +38 -0
  55. data/app/views/mat_views/admin/runs/show.html.erb +64 -0
  56. data/app/views/mat_views/admin/ui/_card.html.erb +15 -0
  57. data/app/views/mat_views/admin/ui/_details.html.erb +10 -0
  58. data/app/views/mat_views/admin/ui/_flash.html.erb +6 -0
  59. data/app/views/mat_views/admin/ui/_table.html.erb +8 -0
  60. data/config/importmap.rb +9 -0
  61. data/config/locales/en-AU-ocker.yml +187 -0
  62. data/config/locales/en-AU.yml +187 -0
  63. data/config/locales/en-BB.yml +187 -0
  64. data/config/locales/en-BD.yml +187 -0
  65. data/config/locales/en-BE.yml +187 -0
  66. data/config/locales/en-BORK.yml +187 -0
  67. data/config/locales/en-BS.yml +187 -0
  68. data/config/locales/en-BZ.yml +187 -0
  69. data/config/locales/en-CA.yml +187 -0
  70. data/config/locales/en-CM.yml +187 -0
  71. data/config/locales/en-CY.yml +187 -0
  72. data/config/locales/en-EG.yml +187 -0
  73. data/config/locales/en-FJ.yml +187 -0
  74. data/config/locales/en-GB.yml +187 -0
  75. data/config/locales/en-GH.yml +187 -0
  76. data/config/locales/en-GI.yml +187 -0
  77. data/config/locales/en-GM.yml +187 -0
  78. data/config/locales/en-GY.yml +187 -0
  79. data/config/locales/en-HK.yml +187 -0
  80. data/config/locales/en-IE.yml +187 -0
  81. data/config/locales/en-IN.yml +187 -0
  82. data/config/locales/en-JM.yml +187 -0
  83. data/config/locales/en-KE.yml +187 -0
  84. data/config/locales/en-LK.yml +187 -0
  85. data/config/locales/en-LOL.yml +187 -0
  86. data/config/locales/en-LR.yml +187 -0
  87. data/config/locales/en-MS.yml +187 -0
  88. data/config/locales/en-MT.yml +187 -0
  89. data/config/locales/en-MW.yml +187 -0
  90. data/config/locales/en-MY.yml +187 -0
  91. data/config/locales/en-NG.yml +187 -0
  92. data/config/locales/en-NP.yml +187 -0
  93. data/config/locales/en-NZ.yml +187 -0
  94. data/config/locales/en-PG.yml +187 -0
  95. data/config/locales/en-PH.yml +187 -0
  96. data/config/locales/en-PK.yml +187 -0
  97. data/config/locales/en-RW.yml +187 -0
  98. data/config/locales/en-SCOT.yml +187 -0
  99. data/config/locales/en-SG.yml +187 -0
  100. data/config/locales/en-SHAKESPEARE.yml +187 -0
  101. data/config/locales/en-SL.yml +187 -0
  102. data/config/locales/en-SS.yml +187 -0
  103. data/config/locales/en-TH.yml +187 -0
  104. data/config/locales/en-TT.yml +187 -0
  105. data/config/locales/en-TZ.yml +187 -0
  106. data/config/locales/en-UG.yml +187 -0
  107. data/config/locales/en-US-pirate.yml +187 -0
  108. data/config/locales/en-US.yml +187 -0
  109. data/config/locales/en-YODA.yml +187 -0
  110. data/config/locales/en-ZA.yml +187 -0
  111. data/config/locales/en-ZW.yml +187 -0
  112. data/config/locales/en.yml +187 -0
  113. data/config/routes.rb +27 -3
  114. data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +7 -7
  115. data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +5 -5
  116. data/lib/mat_views/admin/auth_bridge.rb +93 -0
  117. data/lib/mat_views/admin/default_auth.rb +61 -0
  118. data/lib/mat_views/configuration.rb +9 -0
  119. data/lib/mat_views/engine.rb +50 -2
  120. data/lib/mat_views/helpers/ui_test_ids.rb +43 -0
  121. data/lib/mat_views/services/base_service.rb +46 -38
  122. data/lib/mat_views/services/check_matview_exists.rb +76 -0
  123. data/lib/mat_views/services/concurrent_refresh.rb +9 -6
  124. data/lib/mat_views/services/create_view.rb +15 -15
  125. data/lib/mat_views/services/delete_view.rb +8 -11
  126. data/lib/mat_views/services/regular_refresh.rb +6 -5
  127. data/lib/mat_views/services/swap_refresh.rb +11 -9
  128. data/lib/mat_views/version.rb +1 -1
  129. data/lib/mat_views.rb +10 -4
  130. data/lib/tasks/helpers.rb +13 -13
  131. data/lib/tasks/mat_views_tasks.rake +15 -15
  132. metadata +130 -5
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ module MatViews
9
+ module Admin
10
+ # MatViews::Admin::UiHelper
11
+ # -------------------------
12
+ # View helper methods for the MatViews admin UI.
13
+ #
14
+ # Responsibilities:
15
+ # - Provides consistent button, link, drawer, badge, and icon components.
16
+ # - Wraps standard Rails helpers (`link_to`, `button_to`, `button_tag`, etc.)
17
+ # with MatViews-specific styling and Stimulus integration.
18
+ # - Defines inline SVG icon snippets for use across the admin dashboard.
19
+ #
20
+ # Key Components:
21
+ # - Buttons: {#mv_button_link}, {#mv_button_to}, {#mv_drawer_link}, {#mv_drawer_action_button}
22
+ # - Links: {#mv_link_to}
23
+ # - Badges: {#mv_badge}
24
+ # - Icons: {#mv_icon} with private `svg_icon_*` methods
25
+ # - Translations: {#mv_t}
26
+ #
27
+ module UiHelper
28
+ # Builds CSS classes for a MatViews-styled button.
29
+ #
30
+ # @param variant [Symbol] one of `:primary`, `:secondary`, `:ghost`, `:negative`
31
+ # @param size [Symbol] one of `:sm`, `:md`, `:lg`
32
+ # @return [String] concatenated CSS class string
33
+ def mv_button_classes(variant, size)
34
+ [
35
+ 'mv-btn',
36
+ "mv-btn--#{variant}",
37
+ "mv-btn--#{size}"
38
+ ].join(' ')
39
+ end
40
+
41
+ # Renders a styled link button.
42
+ #
43
+ # @param href [String] target URL
44
+ # @param opts [Hash] options for styling, data attributes, etc.
45
+ # @yield link body content
46
+ # @return [String] HTML-safe link tag
47
+ def mv_button_link(href, opts = {}, &)
48
+ link_to capture(&), href, **link_options(assign_test_id(opts))
49
+ end
50
+
51
+ # Renders a button link that opens a drawer via Stimulus.
52
+ #
53
+ # @param drawer_url [String] URL to load into the drawer
54
+ # @param drawer_title [String] title for the drawer
55
+ # @param args [Hash] additional options
56
+ # @yield button body
57
+ # @return [String] HTML-safe link tag
58
+ def mv_drawer_link(drawer_url, drawer_title, args = {}, &)
59
+ data = { action: 'click->drawer#open', drawer_title: drawer_title, drawer_url: drawer_url }
60
+ args[:data] = (args[:data] || {}).merge(data)
61
+ mv_button_link '#', assign_test_id(args), &
62
+ end
63
+
64
+ # Renders a styled `button_to` element.
65
+ #
66
+ # @param href [String] target URL
67
+ # @param opts [Hash] options for styling, data attributes, etc.
68
+ # @yield button content
69
+ # @return [String] HTML-safe button tag
70
+ def mv_button_to(href, opts = {}, &)
71
+ button_to href, **link_options(assign_test_id(opts)) do
72
+ capture(&)
73
+ end
74
+ end
75
+
76
+ # Renders a drawer action button with optional tooltip.
77
+ #
78
+ # @param label [String] ARIA label for accessibility
79
+ # @param action [String] Stimulus action method
80
+ # @param tooltip [String, nil] optional tooltip text
81
+ # @param tooltip_placement [String, nil] placement of tooltip (default: "top")
82
+ # @param args_orig [Hash] additional HTML options
83
+ # @yield button content
84
+ # @return [String] HTML-safe button tag
85
+ def mv_drawer_action_button(label, action, tooltip = nil, tooltip_placement = nil, args_org = {}, &)
86
+ args = assign_test_id(args_org)
87
+ data = { action: "drawer##{action}" }
88
+ data = data.merge(args[:data] || {})
89
+ if tooltip
90
+ data[:controller] = 'tooltip'
91
+ data[:'tooltip-text-value'] = tooltip
92
+ data[:'tooltip-placement'] = tooltip_placement || 'top'
93
+ end
94
+
95
+ args[:data] = data
96
+
97
+ button_tag(type: 'button', class: 'mv-drawer-action', 'aria-label': label, **args, &)
98
+ end
99
+
100
+ # Renders a styled external or internal link, with optional tooltip.
101
+ #
102
+ # @param text [String, nil] link text (nil if using block form)
103
+ # @param url [String, nil] target URL
104
+ # @param args_orig [Hash] HTML options (supports `:tooltip`, `:is_blank`)
105
+ # @yield link body when block form is used
106
+ # @return [String] HTML-safe link tag
107
+ def mv_link_to(text = nil, url = nil, args_orig = nil, &block)
108
+ args = assign_test_id(args_orig || {})
109
+ if block_given?
110
+ args = assign_test_id(url || {})
111
+ url = text
112
+ end
113
+
114
+ tooltip = args.fetch(:tooltip, nil)
115
+ is_blank = args.fetch(:is_blank, true)
116
+ underline = args.fetch(:underline, true)
117
+ args_to_apply = args.except(:tooltip, :is_blank)
118
+ if is_blank
119
+ args_to_apply[:target] = '_blank'
120
+ args_to_apply[:rel] = 'noopener noreferrer'
121
+ end
122
+ args_to_apply[:class] = 'underline' if underline
123
+ if tooltip
124
+ args_to_apply[:'data-controller'] = 'tooltip'
125
+ args_to_apply[:'data-tooltip-text-value'] = tooltip
126
+ end
127
+
128
+ if block_given?
129
+ link_to url, args_to_apply do
130
+ capture(&block)
131
+ end
132
+ else
133
+ link_to text, url, **args_to_apply
134
+ end
135
+ end
136
+
137
+ # Renders a tab link for the tabs component.
138
+ # @param tab_name [String] unique name of the tab
139
+ # @param args_org [Hash] additional HTML options
140
+ #
141
+ # @yield tab link content
142
+ #
143
+ # @return [String] HTML-safe link tag
144
+ def mv_tab_link(tab_name, args_org = {}, &)
145
+ args = assign_test_id(args_org)
146
+ classes = ['mv-tab']
147
+ selected = args.delete(:selected)
148
+ classes << (selected ? 'mv-tab--on' : '')
149
+ args[:class] = [args[:class], classes.compact.join(' ')].compact.join(' ').strip
150
+ args[:data] = args.fetch(:data, {}).merge({ action: 'click->tabs#show', 'tabs-target': 'link', name: tab_name })
151
+ link_to '#', **args, &
152
+ end
153
+
154
+ # Shortcut for translating keys under the `mat_views` namespace.
155
+ #
156
+ # @param key [String, Symbol] translation key under `mat_views.*`
157
+ # @param args [Hash] interpolation values
158
+ # @return [String] translated string
159
+ def mv_t(key, **args)
160
+ I18n.t("mat_views.#{key}", **args)
161
+ end
162
+
163
+ # Renders a badge element styled by status.
164
+ #
165
+ # @param status [String, Symbol] status name (`success`, `running`, `failed`, etc.)
166
+ # @param text [String] text to display inside the badge
167
+ # @return [String] HTML-safe span tag with badge classes
168
+ def mv_badge(status, text)
169
+ klass = case status.to_s.downcase
170
+ when 'success' then 'mv-badge mv-badge--success'
171
+ when 'running' then 'mv-badge mv-badge--running'
172
+ when 'failed' then 'mv-badge mv-badge--failed'
173
+ else 'mv-badge'
174
+ end
175
+ content_tag(:span, text, class: klass)
176
+ end
177
+
178
+ # Renders an inline SVG icon.
179
+ #
180
+ # @param name [String, Symbol] icon name (method suffix after `svg_icon_`)
181
+ # @param size [Integer] icon width/height in pixels
182
+ # @param class_name [String, nil] optional extra CSS class
183
+ # @return [String] HTML-safe SVG element
184
+ def mv_icon(name, size: 16, class_name: nil)
185
+ icon_method_name = :"svg_icon_#{name}"
186
+ content_tag :svg,
187
+ class: ['mv-icon', class_name].compact.join(' '),
188
+ xmlns: 'http://www.w3.org/2000/svg',
189
+ width: size, height: size, viewBox: '0 0 24 24',
190
+ fill: 'none', stroke: 'currentColor',
191
+ 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
192
+ 'aria-hidden': 'true' do
193
+ respond_to?(icon_method_name, true) ? raw(send(icon_method_name)) : ''.html_safe
194
+ end
195
+ end
196
+
197
+ # Renders a styled submit button.
198
+ #
199
+ # @param opts [Hash] options for styling, data attributes, etc.
200
+ #
201
+ # @yield button content
202
+ #
203
+ # @return [String] HTML-safe button tag
204
+ def mv_submit_button(opts = {}, &)
205
+ opts = link_options(assign_test_id(opts))
206
+ opts[:type] = 'submit'
207
+ opts.delete(:method)
208
+ button_tag(capture(&), **opts)
209
+ end
210
+
211
+ # Renders a styled cancel button.
212
+ # @param opts [Hash] options for styling, data attributes, etc.
213
+ #
214
+ # @yield button content
215
+ #
216
+ # @return [String] HTML-safe button tag
217
+ def mv_cancel_button(opts = {}, &)
218
+ opts = link_options(assign_test_id(opts))
219
+ opts[:type] = 'button'
220
+ opts.delete(:method)
221
+ button_tag(capture(&), **opts)
222
+ end
223
+
224
+ private
225
+
226
+ # Builds standardized options hash for button/link helpers.
227
+ #
228
+ # @param opts [Hash] options including :variant, :size, :method, :tooltip, etc.
229
+ # @return [Hash] merged HTML attributes (class, method, data)
230
+ def link_options(opts)
231
+ variant = opts.fetch(:variant, :primary)
232
+ size = opts.fetch(:size, :md)
233
+ method = opts.fetch(:method, :get)
234
+ underline = opts[:underline] ? ' underline' : ''
235
+
236
+ tip = opts[:tooltip]
237
+ tooltip = if tip
238
+ {
239
+ controller: 'tooltip',
240
+ 'tooltip-text-value': tip,
241
+ 'tooltip-placement': opts.fetch(:tooltip_placement, 'top')
242
+ }
243
+ else
244
+ {}
245
+ end
246
+
247
+ html_data = (opts[:data] || {}).dup.merge(tooltip)
248
+ html_data[:'turbo-confirm'] = opts.fetch(:confirm, nil)
249
+ { class: "#{mv_button_classes(variant, size)}#{underline}", method: method, data: html_data }
250
+ end
251
+
252
+ # @return [String] SVG markup for a right-pointing arrow icon
253
+ def svg_icon_arrow_right
254
+ <<~SVG.strip.freeze
255
+ <polyline points="9 18 15 12 9 6"/>
256
+ SVG
257
+ end
258
+
259
+ # @return [String] SVG markup for a refresh/reload icon
260
+ def svg_icon_refresh
261
+ <<~SVG.strip.freeze
262
+ <path d="M21 12a9 9 0 1 1-2.64-6.36"/>
263
+ <polyline points="23 4 23 10 17 10"/>
264
+ SVG
265
+ end
266
+
267
+ # @return [String] SVG markup for a trash/delete (bin) icon
268
+ def svg_icon_trash
269
+ <<~SVG.strip.freeze
270
+ <polyline points="3 6 5 6 21 6"/>
271
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
272
+ <path d="M10 11v6"/>
273
+ <path d="M14 11v6"/>
274
+ <path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>
275
+ SVG
276
+ end
277
+
278
+ # @return [String] SVG markup for a hammer/tool icon
279
+ def svg_icon_hammer
280
+ <<~SVG.strip.freeze
281
+ <path d="M14 4l7 7"/>
282
+ <path d="M5 15l7-7 3 3-7 7H5v-3z"/>
283
+ SVG
284
+ end
285
+
286
+ # @return [String] SVG markup for an “X in a circle” (close/error) icon
287
+ def svg_icon_x_circle
288
+ <<~SVG.strip.freeze
289
+ <circle cx="12" cy="12" r="10"/>
290
+ <path d="M15 9l-6 6"/>
291
+ <path d="M9 9l6 6"/>
292
+ SVG
293
+ end
294
+
295
+ # @return [String] SVG markup for a “plus in a circle” (add) icon
296
+ def svg_icon_plus_circle
297
+ <<~SVG.strip.freeze
298
+ <circle cx="12" cy="12" r="10"/>
299
+ <line x1="12" y1="8" x2="12" y2="16"/>
300
+ <line x1="8" y1="12" x2="16" y2="12"/>
301
+ SVG
302
+ end
303
+
304
+ # @return [String] SVG markup for a checkmark-in-circle (success) icon
305
+ def svg_icon_check_circle
306
+ <<~SVG.strip.freeze
307
+ <circle cx="12" cy="12" r="10"/>
308
+ <polyline points="9 12 12 15 16 9"/>
309
+ SVG
310
+ end
311
+
312
+ # @return [String] SVG markup for an alert/warning triangle icon
313
+ def svg_icon_alert
314
+ <<~SVG.strip.freeze
315
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
316
+ <line x1="12" y1="9" x2="12" y2="13"/>
317
+ <line x1="12" y1="17" x2="12.01" y2="17"/>
318
+ SVG
319
+ end
320
+
321
+ # @return [String] SVG markup for a history/clock icon
322
+ def svg_icon_history
323
+ <<~SVG.strip.freeze
324
+ <path d="M3 3v5h5"/>
325
+ <path d="M3.05 13a9 9 0 1 0 .5-5.5"/>
326
+ <path d="M12 7v5l3 3"/>
327
+ SVG
328
+ end
329
+
330
+ # @return [String] SVG markup for an edit/pencil icon
331
+ def svg_icon_edit
332
+ <<~SVG.strip.freeze
333
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
334
+ <path d="M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
335
+ SVG
336
+ end
337
+
338
+ # @return [String] SVG markup for a stacked-layers icon
339
+ def svg_icon_layers
340
+ <<~SVG.strip.freeze
341
+ <polygon points="12 2 2 7 12 12 22 7 12 2"/>
342
+ <polyline points="2 17 12 22 22 17"/>
343
+ <polyline points="2 12 12 17 22 12"/>
344
+ SVG
345
+ end
346
+
347
+ # @return [String] SVG markup for a database/cylinder icon
348
+ def svg_icon_database
349
+ <<~SVG.strip.freeze
350
+ <ellipse cx="12" cy="5" rx="9" ry="3"/>
351
+ <path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
352
+ <path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"/>
353
+ SVG
354
+ end
355
+
356
+ # @return [String] SVG markup for a gear/settings icon
357
+ def svg_icon_gear
358
+ <<~SVG.strip.freeze
359
+ <circle cx="12" cy="12" r="3"></circle>
360
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
361
+ SVG
362
+ end
363
+
364
+ # Maps a symbolic test ID to its actual string value for data attributes.
365
+ # if `:testid` key is not present, returns original args unchanged.
366
+ #
367
+ # @param args [Hash] original options hash
368
+ # @return [Hash] modified options hash with `data-testid` set
369
+ #
370
+ # @example
371
+ # assign_test_id(class: 'btn', testid: :HEADER_LINK)
372
+ # # => { class: 'btn', data: { testid: 'header_link' } }
373
+ #
374
+ def assign_test_id(args = {})
375
+ return args unless args[:testid].present?
376
+
377
+ testid_constant = args.delete(:testid)
378
+ testid_identifier = args.delete(:testid_identifier) || ''
379
+ args_data = args[:data] || {}
380
+ args_data[:testid] = "#{MatViews::Helpers::UiTestIds.const_get(testid_constant)}-#{testid_identifier}".chomp('-')
381
+ args.merge data: args_data
382
+ end
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import "@hotwired/turbo-rails";
8
+ import "mat_views/controllers";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { Application } from "@hotwired/stimulus";
8
+ const application = Application.start();
9
+ window.Stimulus = application;
10
+ export { application };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ static targets = ["content"];
11
+ static values = { duration: Number };
12
+
13
+ connect() {
14
+ this.animating = false;
15
+ // Normalize initial state (support SSR or toggled-by-server)
16
+ if (this.element.open) {
17
+ this._setHeightAuto();
18
+ } else {
19
+ this._setHeight(0);
20
+ }
21
+ }
22
+
23
+ toggle(event) {
24
+ event.preventDefault();
25
+ if (this.animating) this._cancelAnimation();
26
+
27
+ if (this.element.open) {
28
+ this._collapse();
29
+ } else {
30
+ this._expand();
31
+ }
32
+ }
33
+
34
+ // ────────────────────────────────────────────────────────────────
35
+ // internal
36
+ // ────────────────────────────────────────────────────────────────
37
+
38
+ _expand() {
39
+ const el = this.contentTarget;
40
+ this.animating = true;
41
+
42
+ // Set starting point
43
+ this._setTransitionNone();
44
+ this._setHeight(0);
45
+ // Make <details> open *before* measuring end height, so children are laid out.
46
+ this.element.open = true;
47
+
48
+ // Next frame: measure, then animate to end height
49
+ requestAnimationFrame(() => {
50
+ const end = el.scrollHeight;
51
+ this._setTransition();
52
+ this._setHeight(end);
53
+
54
+ this._onTransitionEnd(() => {
55
+ this._setHeightAuto(); // allow content to grow/shrink after expand
56
+ this.animating = false;
57
+ });
58
+ });
59
+ }
60
+
61
+ _collapse() {
62
+ const el = this.contentTarget;
63
+ this.animating = true;
64
+
65
+ // Freeze current height (auto → px) to start a smooth collapse
66
+ this._setTransitionNone();
67
+ const start = el.scrollHeight;
68
+ this._setHeight(start);
69
+
70
+ // Next frame: animate to 0, then close details
71
+ requestAnimationFrame(() => {
72
+ this._setTransition();
73
+ this._setHeight(0);
74
+
75
+ this._onTransitionEnd(() => {
76
+ this.element.open = false;
77
+ this._setTransitionNone();
78
+ this._setHeight(0); // keep at 0 when closed
79
+ this.animating = false;
80
+ });
81
+ });
82
+ }
83
+
84
+ _cancelAnimation() {
85
+ // Interrupt ongoing animation cleanly
86
+ const el = this.contentTarget;
87
+ const computed = parseFloat(getComputedStyle(el).height);
88
+ this._setTransitionNone();
89
+ this._setHeight(computed); // lock current visual height
90
+ this.animating = false;
91
+ }
92
+
93
+ _onTransitionEnd(cb) {
94
+ const el = this.contentTarget;
95
+ const handler = (e) => {
96
+ if (e.target !== el || e.propertyName !== "height") return;
97
+ el.removeEventListener("transitionend", handler);
98
+ el.removeEventListener("transitioncancel", handler);
99
+ cb();
100
+ };
101
+ el.addEventListener("transitionend", handler, { once: false });
102
+ el.addEventListener("transitioncancel", handler, { once: false });
103
+ }
104
+
105
+ _setTransition() {
106
+ const ms = this.hasDurationValue ? this.durationValue : 200;
107
+ this.contentTarget.style.transition = `height ${ms}ms ease`;
108
+ }
109
+
110
+ _setTransitionNone() {
111
+ this.contentTarget.style.transition = "none";
112
+ }
113
+
114
+ _setHeight(px) {
115
+ this.contentTarget.style.height = `${px}px`;
116
+ }
117
+
118
+ _setHeightAuto() {
119
+ this._setTransitionNone();
120
+ this.contentTarget.style.height = "auto";
121
+ }
122
+ }