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,70 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="modal"
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["container", "panel", "backdrop"]
|
|
6
|
+
static values = {
|
|
7
|
+
open: { type: Boolean, default: false },
|
|
8
|
+
confirmClose: { type: Boolean, default: false }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.boundKeydown = this.keydown.bind(this)
|
|
13
|
+
document.addEventListener("keydown", this.boundKeydown)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
document.removeEventListener("keydown", this.boundKeydown)
|
|
18
|
+
document.body.classList.remove("overflow-hidden")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
open() {
|
|
22
|
+
this.openValue = true
|
|
23
|
+
this.containerTarget.classList.remove("hidden")
|
|
24
|
+
document.body.classList.add("overflow-hidden")
|
|
25
|
+
|
|
26
|
+
requestAnimationFrame(() => {
|
|
27
|
+
this.backdropTarget.classList.remove("opacity-0")
|
|
28
|
+
this.panelTarget.classList.remove("opacity-0", "translate-y-4", "sm:translate-y-0", "sm:scale-95")
|
|
29
|
+
this.panelTarget.classList.add("opacity-100", "translate-y-0", "sm:scale-100")
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
close() {
|
|
34
|
+
this.openValue = false
|
|
35
|
+
this.backdropTarget.classList.add("opacity-0")
|
|
36
|
+
this.panelTarget.classList.remove("opacity-100", "translate-y-0", "sm:scale-100")
|
|
37
|
+
this.panelTarget.classList.add("opacity-0", "translate-y-4", "sm:translate-y-0", "sm:scale-95")
|
|
38
|
+
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
this.containerTarget.classList.add("hidden")
|
|
41
|
+
document.body.classList.remove("overflow-hidden")
|
|
42
|
+
}, 300)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
toggle() {
|
|
46
|
+
this.openValue ? this.close() : this.open()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
requestClose() {
|
|
50
|
+
if (this.confirmCloseValue) {
|
|
51
|
+
if (confirm("Sei sicuro di voler chiudere? I dati non salvati andranno persi.")) {
|
|
52
|
+
this.close()
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
this.close()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
keydown(event) {
|
|
60
|
+
if (event.key === "Escape" && this.openValue) {
|
|
61
|
+
this.requestClose()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
backdropClick(event) {
|
|
66
|
+
if (event.target === this.backdropTarget) {
|
|
67
|
+
this.requestClose()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sidebar Controller
|
|
5
|
+
*
|
|
6
|
+
* A Stimulus controller for collapsible sidebar navigation.
|
|
7
|
+
* Connects to data-controller="sidebar"
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Toggle entire sidebar collapse/expand
|
|
11
|
+
* - Toggle individual menu groups
|
|
12
|
+
* - Persist collapse state in localStorage
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* <aside data-controller="sidebar"
|
|
16
|
+
* data-sidebar-collapsed-value="false"
|
|
17
|
+
* data-sidebar-persist-value="true"
|
|
18
|
+
* data-sidebar-storage-key-value="main-sidebar-collapsed">
|
|
19
|
+
* <button data-action="click->sidebar#toggle">Toggle</button>
|
|
20
|
+
* <div data-sidebar-target="group">
|
|
21
|
+
* <button data-action="click->sidebar#toggleGroup" data-group-id="nav">
|
|
22
|
+
* <span data-sidebar-target="groupLabel">Navigation</span>
|
|
23
|
+
* <svg data-sidebar-target="groupIcon">...</svg>
|
|
24
|
+
* </button>
|
|
25
|
+
* <div data-sidebar-target="groupContent" data-group-id="nav">
|
|
26
|
+
* <!-- group items -->
|
|
27
|
+
* </div>
|
|
28
|
+
* </div>
|
|
29
|
+
* </aside>
|
|
30
|
+
*/
|
|
31
|
+
export default class extends Controller {
|
|
32
|
+
static targets = [
|
|
33
|
+
"brand",
|
|
34
|
+
"brandText",
|
|
35
|
+
"toggleIcon",
|
|
36
|
+
"group",
|
|
37
|
+
"groupToggle",
|
|
38
|
+
"groupLabel",
|
|
39
|
+
"groupIcon",
|
|
40
|
+
"groupContent",
|
|
41
|
+
"item",
|
|
42
|
+
"itemLabel"
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
static values = {
|
|
46
|
+
collapsed: Boolean,
|
|
47
|
+
persist: Boolean,
|
|
48
|
+
storageKey: String
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
connect() {
|
|
52
|
+
// Restore saved state if persistence is enabled
|
|
53
|
+
if (this.persistValue && this.storageKeyValue) {
|
|
54
|
+
const saved = localStorage.getItem(this.storageKeyValue)
|
|
55
|
+
if (saved !== null) {
|
|
56
|
+
this.collapsedValue = saved === "true"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this.updateUI()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Toggle the entire sidebar collapse state
|
|
64
|
+
*/
|
|
65
|
+
toggle() {
|
|
66
|
+
this.collapsedValue = !this.collapsedValue
|
|
67
|
+
|
|
68
|
+
// Save state if persistence is enabled
|
|
69
|
+
if (this.persistValue && this.storageKeyValue) {
|
|
70
|
+
localStorage.setItem(this.storageKeyValue, this.collapsedValue.toString())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.updateUI()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Toggle a specific menu group
|
|
78
|
+
* @param {Event} event - Click event from the group toggle button
|
|
79
|
+
*/
|
|
80
|
+
toggleGroup(event) {
|
|
81
|
+
const button = event.currentTarget
|
|
82
|
+
const groupId = button.dataset.groupId
|
|
83
|
+
|
|
84
|
+
// Find the content container for this group
|
|
85
|
+
const content = this.groupContentTargets.find(
|
|
86
|
+
(el) => el.dataset.groupId === groupId
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// Find the icon within the button
|
|
90
|
+
const icon = button.querySelector('[data-sidebar-target="groupIcon"]')
|
|
91
|
+
|
|
92
|
+
// Toggle aria-expanded
|
|
93
|
+
const isExpanded = button.getAttribute("aria-expanded") === "true"
|
|
94
|
+
button.setAttribute("aria-expanded", (!isExpanded).toString())
|
|
95
|
+
|
|
96
|
+
// Toggle content visibility
|
|
97
|
+
if (content) {
|
|
98
|
+
content.classList.toggle("hidden")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Toggle icon rotation
|
|
102
|
+
if (icon) {
|
|
103
|
+
icon.classList.toggle("-rotate-90")
|
|
104
|
+
icon.classList.toggle("rotate-0")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Update the UI based on current collapsed state
|
|
110
|
+
*/
|
|
111
|
+
updateUI() {
|
|
112
|
+
const collapsed = this.collapsedValue
|
|
113
|
+
|
|
114
|
+
// Update width classes
|
|
115
|
+
this.element.classList.toggle("w-64", !collapsed)
|
|
116
|
+
this.element.classList.toggle("w-16", collapsed)
|
|
117
|
+
|
|
118
|
+
// Update toggle icon rotation
|
|
119
|
+
if (this.hasToggleIconTarget) {
|
|
120
|
+
this.toggleIconTarget.classList.toggle("rotate-180", collapsed)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Collect all text elements to hide/show
|
|
124
|
+
const textTargets = [
|
|
125
|
+
...(this.hasBrandTextTarget ? this.brandTextTargets : []),
|
|
126
|
+
...(this.hasGroupLabelTarget ? this.groupLabelTargets : []),
|
|
127
|
+
...(this.hasItemLabelTarget ? this.itemLabelTargets : [])
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
// Toggle visibility of text elements
|
|
131
|
+
textTargets.forEach((el) => {
|
|
132
|
+
el.classList.toggle("opacity-0", collapsed)
|
|
133
|
+
el.classList.toggle("w-0", collapsed)
|
|
134
|
+
el.classList.toggle("overflow-hidden", collapsed)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Hide badges when collapsed
|
|
138
|
+
if (collapsed) {
|
|
139
|
+
this.element.querySelectorAll("[class*='badge'], [class*='rounded-full']").forEach((badge) => {
|
|
140
|
+
if (badge.classList.contains("bg-blue-100") || badge.classList.contains("bg-red-500")) {
|
|
141
|
+
badge.classList.add("hidden")
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
} else {
|
|
145
|
+
this.element.querySelectorAll("[class*='badge'], [class*='rounded-full']").forEach((badge) => {
|
|
146
|
+
if (badge.classList.contains("bg-blue-100") || badge.classList.contains("bg-red-500")) {
|
|
147
|
+
badge.classList.remove("hidden")
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Table Controller
|
|
5
|
+
*
|
|
6
|
+
* A Stimulus controller for table row selection.
|
|
7
|
+
* Connects to data-controller="table"
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <div data-controller="table">
|
|
11
|
+
* <table>
|
|
12
|
+
* <thead>
|
|
13
|
+
* <tr>
|
|
14
|
+
* <th>
|
|
15
|
+
* <input type="checkbox"
|
|
16
|
+
* data-table-target="selectAll"
|
|
17
|
+
* data-action="change->table#selectAll">
|
|
18
|
+
* </th>
|
|
19
|
+
* </tr>
|
|
20
|
+
* </thead>
|
|
21
|
+
* <tbody>
|
|
22
|
+
* <tr>
|
|
23
|
+
* <td>
|
|
24
|
+
* <input type="checkbox"
|
|
25
|
+
* data-table-target="row"
|
|
26
|
+
* data-action="change->table#rowChanged">
|
|
27
|
+
* </td>
|
|
28
|
+
* </tr>
|
|
29
|
+
* </tbody>
|
|
30
|
+
* </table>
|
|
31
|
+
* </div>
|
|
32
|
+
*/
|
|
33
|
+
export default class extends Controller {
|
|
34
|
+
static targets = ["selectAll", "row"]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Select or deselect all row checkboxes
|
|
38
|
+
* @param {Event} event - The change event from the select all checkbox
|
|
39
|
+
*/
|
|
40
|
+
selectAll(event) {
|
|
41
|
+
const checked = event.target.checked
|
|
42
|
+
this.rowTargets.forEach(checkbox => {
|
|
43
|
+
checkbox.checked = checked
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Update the select all checkbox state based on individual row selections
|
|
49
|
+
* Sets indeterminate state when some (but not all) rows are selected
|
|
50
|
+
*/
|
|
51
|
+
rowChanged() {
|
|
52
|
+
const total = this.rowTargets.length
|
|
53
|
+
const checked = this.rowTargets.filter(cb => cb.checked).length
|
|
54
|
+
|
|
55
|
+
if (this.hasSelectAllTarget) {
|
|
56
|
+
this.selectAllTarget.checked = checked === total
|
|
57
|
+
this.selectAllTarget.indeterminate = checked > 0 && checked < total
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="tabs"
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["tab", "panel"]
|
|
6
|
+
static values = {
|
|
7
|
+
default: String,
|
|
8
|
+
style: { type: String, default: "underline" },
|
|
9
|
+
activeClass: String,
|
|
10
|
+
inactiveClass: String
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
const defaultId = this.defaultValue || this.tabTargets[0]?.dataset.tabId
|
|
15
|
+
if (defaultId) {
|
|
16
|
+
this.selectTab(defaultId, false)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
select(event) {
|
|
21
|
+
const tabId = event.currentTarget.dataset.tabId
|
|
22
|
+
this.selectTab(tabId)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
selectTab(tabId, animate = true) {
|
|
26
|
+
// Update tabs
|
|
27
|
+
this.tabTargets.forEach(tab => {
|
|
28
|
+
const isActive = tab.dataset.tabId === tabId
|
|
29
|
+
tab.setAttribute("aria-selected", isActive)
|
|
30
|
+
tab.setAttribute("tabindex", isActive ? "0" : "-1")
|
|
31
|
+
|
|
32
|
+
// Update classes
|
|
33
|
+
const activeClasses = this.activeClassValue.split(" ").filter(c => c)
|
|
34
|
+
const inactiveClasses = this.inactiveClassValue.split(" ").filter(c => c)
|
|
35
|
+
|
|
36
|
+
if (isActive) {
|
|
37
|
+
tab.classList.remove(...inactiveClasses)
|
|
38
|
+
tab.classList.add(...activeClasses)
|
|
39
|
+
} else {
|
|
40
|
+
tab.classList.remove(...activeClasses)
|
|
41
|
+
tab.classList.add(...inactiveClasses)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Update panels
|
|
46
|
+
this.panelTargets.forEach(panel => {
|
|
47
|
+
const isActive = panel.dataset.tabId === tabId
|
|
48
|
+
panel.classList.toggle("hidden", !isActive)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Dispatch custom event
|
|
52
|
+
this.dispatch("changed", { detail: { tabId } })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
keydown(event) {
|
|
56
|
+
const currentTab = event.currentTarget
|
|
57
|
+
const currentIndex = this.tabTargets.indexOf(currentTab)
|
|
58
|
+
let newIndex
|
|
59
|
+
|
|
60
|
+
switch (event.key) {
|
|
61
|
+
case "ArrowLeft":
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
newIndex = currentIndex - 1
|
|
64
|
+
if (newIndex < 0) newIndex = this.tabTargets.length - 1
|
|
65
|
+
break
|
|
66
|
+
case "ArrowRight":
|
|
67
|
+
event.preventDefault()
|
|
68
|
+
newIndex = currentIndex + 1
|
|
69
|
+
if (newIndex >= this.tabTargets.length) newIndex = 0
|
|
70
|
+
break
|
|
71
|
+
case "Home":
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
newIndex = 0
|
|
74
|
+
break
|
|
75
|
+
case "End":
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
newIndex = this.tabTargets.length - 1
|
|
78
|
+
break
|
|
79
|
+
default:
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const newTab = this.tabTargets[newIndex]
|
|
84
|
+
if (newTab) {
|
|
85
|
+
newTab.focus()
|
|
86
|
+
this.selectTab(newTab.dataset.tabId)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -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 NewPage < 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: "New <%= resource_singular.humanize %>",
|
|
19
|
+
<%= " " * namespace_path.length %> description: "Create a new <%= resource_singular.humanize.downcase %>.",
|
|
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,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base class for all show/detail pages in this application.
|
|
4
|
+
#
|
|
5
|
+
# Components available (from global configuration):
|
|
6
|
+
# Required: header
|
|
7
|
+
# Optional: alerts, statistics, overview, content_sections, footer
|
|
8
|
+
#
|
|
9
|
+
# Customize this class to:
|
|
10
|
+
# - Add custom components with register_component
|
|
11
|
+
# - Override helper methods
|
|
12
|
+
# - Change stream_components
|
|
13
|
+
#
|
|
14
|
+
# @example Add a custom component
|
|
15
|
+
# register_component :activity_feed, default: { events: [] }
|
|
16
|
+
#
|
|
17
|
+
class ShowBasePage < ApplicationPage
|
|
18
|
+
page_type :show
|
|
19
|
+
|
|
20
|
+
# ─────────────────────────────────────────────────────────────────
|
|
21
|
+
# CUSTOM COMPONENTS
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────
|
|
23
|
+
# Add custom components for all show pages here:
|
|
24
|
+
#
|
|
25
|
+
# register_component :timeline, default: []
|
|
26
|
+
# register_component :comments, default: { enabled: false }
|
|
27
|
+
|
|
28
|
+
# Main method that builds the complete show page configuration
|
|
29
|
+
# @return [Hash] complete show page configuration with :klass for rendering
|
|
30
|
+
def show
|
|
31
|
+
build_page
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The ViewComponent class used to render this show page
|
|
35
|
+
# @return [Class] BetterPage::ShowViewComponent
|
|
36
|
+
def view_component_class
|
|
37
|
+
return BetterPage::ShowViewComponent if defined?(BetterPage::ShowViewComponent)
|
|
38
|
+
|
|
39
|
+
raise NotImplementedError, "BetterPage::ShowViewComponent not found. Run: rails g better_page:install"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Components to include in stream updates by default
|
|
43
|
+
# Override to customize which components update via Turbo Streams
|
|
44
|
+
# @return [Array<Symbol>]
|
|
45
|
+
def stream_components
|
|
46
|
+
%i[alerts statistics overview content_sections]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
# Helper to convert hash to info grid format
|
|
52
|
+
# @param hash [Hash] key-value pairs to convert
|
|
53
|
+
# @return [Array<Hash>] formatted info grid items
|
|
54
|
+
def info_grid_content_format(hash)
|
|
55
|
+
hash.map { |name, value| { name: name, value: value } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Helper to build content section
|
|
59
|
+
# @param title [String] section title
|
|
60
|
+
# @param icon [String] section icon
|
|
61
|
+
# @param color [String] section color
|
|
62
|
+
# @param type [Symbol] section type (:info_grid, :text_content, :custom)
|
|
63
|
+
# @param content [Hash, Array, nil] section content
|
|
64
|
+
# @return [Hash] formatted content section
|
|
65
|
+
def content_section_format(title:, icon:, color:, type:, content: nil)
|
|
66
|
+
section = {
|
|
67
|
+
title: title,
|
|
68
|
+
icon: icon,
|
|
69
|
+
color: color,
|
|
70
|
+
type: type
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if type == :info_grid && content.is_a?(Array) && content.first.is_a?(Hash) && content.first.key?(:name)
|
|
74
|
+
section[:items] = content
|
|
75
|
+
elsif type == :info_grid && content.is_a?(Hash)
|
|
76
|
+
section[:items] = info_grid_content_format(content)
|
|
77
|
+
elsif type == :info_grid
|
|
78
|
+
section[:items] = content
|
|
79
|
+
else
|
|
80
|
+
section[:content] = content
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
section
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Helper to build statistic card
|
|
87
|
+
# @param label [String] statistic label
|
|
88
|
+
# @param value [String, Numeric] statistic value
|
|
89
|
+
# @param icon [String] statistic icon
|
|
90
|
+
# @param color [String] statistic color
|
|
91
|
+
# @return [Hash] formatted statistic
|
|
92
|
+
def statistic_format(label:, value:, icon:, color:)
|
|
93
|
+
{
|
|
94
|
+
label: label,
|
|
95
|
+
value: value,
|
|
96
|
+
icon: icon,
|
|
97
|
+
color: color
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Helper to build header action
|
|
102
|
+
# @param path [String] action path
|
|
103
|
+
# @param label [String] action label
|
|
104
|
+
# @param icon [String] action icon
|
|
105
|
+
# @param style [String] action style
|
|
106
|
+
# @param method [Symbol] HTTP method
|
|
107
|
+
# @return [Hash] formatted action
|
|
108
|
+
def action_format(path:, label:, icon:, style:, method: :get)
|
|
109
|
+
{
|
|
110
|
+
path: path,
|
|
111
|
+
label: label,
|
|
112
|
+
icon: icon,
|
|
113
|
+
style: style,
|
|
114
|
+
method: method
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
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 ShowPage < ShowBasePage
|
|
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: Page header configuration
|
|
16
|
+
<%= " " * namespace_path.length %> def header
|
|
17
|
+
<%= " " * namespace_path.length %> {
|
|
18
|
+
<%= " " * namespace_path.length %> title: @<%= resource_singular %>.to_s,
|
|
19
|
+
<%= " " * namespace_path.length %> breadcrumbs: breadcrumbs_config,
|
|
20
|
+
<%= " " * namespace_path.length %> metadata: [],
|
|
21
|
+
<%= " " * namespace_path.length %> actions: [
|
|
22
|
+
<%= " " * namespace_path.length %> # { label: "Edit", path: edit_<%= resource_singular %>_path(@<%= resource_singular %>), icon: "edit", style: "primary" }
|
|
23
|
+
<%= " " * namespace_path.length %> ]
|
|
24
|
+
<%= " " * namespace_path.length %> }
|
|
25
|
+
<%= " " * namespace_path.length %> end
|
|
26
|
+
|
|
27
|
+
<%= " " * namespace_path.length %> # Optional: Content sections
|
|
28
|
+
<%= " " * namespace_path.length %> def content_sections
|
|
29
|
+
<%= " " * namespace_path.length %> [
|
|
30
|
+
<%= " " * namespace_path.length %> # {
|
|
31
|
+
<%= " " * namespace_path.length %> # title: "Details",
|
|
32
|
+
<%= " " * namespace_path.length %> # icon: "info-circle",
|
|
33
|
+
<%= " " * namespace_path.length %> # color: "blue",
|
|
34
|
+
<%= " " * namespace_path.length %> # type: :info_grid,
|
|
35
|
+
<%= " " * namespace_path.length %> # items: [
|
|
36
|
+
<%= " " * namespace_path.length %> # { name: "ID", value: @<%= resource_singular %>.id },
|
|
37
|
+
<%= " " * namespace_path.length %> # { name: "Created", value: format_date(@<%= resource_singular %>.created_at) }
|
|
38
|
+
<%= " " * namespace_path.length %> # ]
|
|
39
|
+
<%= " " * namespace_path.length %> # }
|
|
40
|
+
<%= " " * namespace_path.length %> ]
|
|
41
|
+
<%= " " * namespace_path.length %> end
|
|
42
|
+
<%= " " * namespace_path.length %>end
|
|
43
|
+
<% namespace_path.reverse.each_with_index do |_, i| -%>
|
|
44
|
+
<%= " " * (namespace_path.length - i - 1) %>end
|
|
45
|
+
<% end -%>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<div class="better-page better-page--custom">
|
|
2
|
+
<%% if alerts? %>
|
|
3
|
+
<%%= render BetterPage::Ui::AlertsComponent.new(alerts: alerts) %>
|
|
4
|
+
<%% end %>
|
|
5
|
+
|
|
6
|
+
<%% if header? %>
|
|
7
|
+
<%%= render BetterPage::Ui::HeaderComponent.new(**header) %>
|
|
8
|
+
<%% end %>
|
|
9
|
+
|
|
10
|
+
<%% if content? %>
|
|
11
|
+
<div class="space-y-6">
|
|
12
|
+
<%% content.each do |widget| %>
|
|
13
|
+
<%%= render BetterPage::Ui::WidgetComponent.new(**widget) %>
|
|
14
|
+
<%% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<%% end %>
|
|
17
|
+
|
|
18
|
+
<%% if footer? %>
|
|
19
|
+
<%%= render BetterPage::Ui::FooterComponent.new(**footer) %>
|
|
20
|
+
<%% end %>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterPage
|
|
4
|
+
class CustomViewComponent < ApplicationViewComponent
|
|
5
|
+
def initialize(config:)
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Component accessors
|
|
10
|
+
def header = @config[:header]
|
|
11
|
+
def alerts = @config[:alerts]
|
|
12
|
+
def content = @config[:content]
|
|
13
|
+
def footer = @config[:footer]
|
|
14
|
+
|
|
15
|
+
# Presence helpers
|
|
16
|
+
def header? = header.present?
|
|
17
|
+
def alerts? = alerts.present?
|
|
18
|
+
def content? = content.present?
|
|
19
|
+
def footer? = footer.present?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<div class="better-page better-page--form">
|
|
2
|
+
<%% if alerts? %>
|
|
3
|
+
<%%= render BetterPage::Ui::AlertsComponent.new(alerts: alerts) %>
|
|
4
|
+
<%% end %>
|
|
5
|
+
|
|
6
|
+
<%% if header? %>
|
|
7
|
+
<%%= render BetterPage::Ui::HeaderComponent.new(**header) %>
|
|
8
|
+
<%% end %>
|
|
9
|
+
|
|
10
|
+
<%% if errors? %>
|
|
11
|
+
<%%= render BetterPage::Ui::ErrorsComponent.new(**errors) %>
|
|
12
|
+
<%% end %>
|
|
13
|
+
|
|
14
|
+
<%% if panels? %>
|
|
15
|
+
<div class="space-y-6">
|
|
16
|
+
<%% panels.each do |panel| %>
|
|
17
|
+
<%%= render BetterPage::Ui::PanelComponent.new(**panel) %>
|
|
18
|
+
<%% end %>
|
|
19
|
+
</div>
|
|
20
|
+
<%% end %>
|
|
21
|
+
|
|
22
|
+
<%% if footer? %>
|
|
23
|
+
<%%= render BetterPage::Ui::FooterComponent.new(**footer) %>
|
|
24
|
+
<%% end %>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterPage
|
|
4
|
+
class FormViewComponent < ApplicationViewComponent
|
|
5
|
+
def initialize(config:)
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Component accessors
|
|
10
|
+
def header = @config[:header]
|
|
11
|
+
def alerts = @config[:alerts]
|
|
12
|
+
def errors = @config[:errors]
|
|
13
|
+
def panels = @config[:panels]
|
|
14
|
+
def footer = @config[:footer]
|
|
15
|
+
|
|
16
|
+
# Presence helpers
|
|
17
|
+
def header? = header.present?
|
|
18
|
+
def alerts? = alerts.present?
|
|
19
|
+
def errors? = errors.present?
|
|
20
|
+
def panels? = panels.present?
|
|
21
|
+
def footer? = footer.present?
|
|
22
|
+
end
|
|
23
|
+
end
|