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,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
+ }