easy-admin-rails 0.1.15 → 0.2.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +254 -18
  3. data/app/assets/builds/easy_admin.base.js.map +4 -4
  4. data/app/assets/builds/easy_admin.css +112 -18
  5. data/app/components/easy_admin/base_component.rb +1 -0
  6. data/app/components/easy_admin/form_tabs_component.rb +5 -2
  7. data/app/components/easy_admin/navbar_component.rb +5 -1
  8. data/app/components/easy_admin/permissions/user_role_assignment_component.rb +254 -0
  9. data/app/components/easy_admin/permissions/user_role_permissions_component.rb +186 -0
  10. data/app/components/easy_admin/resources/index_component.rb +1 -4
  11. data/app/components/easy_admin/sidebar_component.rb +67 -2
  12. data/app/controllers/easy_admin/application_controller.rb +131 -1
  13. data/app/controllers/easy_admin/batch_actions_controller.rb +27 -0
  14. data/app/controllers/easy_admin/concerns/belongs_to_editing.rb +201 -0
  15. data/app/controllers/easy_admin/concerns/inline_field_editing.rb +297 -0
  16. data/app/controllers/easy_admin/concerns/resource_authorization.rb +55 -0
  17. data/app/controllers/easy_admin/concerns/resource_filtering.rb +178 -0
  18. data/app/controllers/easy_admin/concerns/resource_loading.rb +149 -0
  19. data/app/controllers/easy_admin/concerns/resource_pagination.rb +135 -0
  20. data/app/controllers/easy_admin/dashboard_controller.rb +2 -1
  21. data/app/controllers/easy_admin/dashboards_controller.rb +6 -40
  22. data/app/controllers/easy_admin/resources_controller.rb +13 -762
  23. data/app/controllers/easy_admin/row_actions_controller.rb +25 -0
  24. data/app/helpers/easy_admin/fields_helper.rb +61 -9
  25. data/app/javascript/easy_admin/controllers/event_emitter_controller.js +2 -4
  26. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +0 -10
  27. data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +1 -4
  28. data/app/javascript/easy_admin/controllers/permission_toggle_controller.js +227 -0
  29. data/app/javascript/easy_admin/controllers/role_preview_controller.js +93 -0
  30. data/app/javascript/easy_admin/controllers/select_field_controller.js +1 -2
  31. data/app/javascript/easy_admin/controllers/settings_button_controller.js +1 -2
  32. data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +1 -4
  33. data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +0 -2
  34. data/app/javascript/easy_admin/controllers.js +5 -1
  35. data/app/models/easy_admin/admin_user.rb +6 -0
  36. data/app/policies/admin_user_policy.rb +36 -0
  37. data/app/policies/application_policy.rb +83 -0
  38. data/app/views/easy_admin/application/authorization_failure.turbo_stream.erb +8 -0
  39. data/app/views/easy_admin/dashboards/card.html.erb +5 -0
  40. data/app/views/easy_admin/dashboards/card.turbo_stream.erb +7 -0
  41. data/app/views/easy_admin/dashboards/card_error.html.erb +3 -0
  42. data/app/views/easy_admin/dashboards/card_error.turbo_stream.erb +5 -0
  43. data/app/views/easy_admin/dashboards/show.turbo_stream.erb +7 -0
  44. data/app/views/easy_admin/resources/belongs_to_edit_attached.html.erb +6 -0
  45. data/app/views/easy_admin/resources/belongs_to_edit_attached.turbo_stream.erb +8 -0
  46. data/app/views/easy_admin/resources/belongs_to_reattach.html.erb +5 -0
  47. data/app/views/easy_admin/resources/edit.html.erb +1 -1
  48. data/app/views/easy_admin/resources/edit_field.html.erb +5 -0
  49. data/app/views/easy_admin/resources/edit_field.turbo_stream.erb +7 -0
  50. data/app/views/easy_admin/resources/index.html.erb +1 -1
  51. data/app/views/easy_admin/resources/index_frame.html.erb +8 -142
  52. data/app/views/easy_admin/resources/update_belongs_to_attached.turbo_stream.erb +25 -0
  53. data/app/views/layouts/easy_admin/application.html.erb +15 -2
  54. data/config/initializers/easy_admin_permissions.rb +73 -0
  55. data/db/seeds/easy_admin_permissions.rb +121 -0
  56. data/lib/easy-admin-rails.rb +2 -0
  57. data/lib/easy_admin/permissions/component.rb +168 -0
  58. data/lib/easy_admin/permissions/configuration.rb +37 -0
  59. data/lib/easy_admin/permissions/controller.rb +164 -0
  60. data/lib/easy_admin/permissions/dsl.rb +180 -0
  61. data/lib/easy_admin/permissions/models.rb +44 -0
  62. data/lib/easy_admin/permissions/permission_denied_component.rb +121 -0
  63. data/lib/easy_admin/permissions/resource_permissions.rb +231 -0
  64. data/lib/easy_admin/permissions/role_definition.rb +45 -0
  65. data/lib/easy_admin/permissions/role_denied_component.rb +159 -0
  66. data/lib/easy_admin/permissions/role_dsl.rb +73 -0
  67. data/lib/easy_admin/permissions/user_extensions.rb +129 -0
  68. data/lib/easy_admin/permissions.rb +113 -0
  69. data/lib/easy_admin/resource/base.rb +119 -0
  70. data/lib/easy_admin/resource/configuration.rb +148 -0
  71. data/lib/easy_admin/resource/dsl.rb +117 -0
  72. data/lib/easy_admin/resource/field_registry.rb +189 -0
  73. data/lib/easy_admin/resource/form_builder.rb +123 -0
  74. data/lib/easy_admin/resource/layout_builder.rb +249 -0
  75. data/lib/easy_admin/resource/scope_manager.rb +252 -0
  76. data/lib/easy_admin/resource/show_builder.rb +359 -0
  77. data/lib/easy_admin/resource.rb +8 -835
  78. data/lib/easy_admin/resource_modules.rb +11 -0
  79. data/lib/easy_admin/version.rb +1 -1
  80. data/lib/generators/easy_admin/permissions/install_generator.rb +90 -0
  81. data/lib/generators/easy_admin/permissions/templates/initializers/permissions.rb +37 -0
  82. data/lib/generators/easy_admin/permissions/templates/migrations/create_permission_tables.rb +27 -0
  83. data/lib/generators/easy_admin/permissions/templates/migrations/update_users_for_permissions.rb +6 -0
  84. data/lib/generators/easy_admin/permissions/templates/models/permission.rb +9 -0
  85. data/lib/generators/easy_admin/permissions/templates/models/role.rb +9 -0
  86. data/lib/generators/easy_admin/permissions/templates/models/role_permission.rb +9 -0
  87. data/lib/generators/easy_admin/permissions/templates/models/user_role.rb +9 -0
  88. data/lib/generators/easy_admin/permissions/templates/policies/application_policy.rb +47 -0
  89. data/lib/generators/easy_admin/permissions/templates/policies/user_policy.rb +36 -0
  90. data/lib/generators/easy_admin/permissions/templates/seeds/permissions.rb +89 -0
  91. metadata +62 -5
  92. data/db/migrate/20250101000001_create_easy_admin_admin_users.rb +0 -45
