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,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base class for all custom pages in this application.
|
|
4
|
+
# Use for dashboards, reports, and any page that doesn't fit
|
|
5
|
+
# the standard index/show/form patterns.
|
|
6
|
+
#
|
|
7
|
+
# Components available (from global configuration):
|
|
8
|
+
# Required: content
|
|
9
|
+
# Optional: header, footer, alerts
|
|
10
|
+
#
|
|
11
|
+
# Customize this class to:
|
|
12
|
+
# - Add custom components with register_component
|
|
13
|
+
# - Override helper methods
|
|
14
|
+
# - Change stream_components
|
|
15
|
+
#
|
|
16
|
+
# @example Add a custom component
|
|
17
|
+
# register_component :charts, default: []
|
|
18
|
+
#
|
|
19
|
+
class CustomBasePage < ApplicationPage
|
|
20
|
+
page_type :custom
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────
|
|
23
|
+
# CUSTOM COMPONENTS
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────
|
|
25
|
+
# Add custom components for all custom pages here:
|
|
26
|
+
#
|
|
27
|
+
# register_component :charts, default: []
|
|
28
|
+
# register_component :kpis, default: []
|
|
29
|
+
# register_component :filters, default: { enabled: false }
|
|
30
|
+
|
|
31
|
+
# Main method that builds the complete custom page configuration
|
|
32
|
+
# @return [Hash] complete custom page configuration with :klass for rendering
|
|
33
|
+
def custom
|
|
34
|
+
build_page
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The ViewComponent class used to render this custom page
|
|
38
|
+
# @return [Class] BetterPage::CustomViewComponent
|
|
39
|
+
def view_component_class
|
|
40
|
+
return BetterPage::CustomViewComponent if defined?(BetterPage::CustomViewComponent)
|
|
41
|
+
|
|
42
|
+
raise NotImplementedError, "BetterPage::CustomViewComponent not found. Run: rails g better_page:install"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Components to include in stream updates by default
|
|
46
|
+
# Override to customize which components update via Turbo Streams
|
|
47
|
+
# @return [Array<Symbol>]
|
|
48
|
+
def stream_components
|
|
49
|
+
%i[alerts content]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
protected
|
|
53
|
+
|
|
54
|
+
# Helper to build a widget section
|
|
55
|
+
# @param title [String] widget title
|
|
56
|
+
# @param type [Symbol] widget type
|
|
57
|
+
# @param data [Hash, Array] widget data
|
|
58
|
+
# @param options [Hash] additional options
|
|
59
|
+
# @return [Hash] formatted widget
|
|
60
|
+
def widget_format(title:, type:, data:, **options)
|
|
61
|
+
{
|
|
62
|
+
title: title,
|
|
63
|
+
type: type,
|
|
64
|
+
data: data,
|
|
65
|
+
**options
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Helper to build a chart configuration
|
|
70
|
+
# @param title [String] chart title
|
|
71
|
+
# @param type [Symbol] chart type (:line, :bar, :pie, etc.)
|
|
72
|
+
# @param data [Hash] chart data with labels and datasets
|
|
73
|
+
# @param options [Hash] additional chart options
|
|
74
|
+
# @return [Hash] formatted chart
|
|
75
|
+
def chart_format(title:, type:, data:, **options)
|
|
76
|
+
{
|
|
77
|
+
title: title,
|
|
78
|
+
type: type,
|
|
79
|
+
data: data,
|
|
80
|
+
**options
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% namespace_path.each_with_index do |ns, i| -%>
|
|
4
|
+
<%= " " * i %>module <%= ns.camelize %>
|
|
5
|
+
<% end -%>
|
|
6
|
+
<%= " " * namespace_path.length %>class CustomPage < CustomBasePage
|
|
7
|
+
<%= " " * namespace_path.length %> def initialize(data, user)
|
|
8
|
+
<%= " " * namespace_path.length %> @data = data
|
|
9
|
+
<%= " " * namespace_path.length %> @user = user
|
|
10
|
+
<%= " " * namespace_path.length %> end
|
|
11
|
+
|
|
12
|
+
<%= " " * namespace_path.length %> private
|
|
13
|
+
|
|
14
|
+
<%= " " * namespace_path.length %> # Optional: Page header configuration
|
|
15
|
+
<%= " " * namespace_path.length %> def header
|
|
16
|
+
<%= " " * namespace_path.length %> {
|
|
17
|
+
<%= " " * namespace_path.length %> title: "<%= resource_name.titleize %>",
|
|
18
|
+
<%= " " * namespace_path.length %> breadcrumbs: breadcrumbs_config
|
|
19
|
+
<%= " " * namespace_path.length %> }
|
|
20
|
+
<%= " " * namespace_path.length %> end
|
|
21
|
+
|
|
22
|
+
<%= " " * namespace_path.length %> # Required: Custom content configuration
|
|
23
|
+
<%= " " * namespace_path.length %> def content
|
|
24
|
+
<%= " " * namespace_path.length %> {
|
|
25
|
+
<%= " " * namespace_path.length %> # Add your custom content configuration here
|
|
26
|
+
<%= " " * namespace_path.length %> }
|
|
27
|
+
<%= " " * namespace_path.length %> end
|
|
28
|
+
<%= " " * namespace_path.length %>end
|
|
29
|
+
<% namespace_path.reverse.each_with_index do |_, i| -%>
|
|
30
|
+
<%= " " * (namespace_path.length - i - 1) %>end
|
|
31
|
+
<% end -%>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% namespace_path.each_with_index do |ns, i| -%>
|
|
4
|
+
<%= " " * i %>module <%= ns.camelize %>
|
|
5
|
+
<% end -%>
|
|
6
|
+
<%= " " * namespace_path.length %>class EditPage < FormBasePage
|
|
7
|
+
<%= " " * namespace_path.length %> def initialize(<%= resource_singular %>, metadata = {})
|
|
8
|
+
<%= " " * namespace_path.length %> @<%= resource_singular %> = <%= resource_singular %>
|
|
9
|
+
<%= " " * namespace_path.length %> @user = metadata[:user]
|
|
10
|
+
<%= " " * namespace_path.length %> super(<%= resource_singular %>, metadata)
|
|
11
|
+
<%= " " * namespace_path.length %> end
|
|
12
|
+
|
|
13
|
+
<%= " " * namespace_path.length %> private
|
|
14
|
+
|
|
15
|
+
<%= " " * namespace_path.length %> # Required: Form header configuration
|
|
16
|
+
<%= " " * namespace_path.length %> def header
|
|
17
|
+
<%= " " * namespace_path.length %> {
|
|
18
|
+
<%= " " * namespace_path.length %> title: "Edit <%= resource_singular.humanize %>",
|
|
19
|
+
<%= " " * namespace_path.length %> description: "Update <%= resource_singular.humanize.downcase %> details.",
|
|
20
|
+
<%= " " * namespace_path.length %> breadcrumbs: breadcrumbs_config
|
|
21
|
+
<%= " " * namespace_path.length %> }
|
|
22
|
+
<%= " " * namespace_path.length %> end
|
|
23
|
+
|
|
24
|
+
<%= " " * namespace_path.length %> # Required: Form panels with fields
|
|
25
|
+
<%= " " * namespace_path.length %> def panels
|
|
26
|
+
<%= " " * namespace_path.length %> [
|
|
27
|
+
<%= " " * namespace_path.length %> {
|
|
28
|
+
<%= " " * namespace_path.length %> title: "Basic Information",
|
|
29
|
+
<%= " " * namespace_path.length %> fields: [
|
|
30
|
+
<%= " " * namespace_path.length %> # { name: :name, type: :text, label: "Name", required: true },
|
|
31
|
+
<%= " " * namespace_path.length %> # { name: :email, type: :email, label: "Email" }
|
|
32
|
+
<%= " " * namespace_path.length %> ]
|
|
33
|
+
<%= " " * namespace_path.length %> }
|
|
34
|
+
<%= " " * namespace_path.length %> # NOTE: Checkbox and radio fields must be in separate panels
|
|
35
|
+
<%= " " * namespace_path.length %> # {
|
|
36
|
+
<%= " " * namespace_path.length %> # title: "Settings",
|
|
37
|
+
<%= " " * namespace_path.length %> # fields: [
|
|
38
|
+
<%= " " * namespace_path.length %> # { name: :is_active, type: :checkbox, label: "Active" }
|
|
39
|
+
<%= " " * namespace_path.length %> # ]
|
|
40
|
+
<%= " " * namespace_path.length %> # }
|
|
41
|
+
<%= " " * namespace_path.length %> ]
|
|
42
|
+
<%= " " * namespace_path.length %> end
|
|
43
|
+
<%= " " * namespace_path.length %>end
|
|
44
|
+
<% namespace_path.reverse.each_with_index do |_, i| -%>
|
|
45
|
+
<%= " " * (namespace_path.length - i - 1) %>end
|
|
46
|
+
<% end -%>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base class for all form pages (new/edit) in this application.
|
|
4
|
+
#
|
|
5
|
+
# FORM ORGANIZATION RULES (MANDATORY):
|
|
6
|
+
# =====================================
|
|
7
|
+
#
|
|
8
|
+
# RULE 1: INPUT TYPE SEPARATION
|
|
9
|
+
# - CHECKBOX fields must be grouped in dedicated panels
|
|
10
|
+
# - RADIO BUTTON fields must be grouped in dedicated panels
|
|
11
|
+
# - Text, email, select, textarea, number, date can be mixed together
|
|
12
|
+
#
|
|
13
|
+
# Components available (from global configuration):
|
|
14
|
+
# Required: header, panels
|
|
15
|
+
# Optional: alerts, errors, footer
|
|
16
|
+
#
|
|
17
|
+
# Customize this class to:
|
|
18
|
+
# - Add custom components with register_component
|
|
19
|
+
# - Override helper methods
|
|
20
|
+
# - Change stream_components
|
|
21
|
+
#
|
|
22
|
+
class FormBasePage < ApplicationPage
|
|
23
|
+
page_type :form
|
|
24
|
+
|
|
25
|
+
# ─────────────────────────────────────────────────────────────────
|
|
26
|
+
# CUSTOM COMPONENTS
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────
|
|
28
|
+
# Add custom components for all form pages here:
|
|
29
|
+
#
|
|
30
|
+
# register_component :sidebar_help, default: nil
|
|
31
|
+
# register_component :preview, default: { enabled: false }
|
|
32
|
+
|
|
33
|
+
# Main method that builds the complete form page configuration
|
|
34
|
+
# @return [Hash] complete form page configuration with :klass for rendering
|
|
35
|
+
def form
|
|
36
|
+
result = build_page
|
|
37
|
+
validate_form_panels_rules(result[:panels]) if result[:panels]
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The ViewComponent class used to render this form page
|
|
42
|
+
# @return [Class] BetterPage::FormViewComponent
|
|
43
|
+
def view_component_class
|
|
44
|
+
return BetterPage::FormViewComponent if defined?(BetterPage::FormViewComponent)
|
|
45
|
+
|
|
46
|
+
raise NotImplementedError, "BetterPage::FormViewComponent not found. Run: rails g better_page:install"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Components to include in stream updates by default
|
|
50
|
+
# Override to customize which components update via Turbo Streams
|
|
51
|
+
# @return [Array<Symbol>]
|
|
52
|
+
def stream_components
|
|
53
|
+
%i[alerts errors panels]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
protected
|
|
57
|
+
|
|
58
|
+
# Validates that all panels follow the input separation rules
|
|
59
|
+
# Logs warnings in development mode when rules are violated
|
|
60
|
+
# @param panels [Array<Hash>] panels to validate
|
|
61
|
+
# @return [void]
|
|
62
|
+
def validate_form_panels_rules(panels)
|
|
63
|
+
return unless defined?(Rails) && Rails.env.development?
|
|
64
|
+
|
|
65
|
+
panels.each_with_index do |panel, index|
|
|
66
|
+
next unless panel[:fields].is_a?(Array)
|
|
67
|
+
|
|
68
|
+
checkbox_count = panel[:fields].count { |field| field[:type] == :checkbox }
|
|
69
|
+
radio_count = panel[:fields].count { |field| field[:type] == :radio }
|
|
70
|
+
other_count = panel[:fields].count { |field| %i[checkbox radio].exclude?(field[:type]) }
|
|
71
|
+
|
|
72
|
+
if checkbox_count.positive? && other_count.positive?
|
|
73
|
+
Rails.logger.warn "[FormBasePage] RULE VIOLATION in panel '#{panel[:title]}' (#{index}): " \
|
|
74
|
+
"checkboxes mixed with other inputs"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if radio_count.positive? && other_count.positive?
|
|
78
|
+
Rails.logger.warn "[FormBasePage] RULE VIOLATION in panel '#{panel[:title]}' (#{index}): " \
|
|
79
|
+
"radio buttons mixed with other inputs"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Helper to build a form field
|
|
85
|
+
# @param name [Symbol] field name
|
|
86
|
+
# @param type [Symbol] field type (:text, :email, :select, :checkbox, etc.)
|
|
87
|
+
# @param label [String] field label
|
|
88
|
+
# @param options [Hash] additional options (required, placeholder, collection, etc.)
|
|
89
|
+
# @return [Hash] formatted field
|
|
90
|
+
def field_format(name:, type:, label:, **options)
|
|
91
|
+
{
|
|
92
|
+
name: name,
|
|
93
|
+
type: type,
|
|
94
|
+
label: label,
|
|
95
|
+
**options
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Helper to build a form panel
|
|
100
|
+
# @param title [String] panel title
|
|
101
|
+
# @param fields [Array<Hash>] panel fields
|
|
102
|
+
# @param description [String, nil] panel description
|
|
103
|
+
# @param icon [String, nil] panel icon
|
|
104
|
+
# @return [Hash] formatted panel
|
|
105
|
+
def panel_format(title:, fields:, description: nil, icon: nil)
|
|
106
|
+
panel = {
|
|
107
|
+
title: title,
|
|
108
|
+
fields: fields
|
|
109
|
+
}
|
|
110
|
+
panel[:description] = description if description
|
|
111
|
+
panel[:icon] = icon if icon
|
|
112
|
+
panel
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Default breadcrumbs for forms
|
|
116
|
+
# @return [Array<Hash>] empty breadcrumbs
|
|
117
|
+
def default_breadcrumbs
|
|
118
|
+
[]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Extract resource name from class name
|
|
122
|
+
# @return [String] downcased resource name
|
|
123
|
+
def resource_name
|
|
124
|
+
self.class.name.split("::").last.gsub(/Page$/, "").downcase
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base class for all index/list pages in this application.
|
|
4
|
+
#
|
|
5
|
+
# Components available (from global configuration):
|
|
6
|
+
# Required: header, table
|
|
7
|
+
# Optional: alerts, statistics, metrics, tabs, search, pagination,
|
|
8
|
+
# overview, calendar, footer, modals, split_view
|
|
9
|
+
#
|
|
10
|
+
# Customize this class to:
|
|
11
|
+
# - Add custom components with register_component
|
|
12
|
+
# - Override helper methods
|
|
13
|
+
# - Change stream_components
|
|
14
|
+
#
|
|
15
|
+
# @example Add a custom component
|
|
16
|
+
# register_component :sidebar, default: { enabled: false }
|
|
17
|
+
#
|
|
18
|
+
class IndexBasePage < ApplicationPage
|
|
19
|
+
page_type :index
|
|
20
|
+
|
|
21
|
+
# ─────────────────────────────────────────────────────────────────
|
|
22
|
+
# CUSTOM COMPONENTS
|
|
23
|
+
# ─────────────────────────────────────────────────────────────────
|
|
24
|
+
# Add custom components for all index pages here:
|
|
25
|
+
#
|
|
26
|
+
# register_component :filters, default: []
|
|
27
|
+
# register_component :bulk_actions, default: { enabled: false }
|
|
28
|
+
|
|
29
|
+
# Main method that builds the complete index page configuration
|
|
30
|
+
# @return [Hash] complete index page configuration with :klass for rendering
|
|
31
|
+
def index
|
|
32
|
+
build_page
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The ViewComponent class used to render this index page
|
|
36
|
+
# @return [Class] BetterPage::IndexViewComponent
|
|
37
|
+
def view_component_class
|
|
38
|
+
return BetterPage::IndexViewComponent if defined?(BetterPage::IndexViewComponent)
|
|
39
|
+
|
|
40
|
+
raise NotImplementedError, "BetterPage::IndexViewComponent not found. Run: rails g better_page:install"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Components to include in stream updates by default
|
|
44
|
+
# Override to customize which components update via Turbo Streams
|
|
45
|
+
# @return [Array<Symbol>]
|
|
46
|
+
def stream_components
|
|
47
|
+
%i[alerts statistics table pagination]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
# Helper for split view empty state
|
|
53
|
+
# @param icon [String] icon name
|
|
54
|
+
# @param title [String] title text
|
|
55
|
+
# @param message [String] message text
|
|
56
|
+
# @return [Hash] empty state configuration
|
|
57
|
+
def split_view_empty_state_format(icon: "hand-pointer", title: "Select an item",
|
|
58
|
+
message: "Click on an item from the list to see its details")
|
|
59
|
+
{
|
|
60
|
+
icon: icon,
|
|
61
|
+
title: title,
|
|
62
|
+
message: message
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% namespace_path.each_with_index do |ns, i| -%>
|
|
4
|
+
<%= " " * i %>module <%= ns.camelize %>
|
|
5
|
+
<% end -%>
|
|
6
|
+
<%= " " * namespace_path.length %>class IndexPage < IndexBasePage
|
|
7
|
+
<%= " " * namespace_path.length %> def initialize(<%= resource_plural %>, metadata = {})
|
|
8
|
+
<%= " " * namespace_path.length %> @<%= resource_plural %> = <%= resource_plural %>
|
|
9
|
+
<%= " " * namespace_path.length %> @user = metadata[:user]
|
|
10
|
+
<%= " " * namespace_path.length %> @params = metadata[:params] || {}
|
|
11
|
+
<%= " " * namespace_path.length %> super(<%= resource_plural %>, metadata)
|
|
12
|
+
<%= " " * namespace_path.length %> end
|
|
13
|
+
|
|
14
|
+
<%= " " * namespace_path.length %> private
|
|
15
|
+
|
|
16
|
+
<%= " " * namespace_path.length %> # Required: Page header configuration
|
|
17
|
+
<%= " " * namespace_path.length %> def header
|
|
18
|
+
<%= " " * namespace_path.length %> {
|
|
19
|
+
<%= " " * namespace_path.length %> title: "<%= resource_name.titleize %>",
|
|
20
|
+
<%= " " * namespace_path.length %> breadcrumbs: breadcrumbs_config,
|
|
21
|
+
<%= " " * namespace_path.length %> metadata: [],
|
|
22
|
+
<%= " " * namespace_path.length %> actions: [
|
|
23
|
+
<%= " " * namespace_path.length %> # { label: "New", path: new_<%= resource_singular %>_path, icon: "plus", style: "primary" }
|
|
24
|
+
<%= " " * namespace_path.length %> ]
|
|
25
|
+
<%= " " * namespace_path.length %> }
|
|
26
|
+
<%= " " * namespace_path.length %> end
|
|
27
|
+
|
|
28
|
+
<%= " " * namespace_path.length %> # Required: Table configuration
|
|
29
|
+
<%= " " * namespace_path.length %> def table
|
|
30
|
+
<%= " " * namespace_path.length %> {
|
|
31
|
+
<%= " " * namespace_path.length %> items: @<%= resource_plural %>,
|
|
32
|
+
<%= " " * namespace_path.length %> columns: [
|
|
33
|
+
<%= " " * namespace_path.length %> # { key: :id, label: "ID", type: :text },
|
|
34
|
+
<%= " " * namespace_path.length %> # { key: :name, label: "Name", type: :link, path: ->(item) { <%= resource_singular %>_path(item) } }
|
|
35
|
+
<%= " " * namespace_path.length %> ],
|
|
36
|
+
<%= " " * namespace_path.length %> actions: table_actions,
|
|
37
|
+
<%= " " * namespace_path.length %> empty_state: {
|
|
38
|
+
<%= " " * namespace_path.length %> icon: "inbox",
|
|
39
|
+
<%= " " * namespace_path.length %> title: "No <%= resource_plural.humanize.downcase %> found",
|
|
40
|
+
<%= " " * namespace_path.length %> message: "There are no <%= resource_plural.humanize.downcase %> to display at the moment."
|
|
41
|
+
<%= " " * namespace_path.length %> }
|
|
42
|
+
<%= " " * namespace_path.length %> }
|
|
43
|
+
<%= " " * namespace_path.length %> end
|
|
44
|
+
|
|
45
|
+
<%= " " * namespace_path.length %> def table_actions
|
|
46
|
+
<%= " " * namespace_path.length %> lambda { |item|
|
|
47
|
+
<%= " " * namespace_path.length %> [
|
|
48
|
+
<%= " " * namespace_path.length %> # { label: "View", path: <%= resource_singular %>_path(item), icon: "eye", style: "secondary" },
|
|
49
|
+
<%= " " * namespace_path.length %> # { label: "Edit", path: edit_<%= resource_singular %>_path(item), icon: "edit", style: "secondary" }
|
|
50
|
+
<%= " " * namespace_path.length %> ]
|
|
51
|
+
<%= " " * namespace_path.length %> }
|
|
52
|
+
<%= " " * namespace_path.length %> end
|
|
53
|
+
<%= " " * namespace_path.length %>end
|
|
54
|
+
<% namespace_path.reverse.each_with_index do |_, i| -%>
|
|
55
|
+
<%= " " * (namespace_path.length - i - 1) %>end
|
|
56
|
+
<% end -%>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AppNav Controller
|
|
5
|
+
*
|
|
6
|
+
* A Stimulus controller for the application navigation bar.
|
|
7
|
+
* Connects to data-controller="app-nav"
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Toggle mobile navigation menu
|
|
11
|
+
* - Toggle notification panel
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <nav data-controller="app-nav">
|
|
15
|
+
* <button data-action="click->app-nav#toggleMobile">
|
|
16
|
+
* <svg data-app-nav-target="menuIcon">...</svg>
|
|
17
|
+
* <svg data-app-nav-target="closeIcon" class="hidden">...</svg>
|
|
18
|
+
* </button>
|
|
19
|
+
* <div data-app-nav-target="mobileMenu" class="hidden">
|
|
20
|
+
* <!-- mobile menu content -->
|
|
21
|
+
* </div>
|
|
22
|
+
* <button data-action="click->app-nav#toggleNotification">
|
|
23
|
+
* Notifications
|
|
24
|
+
* </button>
|
|
25
|
+
* <div data-app-nav-target="notificationPanel" class="hidden">
|
|
26
|
+
* <!-- notification content -->
|
|
27
|
+
* </div>
|
|
28
|
+
* </nav>
|
|
29
|
+
*/
|
|
30
|
+
export default class extends Controller {
|
|
31
|
+
static targets = ["mobileMenu", "menuIcon", "closeIcon", "notificationPanel"]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Toggle the mobile navigation menu visibility
|
|
35
|
+
* Swaps between hamburger and close icons
|
|
36
|
+
*/
|
|
37
|
+
toggleMobile() {
|
|
38
|
+
if (this.hasMobileMenuTarget) {
|
|
39
|
+
this.mobileMenuTarget.classList.toggle("hidden")
|
|
40
|
+
}
|
|
41
|
+
if (this.hasMenuIconTarget) {
|
|
42
|
+
this.menuIconTarget.classList.toggle("hidden")
|
|
43
|
+
}
|
|
44
|
+
if (this.hasCloseIconTarget) {
|
|
45
|
+
this.closeIconTarget.classList.toggle("hidden")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Toggle the notification panel visibility
|
|
51
|
+
*/
|
|
52
|
+
toggleNotification() {
|
|
53
|
+
if (this.hasNotificationPanelTarget) {
|
|
54
|
+
this.notificationPanelTarget.classList.toggle("hidden")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drawer Controller
|
|
5
|
+
*
|
|
6
|
+
* A Stimulus controller for slide-out drawer panels.
|
|
7
|
+
* Connects to data-controller="drawer"
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <div data-controller="drawer" data-drawer-direction-value="right">
|
|
11
|
+
* <button data-action="click->drawer#open">Open Drawer</button>
|
|
12
|
+
*
|
|
13
|
+
* <div data-drawer-target="container" class="hidden">
|
|
14
|
+
* <div data-drawer-target="backdrop" data-action="click->drawer#backdropClick"></div>
|
|
15
|
+
* <div data-drawer-target="panel">
|
|
16
|
+
* <button data-action="click->drawer#close">Close</button>
|
|
17
|
+
* <!-- content -->
|
|
18
|
+
* </div>
|
|
19
|
+
* </div>
|
|
20
|
+
* </div>
|
|
21
|
+
*/
|
|
22
|
+
export default class extends Controller {
|
|
23
|
+
static targets = ["container", "panel", "backdrop"]
|
|
24
|
+
static values = {
|
|
25
|
+
direction: { type: String, default: "right" },
|
|
26
|
+
open: { type: Boolean, default: false },
|
|
27
|
+
confirmClose: { type: Boolean, default: false }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect() {
|
|
31
|
+
this.boundKeydown = this.keydown.bind(this)
|
|
32
|
+
document.addEventListener("keydown", this.boundKeydown)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
disconnect() {
|
|
36
|
+
document.removeEventListener("keydown", this.boundKeydown)
|
|
37
|
+
document.body.classList.remove("overflow-hidden")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
open() {
|
|
41
|
+
this.openValue = true
|
|
42
|
+
this.containerTarget.classList.remove("hidden")
|
|
43
|
+
document.body.classList.add("overflow-hidden")
|
|
44
|
+
|
|
45
|
+
// Trigger animation on next frame
|
|
46
|
+
requestAnimationFrame(() => {
|
|
47
|
+
this.panelTarget.classList.remove(this.hiddenClass)
|
|
48
|
+
this.backdropTarget.classList.remove("opacity-0")
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
close() {
|
|
53
|
+
this.openValue = false
|
|
54
|
+
this.panelTarget.classList.add(this.hiddenClass)
|
|
55
|
+
this.backdropTarget.classList.add("opacity-0")
|
|
56
|
+
|
|
57
|
+
// Wait for animation to complete
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
this.containerTarget.classList.add("hidden")
|
|
60
|
+
document.body.classList.remove("overflow-hidden")
|
|
61
|
+
}, 300)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
toggle() {
|
|
65
|
+
this.openValue ? this.close() : this.open()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
requestClose() {
|
|
69
|
+
if (this.confirmCloseValue) {
|
|
70
|
+
if (confirm("Sei sicuro di voler chiudere? I dati non salvati andranno persi.")) {
|
|
71
|
+
this.close()
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
this.close()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
keydown(event) {
|
|
79
|
+
if (event.key === "Escape" && this.openValue) {
|
|
80
|
+
this.requestClose()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
backdropClick(event) {
|
|
85
|
+
if (event.target === this.backdropTarget) {
|
|
86
|
+
this.requestClose()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get hiddenClass() {
|
|
91
|
+
const classes = {
|
|
92
|
+
right: "translate-x-full",
|
|
93
|
+
left: "-translate-x-full",
|
|
94
|
+
top: "-translate-y-full",
|
|
95
|
+
bottom: "translate-y-full"
|
|
96
|
+
}
|
|
97
|
+
return classes[this.directionValue] || "translate-x-full"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="dropdown"
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["menu"]
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.boundHide = this.hide.bind(this)
|
|
9
|
+
this.boundUpdatePosition = this.updatePosition.bind(this)
|
|
10
|
+
document.addEventListener("click", this.boundHide)
|
|
11
|
+
window.addEventListener("scroll", this.boundUpdatePosition, true)
|
|
12
|
+
window.addEventListener("resize", this.boundUpdatePosition)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
disconnect() {
|
|
16
|
+
document.removeEventListener("click", this.boundHide)
|
|
17
|
+
window.removeEventListener("scroll", this.boundUpdatePosition, true)
|
|
18
|
+
window.removeEventListener("resize", this.boundUpdatePosition)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toggle(event) {
|
|
22
|
+
event.stopPropagation()
|
|
23
|
+
const isHidden = this.menuTarget.classList.contains("hidden")
|
|
24
|
+
|
|
25
|
+
if (isHidden) {
|
|
26
|
+
this.menuTarget.classList.remove("hidden")
|
|
27
|
+
this.menuTarget.style.position = "fixed"
|
|
28
|
+
this.updatePosition()
|
|
29
|
+
} else {
|
|
30
|
+
this.menuTarget.classList.add("hidden")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
updatePosition() {
|
|
35
|
+
if (this.menuTarget.classList.contains("hidden")) return
|
|
36
|
+
|
|
37
|
+
const button = this.element.querySelector("button")
|
|
38
|
+
const rect = button.getBoundingClientRect()
|
|
39
|
+
const menuRect = this.menuTarget.getBoundingClientRect()
|
|
40
|
+
|
|
41
|
+
// Posiziona sotto il bottone, allineato a destra
|
|
42
|
+
let top = rect.bottom + 8
|
|
43
|
+
let left = rect.right - menuRect.width
|
|
44
|
+
|
|
45
|
+
// Controlla se esce dalla viewport
|
|
46
|
+
if (left < 0) left = rect.left
|
|
47
|
+
if (top + menuRect.height > window.innerHeight) {
|
|
48
|
+
top = rect.top - menuRect.height - 8
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.menuTarget.style.top = `${top}px`
|
|
52
|
+
this.menuTarget.style.left = `${left}px`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
hide(event) {
|
|
56
|
+
if (!this.element.contains(event.target)) {
|
|
57
|
+
this.menuTarget.classList.add("hidden")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// BetterPage Stimulus Controllers
|
|
2
|
+
// This file exports all controllers for easy registration
|
|
3
|
+
|
|
4
|
+
import DropdownController from "./dropdown_controller"
|
|
5
|
+
import TableController from "./table_controller"
|
|
6
|
+
import DrawerController from "./drawer_controller"
|
|
7
|
+
import ModalController from "./modal_controller"
|
|
8
|
+
import TabsController from "./tabs_controller"
|
|
9
|
+
import SidebarController from "./sidebar_controller"
|
|
10
|
+
import AppNavController from "./app_nav_controller"
|
|
11
|
+
|
|
12
|
+
// Export individual controllers
|
|
13
|
+
export { DropdownController, TableController, DrawerController, ModalController, TabsController, SidebarController, AppNavController }
|
|
14
|
+
|
|
15
|
+
// Helper function to register all controllers at once
|
|
16
|
+
export function registerBetterPageControllers(application) {
|
|
17
|
+
application.register("dropdown", DropdownController)
|
|
18
|
+
application.register("table", TableController)
|
|
19
|
+
application.register("drawer", DrawerController)
|
|
20
|
+
application.register("modal", ModalController)
|
|
21
|
+
application.register("tabs", TabsController)
|
|
22
|
+
application.register("sidebar", SidebarController)
|
|
23
|
+
application.register("app-nav", AppNavController)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Default export for convenience
|
|
27
|
+
export default {
|
|
28
|
+
DropdownController,
|
|
29
|
+
TableController,
|
|
30
|
+
DrawerController,
|
|
31
|
+
ModalController,
|
|
32
|
+
TabsController,
|
|
33
|
+
SidebarController,
|
|
34
|
+
AppNavController,
|
|
35
|
+
registerBetterPageControllers
|
|
36
|
+
}
|