uchi 0.1.6 → 0.1.7
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 +4 -4
- data/README.md +7 -0
- data/app/assets/javascripts/controllers/fields/belongs_to_controller.js +130 -0
- data/app/assets/javascripts/controllers/fields/has_many_controller.js +146 -0
- data/app/assets/javascripts/uchi/application.js +804 -3
- data/app/assets/javascripts/uchi.js +9 -0
- data/app/assets/stylesheets/uchi/application.css +81 -1549
- data/app/assets/tailwind/uchi.css +2 -2
- data/app/components/uchi/field/belongs_to/edit.html.erb +73 -1
- data/app/components/uchi/field/belongs_to.rb +25 -25
- data/app/components/uchi/field/has_and_belongs_to_many/show.html.erb +1 -1
- data/app/components/uchi/field/has_many/edit.html.erb +86 -1
- data/app/components/uchi/field/has_many/show.html.erb +1 -1
- data/app/components/uchi/field/has_many.rb +59 -11
- data/app/components/uchi/ui/navigation/navigation.html.erb +1 -1
- data/app/components/uchi/ui/page_header/page_header.html.erb +7 -7
- data/app/controllers/uchi/belongs_to/associated_records_controller.rb +89 -0
- data/app/controllers/uchi/has_many/associated_records_controller.rb +89 -0
- data/app/views/layouts/uchi/_javascript.html.erb +1 -0
- data/app/views/layouts/uchi/_stylesheets.html.erb +1 -0
- data/app/views/layouts/uchi/application.html.erb +4 -4
- data/app/views/uchi/belongs_to/associated_records/index.html.erb +13 -0
- data/app/views/uchi/has_many/associated_records/index.html.erb +26 -0
- data/app/views/uchi/navigation/_main.html.erb +83 -0
- data/lib/generators/uchi/controller/controller_generator.rb +0 -4
- data/lib/generators/uchi/install/install_generator.rb +1 -1
- data/lib/uchi/field/configuration.rb +1 -1
- data/lib/uchi/repository.rb +13 -2
- data/lib/uchi/routes.rb +45 -0
- data/lib/uchi/version.rb +1 -1
- data/lib/uchi.rb +4 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de2b1d1a3dd3e56ab24bce7e64663414c0dbfe4dfc4ac782ca47fcd967e4b048
|
|
4
|
+
data.tar.gz: b9e0322ebafd58e4369f8b1e0087c8e4ec3bb637226a02b37b890d16be9986ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2ff7837db46a22eb39adc6cbd07077d9dd288032e6d77468a596c8042e3ab8476bc92ee57a3161ab177523701c0f7e4e4e8f8f49a45fc2691e8eaf0c4c2a0a2
|
|
7
|
+
data.tar.gz: 01accad1e6c453af98f1cccde568c46eb6b6eb9e0474f5454f3b9b59e215df636b00a599909b506f0c878a44d40d2f4b936329d0dab8aea82f422cf0709586ff
|
data/README.md
CHANGED
|
@@ -216,7 +216,14 @@ Rely on defaults whenever possible. If something has already been decided for us
|
|
|
216
216
|
|
|
217
217
|
We don't want to force you to translate everything. If a field doesn't need a translation, don't add one, we'll just fall back to the fields name.
|
|
218
218
|
|
|
219
|
+
### Edits happen on the edit page
|
|
220
|
+
|
|
221
|
+
This includes both attributes and associations as much as feasible.
|
|
222
|
+
|
|
219
223
|
## Credits
|
|
220
224
|
|
|
221
225
|
* Uchi contains parts of [Pagy](https://github.com/ddnexus/pagy), Copyright (c) 2017-2025 Domizio Demichelis
|
|
222
226
|
* Uchi contains parts of [Flowbite Components](https://github.com/substancelab/flowbite-components), Copyright (c) 2025 Substance Lab
|
|
227
|
+
* Uchi uses [combobox-nav](https://github.com/github/combobox-nav), Copyright (c) 2018 GitHub
|
|
228
|
+
* Uchi uses [requestjs-rails](https://github.com/rails/requestjs-rails), Copyright (c) 2021 Marcelo Lauxen
|
|
229
|
+
* Uchi uses [stimulus-use](https://github.com/stimulus-use/stimulus-use), Copyright (c) 2020 Adrien POLY
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { get } from "@rails/request.js"
|
|
3
|
+
import { useClickOutside, useDebounce } from 'stimulus-use'
|
|
4
|
+
import Combobox from '@github/combobox-nav'
|
|
5
|
+
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static debounces = ["handleChange"]
|
|
8
|
+
|
|
9
|
+
static targets = ["id", "dropdown", "input", "label", "list"]
|
|
10
|
+
|
|
11
|
+
static values = {
|
|
12
|
+
backendUrl: String
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
buildCombobox() {
|
|
16
|
+
return new Combobox(this.inputTarget, this.listTarget)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clickOutside(event) {
|
|
20
|
+
if (!this.dropdownTarget.hidden) {
|
|
21
|
+
this.closeDropdown()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
closeDropdown() {
|
|
26
|
+
this.combobox.stop()
|
|
27
|
+
this.dropdownTarget.hidden = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect() {
|
|
31
|
+
useClickOutside(this, {element: this.dropdownTarget})
|
|
32
|
+
useDebounce(this)
|
|
33
|
+
|
|
34
|
+
this.combobox = this.buildCombobox()
|
|
35
|
+
|
|
36
|
+
this.listTarget.addEventListener('combobox-commit', this.handleComboboxCommit.bind(this))
|
|
37
|
+
this.dropdownTarget.hidden = true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disconnect() {
|
|
41
|
+
this.listTarget.removeEventListener('combobox-commit', this.handleComboboxCommit)
|
|
42
|
+
|
|
43
|
+
this.combobox.destroy()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fetchOptions(options) {
|
|
47
|
+
get(this.backendUrlValue, {
|
|
48
|
+
query: { query: this.inputTarget.value }
|
|
49
|
+
}).then(({response}) => {
|
|
50
|
+
return response.text()
|
|
51
|
+
}).then((html) => {
|
|
52
|
+
this.listTarget.innerHTML = html
|
|
53
|
+
this.openDropdown()
|
|
54
|
+
this.markSelectedOption()
|
|
55
|
+
if (options?.scrollToSelected) {
|
|
56
|
+
this.scrollToSelectedOption()
|
|
57
|
+
}
|
|
58
|
+
}).catch((error) => {
|
|
59
|
+
console.error("Failed to fetch options:", error)
|
|
60
|
+
this.dropdownTarget.hidden = true
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleChange() {
|
|
65
|
+
this.combobox.stop()
|
|
66
|
+
this.fetchOptions()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handleComboboxCommit(event) {
|
|
70
|
+
this.setValuesFromElement(event.target)
|
|
71
|
+
this.closeDropdown()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
handleFocus() {
|
|
75
|
+
this.fetchOptions({ scrollToSelected: true })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
markSelectedOption() {
|
|
79
|
+
const options = this.listTarget.querySelectorAll('[role="option"]')
|
|
80
|
+
options.forEach((option) => {
|
|
81
|
+
option.removeAttribute('aria-selected')
|
|
82
|
+
const recordId = option.getAttribute('data-id')
|
|
83
|
+
if (recordId === this.idTarget.value) {
|
|
84
|
+
option.setAttribute('aria-selected', 'true')
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
openDropdown() {
|
|
90
|
+
this.combobox.start()
|
|
91
|
+
this.dropdownTarget.hidden = false
|
|
92
|
+
this.inputTarget.focus()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
scrollToSelectedOption() {
|
|
96
|
+
const selectedOption = this.listTarget.querySelector('[aria-selected="true"]')
|
|
97
|
+
if (selectedOption) {
|
|
98
|
+
selectedOption.scrollIntoView({
|
|
99
|
+
// Aligns the element at the center of the scrollable container,
|
|
100
|
+
// positioning it in the middle of the visible area.
|
|
101
|
+
block: "center",
|
|
102
|
+
inline: "center",
|
|
103
|
+
|
|
104
|
+
// Only the nearest scrollable container is impacted by the scroll.
|
|
105
|
+
container: "nearest"
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
selectOption(event) {
|
|
111
|
+
this.combobox.clearSelection()
|
|
112
|
+
event.target.setAttribute('aria-selected', 'true')
|
|
113
|
+
|
|
114
|
+
this.setValuesFromElement(event.target)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setValuesFromElement(element) {
|
|
118
|
+
const recordId = element.getAttribute('data-id')
|
|
119
|
+
this.idTarget.value = recordId
|
|
120
|
+
this.labelTarget.textContent = element.textContent.trim()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
toggle() {
|
|
124
|
+
if (this.dropdownTarget.hidden) {
|
|
125
|
+
this.openDropdown()
|
|
126
|
+
} else {
|
|
127
|
+
this.closeDropdown()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { get } from "@rails/request.js"
|
|
3
|
+
import { useClickOutside, useDebounce } from 'stimulus-use'
|
|
4
|
+
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static debounces = ["handleChange"]
|
|
7
|
+
|
|
8
|
+
static targets = ["checkbox", "dropdown", "idField", "idsContainer", "input", "label", "list"]
|
|
9
|
+
|
|
10
|
+
static values = {
|
|
11
|
+
backendUrl: String,
|
|
12
|
+
fieldName: String
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
clickOutside(event) {
|
|
16
|
+
if (!this.dropdownTarget.hidden) {
|
|
17
|
+
this.closeDropdown()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
closeDropdown() {
|
|
22
|
+
this.dropdownTarget.hidden = true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connect() {
|
|
26
|
+
useClickOutside(this, {element: this.dropdownTarget})
|
|
27
|
+
useDebounce(this)
|
|
28
|
+
|
|
29
|
+
this.dropdownTarget.hidden = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fetchOptions() {
|
|
33
|
+
get(this.backendUrlValue, {
|
|
34
|
+
query: { query: this.inputTarget.value }
|
|
35
|
+
}).then(({response}) => {
|
|
36
|
+
return response.text()
|
|
37
|
+
}).then((html) => {
|
|
38
|
+
this.listTarget.innerHTML = html
|
|
39
|
+
this.openDropdown()
|
|
40
|
+
this.updateCheckboxStates()
|
|
41
|
+
}).catch((error) => {
|
|
42
|
+
console.error("Failed to fetch options:", error)
|
|
43
|
+
this.dropdownTarget.hidden = true
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getSelectedIds() {
|
|
48
|
+
return this.idFieldTargets.map(field => String(field.value))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
handleChange() {
|
|
52
|
+
this.fetchOptions()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
handleFocus() {
|
|
56
|
+
this.fetchOptions()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
openDropdown() {
|
|
60
|
+
this.dropdownTarget.hidden = false
|
|
61
|
+
this.inputTarget.focus()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleCheckboxChange(event) {
|
|
65
|
+
const checkbox = event.target
|
|
66
|
+
const listItem = checkbox.closest('li[data-id]')
|
|
67
|
+
const recordId = listItem?.getAttribute('data-id')
|
|
68
|
+
|
|
69
|
+
if (!recordId) return
|
|
70
|
+
|
|
71
|
+
if (checkbox.checked) {
|
|
72
|
+
// Get the title from the label
|
|
73
|
+
const label = listItem?.querySelector('label')
|
|
74
|
+
const title = label ? label.textContent.trim() : ''
|
|
75
|
+
this.addId(recordId, title)
|
|
76
|
+
} else {
|
|
77
|
+
this.removeId(recordId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.updateLabel()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
addId(id, title) {
|
|
84
|
+
const selectedIds = this.getSelectedIds()
|
|
85
|
+
if (selectedIds.includes(id)) return
|
|
86
|
+
|
|
87
|
+
// Create a new hidden field for this ID
|
|
88
|
+
const hiddenField = document.createElement('input')
|
|
89
|
+
hiddenField.type = 'hidden'
|
|
90
|
+
hiddenField.name = this.fieldNameValue
|
|
91
|
+
hiddenField.value = id
|
|
92
|
+
hiddenField.setAttribute('data-has-many-target', 'idField')
|
|
93
|
+
hiddenField.setAttribute('data-title', title)
|
|
94
|
+
|
|
95
|
+
this.idsContainerTarget.appendChild(hiddenField)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
removeId(id) {
|
|
99
|
+
const field = this.idFieldTargets.find(f => f.value === id)
|
|
100
|
+
if (field) {
|
|
101
|
+
field.remove()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
toggle() {
|
|
106
|
+
if (this.dropdownTarget.hidden) {
|
|
107
|
+
this.openDropdown()
|
|
108
|
+
} else {
|
|
109
|
+
this.closeDropdown()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
updateCheckboxStates() {
|
|
114
|
+
const selectedIds = this.getSelectedIds()
|
|
115
|
+
|
|
116
|
+
this.checkboxTargets.forEach((checkbox) => {
|
|
117
|
+
const listItem = checkbox.closest('li[data-id]')
|
|
118
|
+
if (!listItem) return
|
|
119
|
+
|
|
120
|
+
const recordId = listItem.getAttribute('data-id')
|
|
121
|
+
const isSelected = selectedIds.includes(recordId)
|
|
122
|
+
|
|
123
|
+
checkbox.checked = isSelected
|
|
124
|
+
|
|
125
|
+
if (isSelected) {
|
|
126
|
+
listItem.setAttribute('aria-selected', 'true')
|
|
127
|
+
} else {
|
|
128
|
+
listItem.removeAttribute('aria-selected')
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
updateLabel() {
|
|
134
|
+
// Get titles from the hidden fields (which persist even when items are filtered)
|
|
135
|
+
const titles = this.idFieldTargets
|
|
136
|
+
.map(field => field.getAttribute('data-title'))
|
|
137
|
+
.filter(title => title) // Remove empty titles
|
|
138
|
+
|
|
139
|
+
if (titles.length === 0) {
|
|
140
|
+
this.labelTarget.innerHTML = '<span class="text-body-subtle">Select items...</span>'
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.labelTarget.textContent = titles.join(', ')
|
|
145
|
+
}
|
|
146
|
+
}
|