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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +357 -0
- data/Rakefile +3 -0
- data/docs/00-README.md +17 -0
- data/docs/01-getting-started.md +137 -0
- data/docs/02-component-registry.md +192 -0
- data/docs/03-base-pages.md +238 -0
- data/docs/04-schema-validation.md +180 -0
- data/docs/05-turbo-support.md +220 -0
- data/docs/06-compliance-analyzer.md +147 -0
- data/docs/07-configuration.md +157 -0
- data/guide/00-README.md +32 -0
- data/guide/01-quick-start.md +148 -0
- data/guide/02-building-index-page.md +258 -0
- data/guide/03-building-show-page.md +266 -0
- data/guide/04-building-form-page.md +309 -0
- data/guide/05-custom-pages.md +325 -0
- data/guide/06-best-practices.md +311 -0
- data/lib/better_page/base_page.rb +161 -0
- data/lib/better_page/compliance/analyzer.rb +409 -0
- data/lib/better_page/component_registry.rb +393 -0
- data/lib/better_page/config.rb +165 -0
- data/lib/better_page/configuration.rb +153 -0
- data/lib/better_page/custom_base_page.rb +85 -0
- data/lib/better_page/default_components.rb +200 -0
- data/lib/better_page/form_base_page.rb +170 -0
- data/lib/better_page/index_base_page.rb +69 -0
- data/lib/better_page/railtie.rb +34 -0
- data/lib/better_page/show_base_page.rb +120 -0
- data/lib/better_page/validation_error.rb +7 -0
- data/lib/better_page/version.rb +3 -0
- data/lib/better_page.rb +80 -0
- data/lib/generators/better_page/component_generator.rb +131 -0
- data/lib/generators/better_page/install_generator.rb +160 -0
- data/lib/generators/better_page/page_generator.rb +101 -0
- data/lib/generators/better_page/sync_generator.rb +109 -0
- data/lib/generators/better_page/templates/application_page.rb.tt +12 -0
- data/lib/generators/better_page/templates/better_page_initializer.rb.tt +53 -0
- data/lib/generators/better_page/templates/custom_base_page.rb.tt +83 -0
- data/lib/generators/better_page/templates/custom_page.rb.tt +31 -0
- data/lib/generators/better_page/templates/edit_page.rb.tt +46 -0
- data/lib/generators/better_page/templates/form_base_page.rb.tt +126 -0
- data/lib/generators/better_page/templates/index_base_page.rb.tt +65 -0
- data/lib/generators/better_page/templates/index_page.rb.tt +56 -0
- data/lib/generators/better_page/templates/javascript/controllers/app_nav_controller.js +57 -0
- data/lib/generators/better_page/templates/javascript/controllers/drawer_controller.js +99 -0
- data/lib/generators/better_page/templates/javascript/controllers/dropdown_controller.js +60 -0
- data/lib/generators/better_page/templates/javascript/controllers/index.js +36 -0
- data/lib/generators/better_page/templates/javascript/controllers/modal_controller.js +70 -0
- data/lib/generators/better_page/templates/javascript/controllers/sidebar_controller.js +152 -0
- data/lib/generators/better_page/templates/javascript/controllers/table_controller.js +60 -0
- data/lib/generators/better_page/templates/javascript/controllers/tabs_controller.js +89 -0
- data/lib/generators/better_page/templates/new_page.rb.tt +46 -0
- data/lib/generators/better_page/templates/show_base_page.rb.tt +117 -0
- data/lib/generators/better_page/templates/show_page.rb.tt +45 -0
- data/lib/generators/better_page/templates/view_components/application_view_component.rb.tt +7 -0
- data/lib/generators/better_page/templates/view_components/custom_view_component.html.erb.tt +21 -0
- data/lib/generators/better_page/templates/view_components/custom_view_component.rb.tt +21 -0
- data/lib/generators/better_page/templates/view_components/form_view_component.html.erb.tt +25 -0
- data/lib/generators/better_page/templates/view_components/form_view_component.rb.tt +23 -0
- data/lib/generators/better_page/templates/view_components/index_view_component.html.erb.tt +33 -0
- data/lib/generators/better_page/templates/view_components/index_view_component.rb.tt +29 -0
- data/lib/generators/better_page/templates/view_components/show_view_component.html.erb.tt +29 -0
- data/lib/generators/better_page/templates/view_components/show_view_component.rb.tt +25 -0
- data/lib/generators/better_page/templates/view_components/ui/alerts_component.html.erb.tt +47 -0
- data/lib/generators/better_page/templates/view_components/ui/alerts_component.rb.tt +47 -0
- data/lib/generators/better_page/templates/view_components/ui/content_section_component.html.erb.tt +42 -0
- data/lib/generators/better_page/templates/view_components/ui/content_section_component.rb.tt +34 -0
- data/lib/generators/better_page/templates/view_components/ui/drawer_component.html.erb.tt +73 -0
- data/lib/generators/better_page/templates/view_components/ui/drawer_component.rb.tt +78 -0
- data/lib/generators/better_page/templates/view_components/ui/errors_component.html.erb.tt +23 -0
- data/lib/generators/better_page/templates/view_components/ui/errors_component.rb.tt +18 -0
- data/lib/generators/better_page/templates/view_components/ui/field_component.html.erb.tt +65 -0
- data/lib/generators/better_page/templates/view_components/ui/field_component.rb.tt +91 -0
- data/lib/generators/better_page/templates/view_components/ui/footer_component.html.erb.tt +33 -0
- data/lib/generators/better_page/templates/view_components/ui/footer_component.rb.tt +32 -0
- data/lib/generators/better_page/templates/view_components/ui/header_component.html.erb.tt +55 -0
- data/lib/generators/better_page/templates/view_components/ui/header_component.rb.tt +39 -0
- data/lib/generators/better_page/templates/view_components/ui/modal_component.html.erb.tt +70 -0
- data/lib/generators/better_page/templates/view_components/ui/modal_component.rb.tt +54 -0
- data/lib/generators/better_page/templates/view_components/ui/overview_component.html.erb.tt +22 -0
- data/lib/generators/better_page/templates/view_components/ui/overview_component.rb.tt +71 -0
- data/lib/generators/better_page/templates/view_components/ui/pagination_component.html.erb.tt +63 -0
- data/lib/generators/better_page/templates/view_components/ui/pagination_component.rb.tt +69 -0
- data/lib/generators/better_page/templates/view_components/ui/panel_component.html.erb.tt +31 -0
- data/lib/generators/better_page/templates/view_components/ui/panel_component.rb.tt +23 -0
- data/lib/generators/better_page/templates/view_components/ui/statistics_component.html.erb.tt +33 -0
- data/lib/generators/better_page/templates/view_components/ui/statistics_component.rb.tt +51 -0
- data/lib/generators/better_page/templates/view_components/ui/table_component.html.erb.tt +112 -0
- data/lib/generators/better_page/templates/view_components/ui/table_component.rb.tt +88 -0
- data/lib/generators/better_page/templates/view_components/ui/tabs_component.html.erb.tt +52 -0
- data/lib/generators/better_page/templates/view_components/ui/tabs_component.rb.tt +76 -0
- data/lib/generators/better_page/templates/view_components/ui/widget_component.html.erb.tt +72 -0
- data/lib/generators/better_page/templates/view_components/ui/widget_component.rb.tt +34 -0
- data/lib/tasks/better_page.rake +70 -0
- data/lib/tasks/better_page_tasks.rake +4 -0
- 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
|