@@ -0,0 +1,252 @@
1
+ module EasyAdmin
2
+ module ResourceModules
3
+ # ScopeManager module for handling resource scopes and filtering
4
+ # Manages scope definitions, default scopes, and scope-related queries
5
+ module ScopeManager
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :scopes_config
10
+
11
+ def self.initialize_scope_manager
12
+ self.scopes_config = []
13
+ end
14
+ end
15
+
16
+ class_methods do
17
+ # Core scope definition method
18
+ def scope(name, **options)
19
+ scope_config = {
20
+ name: name,
21
+ label: options[:label] || name.to_s.humanize,
22
+ scope_method: options[:scope] || name,
23
+ default: options.fetch(:default, false),
24
+ icon: options[:icon],
25
+ color: options[:color] || 'blue',
26
+ count: options.fetch(:show_count, true),
27
+ condition: options[:if] # Conditional scope visibility
28
+ }
29
+
30
+ add_scope(scope_config)
31
+ end
32
+
33
+ # Add scope to configuration
34
+ def add_scope(scope_config)
35
+ self.scopes_config = (self.scopes_config || []) + [scope_config]
36
+ end
37
+
38
+ # Scope query methods
39
+ def scopes
40
+ scopes_config
41
+ end
42
+
43
+ def has_scopes?
44
+ scopes_config.any?
45
+ end
46
+
47
+ def scope_names
48
+ scopes_config.map { |scope| scope[:name] }
49
+ end
50
+
51
+ def find_scope(name)
52
+ scopes_config.find { |scope| scope[:name].to_s == name.to_s }
53
+ end
54
+
55
+ def scope_exists?(name)
56
+ find_scope(name).present?
57
+ end
58
+
59
+ # Default scope management
60
+ def default_scope
61
+ scopes_config.find { |scope| scope[:default] } || scopes_config.first
62
+ end
63
+
64
+ def set_default_scope(name)
65
+ # Remove existing default
66
+ scopes_config.each { |scope| scope[:default] = false }
67
+
68
+ # Set new default
69
+ scope = find_scope(name)
70
+ scope[:default] = true if scope
71
+ end
72
+
73
+ def has_default_scope?
74
+ default_scope.present?
75
+ end
76
+
77
+ # Conditional scopes
78
+ def visible_scopes(context = {})
79
+ scopes_config.select do |scope_config|
80
+ condition = scope_config[:condition]
81
+ condition.nil? || (condition.respond_to?(:call) ? context.instance_eval(&condition) : condition)
82
+ end
83
+ end
84
+
85
+ def conditional_scopes
86
+ scopes_config.select { |scope| scope[:condition].present? }
87
+ end
88
+
89
+ # Scope application methods
90
+ def apply_scope_to_records(records, scope_name)
91
+ return records unless scope_exists?(scope_name)
92
+
93
+ scope_config = find_scope(scope_name)
94
+ return records if scope_config[:name] == :all
95
+
96
+ scope_method = scope_config[:scope_method]
97
+ if records.respond_to?(scope_method)
98
+ records.public_send(scope_method)
99
+ else
100
+ records
101
+ end
102
+ end
103
+
104
+ def apply_default_scope_to_records(records)
105
+ return records unless has_default_scope?
106
+
107
+ default = default_scope
108
+ return records if default[:name] == :all
109
+
110
+ apply_scope_to_records(records, default[:name])
111
+ end
112
+
113
+ # Scope counts calculation
114
+ def calculate_scope_counts(base_records = nil)
115
+ base_records ||= model_class.all
116
+ counts = {}
117
+
118
+ scopes_config.each do |scope_config|
119
+ scope_name = scope_config[:name]
120
+ next unless scope_config[:count] # Skip if count is disabled
121
+
122
+ if scope_name == :all
123
+ counts[scope_name] = base_records.count
124
+ else
125
+ scope_method = scope_config[:scope_method]
126
+ if model_class.respond_to?(scope_method)
127
+ counts[scope_name] = model_class.public_send(scope_method).count
128
+ else
129
+ counts[scope_name] = 0
130
+ end
131
+ end
132
+ end
133
+
134
+ counts
135
+ end
136
+
137
+ def scope_count(scope_name, base_records = nil)
138
+ return 0 unless scope_exists?(scope_name)
139
+
140
+ scope_config = find_scope(scope_name)
141
+ return 0 unless scope_config[:count]
142
+
143
+ if scope_name == :all
144
+ base_records ||= model_class.all
145
+ base_records.count
146
+ else
147
+ scope_method = scope_config[:scope_method]
148
+ if model_class.respond_to?(scope_method)
149
+ model_class.public_send(scope_method).count
150
+ else
151
+ 0
152
+ end
153
+ end
154
+ end
155
+
156
+ # Scope utilities
157
+ def scope_label(scope_name)
158
+ scope_config = find_scope(scope_name)
159
+ scope_config ? scope_config[:label] : scope_name.to_s.humanize
160
+ end
161
+
162
+ def scope_icon(scope_name)
163
+ scope_config = find_scope(scope_name)
164
+ scope_config&.dig(:icon)
165
+ end
166
+
167
+ def scope_color(scope_name)
168
+ scope_config = find_scope(scope_name)
169
+ scope_config&.dig(:color) || 'blue'
170
+ end
171
+
172
+ def scope_method_name(scope_name)
173
+ scope_config = find_scope(scope_name)
174
+ scope_config ? scope_config[:scope_method] : scope_name
175
+ end
176
+
177
+ # Scope validation
178
+ def validate_scopes
179
+ errors = []
180
+
181
+ scopes_config.each do |scope_config|
182
+ scope_name = scope_config[:name]
183
+ scope_method = scope_config[:scope_method]
184
+
185
+ # Check if scope method exists on model (skip :all)
186
+ if scope_name != :all && !model_class.respond_to?(scope_method)
187
+ errors << "Scope method '#{scope_method}' does not exist on #{model_class.name}"
188
+ end
189
+
190
+ # Check for duplicate scope names
191
+ duplicate_count = scopes_config.count { |s| s[:name] == scope_name }
192
+ if duplicate_count > 1
193
+ errors << "Duplicate scope name '#{scope_name}' found"
194
+ end
195
+ end
196
+
197
+ errors
198
+ end
199
+
200
+ def valid_scopes?
201
+ validate_scopes.empty?
202
+ end
203
+
204
+ # Scope management
205
+ def clear_scopes
206
+ self.scopes_config = []
207
+ end
208
+
209
+ def remove_scope(scope_name)
210
+ self.scopes_config = scopes_config.reject { |scope| scope[:name] == scope_name }
211
+ end
212
+
213
+ def total_scopes_count
214
+ scopes_config.count
215
+ end
216
+
217
+ def enabled_scope_counts_count
218
+ scopes_config.count { |scope| scope[:count] }
219
+ end
220
+
221
+ # Predefined scope helpers
222
+ def add_all_scope(label: "All", default: true)
223
+ scope(:all, label: label, scope: :all, default: default)
224
+ end
225
+
226
+ def add_recent_scope(days: 7, label: "Recent")
227
+ return unless model_class.column_names.include?('created_at')
228
+
229
+ scope(:recent,
230
+ label: label,
231
+ scope: -> { where('created_at >= ?', days.days.ago) })
232
+ end
233
+
234
+ def add_published_scope(column: :published, label: "Published")
235
+ return unless model_class.column_names.include?(column.to_s)
236
+
237
+ scope(:published,
238
+ label: label,
239
+ scope: -> { where(column => true) })
240
+ end
241
+
242
+ def add_unpublished_scope(column: :published, label: "Unpublished")
243
+ return unless model_class.column_names.include?(column.to_s)
244
+
245
+ scope(:unpublished,
246
+ label: label,
247
+ scope: -> { where(column => [false, nil]) })
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,359 @@
1
+ module EasyAdmin
2
+ module ResourceModules
3
+ # ShowBuilder class for show layout DSL
4
+ # Handles all show page layout definitions with rows, columns, cards, and tabs
5
+ class ShowBuilder
6
+ PREDEFINED_COLORS = %w[primary secondary success danger warning info light dark white transparent].freeze
7
+ COLUMN_SIZES = [1, 2, 3, 4, 6, 8, 12].freeze
8
+ SPACING_OPTIONS = %w[none small medium large].freeze
9
+ PADDING_OPTIONS = %w[none small medium large].freeze
10
+ HEADING_SIZES = %w[small medium large].freeze
11
+ DIVIDER_STYLES = %w[default dashed dotted thick].freeze
12
+ SHADOW_OPTIONS = %w[none small medium large].freeze
13
+
14
+ def initialize(resource_class)
15
+ @resource_class = resource_class
16
+ @context_stack = []
17
+ @current_row = nil
18
+ @current_column = nil
19
+ @current_card = nil
20
+ @current_tab_container = nil
21
+ @current_tab = nil
22
+ end
23
+
24
+ # Layout methods
25
+ def row(columns: 1, spacing: "medium", &block)
26
+ validate_spacing!(spacing)
27
+
28
+ row_config = {
29
+ type: :row,
30
+ columns_count: columns,
31
+ spacing: spacing,
32
+ columns: []
33
+ }
34
+
35
+ push_context
36
+ @current_row = row_config
37
+
38
+ if @current_column
39
+ @current_column[:elements] << row_config
40
+ else
41
+ @resource_class.add_show_layout_element(row_config)
42
+ end
43
+
44
+ instance_eval(&block) if block_given?
45
+ pop_context
46
+ end
47
+
48
+ def column(size: nil, &block)
49
+ raise "column must be called within a row block" unless @current_row
50
+
51
+ calculated_size = size || (12 / [@current_row[:columns_count], 1].max)
52
+ validate_column_size!(calculated_size)
53
+
54
+ column_config = {
55
+ type: :column,
56
+ size: calculated_size,
57
+ elements: []
58
+ }
59
+
60
+ push_context
61
+ @current_column = column_config
62
+ @current_row[:columns] << column_config
63
+
64
+ instance_eval(&block) if block_given?
65
+ pop_context
66
+ end
67
+
68
+ # Card methods
69
+ def card(title: nil, color: "light", padding: "medium", shadow: "small", &block)
70
+ raise "card must be called within a column block" unless @current_column
71
+ validate_color!(color)
72
+ validate_padding!(padding)
73
+ validate_shadow!(shadow)
74
+
75
+ card_config = {
76
+ type: :card,
77
+ title: title,
78
+ color: color,
79
+ padding: padding,
80
+ shadow: shadow,
81
+ elements: []
82
+ }
83
+
84
+ push_context
85
+ @current_card = card_config
86
+ @current_column[:elements] << card_config
87
+
88
+ instance_eval(&block) if block_given?
89
+ pop_context
90
+ end
91
+
92
+ def metric_card(title:, value:, color: "primary", icon: nil, trend: nil)
93
+ raise "metric_card must be called within a column block" unless @current_column
94
+ validate_color!(color)
95
+
96
+ metric_config = {
97
+ type: :metric_card,
98
+ title: title,
99
+ value: value,
100
+ icon: icon,
101
+ color: color,
102
+ trend: trend
103
+ }
104
+
105
+ @current_column[:elements] << metric_config
106
+ end
107
+
108
+ def chart_card(title:, chart_type:, **options, &block)
109
+ raise "chart_card must be called within a column block" unless @current_column
110
+
111
+ chart_config = {
112
+ type: :chart_card,
113
+ title: title,
114
+ chart_type: chart_type,
115
+ height: options[:height] || 350,
116
+ data_source: options[:data_source],
117
+ css_classes: options[:class] || "card",
118
+ config: {}
119
+ }
120
+
121
+ if block_given?
122
+ chart_builder = ChartBuilder.new
123
+ chart_builder.instance_eval(&block)
124
+ chart_config[:config] = chart_builder.config
125
+ end
126
+
127
+ @current_column[:elements] << chart_config
128
+ end
129
+
130
+ # Tab methods
131
+ def tabs(**options, &block)
132
+ container = @current_card || @current_column
133
+ raise "tabs must be called within a card or column block" unless container
134
+
135
+ tabs_config = {
136
+ type: :tabs,
137
+ css_classes: options[:class] || "tabs-menu",
138
+ tabs: []
139
+ }
140
+
141
+ push_context
142
+ @current_tab_container = tabs_config
143
+ container[:elements] << tabs_config
144
+
145
+ instance_eval(&block) if block_given?
146
+ pop_context
147
+ end
148
+
149
+ def tab(name, **options, &block)
150
+ raise "tab must be called within a tabs block" unless @current_tab_container
151
+
152
+ tab_config = {
153
+ name: name,
154
+ label: options[:label] || name,
155
+ icon: options[:icon],
156
+ active: options[:active] || false,
157
+ elements: []
158
+ }
159
+
160
+ push_context
161
+ @current_tab = tab_config
162
+ @current_tab_container[:tabs] << tab_config
163
+
164
+ instance_eval(&block) if block_given?
165
+ pop_context
166
+ end
167
+
168
+ # Field display methods
169
+ def field(name, **options)
170
+ container = @current_tab || @current_card || @current_column
171
+ raise "field must be called within a tab, card, or column block" unless container
172
+
173
+ label_value = if options.key?(:label) && options[:label] == false
174
+ nil
175
+ else
176
+ options[:label] || name.to_s.humanize
177
+ end
178
+
179
+ field_config = {
180
+ type: :field,
181
+ name: name,
182
+ label: label_value,
183
+ field_type: options[:field_type] || :default,
184
+ format: options[:format],
185
+ css_classes: options[:class],
186
+ show_label: options.fetch(:show_label, true)
187
+ }
188
+
189
+ container[:elements] << field_config
190
+ end
191
+
192
+ def fields(*field_names, **options)
193
+ field_names.each do |name|
194
+ field(name, **options)
195
+ end
196
+ end
197
+
198
+ # Component rendering support with context
199
+ def render(component_class_or_instance, **options)
200
+ container = @current_tab || @current_card || @current_column
201
+ raise "render must be called within a tab, card, or column block" unless container
202
+
203
+ component_config = {
204
+ type: :custom_component,
205
+ component_class: component_class_or_instance.class,
206
+ component_options: options,
207
+ component_instance: component_class_or_instance
208
+ }
209
+
210
+ container[:elements] << component_config
211
+ component_class_or_instance
212
+ end
213
+
214
+ # Content methods
215
+ def content(html = nil, **options, &block)
216
+ container = @current_tab || @current_card || @current_column
217
+ raise "content must be called within a tab, card, or column block" unless container
218
+
219
+ content_config = {
220
+ type: :content,
221
+ html: html,
222
+ css_classes: options[:class],
223
+ block: block
224
+ }
225
+
226
+ container[:elements] << content_config
227
+ end
228
+
229
+ def heading(text, level: 2, size: "medium")
230
+ container = @current_tab || @current_card || @current_column
231
+ raise "heading must be called within a tab, card, or column block" unless container
232
+
233
+ validate_heading_level!(level)
234
+ validate_heading_size!(size)
235
+
236
+ heading_config = {
237
+ type: :heading,
238
+ text: text,
239
+ level: level,
240
+ size: size
241
+ }
242
+
243
+ container[:elements] << heading_config
244
+ end
245
+
246
+ def divider(style: "default")
247
+ container = @current_tab || @current_card || @current_column
248
+ raise "divider must be called within a tab, card, or column block" unless container
249
+
250
+ validate_divider_style!(style)
251
+
252
+ divider_config = {
253
+ type: :divider,
254
+ style: style
255
+ }
256
+
257
+ container[:elements] << divider_config
258
+ end
259
+
260
+ private
261
+
262
+ def validate_color!(color)
263
+ return if PREDEFINED_COLORS.include?(color.to_s)
264
+ raise ArgumentError, "Invalid color '#{color}'. Available colors: #{PREDEFINED_COLORS.join(', ')}"
265
+ end
266
+
267
+ def validate_column_size!(size)
268
+ return if COLUMN_SIZES.include?(size)
269
+ raise ArgumentError, "Invalid column size '#{size}'. Available sizes: #{COLUMN_SIZES.join(', ')}"
270
+ end
271
+
272
+ def validate_spacing!(spacing)
273
+ return if SPACING_OPTIONS.include?(spacing.to_s)
274
+ raise ArgumentError, "Invalid spacing '#{spacing}'. Available options: #{SPACING_OPTIONS.join(', ')}"
275
+ end
276
+
277
+ def validate_padding!(padding)
278
+ return if PADDING_OPTIONS.include?(padding.to_s)
279
+ raise ArgumentError, "Invalid padding '#{padding}'. Available options: #{PADDING_OPTIONS.join(', ')}"
280
+ end
281
+
282
+ def validate_shadow!(shadow)
283
+ return if SHADOW_OPTIONS.include?(shadow.to_s)
284
+ raise ArgumentError, "Invalid shadow '#{shadow}'. Available options: #{SHADOW_OPTIONS.join(', ')}"
285
+ end
286
+
287
+ def validate_heading_level!(level)
288
+ return if (1..6).include?(level)
289
+ raise ArgumentError, "Invalid heading level '#{level}'. Must be between 1 and 6"
290
+ end
291
+
292
+ def validate_heading_size!(size)
293
+ return if HEADING_SIZES.include?(size.to_s)
294
+ raise ArgumentError, "Invalid heading size '#{size}'. Available sizes: #{HEADING_SIZES.join(', ')}"
295
+ end
296
+
297
+ def validate_divider_style!(style)
298
+ return if DIVIDER_STYLES.include?(style.to_s)
299
+ raise ArgumentError, "Invalid divider style '#{style}'. Available styles: #{DIVIDER_STYLES.join(', ')}"
300
+ end
301
+
302
+ def push_context
303
+ @context_stack.push({
304
+ row: @current_row,
305
+ column: @current_column,
306
+ card: @current_card,
307
+ tab_container: @current_tab_container,
308
+ tab: @current_tab
309
+ })
310
+ end
311
+
312
+ def pop_context
313
+ if @context_stack.any?
314
+ context = @context_stack.pop
315
+ @current_row = context[:row]
316
+ @current_column = context[:column]
317
+ @current_card = context[:card]
318
+ @current_tab_container = context[:tab_container]
319
+ @current_tab = context[:tab]
320
+ else
321
+ @current_row = nil
322
+ @current_column = nil
323
+ @current_card = nil
324
+ @current_tab_container = nil
325
+ @current_tab = nil
326
+ end
327
+ end
328
+ end
329
+
330
+ # ChartBuilder class for chart configuration
331
+ class ChartBuilder
332
+ attr_reader :config
333
+
334
+ def initialize
335
+ @config = {}
336
+ end
337
+
338
+ def series(data)
339
+ @config[:series] = data
340
+ end
341
+
342
+ def categories(data)
343
+ @config[:categories] = data
344
+ end
345
+
346
+ def colors(data)
347
+ @config[:colors] = data
348
+ end
349
+
350
+ def title(text)
351
+ @config[:title] = text
352
+ end
353
+
354
+ def subtitle(text)
355
+ @config[:subtitle] = text
356
+ end
357
+ end
358
+ end
359
+ end