easy-admin-rails 0.2.5 → 0.2.7

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +95 -0
  3. data/app/assets/builds/easy_admin.base.js.map +3 -3
  4. data/app/assets/builds/easy_admin.css +226 -0
  5. data/app/components/easy_admin/fields/form/belongs_to_component.rb +0 -1
  6. data/app/components/easy_admin/form_layout_component.rb +553 -0
  7. data/app/components/easy_admin/navbar_component.rb +19 -4
  8. data/app/components/easy_admin/permissions/user_role_permissions_component.rb +1 -3
  9. data/app/components/easy_admin/profile/change_password_modal_component.rb +75 -0
  10. data/app/components/easy_admin/profile/profile_tab_component.rb +92 -0
  11. data/app/components/easy_admin/profile/security_tab_component.rb +53 -0
  12. data/app/components/easy_admin/profile/settings_component.rb +103 -0
  13. data/app/components/easy_admin/show_layout_component.rb +694 -24
  14. data/app/components/easy_admin/two_factor/backup_codes_component.rb +118 -0
  15. data/app/components/easy_admin/two_factor/setup_component.rb +124 -0
  16. data/app/components/easy_admin/two_factor/status_component.rb +92 -0
  17. data/app/controllers/concerns/easy_admin/two_factor.rb +110 -0
  18. data/app/controllers/easy_admin/application_controller.rb +10 -5
  19. data/app/controllers/easy_admin/batch_actions_controller.rb +0 -1
  20. data/app/controllers/easy_admin/concerns/inline_field_editing.rb +4 -11
  21. data/app/controllers/easy_admin/concerns/resource_loading.rb +10 -9
  22. data/app/controllers/easy_admin/concerns/resource_pagination.rb +3 -0
  23. data/app/controllers/easy_admin/dashboards_controller.rb +0 -1
  24. data/app/controllers/easy_admin/profile_controller.rb +25 -0
  25. data/app/controllers/easy_admin/resources_controller.rb +1 -5
  26. data/app/controllers/easy_admin/row_actions_controller.rb +1 -4
  27. data/app/controllers/easy_admin/sessions_controller.rb +107 -1
  28. data/app/helpers/easy_admin/fields_helper.rb +8 -22
  29. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +12 -0
  30. data/app/javascript/easy_admin/controllers/vertical_tabs_controller.js +112 -0
  31. data/app/javascript/easy_admin/controllers.js +3 -1
  32. data/app/models/easy_admin/admin_user.rb +3 -0
  33. data/app/views/easy_admin/profile/backup_codes_regenerated.turbo_stream.erb +12 -0
  34. data/app/views/easy_admin/profile/change_password.html.erb +24 -0
  35. data/app/views/easy_admin/profile/index.html.erb +1 -0
  36. data/app/views/easy_admin/profile/password_error.turbo_stream.erb +6 -0
  37. data/app/views/easy_admin/profile/password_invalid_current.turbo_stream.erb +6 -0
  38. data/app/views/easy_admin/profile/password_updated.turbo_stream.erb +9 -0
  39. data/app/views/easy_admin/profile/two_factor_backup_codes.html.erb +24 -0
  40. data/app/views/easy_admin/profile/two_factor_enabled.turbo_stream.erb +12 -0
  41. data/app/views/easy_admin/profile/two_factor_invalid_code.turbo_stream.erb +6 -0
  42. data/app/views/easy_admin/profile/two_factor_not_enabled.turbo_stream.erb +6 -0
  43. data/app/views/easy_admin/profile/two_factor_setup.html.erb +24 -0
  44. data/app/views/easy_admin/profile/two_factor_unavailable.turbo_stream.erb +6 -0
  45. data/app/views/easy_admin/resources/edit.html.erb +2 -2
  46. data/app/views/easy_admin/resources/new.html.erb +2 -2
  47. data/app/views/easy_admin/resources/show.html.erb +3 -1
  48. data/app/views/easy_admin/sessions/two_factor_verification.html.erb +48 -0
  49. data/app/views/easy_admin/sessions/verify_2fa_error.turbo_stream.erb +13 -0
  50. data/config/routes.rb +20 -1
  51. data/lib/easy-admin-rails.rb +1 -0
  52. data/lib/easy_admin/field.rb +3 -2
  53. data/lib/easy_admin/layouts/builders/base_layout_builder.rb +245 -0
  54. data/lib/easy_admin/layouts/builders/form_layout_builder.rb +208 -0
  55. data/lib/easy_admin/layouts/builders/index_layout_builder.rb +22 -0
  56. data/lib/easy_admin/layouts/builders/show_layout_builder.rb +199 -0
  57. data/lib/easy_admin/layouts/dsl.rb +200 -0
  58. data/lib/easy_admin/layouts/layout_context.rb +189 -0
  59. data/lib/easy_admin/layouts/nodes/base_node.rb +88 -0
  60. data/lib/easy_admin/layouts/nodes/divider.rb +27 -0
  61. data/lib/easy_admin/layouts/nodes/field_node.rb +57 -0
  62. data/lib/easy_admin/layouts/nodes/grid.rb +60 -0
  63. data/lib/easy_admin/layouts/nodes/render_node.rb +41 -0
  64. data/lib/easy_admin/layouts/nodes/root.rb +25 -0
  65. data/lib/easy_admin/layouts/nodes/section.rb +46 -0
  66. data/lib/easy_admin/layouts/nodes/spacer.rb +17 -0
  67. data/lib/easy_admin/layouts/nodes/stubs.rb +109 -0
  68. data/lib/easy_admin/layouts/nodes/tab.rb +40 -0
  69. data/lib/easy_admin/layouts/nodes/tabs.rb +40 -0
  70. data/lib/easy_admin/layouts.rb +28 -0
  71. data/lib/easy_admin/permissions/resource_permissions.rb +1 -5
  72. data/lib/easy_admin/resource/base.rb +2 -2
  73. data/lib/easy_admin/resource/dsl.rb +2 -11
  74. data/lib/easy_admin/resource/field_registry.rb +58 -2
  75. data/lib/easy_admin/resource.rb +0 -9
  76. data/lib/easy_admin/resource_modules.rb +21 -4
  77. data/lib/easy_admin/two_factor_authentication.rb +156 -0
  78. data/lib/easy_admin/version.rb +1 -1
  79. data/lib/generators/easy_admin/permissions/install_generator.rb +0 -10
  80. data/lib/generators/easy_admin/permissions/templates/migrations/create_permission_tables.rb +33 -3
  81. data/lib/generators/easy_admin/two_factor/templates/README +29 -0
  82. data/lib/generators/easy_admin/two_factor/templates/migration.rb +10 -0
  83. data/lib/generators/easy_admin/two_factor/two_factor_generator.rb +22 -0
  84. metadata +49 -9
  85. data/lib/easy_admin/resource/form_builder.rb +0 -123
  86. data/lib/easy_admin/resource/layout_builder.rb +0 -249
  87. data/lib/easy_admin/resource/show_builder.rb +0 -359
  88. data/lib/generators/easy_admin/permissions/templates/migrations/update_users_for_permissions.rb +0 -6
  89. data/lib/generators/easy_admin/rbac/rbac_generator.rb +0 -244
  90. data/lib/generators/easy_admin/rbac/templates/add_rbac_to_admin_users.rb +0 -23
  91. data/lib/generators/easy_admin/rbac/templates/super_admin.rb +0 -34
