better_page 2.0.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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +357 -0
  5. data/Rakefile +3 -0
  6. data/docs/00-README.md +17 -0
  7. data/docs/01-getting-started.md +137 -0
  8. data/docs/02-component-registry.md +192 -0
  9. data/docs/03-base-pages.md +238 -0
  10. data/docs/04-schema-validation.md +180 -0
  11. data/docs/05-turbo-support.md +220 -0
  12. data/docs/06-compliance-analyzer.md +147 -0
  13. data/docs/07-configuration.md +157 -0
  14. data/guide/00-README.md +32 -0
  15. data/guide/01-quick-start.md +148 -0
  16. data/guide/02-building-index-page.md +258 -0
  17. data/guide/03-building-show-page.md +266 -0
  18. data/guide/04-building-form-page.md +309 -0
  19. data/guide/05-custom-pages.md +325 -0
  20. data/guide/06-best-practices.md +311 -0
  21. data/lib/better_page/base_page.rb +161 -0
  22. data/lib/better_page/compliance/analyzer.rb +409 -0
  23. data/lib/better_page/component_registry.rb +393 -0
  24. data/lib/better_page/config.rb +165 -0
  25. data/lib/better_page/configuration.rb +153 -0
  26. data/lib/better_page/custom_base_page.rb +85 -0
  27. data/lib/better_page/default_components.rb +200 -0
  28. data/lib/better_page/form_base_page.rb +170 -0
  29. data/lib/better_page/index_base_page.rb +69 -0
  30. data/lib/better_page/railtie.rb +34 -0
  31. data/lib/better_page/show_base_page.rb +120 -0
  32. data/lib/better_page/validation_error.rb +7 -0
  33. data/lib/better_page/version.rb +3 -0
  34. data/lib/better_page.rb +80 -0
  35. data/lib/generators/better_page/component_generator.rb +131 -0
  36. data/lib/generators/better_page/install_generator.rb +160 -0
  37. data/lib/generators/better_page/page_generator.rb +101 -0
  38. data/lib/generators/better_page/sync_generator.rb +109 -0
  39. data/lib/generators/better_page/templates/application_page.rb.tt +12 -0
  40. data/lib/generators/better_page/templates/better_page_initializer.rb.tt +53 -0
  41. data/lib/generators/better_page/templates/custom_base_page.rb.tt +83 -0
  42. data/lib/generators/better_page/templates/custom_page.rb.tt +31 -0
  43. data/lib/generators/better_page/templates/edit_page.rb.tt +46 -0
  44. data/lib/generators/better_page/templates/form_base_page.rb.tt +126 -0
  45. data/lib/generators/better_page/templates/index_base_page.rb.tt +65 -0
  46. data/lib/generators/better_page/templates/index_page.rb.tt +56 -0
  47. data/lib/generators/better_page/templates/javascript/controllers/app_nav_controller.js +57 -0
  48. data/lib/generators/better_page/templates/javascript/controllers/drawer_controller.js +99 -0
  49. data/lib/generators/better_page/templates/javascript/controllers/dropdown_controller.js +60 -0
  50. data/lib/generators/better_page/templates/javascript/controllers/index.js +36 -0
  51. data/lib/generators/better_page/templates/javascript/controllers/modal_controller.js +70 -0
  52. data/lib/generators/better_page/templates/javascript/controllers/sidebar_controller.js +152 -0
  53. data/lib/generators/better_page/templates/javascript/controllers/table_controller.js +60 -0
  54. data/lib/generators/better_page/templates/javascript/controllers/tabs_controller.js +89 -0
  55. data/lib/generators/better_page/templates/new_page.rb.tt +46 -0
  56. data/lib/generators/better_page/templates/show_base_page.rb.tt +117 -0
  57. data/lib/generators/better_page/templates/show_page.rb.tt +45 -0
  58. data/lib/generators/better_page/templates/view_components/application_view_component.rb.tt +7 -0
  59. data/lib/generators/better_page/templates/view_components/custom_view_component.html.erb.tt +21 -0
  60. data/lib/generators/better_page/templates/view_components/custom_view_component.rb.tt +21 -0
  61. data/lib/generators/better_page/templates/view_components/form_view_component.html.erb.tt +25 -0
  62. data/lib/generators/better_page/templates/view_components/form_view_component.rb.tt +23 -0
  63. data/lib/generators/better_page/templates/view_components/index_view_component.html.erb.tt +33 -0
  64. data/lib/generators/better_page/templates/view_components/index_view_component.rb.tt +29 -0
  65. data/lib/generators/better_page/templates/view_components/show_view_component.html.erb.tt +29 -0
  66. data/lib/generators/better_page/templates/view_components/show_view_component.rb.tt +25 -0
  67. data/lib/generators/better_page/templates/view_components/ui/alerts_component.html.erb.tt +47 -0
  68. data/lib/generators/better_page/templates/view_components/ui/alerts_component.rb.tt +47 -0
  69. data/lib/generators/better_page/templates/view_components/ui/content_section_component.html.erb.tt +42 -0
  70. data/lib/generators/better_page/templates/view_components/ui/content_section_component.rb.tt +34 -0
  71. data/lib/generators/better_page/templates/view_components/ui/drawer_component.html.erb.tt +73 -0
  72. data/lib/generators/better_page/templates/view_components/ui/drawer_component.rb.tt +78 -0
  73. data/lib/generators/better_page/templates/view_components/ui/errors_component.html.erb.tt +23 -0
  74. data/lib/generators/better_page/templates/view_components/ui/errors_component.rb.tt +18 -0
  75. data/lib/generators/better_page/templates/view_components/ui/field_component.html.erb.tt +65 -0
  76. data/lib/generators/better_page/templates/view_components/ui/field_component.rb.tt +91 -0
  77. data/lib/generators/better_page/templates/view_components/ui/footer_component.html.erb.tt +33 -0
  78. data/lib/generators/better_page/templates/view_components/ui/footer_component.rb.tt +32 -0
  79. data/lib/generators/better_page/templates/view_components/ui/header_component.html.erb.tt +55 -0
  80. data/lib/generators/better_page/templates/view_components/ui/header_component.rb.tt +39 -0
  81. data/lib/generators/better_page/templates/view_components/ui/modal_component.html.erb.tt +70 -0
  82. data/lib/generators/better_page/templates/view_components/ui/modal_component.rb.tt +54 -0
  83. data/lib/generators/better_page/templates/view_components/ui/overview_component.html.erb.tt +22 -0
  84. data/lib/generators/better_page/templates/view_components/ui/overview_component.rb.tt +71 -0
  85. data/lib/generators/better_page/templates/view_components/ui/pagination_component.html.erb.tt +63 -0
  86. data/lib/generators/better_page/templates/view_components/ui/pagination_component.rb.tt +69 -0
  87. data/lib/generators/better_page/templates/view_components/ui/panel_component.html.erb.tt +31 -0
  88. data/lib/generators/better_page/templates/view_components/ui/panel_component.rb.tt +23 -0
  89. data/lib/generators/better_page/templates/view_components/ui/statistics_component.html.erb.tt +33 -0
  90. data/lib/generators/better_page/templates/view_components/ui/statistics_component.rb.tt +51 -0
  91. data/lib/generators/better_page/templates/view_components/ui/table_component.html.erb.tt +112 -0
  92. data/lib/generators/better_page/templates/view_components/ui/table_component.rb.tt +88 -0
  93. data/lib/generators/better_page/templates/view_components/ui/tabs_component.html.erb.tt +52 -0
  94. data/lib/generators/better_page/templates/view_components/ui/tabs_component.rb.tt +76 -0
  95. data/lib/generators/better_page/templates/view_components/ui/widget_component.html.erb.tt +72 -0
  96. data/lib/generators/better_page/templates/view_components/ui/widget_component.rb.tt +34 -0
  97. data/lib/tasks/better_page.rake +70 -0
  98. data/lib/tasks/better_page_tasks.rake +4 -0
  99. metadata +188 -0
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-schema"
4
+
5
+ module BetterPage
6
+ # Module that provides component registration DSL for page classes.
7
+ #
8
+ # Components can be registered at three levels:
9
+ # 1. Global configuration (via BetterPage.configure in initializer)
10
+ # 2. Class level (via register_component in base classes)
11
+ # 3. Page type mapping (via page_type in base classes)
12
+ #
13
+ # @example Using page_type to inherit global components
14
+ # class IndexBasePage < ApplicationPage
15
+ # page_type :index # Inherits components from BetterPage.configuration.components_for(:index)
16
+ # end
17
+ #
18
+ # @example Registering local components
19
+ # class MyPage < IndexBasePage
20
+ # register_component :custom_widget, default: nil
21
+ #
22
+ # def custom_widget
23
+ # { data: @data }
24
+ # end
25
+ # end
26
+ #
27
+ module ComponentRegistry
28
+ extend ActiveSupport::Concern
29
+
30
+ included do
31
+ class_attribute :registered_components, default: {}
32
+ class_attribute :_page_type, default: nil
33
+ end
34
+
35
+ class_methods do
36
+ # Set the page type for this class
37
+ # This determines which global components are available
38
+ #
39
+ # @param type [Symbol] :index, :show, :form, or :custom
40
+ # @return [Symbol]
41
+ #
42
+ # @example
43
+ # class IndexBasePage < ApplicationPage
44
+ # page_type :index
45
+ # end
46
+ #
47
+ def page_type(type = nil)
48
+ if type
49
+ self._page_type = type
50
+ else
51
+ _page_type
52
+ end
53
+ end
54
+
55
+ # Register a component with optional schema validation
56
+ # Local components are added to those from global configuration
57
+ #
58
+ # @param name [Symbol] the component name
59
+ # @param required [Boolean] whether the component is required
60
+ # @param default [Object] default value if component method is not defined
61
+ # @yield optional dry-schema block for validation
62
+ #
63
+ # @example With schema
64
+ # register_component :header, required: true do
65
+ # required(:title).filled(:string)
66
+ # end
67
+ #
68
+ # @example Without schema
69
+ # register_component :alerts, default: []
70
+ #
71
+ def register_component(name, required: false, default: nil, &schema_block)
72
+ schema = schema_block ? build_schema(&schema_block) : nil
73
+
74
+ self.registered_components = registered_components.merge(
75
+ name => ComponentDefinition.new(
76
+ name: name,
77
+ required: required,
78
+ default: default,
79
+ schema: schema
80
+ )
81
+ )
82
+ end
83
+
84
+ # Get all components available for this page class
85
+ # Combines global configuration components with locally registered ones
86
+ #
87
+ # @return [Hash<Symbol, ComponentDefinition>]
88
+ def effective_components
89
+ result = {}
90
+
91
+ # First, add components from global configuration for this page type
92
+ if _page_type && BetterPage.defaults_registered?
93
+ global_names = BetterPage.configuration.components_for(_page_type)
94
+ global_names.each do |name|
95
+ global_def = BetterPage.configuration.component(name)
96
+ next unless global_def
97
+
98
+ # Check if this component is required for this page type
99
+ is_required = BetterPage.configuration.component_required?(_page_type, name)
100
+
101
+ result[name] = ComponentDefinition.new(
102
+ name: global_def.name,
103
+ required: is_required,
104
+ default: global_def.default,
105
+ schema: global_def.schema
106
+ )
107
+ end
108
+ end
109
+
110
+ # Then, merge locally registered components (they override global ones)
111
+ registered_components.each do |name, definition|
112
+ result[name] = definition
113
+ end
114
+
115
+ result
116
+ end
117
+
118
+ # Get the list of component names available for this page class
119
+ #
120
+ # @return [Array<Symbol>]
121
+ def allowed_component_names
122
+ effective_components.keys
123
+ end
124
+
125
+ # Ensure subclasses inherit registered components
126
+ def inherited(subclass)
127
+ super
128
+ subclass.registered_components = registered_components.dup
129
+ end
130
+
131
+ private
132
+
133
+ def build_schema(&block)
134
+ Dry::Schema.Params(&block)
135
+ end
136
+ end
137
+
138
+ # Build the page by collecting and validating all registered components
139
+ #
140
+ # @return [BetterPage::Config] config object with components and metadata
141
+ def build_page
142
+ result = {}
143
+
144
+ self.class.effective_components.each do |name, definition|
145
+ value = resolve_component_value(name, definition)
146
+ validate_component(name, value, definition)
147
+ result[name] = value
148
+ end
149
+
150
+ BetterPage::Config.new(
151
+ result,
152
+ meta: {
153
+ page_type: self.class.page_type,
154
+ klass: view_component_class
155
+ }
156
+ )
157
+ end
158
+
159
+ # Build a single component for Turbo Frame
160
+ # Returns a single component hash for lazy loading or navigation
161
+ #
162
+ # @param component_name [Symbol] the component to return
163
+ # @return [Hash, nil] component configuration for Turbo Frame, or nil if not found/empty
164
+ def frame_page(component_name)
165
+ full_page = build_page
166
+ return nil unless full_page.key?(component_name)
167
+
168
+ value = full_page[component_name]
169
+ return nil if skip_empty_component?(value)
170
+
171
+ {
172
+ component: component_name,
173
+ config: value,
174
+ klass: ui_component_class(component_name),
175
+ target: frame_target(component_name)
176
+ }
177
+ end
178
+
179
+ # Build multiple components for Turbo Streams
180
+ # Returns an array of component hashes for real-time updates
181
+ #
182
+ # @param components [Array<Symbol>] specific components to return, or all if empty
183
+ # @return [Array<Hash>] array of component configurations for Turbo Streams
184
+ def stream_page(*components)
185
+ full_page = build_page
186
+ component_names = components.empty? ? stream_components : components.flatten
187
+
188
+ component_names.filter_map do |name|
189
+ next unless full_page.key?(name)
190
+
191
+ value = full_page[name]
192
+ next if skip_empty_component?(value)
193
+
194
+ {
195
+ component: name,
196
+ config: value,
197
+ klass: ui_component_class(name),
198
+ target: stream_target(name)
199
+ }
200
+ end
201
+ end
202
+
203
+ # Get the ViewComponent class for this page type
204
+ # Override in subclasses if using a custom component
205
+ #
206
+ # @return [Class] the ViewComponent class to use for rendering
207
+ def view_component_class
208
+ raise NotImplementedError, "Subclasses must implement #view_component_class"
209
+ end
210
+
211
+ # Get the UI ViewComponent class for a specific component
212
+ #
213
+ # @param name [Symbol] the component name
214
+ # @return [Class, nil] the ViewComponent class or nil
215
+ def ui_component_class(name)
216
+ component_mapping[name]
217
+ end
218
+
219
+ # Components to include in stream_page by default
220
+ # Override in subclasses to customize
221
+ #
222
+ # @return [Array<Symbol>] component names
223
+ def stream_components
224
+ self.class.effective_components.keys
225
+ end
226
+
227
+ # Get the Turbo Frame target for a component
228
+ #
229
+ # @param name [Symbol] the component name
230
+ # @return [String] the target ID for turbo-frame
231
+ def frame_target(name)
232
+ "better_page_#{name}"
233
+ end
234
+
235
+ # Get the Turbo Stream target for a component
236
+ #
237
+ # @param name [Symbol] the component name
238
+ # @return [String] the target ID for turbo-stream
239
+ def stream_target(name)
240
+ "better_page_#{name}"
241
+ end
242
+
243
+ # Dynamic frame_* and stream_* method support
244
+ # Allows calling frame_<action> or stream_<action> for any action method defined on the page
245
+ #
246
+ # @example Turbo Frame (single component, lazy loading)
247
+ # page.frame_index(:table) # calls frame_page(:table) for IndexBasePage
248
+ # page.frame_show(:header) # calls frame_page(:header) for ShowBasePage
249
+ # page.frame_daily(:chart) # calls frame_page(:chart) for a custom DailyPage
250
+ #
251
+ # @example Turbo Stream (multiple components, real-time updates)
252
+ # page.stream_index # all stream components
253
+ # page.stream_index(:table, :statistics) # specific components
254
+ # page.stream_daily(:chart, :summary) # for custom DailyPage
255
+ #
256
+ def method_missing(method_name, *args, &block)
257
+ method_str = method_name.to_s
258
+
259
+ if method_str.start_with?("frame_")
260
+ action_name = method_str.sub("frame_", "")
261
+ if respond_to?(action_name, true)
262
+ frame_page(*args)
263
+ else
264
+ super
265
+ end
266
+ elsif method_str.start_with?("stream_")
267
+ action_name = method_str.sub("stream_", "")
268
+ if respond_to?(action_name, true)
269
+ stream_page(*args)
270
+ else
271
+ super
272
+ end
273
+ else
274
+ super
275
+ end
276
+ end
277
+
278
+ def respond_to_missing?(method_name, include_private = false)
279
+ method_str = method_name.to_s
280
+
281
+ if method_str.start_with?("frame_") || method_str.start_with?("stream_")
282
+ prefix = method_str.start_with?("frame_") ? "frame_" : "stream_"
283
+ action_name = method_str.sub(prefix, "")
284
+ respond_to?(action_name, true) || super
285
+ else
286
+ super
287
+ end
288
+ end
289
+
290
+ private
291
+
292
+ # Mapping of component names to their ViewComponent classes
293
+ # Override in subclasses to customize
294
+ #
295
+ # @return [Hash<Symbol, Class>]
296
+ def component_mapping
297
+ {
298
+ header: defined?(BetterPage::Ui::HeaderComponent) ? BetterPage::Ui::HeaderComponent : nil,
299
+ table: defined?(BetterPage::Ui::TableComponent) ? BetterPage::Ui::TableComponent : nil,
300
+ alerts: defined?(BetterPage::Ui::AlertsComponent) ? BetterPage::Ui::AlertsComponent : nil,
301
+ statistics: defined?(BetterPage::Ui::StatisticsComponent) ? BetterPage::Ui::StatisticsComponent : nil,
302
+ pagination: defined?(BetterPage::Ui::PaginationComponent) ? BetterPage::Ui::PaginationComponent : nil,
303
+ overview: defined?(BetterPage::Ui::OverviewComponent) ? BetterPage::Ui::OverviewComponent : nil,
304
+ tabs: defined?(BetterPage::Ui::TabsComponent) ? BetterPage::Ui::TabsComponent : nil,
305
+ footer: defined?(BetterPage::Ui::FooterComponent) ? BetterPage::Ui::FooterComponent : nil,
306
+ panel: defined?(BetterPage::Ui::PanelComponent) ? BetterPage::Ui::PanelComponent : nil,
307
+ errors: defined?(BetterPage::Ui::ErrorsComponent) ? BetterPage::Ui::ErrorsComponent : nil,
308
+ content_section: defined?(BetterPage::Ui::ContentSectionComponent) ? BetterPage::Ui::ContentSectionComponent : nil,
309
+ widget: defined?(BetterPage::Ui::WidgetComponent) ? BetterPage::Ui::WidgetComponent : nil
310
+ }
311
+ end
312
+
313
+ def skip_empty_component?(value)
314
+ return true if value.nil?
315
+ return true if value.is_a?(Array) && value.empty?
316
+ return true if value.is_a?(Hash) && value[:enabled] == false
317
+
318
+ false
319
+ end
320
+
321
+ def resolve_component_value(name, definition)
322
+ if respond_to?(name, true)
323
+ send(name)
324
+ else
325
+ definition.default
326
+ end
327
+ end
328
+
329
+ def validate_component(name, value, definition)
330
+ # Check required
331
+ if definition.required? && value.nil?
332
+ handle_validation_error("Component :#{name} is required but returned nil")
333
+ end
334
+
335
+ # Schema validation
336
+ return unless definition.schema && value
337
+
338
+ # Handle array schemas vs hash schemas
339
+ validation_result = if value.is_a?(Array)
340
+ validate_array(value, definition.schema)
341
+ else
342
+ definition.schema.call(value)
343
+ end
344
+
345
+ return if validation_result.success?
346
+
347
+ handle_validation_error(
348
+ "Component :#{name} validation failed: #{validation_result.errors.to_h}"
349
+ )
350
+ end
351
+
352
+ def validate_array(array, schema)
353
+ errors = {}
354
+ array.each_with_index do |item, index|
355
+ result = schema.call(item)
356
+ errors[index] = result.errors.to_h unless result.success?
357
+ end
358
+
359
+ if errors.empty?
360
+ Dry::Schema::Result.new(array, message_compiler: nil) { |r| r.success(array) }
361
+ else
362
+ # Return a failure-like object
363
+ OpenStruct.new(success?: false, errors: OpenStruct.new(to_h: errors))
364
+ end
365
+ end
366
+
367
+ def handle_validation_error(message)
368
+ if defined?(Rails) && Rails.env.development?
369
+ raise BetterPage::ValidationError, message
370
+ elsif defined?(Rails)
371
+ Rails.logger.warn "[BetterPage] #{message}"
372
+ else
373
+ warn "[BetterPage] #{message}"
374
+ end
375
+ end
376
+ end
377
+
378
+ # Value object representing a registered component
379
+ class ComponentDefinition
380
+ attr_reader :name, :default, :schema
381
+
382
+ def initialize(name:, required:, default:, schema:)
383
+ @name = name
384
+ @required = required
385
+ @default = default
386
+ @schema = schema
387
+ end
388
+
389
+ def required?
390
+ @required
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ # Config wrapper for page configurations
5
+ #
6
+ # Provides a standardized way to return both components and metadata from pages.
7
+ # Follows the same pattern as BetterService::Result and BetterController::Result.
8
+ #
9
+ # @example Basic usage
10
+ # config = page.index
11
+ # config.header[:title]
12
+ # config[:header][:title] # also works
13
+ #
14
+ # @example Destructuring
15
+ # components, meta = page.index
16
+ #
17
+ class Config
18
+ attr_reader :components, :meta
19
+
20
+ # @param components [Hash] Hash of component configurations
21
+ # @param meta [Hash] Metadata hash (page_type, klass, etc.)
22
+ def initialize(components, meta: {})
23
+ @components = components
24
+ @meta = meta.is_a?(Hash) ? meta.reverse_merge(page_type: nil, klass: nil) : { page_type: nil, klass: nil }
25
+ end
26
+
27
+ # Component accessors
28
+ # @return [Hash, Array, nil] the component configuration
29
+ def header
30
+ components[:header]
31
+ end
32
+
33
+ def table
34
+ components[:table]
35
+ end
36
+
37
+ def statistics
38
+ components[:statistics]
39
+ end
40
+
41
+ def alerts
42
+ components[:alerts]
43
+ end
44
+
45
+ def tabs
46
+ components[:tabs]
47
+ end
48
+
49
+ def pagination
50
+ components[:pagination]
51
+ end
52
+
53
+ def overview
54
+ components[:overview]
55
+ end
56
+
57
+ def footer
58
+ components[:footer]
59
+ end
60
+
61
+ def panel
62
+ components[:panel]
63
+ end
64
+
65
+ def errors
66
+ components[:errors]
67
+ end
68
+
69
+ def content_section
70
+ components[:content_section]
71
+ end
72
+
73
+ def widget
74
+ components[:widget]
75
+ end
76
+
77
+ # Meta accessors
78
+ # @return [Symbol, nil] the page type (:index, :show, :form, :custom)
79
+ def page_type
80
+ meta[:page_type]
81
+ end
82
+
83
+ # @return [Class, nil] the ViewComponent class for rendering
84
+ def klass
85
+ meta[:klass]
86
+ end
87
+
88
+ # Supports destructuring: components, meta = config
89
+ # @return [Array] [components, meta]
90
+ def to_ary
91
+ [components, meta]
92
+ end
93
+
94
+ # Alias for destructuring compatibility
95
+ alias_method :deconstruct, :to_ary
96
+
97
+ # Hash-like access for compatibility
98
+ # @param key [Symbol] The key to access
99
+ # @return [Object, nil] The value associated with the key
100
+ def [](key)
101
+ if components.key?(key)
102
+ components[key]
103
+ else
104
+ meta[key]
105
+ end
106
+ end
107
+
108
+ # Nested Hash-like access (dig)
109
+ # @param keys [Array<Symbol>] Keys for nested access
110
+ # @return [Object, nil] The nested value
111
+ def dig(*keys)
112
+ return nil if keys.empty?
113
+
114
+ value = self[keys.first]
115
+ return value if keys.size == 1
116
+ return nil unless value.respond_to?(:dig)
117
+
118
+ value.dig(*keys[1..])
119
+ end
120
+
121
+ # Check if key exists
122
+ # @param key [Symbol] The key to check
123
+ # @return [Boolean]
124
+ def key?(key)
125
+ components.key?(key) || meta.key?(key)
126
+ end
127
+ alias_method :has_key?, :key?
128
+
129
+ # Convert to hash for compatibility
130
+ # @return [Hash] Full hash representation
131
+ def to_h
132
+ { components: components, meta: meta }
133
+ end
134
+
135
+ # List of available component names
136
+ # @return [Array<Symbol>]
137
+ def component_names
138
+ components.keys
139
+ end
140
+
141
+ # Check if a component is present and not empty
142
+ # @param name [Symbol] The component name
143
+ # @return [Boolean]
144
+ def component?(name)
145
+ value = components[name]
146
+ return false if value.nil?
147
+ return false if value.is_a?(Array) && value.empty?
148
+ return false if value.is_a?(Hash) && value[:enabled] == false
149
+
150
+ true
151
+ end
152
+
153
+ # Iterate over components
154
+ # @yield [name, value] Block to execute for each component
155
+ def each_component(&block)
156
+ components.each(&block)
157
+ end
158
+
159
+ # Get components that are present (not nil/empty)
160
+ # @return [Hash] Hash of present components
161
+ def present_components
162
+ components.select { |name, _| component?(name) }
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-schema"
4
+
5
+ module BetterPage
6
+ # Central configuration for BetterPage components.
7
+ # Allows registering components globally and mapping them to page types.
8
+ #
9
+ # @example Register components in initializer
10
+ # BetterPage.configure do |config|
11
+ # config.register_component :sidebar, default: { enabled: false }
12
+ # config.allow_components :index, :sidebar
13
+ # end
14
+ #
15
+ class Configuration
16
+ attr_reader :components, :page_type_components, :required_components
17
+
18
+ def initialize
19
+ @components = {}
20
+ @page_type_components = {
21
+ index: [],
22
+ show: [],
23
+ form: [],
24
+ custom: []
25
+ }
26
+ @required_components = {
27
+ index: [],
28
+ show: [],
29
+ form: [],
30
+ custom: []
31
+ }
32
+ end
33
+
34
+ # Register a component with optional schema validation
35
+ #
36
+ # @param name [Symbol] the component name
37
+ # @param required [Boolean] whether the component is required by default
38
+ # @param default [Object] default value if component method is not defined
39
+ # @yield optional dry-schema block for validation
40
+ # @return [ComponentDefinition]
41
+ #
42
+ # @example With schema
43
+ # config.register_component :header, required: true do
44
+ # required(:title).filled(:string)
45
+ # end
46
+ #
47
+ # @example Without schema
48
+ # config.register_component :alerts, default: []
49
+ #
50
+ def register_component(name, required: false, default: nil, &schema_block)
51
+ schema = schema_block ? Dry::Schema.Params(&schema_block) : nil
52
+
53
+ @components[name] = ComponentDefinition.new(
54
+ name: name,
55
+ required: required,
56
+ default: default,
57
+ schema: schema
58
+ )
59
+ end
60
+
61
+ # Map components to a page type
62
+ # If called multiple times, appends to existing components
63
+ #
64
+ # @param page_type [Symbol] :index, :show, :form, or :custom
65
+ # @param names [Array<Symbol>] component names to allow
66
+ # @return [Array<Symbol>] updated component list
67
+ #
68
+ # @example
69
+ # config.allow_components :index, :header, :table, :pagination
70
+ #
71
+ def allow_components(page_type, *names)
72
+ @page_type_components[page_type] ||= []
73
+ @page_type_components[page_type].concat(names.flatten)
74
+ @page_type_components[page_type].uniq!
75
+ @page_type_components[page_type]
76
+ end
77
+
78
+ # Mark components as required for a specific page type
79
+ #
80
+ # @param page_type [Symbol] :index, :show, :form, or :custom
81
+ # @param names [Array<Symbol>] component names to mark as required
82
+ # @return [Array<Symbol>] updated required component list
83
+ #
84
+ # @example
85
+ # config.require_components :index, :header, :table
86
+ #
87
+ def require_components(page_type, *names)
88
+ @required_components[page_type] ||= []
89
+ @required_components[page_type].concat(names.flatten)
90
+ @required_components[page_type].uniq!
91
+ @required_components[page_type]
92
+ end
93
+
94
+ # Get components allowed for a specific page type
95
+ #
96
+ # @param page_type [Symbol] :index, :show, :form, or :custom
97
+ # @return [Array<Symbol>] allowed component names
98
+ def components_for(page_type)
99
+ @page_type_components[page_type] || []
100
+ end
101
+
102
+ # Check if a component is required for a specific page type
103
+ #
104
+ # @param page_type [Symbol] :index, :show, :form, or :custom
105
+ # @param name [Symbol] component name
106
+ # @return [Boolean]
107
+ def component_required?(page_type, name)
108
+ required_list = @required_components[page_type] || []
109
+ return true if required_list.include?(name)
110
+
111
+ # Fallback to component's default required status
112
+ @components[name]&.required? || false
113
+ end
114
+
115
+ # Get a component definition by name
116
+ #
117
+ # @param name [Symbol] component name
118
+ # @return [ComponentDefinition, nil]
119
+ def component(name)
120
+ @components[name]
121
+ end
122
+
123
+ # Get all registered component names
124
+ #
125
+ # @return [Array<Symbol>]
126
+ def component_names
127
+ @components.keys
128
+ end
129
+
130
+ # Reset configuration to empty state
131
+ # Used primarily for testing
132
+ def reset!
133
+ @components = {}
134
+ @page_type_components = { index: [], show: [], form: [], custom: [] }
135
+ @required_components = { index: [], show: [], form: [], custom: [] }
136
+ end
137
+
138
+ # Deep copy for inheritance
139
+ def dup
140
+ copy = super
141
+ copy.instance_variable_set(:@components, @components.dup)
142
+ copy.instance_variable_set(:@page_type_components, deep_dup(@page_type_components))
143
+ copy.instance_variable_set(:@required_components, deep_dup(@required_components))
144
+ copy
145
+ end
146
+
147
+ private
148
+
149
+ def deep_dup(hash)
150
+ hash.transform_values { |v| v.is_a?(Array) ? v.dup : v }
151
+ end
152
+ end
153
+ end