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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -0
  3. data/app/assets/javascripts/controllers/fields/belongs_to_controller.js +130 -0
  4. data/app/assets/javascripts/controllers/fields/has_many_controller.js +146 -0
  5. data/app/assets/javascripts/uchi/application.js +804 -3
  6. data/app/assets/javascripts/uchi.js +9 -0
  7. data/app/assets/stylesheets/uchi/application.css +81 -1549
  8. data/app/assets/tailwind/uchi.css +2 -2
  9. data/app/components/uchi/field/belongs_to/edit.html.erb +73 -1
  10. data/app/components/uchi/field/belongs_to.rb +25 -25
  11. data/app/components/uchi/field/has_and_belongs_to_many/show.html.erb +1 -1
  12. data/app/components/uchi/field/has_many/edit.html.erb +86 -1
  13. data/app/components/uchi/field/has_many/show.html.erb +1 -1
  14. data/app/components/uchi/field/has_many.rb +59 -11
  15. data/app/components/uchi/ui/navigation/navigation.html.erb +1 -1
  16. data/app/components/uchi/ui/page_header/page_header.html.erb +7 -7
  17. data/app/controllers/uchi/belongs_to/associated_records_controller.rb +89 -0
  18. data/app/controllers/uchi/has_many/associated_records_controller.rb +89 -0
  19. data/app/views/layouts/uchi/_javascript.html.erb +1 -0
  20. data/app/views/layouts/uchi/_stylesheets.html.erb +1 -0
  21. data/app/views/layouts/uchi/application.html.erb +4 -4
  22. data/app/views/uchi/belongs_to/associated_records/index.html.erb +13 -0
  23. data/app/views/uchi/has_many/associated_records/index.html.erb +26 -0
  24. data/app/views/uchi/navigation/_main.html.erb +83 -0
  25. data/lib/generators/uchi/controller/controller_generator.rb +0 -4
  26. data/lib/generators/uchi/install/install_generator.rb +1 -1
  27. data/lib/uchi/field/configuration.rb +1 -1
  28. data/lib/uchi/repository.rb +13 -2
  29. data/lib/uchi/routes.rb +45 -0
  30. data/lib/uchi/version.rb +1 -1
  31. data/lib/uchi.rb +4 -1
  32. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a55e83c00940576aecf0cf9ba96655718a95f833919861ff05b064450f0c8b8a
4
- data.tar.gz: 911220ea4ded524bbb3b137c577e63d6aa982ac9f6683e23ed8842c476bfe65d
3
+ metadata.gz: de2b1d1a3dd3e56ab24bce7e64663414c0dbfe4dfc4ac782ca47fcd967e4b048
4
+ data.tar.gz: b9e0322ebafd58e4369f8b1e0087c8e4ec3bb637226a02b37b890d16be9986ad
5
5
  SHA512:
6
- metadata.gz: 502ce2498ad61f7f9faf2227314ad18ab4ce975b1e0ee166e4f0daa18f899673436a2c6a2fbe4748d5f89a30f2145655d7f73d46e4edc95b987db0b17e117fd9
7
- data.tar.gz: fdc08b6c1509be2d31f896b8394598f0bf5d4984c9b407547d4e38951a7c4d2a1871f65a67003750df1702e95b572a1b65bdc1e05a89602bf1de4eb30f82fd9a
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
+ }