@@ -92,9 +92,9 @@ module EasyAdmin
92
92
 
93
93
  begin
94
94
  action_component_name.constantize
95
- rescue NameError
95
+ rescue NameError => e
96
96
  # Fallback to base text component for the action
97
- case action
97
+ fallback_class = case action
98
98
  when :index
99
99
  EasyAdmin::Fields::Index::TextComponent
100
100
  when :show
@@ -104,6 +104,7 @@ module EasyAdmin
104
104
  else
105
105
  EasyAdmin::Fields::Index::TextComponent
106
106
  end
107
+ fallback_class
107
108
  end
108
109
  end
109
110
  end
@@ -0,0 +1,245 @@
1
+ module EasyAdmin
2
+ module Layouts
3
+ module Builders
4
+ # Base builder for constructing layout AST
5
+ class BaseLayoutBuilder
6
+ attr_reader :root_node, :resource_class
7
+ attr_accessor :current_container_stack
8
+
9
+ def initialize(layout_type = :show, resource_class: nil)
10
+ @root_node = Nodes::Root.new(layout_type: layout_type)
11
+ @current_container_stack = [@root_node]
12
+ @resource_class = resource_class
13
+ end
14
+
15
+ # Get current container for adding nodes
16
+ def current_container
17
+ @current_container_stack.last
18
+ end
19
+
20
+ # Add node to current container
21
+ def add_node(node)
22
+ current_container.add_child(node)
23
+ node
24
+ end
25
+
26
+ # Execute block with new container
27
+ def with_container(container_node, &block)
28
+ add_node(container_node)
29
+ @current_container_stack.push(container_node)
30
+ instance_exec(&block) if block_given?
31
+ @current_container_stack.pop
32
+ container_node
33
+ end
34
+
35
+ # DSL Methods
36
+
37
+ # Add tabs container
38
+ def tabs(type: :horizontal, **attributes, &block)
39
+ tabs_node = Nodes::Tabs.new(attributes.merge(type: type))
40
+ add_node(tabs_node)
41
+
42
+ if block_given?
43
+ @current_container_stack.push(tabs_node)
44
+ tabs_builder = TabsBuilder.new(self, tabs_node)
45
+ tabs_builder.instance_exec(&block)
46
+ @current_container_stack.pop
47
+ end
48
+
49
+ tabs_node
50
+ end
51
+
52
+ # Add section
53
+ def section(title = nil, **attributes, &block)
54
+ section_node = Nodes::Section.new(title, attributes)
55
+
56
+ if block_given?
57
+ with_container(section_node, &block)
58
+ else
59
+ add_node(section_node)
60
+ end
61
+
62
+ section_node
63
+ end
64
+
65
+ # Add grid layout
66
+ def grid(columns: 2, **attributes, &block)
67
+ grid_node = Nodes::Grid.new(attributes.merge(columns: columns))
68
+
69
+ if block_given?
70
+ with_container(grid_node, &block)
71
+ else
72
+ add_node(grid_node)
73
+ end
74
+
75
+ grid_node
76
+ end
77
+
78
+ # Add field
79
+ def field(name, **options)
80
+ field_node = Nodes::FieldNode.new(name, options)
81
+ add_node(field_node)
82
+ end
83
+
84
+ # Add multiple fields at once
85
+ def fields(*names, **common_options)
86
+ names.each do |name|
87
+ field(name, **common_options)
88
+ end
89
+ end
90
+
91
+ # Render custom component
92
+ def render(component_class, **props)
93
+ render_node = Nodes::RenderNode.new(component_class, props)
94
+ add_node(render_node)
95
+ end
96
+
97
+ # Add divider
98
+ def divider(**attributes)
99
+ add_node(Nodes::Divider.new(attributes))
100
+ end
101
+
102
+ # Add spacer
103
+ def spacer(size: 4, **attributes)
104
+ add_node(Nodes::Spacer.new(attributes.merge(size: size)))
105
+ end
106
+
107
+ # Build and return the AST
108
+ def build
109
+ @root_node
110
+ end
111
+
112
+ # Allow custom DSL extensions
113
+ def method_missing(method, *args, &block)
114
+ # Check if it's a registered component type
115
+ if EasyAdmin::Layouts.registered_components.key?(method)
116
+ component_class = EasyAdmin::Layouts.registered_components[method]
117
+ render(component_class, *args)
118
+ else
119
+ super
120
+ end
121
+ end
122
+
123
+ def respond_to_missing?(method, include_private = false)
124
+ EasyAdmin::Layouts.registered_components.key?(method) ||
125
+ field_method?(method) ||
126
+ super
127
+ end
128
+
129
+ # Field DSL delegation methods - delegate to resource class
130
+
131
+ def id_field(**options)
132
+ delegate_to_resource(:id_field, **options)
133
+ field(:id, type: :number, **options)
134
+ end
135
+
136
+ def text_field(name, **options)
137
+ delegate_to_resource(:text_field, name, **options)
138
+ field(name, type: :string, **options)
139
+ end
140
+
141
+ def textarea_field(name, **options)
142
+ delegate_to_resource(:textarea_field, name, **options)
143
+ field(name, type: :text, **options)
144
+ end
145
+
146
+ def number_field(name, **options)
147
+ delegate_to_resource(:number_field, name, **options)
148
+ field(name, type: :number, **options)
149
+ end
150
+
151
+ def email_field(name, **options)
152
+ delegate_to_resource(:email_field, name, **options)
153
+ field(name, type: :email, **options)
154
+ end
155
+
156
+ def date_field(name, **options)
157
+ delegate_to_resource(:date_field, name, **options)
158
+ field(name, type: :date, **options)
159
+ end
160
+
161
+ def datetime_field(name, **options)
162
+ delegate_to_resource(:datetime_field, name, **options)
163
+ field(name, type: :datetime, **options)
164
+ end
165
+
166
+ def boolean_field(name, **options)
167
+ delegate_to_resource(:boolean_field, name, **options)
168
+ field(name, type: :boolean, **options)
169
+ end
170
+
171
+ def select_field(name, **options)
172
+ delegate_to_resource(:select_field, name, **options)
173
+ field(name, type: :select, **options)
174
+ end
175
+
176
+ def belongs_to_field(name, **options)
177
+ delegate_to_resource(:belongs_to_field, name, **options)
178
+ field(name, type: :belongs_to, **options)
179
+ end
180
+
181
+ def has_many_field(name, **options)
182
+ delegate_to_resource(:has_many_field, name, **options)
183
+ field(name, type: :has_many, **options)
184
+ end
185
+
186
+ def file_field(name, **options)
187
+ delegate_to_resource(:file_field, name, **options)
188
+ field(name, type: :file, **options)
189
+ end
190
+
191
+ def json_field(name, **options)
192
+ delegate_to_resource(:json_field, name, **options)
193
+ field(name, type: :json, **options)
194
+ end
195
+
196
+ def password_field(name = :password, **options)
197
+ delegate_to_resource(:password_field, name, **options)
198
+ field(name, type: :password, **options)
199
+ end
200
+
201
+ # Delegate content method calls
202
+ def content(&block)
203
+ content_node = Nodes::Content.new(block: block)
204
+ add_node(content_node)
205
+ end
206
+
207
+ private
208
+
209
+ def field_method?(method)
210
+ method.to_s.end_with?('_field') && resource_class&.respond_to?(method)
211
+ end
212
+
213
+ def delegate_to_resource(method, *args, **options)
214
+ if resource_class
215
+ resource_class.send(method, *args, **options)
216
+ else
217
+ raise "Resource class not available for field delegation"
218
+ end
219
+ end
220
+ end
221
+
222
+ # Specialized builder for tabs
223
+ class TabsBuilder
224
+ def initialize(parent_builder, tabs_node)
225
+ @parent_builder = parent_builder
226
+ @tabs_node = tabs_node
227
+ end
228
+
229
+ def tab(name, **attributes, &block)
230
+ tab_node = Nodes::Tab.new(name, attributes)
231
+ @tabs_node.add_child(tab_node)
232
+
233
+ if block_given?
234
+ # Push tab as current container and execute block in parent builder context
235
+ @parent_builder.current_container_stack.push(tab_node)
236
+ @parent_builder.instance_exec(&block)
237
+ @parent_builder.current_container_stack.pop
238
+ end
239
+
240
+ tab_node
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,208 @@
1
+ module EasyAdmin
2
+ module Layouts
3
+ module Builders
4
+ # Builder specifically for form layouts
5
+ class FormLayoutBuilder < BaseLayoutBuilder
6
+ def initialize(resource_class: nil)
7
+ super(:form, resource_class: resource_class)
8
+ end
9
+
10
+ # Form-specific DSL methods
11
+
12
+ # Add fieldset for grouping form fields
13
+ def fieldset(legend = nil, **attributes, &block)
14
+ fieldset_node = Nodes::Fieldset.new(legend, attributes)
15
+
16
+ if block_given?
17
+ with_container(fieldset_node, &block)
18
+ else
19
+ add_node(fieldset_node)
20
+ end
21
+
22
+ fieldset_node
23
+ end
24
+
25
+ # Add form actions (submit, cancel, etc.)
26
+ def form_actions(**attributes, &block)
27
+ actions_node = Nodes::FormActions.new(attributes)
28
+ add_node(actions_node)
29
+
30
+ if block_given?
31
+ @current_container_stack.push(actions_node)
32
+ form_actions_builder = FormActionsBuilder.new(self, actions_node)
33
+ form_actions_builder.instance_exec(&block)
34
+ @current_container_stack.pop
35
+ end
36
+
37
+ actions_node
38
+ end
39
+
40
+ # Add inline fields (multiple fields in one row)
41
+ def inline_fields(**attributes, &block)
42
+ inline_node = Nodes::InlineFields.new(attributes)
43
+
44
+ if block_given?
45
+ with_container(inline_node, &block)
46
+ else
47
+ add_node(inline_node)
48
+ end
49
+
50
+ inline_node
51
+ end
52
+
53
+ # Add conditional fields
54
+ def conditional_fields(condition:, **attributes, &block)
55
+ conditional_node = Nodes::ConditionalFields.new(
56
+ condition: condition,
57
+ **attributes
58
+ )
59
+
60
+ if block_given?
61
+ with_container(conditional_node, &block)
62
+ else
63
+ add_node(conditional_node)
64
+ end
65
+
66
+ conditional_node
67
+ end
68
+
69
+ # Add nested fields for associations
70
+ def nested_fields(association, **attributes, &block)
71
+ nested_node = Nodes::NestedFields.new(
72
+ association,
73
+ attributes
74
+ )
75
+
76
+ if block_given?
77
+ with_container(nested_node, &block)
78
+ else
79
+ add_node(nested_node)
80
+ end
81
+
82
+ nested_node
83
+ end
84
+
85
+ # Add help text
86
+ def help_text(text, **attributes)
87
+ help_node = Nodes::HelpText.new(text, attributes)
88
+ add_node(help_node)
89
+ end
90
+
91
+ # Add form errors summary
92
+ def errors_summary(**attributes)
93
+ errors_node = Nodes::ErrorsSummary.new(attributes)
94
+ add_node(errors_node)
95
+ end
96
+
97
+ # Override field method to include form-specific options
98
+ def field(name, **options)
99
+ # Set default form field options
100
+ options[:required] = true if options[:required].nil? && required_field?(name)
101
+ options[:readonly] = true if readonly_field?(name)
102
+
103
+ super(name, **options)
104
+ end
105
+
106
+ # Convenience methods for specific field types
107
+ def text_field(name, **options)
108
+ field(name, field_type: :text, **options)
109
+ end
110
+
111
+ def email_field(name, **options)
112
+ field(name, field_type: :email, **options)
113
+ end
114
+
115
+ def textarea_field(name, **options)
116
+ field(name, field_type: :textarea, **options)
117
+ end
118
+
119
+ def boolean_field(name, **options)
120
+ field(name, field_type: :boolean, **options)
121
+ end
122
+
123
+ def select_field(name, **options)
124
+ field(name, field_type: :select, **options)
125
+ end
126
+
127
+ def has_many_field(name, **options)
128
+ field(name, field_type: :has_many, **options)
129
+ end
130
+
131
+ def date_field(name, **options)
132
+ field(name, field_type: :date, **options)
133
+ end
134
+
135
+ def datetime_field(name, **options)
136
+ field(name, field_type: :datetime, **options)
137
+ end
138
+
139
+ def number_field(name, **options)
140
+ field(name, field_type: :number, **options)
141
+ end
142
+
143
+ def password_field(name, **options)
144
+ field(name, field_type: :password, **options)
145
+ end
146
+
147
+ private
148
+
149
+ def required_field?(name)
150
+ # Check if field is required based on model validations
151
+ # This would need to be implemented based on your validation setup
152
+ false
153
+ end
154
+
155
+ def readonly_field?(name)
156
+ # Check if field should be readonly
157
+ # This would need to be implemented based on your configuration
158
+ false
159
+ end
160
+ end
161
+
162
+ # Builder for form actions
163
+ class FormActionsBuilder
164
+ def initialize(parent_builder)
165
+ @parent_builder = parent_builder
166
+ end
167
+
168
+ def submit(text = "Save", **attributes)
169
+ submit_node = Nodes::FormAction.new(
170
+ :submit,
171
+ text: text,
172
+ **attributes
173
+ )
174
+ @parent_builder.add_node(submit_node)
175
+ end
176
+
177
+ def cancel(text = "Cancel", href: :back, **attributes)
178
+ cancel_node = Nodes::FormAction.new(
179
+ :cancel,
180
+ text: text,
181
+ href: href,
182
+ **attributes
183
+ )
184
+ @parent_builder.add_node(cancel_node)
185
+ end
186
+
187
+ def reset(text = "Reset", **attributes)
188
+ reset_node = Nodes::FormAction.new(
189
+ :reset,
190
+ text: text,
191
+ **attributes
192
+ )
193
+ @parent_builder.add_node(reset_node)
194
+ end
195
+
196
+ def button(text, action:, **attributes)
197
+ button_node = Nodes::FormAction.new(
198
+ :button,
199
+ text: text,
200
+ action: action,
201
+ **attributes
202
+ )
203
+ @parent_builder.add_node(button_node)
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,22 @@
1
+ module EasyAdmin
2
+ module Layouts
3
+ module Builders
4
+ # IndexLayoutBuilder for constructing index page layouts
5
+ # Specialized builder for index table field configurations
6
+ class IndexLayoutBuilder < BaseLayoutBuilder
7
+ def initialize(resource_class: nil)
8
+ super(:index, resource_class: resource_class)
9
+ end
10
+
11
+ # Simple field method for index - only accepts field name
12
+ # Configuration is extracted from already registered resource fields
13
+ def field(name, **options)
14
+ # Create a simple field node with just the name
15
+ # The actual field configuration will be looked up from resource fields_config
16
+ field_node = Nodes::FieldNode.new(name, options)
17
+ add_node(field_node)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,199 @@
1
+ module EasyAdmin
2
+ module Layouts
3
+ module Builders
4
+ # Builder specifically for show page layouts
5
+ class ShowLayoutBuilder < BaseLayoutBuilder
6
+ def initialize(resource_class: nil)
7
+ super(:show, resource_class: resource_class)
8
+ end
9
+
10
+ # Show-specific DSL methods
11
+
12
+ # Add a description list for key-value pairs
13
+ def description_list(**attributes, &block)
14
+ dl_node = Nodes::DescriptionList.new(attributes)
15
+
16
+ if block_given?
17
+ with_container(dl_node, &block)
18
+ else
19
+ add_node(dl_node)
20
+ end
21
+
22
+ dl_node
23
+ end
24
+
25
+ # Add a card component
26
+ def card(title: nil, **attributes, &block)
27
+ card_node = Nodes::Card.new(title: title, **attributes)
28
+
29
+ if block_given?
30
+ with_container(card_node, &block)
31
+ else
32
+ add_node(card_node)
33
+ end
34
+
35
+ card_node
36
+ end
37
+
38
+ # Add a metric card
39
+ def metric(label:, value:, **attributes)
40
+ metric_node = Nodes::MetricCard.new(
41
+ label: label,
42
+ value: value,
43
+ **attributes
44
+ )
45
+ add_node(metric_node)
46
+ end
47
+
48
+ # Add a metric card (accepts both label and title)
49
+ def metric_card(title: nil, label: nil, value: nil, **attributes)
50
+ metric_node = Nodes::MetricCard.new(
51
+ title: title,
52
+ label: label,
53
+ value: value,
54
+ **attributes
55
+ )
56
+ add_node(metric_node)
57
+ end
58
+
59
+ # Add an action bar
60
+ def action_bar(**attributes, &block)
61
+ action_bar_node = Nodes::ActionBar.new(attributes)
62
+ add_node(action_bar_node)
63
+
64
+ if block_given?
65
+ @current_container_stack.push(action_bar_node)
66
+ action_builder = ActionBarBuilder.new(self, action_bar_node)
67
+ action_builder.instance_exec(&block)
68
+ @current_container_stack.pop
69
+ end
70
+
71
+ action_bar_node
72
+ end
73
+
74
+ # Add a panel (collapsible section)
75
+ def panel(title, expanded: true, **attributes, &block)
76
+ panel_node = Nodes::Panel.new(
77
+ title,
78
+ attributes.merge(expanded: expanded)
79
+ )
80
+
81
+ if block_given?
82
+ with_container(panel_node, &block)
83
+ else
84
+ add_node(panel_node)
85
+ end
86
+
87
+ panel_node
88
+ end
89
+
90
+ # Add badge
91
+ def badge(text, variant: :default, **attributes)
92
+ badge_node = Nodes::Badge.new(
93
+ text,
94
+ attributes.merge(variant: variant)
95
+ )
96
+ add_node(badge_node)
97
+ end
98
+
99
+ # Add related resources section
100
+ def related(resource_name, **attributes, &block)
101
+ related_node = Nodes::RelatedResources.new(
102
+ resource_name,
103
+ attributes
104
+ )
105
+
106
+ if block_given?
107
+ with_container(related_node, &block)
108
+ else
109
+ add_node(related_node)
110
+ end
111
+
112
+ related_node
113
+ end
114
+
115
+ # Add row layout
116
+ def row(**attributes, &block)
117
+ row_node = Nodes::Row.new(attributes)
118
+
119
+ if block_given?
120
+ with_container(row_node, &block)
121
+ else
122
+ add_node(row_node)
123
+ end
124
+
125
+ row_node
126
+ end
127
+
128
+ # Add column layout
129
+ def column(**attributes, &block)
130
+ column_node = Nodes::Column.new(attributes)
131
+
132
+ if block_given?
133
+ with_container(column_node, &block)
134
+ else
135
+ add_node(column_node)
136
+ end
137
+
138
+ column_node
139
+ end
140
+
141
+ # Add heading
142
+ def heading(text, level: 2, **attributes)
143
+ heading_node = Nodes::Heading.new(text, attributes.merge(level: level))
144
+ add_node(heading_node)
145
+ end
146
+
147
+ # Add content block
148
+ def content(&block)
149
+ content_node = Nodes::Content.new(block: block)
150
+ add_node(content_node)
151
+ end
152
+ end
153
+
154
+ # Builder for action bars
155
+ class ActionBarBuilder
156
+ def initialize(parent_builder, action_bar_node)
157
+ @parent_builder = parent_builder
158
+ @action_bar_node = action_bar_node
159
+ end
160
+
161
+ def link(text, href:, **attributes)
162
+ action_node = Nodes::Action.new(
163
+ :link,
164
+ text: text,
165
+ href: href,
166
+ **attributes
167
+ )
168
+ @action_bar_node.add_child(action_node)
169
+ end
170
+
171
+ def button(text, action:, **attributes)
172
+ action_node = Nodes::Action.new(
173
+ :button,
174
+ text: text,
175
+ action: action,
176
+ **attributes
177
+ )
178
+ @action_bar_node.add_child(action_node)
179
+ end
180
+
181
+ def dropdown(text, **attributes, &block)
182
+ dropdown_node = Nodes::Dropdown.new(
183
+ text: text,
184
+ **attributes
185
+ )
186
+ @action_bar_node.add_child(dropdown_node)
187
+
188
+ if block_given?
189
+ @parent_builder.current_container_stack.push(dropdown_node)
190
+ @parent_builder.instance_exec(&block)
191
+ @parent_builder.current_container_stack.pop
192
+ end
193
+
194
+ dropdown_node
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end