uchi 0.1.6 → 0.1.8
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 +60 -26
- 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/flowbite/button.rb +1 -1
- data/app/components/uchi/flowbite/input/field.rb +6 -0
- data/app/components/uchi/flowbite/input/label.rb +2 -0
- data/app/components/uchi/flowbite/input_field.rb +11 -1
- data/app/components/uchi/flowbite/styles.rb +34 -0
- data/app/components/uchi/ui/actions/dropdown/dropdown.html.erb +1 -1
- 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/controllers/uchi/repository_controller.rb +24 -8
- 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 +5 -5
- 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/app/views/uchi/repository/new.html.erb +2 -2
- 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 +2 -2
- data/lib/uchi/field.rb +11 -0
- data/lib/uchi/plugins.rb +21 -0
- data/lib/uchi/repository/routes.rb +9 -5
- data/lib/uchi/repository.rb +43 -5
- data/lib/uchi/routes.rb +67 -0
- data/lib/uchi/version.rb +1 -1
- data/lib/uchi.rb +9 -1
- metadata +12 -1
|
@@ -17,5 +17,5 @@ running the following command:
|
|
|
17
17
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
|
18
18
|
@import "flowbite/src/themes/default"; /* This imports them from node_modules/flowbite/src/themes/default.css */
|
|
19
19
|
|
|
20
|
-
/*
|
|
21
|
-
@source "
|
|
20
|
+
/* Don't include classes used in docs into Uchis stylesheet */
|
|
21
|
+
@source not "docs";
|
|
@@ -1 +1,73 @@
|
|
|
1
|
-
|
|
1
|
+
<div
|
|
2
|
+
class="relative"
|
|
3
|
+
data-action="keydown.esc->belongs-to#closeDropdown"
|
|
4
|
+
data-controller="belongs-to"
|
|
5
|
+
data-belongs-to-backend-url-value="<%= helpers.uchi.belongs_to_associated_records_path(field: field.name, model: repository.model, record_id: record&.id) %>"
|
|
6
|
+
>
|
|
7
|
+
<%= render(Uchi::Flowbite::Input::Label.new(attribute: field.name, form: form, options: {for: dom_id_for_toggle})) { label } %>
|
|
8
|
+
|
|
9
|
+
<%# Actual input. This is the hidden part which is what is sent to the server %>
|
|
10
|
+
<%= form.hidden_field "#{field.name}_id",
|
|
11
|
+
data: {
|
|
12
|
+
'belongs-to-target': 'id',
|
|
13
|
+
}
|
|
14
|
+
%>
|
|
15
|
+
|
|
16
|
+
<button
|
|
17
|
+
class="<%= Uchi::Flowbite::Input::Field.classes.join(" ") %> text-left"
|
|
18
|
+
data-action="belongs-to#toggle"
|
|
19
|
+
id="<%= dom_id_for_toggle %>"
|
|
20
|
+
type="button"
|
|
21
|
+
>
|
|
22
|
+
<div class="inline-flex items-center justify-between w-full">
|
|
23
|
+
<div class="min-h-[1em]" data-belongs-to-target="label"><%= record_title(associated_record) %></div>
|
|
24
|
+
<svg
|
|
25
|
+
class="w-4 h-4 ms-1.5 -me-0.5"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
28
|
+
width="24"
|
|
29
|
+
height="24"
|
|
30
|
+
fill="none"
|
|
31
|
+
viewBox="0 0 24 24"
|
|
32
|
+
>
|
|
33
|
+
<path
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
stroke-linecap="round"
|
|
36
|
+
stroke-linejoin="round"
|
|
37
|
+
stroke-width="2"
|
|
38
|
+
d="m19 9-7 7-7-7"
|
|
39
|
+
/>
|
|
40
|
+
</svg>
|
|
41
|
+
</div>
|
|
42
|
+
</button>
|
|
43
|
+
|
|
44
|
+
<div class="absolute z-10 bg-neutral-primary-medium border border-default-medium rounded-base shadow-lg w-full mt-1" data-belongs-to-target="dropdown">
|
|
45
|
+
<div class="bg-neutral-primary-medium border-b border-default-medium p-2 rounded-t-base">
|
|
46
|
+
<%# Search input. This is the visible part that users type into %>
|
|
47
|
+
<label for="<%= dom_id_for_filter_query_input %>" class="sr-only">Search</label>
|
|
48
|
+
<%=
|
|
49
|
+
text_field_tag(
|
|
50
|
+
"filter_query",
|
|
51
|
+
nil,
|
|
52
|
+
class: "bg-neutral-secondary-strong border border-default-strong text-heading text-sm rounded focus:ring-brand focus:border-brand block w-full px-2.5 py-2 shadow-xs placeholder:text-body",
|
|
53
|
+
data: {
|
|
54
|
+
'belongs-to-target': 'input',
|
|
55
|
+
action: 'belongs-to#handleChange focus->belongs-to#handleFocus',
|
|
56
|
+
autocomplete: 'off'
|
|
57
|
+
},
|
|
58
|
+
id: dom_id_for_filter_query_input,
|
|
59
|
+
)
|
|
60
|
+
%>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="max-h-96 overflow-y-auto" data-belongs-to-target="list">
|
|
63
|
+
<!-- Options will be dynamically inserted here -->
|
|
64
|
+
<div class="grid place-items-center p-8">
|
|
65
|
+
<%= render(Uchi::Ui::Spinner.new(message: associated_repository.translate.loading_message)) %>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<% if hint.present? %>
|
|
71
|
+
<%= render(Uchi::Flowbite::Input::Hint.new(attribute: field.name, form: form)) { hint } %>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
@@ -12,7 +12,21 @@ module Uchi
|
|
|
12
12
|
|
|
13
13
|
def associated_repository
|
|
14
14
|
reflection = record.class.reflect_on_association(field.name)
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
unless reflection
|
|
17
|
+
raise \
|
|
18
|
+
ArgumentError,
|
|
19
|
+
"No association named #{field.name.inspect} found on #{record.class}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
model = if reflection.polymorphic?
|
|
23
|
+
associated_record&.class
|
|
24
|
+
else
|
|
25
|
+
reflection.klass
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return nil if model.nil?
|
|
29
|
+
|
|
16
30
|
repository_class = Uchi::Repository.for_model(model)
|
|
17
31
|
repository_class.new
|
|
18
32
|
end
|
|
@@ -23,48 +37,48 @@ module Uchi
|
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
class Edit < Uchi::Field::Base::Edit
|
|
40
|
+
include Helpers
|
|
41
|
+
|
|
26
42
|
def associated_repository
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
@associated_repository ||= begin
|
|
44
|
+
model = if reflection.polymorphic?
|
|
45
|
+
associated_record&.class
|
|
46
|
+
else
|
|
47
|
+
reflection.klass
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return nil if model.nil?
|
|
51
|
+
|
|
52
|
+
repository_class = Uchi::Repository.for_model(model)
|
|
53
|
+
repository_class.new
|
|
54
|
+
end
|
|
30
55
|
end
|
|
31
56
|
|
|
32
57
|
def attribute_name
|
|
33
58
|
reflection.foreign_key
|
|
34
59
|
end
|
|
35
60
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
field.collection_query.call(query)
|
|
61
|
+
def dom_id_for_filter_query_input
|
|
62
|
+
"#{form.object_name}_#{attribute_name}_belongs_to_filter_query"
|
|
39
63
|
end
|
|
40
64
|
|
|
41
|
-
|
|
65
|
+
def dom_id_for_toggle
|
|
66
|
+
"#{form.object_name}_#{attribute_name}_belongs_to_toggle"
|
|
67
|
+
end
|
|
42
68
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
items + collection.map do |item|
|
|
48
|
-
[repository.title(item), item.id]
|
|
49
|
-
end
|
|
69
|
+
def record_title(record)
|
|
70
|
+
return "" if record.nil?
|
|
71
|
+
|
|
72
|
+
associated_repository.title(record)
|
|
50
73
|
end
|
|
51
74
|
|
|
75
|
+
private
|
|
76
|
+
|
|
52
77
|
# Returns true if the association is optional.
|
|
53
78
|
def optional?
|
|
54
79
|
reflection.options[:optional] == true
|
|
55
80
|
end
|
|
56
81
|
|
|
57
|
-
def options
|
|
58
|
-
options = {
|
|
59
|
-
attribute: attribute_name,
|
|
60
|
-
collection: collection_for_select,
|
|
61
|
-
form: form,
|
|
62
|
-
label: {content: label}
|
|
63
|
-
}
|
|
64
|
-
options[:hint] = {content: hint} if hint.present?
|
|
65
|
-
options
|
|
66
|
-
end
|
|
67
|
-
|
|
68
82
|
def reflection
|
|
69
83
|
@reflection ||= record.class.reflect_on_association(field.name)
|
|
70
84
|
end
|
|
@@ -125,11 +139,31 @@ module Uchi
|
|
|
125
139
|
:attributes
|
|
126
140
|
end
|
|
127
141
|
|
|
142
|
+
# Returns the actions this field should appear on.
|
|
143
|
+
#
|
|
144
|
+
# For polymorphic associations, excludes :edit and :new to prevent showing
|
|
145
|
+
# the field on forms where the type cannot be determined.
|
|
146
|
+
def on(*actions)
|
|
147
|
+
on = super
|
|
148
|
+
return on - [:edit, :new] if polymorphic? && actions.empty?
|
|
149
|
+
|
|
150
|
+
on
|
|
151
|
+
end
|
|
152
|
+
|
|
128
153
|
def param_key
|
|
129
154
|
# TODO: This is too naive. We need to match this to the actual foreign
|
|
130
155
|
# key of the model.
|
|
131
156
|
:"#{name}_id"
|
|
132
157
|
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def polymorphic?
|
|
162
|
+
return false unless repository
|
|
163
|
+
|
|
164
|
+
reflection = repository.model.reflect_on_association(name)
|
|
165
|
+
reflection&.polymorphic? || false
|
|
166
|
+
end
|
|
133
167
|
end
|
|
134
168
|
end
|
|
135
169
|
end
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
)
|
|
23
23
|
)) do %>
|
|
24
24
|
<%= render(Uchi::Ui::Frame.new) do %>
|
|
25
|
-
<div class="grid
|
|
25
|
+
<div class="grid place-items-center p-8">
|
|
26
26
|
<%= render(Uchi::Ui::Spinner.new(message: repository.translate.loading_message)) %>
|
|
27
27
|
</div>
|
|
28
28
|
<% end %>
|
|
@@ -1 +1,86 @@
|
|
|
1
|
-
|
|
1
|
+
<div
|
|
2
|
+
class="relative"
|
|
3
|
+
data-action="keydown.esc->has-many#closeDropdown"
|
|
4
|
+
data-controller="has-many"
|
|
5
|
+
data-has-many-backend-url-value="<%= helpers.uchi.has_many_associated_records_path(field: field.name, model: repository.model, record_id: record&.id) %>"
|
|
6
|
+
data-has-many-field-name-value="<%= field_name_for_input %>"
|
|
7
|
+
>
|
|
8
|
+
<%= render(Uchi::Flowbite::Input::Label.new(attribute: field.name, form: form, options: {for: dom_id_for_toggle})) { label } %>
|
|
9
|
+
|
|
10
|
+
<%# Hidden fields to store selected IDs as an array %>
|
|
11
|
+
<div data-has-many-target="idsContainer">
|
|
12
|
+
<% associated_records.each do |assoc_record| %>
|
|
13
|
+
<input
|
|
14
|
+
type="hidden"
|
|
15
|
+
name="<%= field_name_for_input %>"
|
|
16
|
+
value="<%= assoc_record.id %>"
|
|
17
|
+
data-has-many-target="idField"
|
|
18
|
+
data-title="<%= record_title(assoc_record) %>"
|
|
19
|
+
>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<button
|
|
24
|
+
class="<%= Uchi::Flowbite::Input::Field.classes.join(" ") %> text-left"
|
|
25
|
+
data-action="has-many#toggle"
|
|
26
|
+
id="<%= dom_id_for_toggle %>"
|
|
27
|
+
type="button"
|
|
28
|
+
>
|
|
29
|
+
<div class="inline-flex items-center justify-between w-full">
|
|
30
|
+
<div class="min-h-[1em] truncate" data-has-many-target="label">
|
|
31
|
+
<% if associated_records.any? %>
|
|
32
|
+
<%= selected_titles %>
|
|
33
|
+
<% else %>
|
|
34
|
+
<span class="text-body-subtle">Select items...</span>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
<svg
|
|
38
|
+
class="w-4 h-4 ms-1.5 -me-0.5 flex-shrink-0"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
width="24"
|
|
42
|
+
height="24"
|
|
43
|
+
fill="none"
|
|
44
|
+
viewBox="0 0 24 24"
|
|
45
|
+
>
|
|
46
|
+
<path
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
stroke-linecap="round"
|
|
49
|
+
stroke-linejoin="round"
|
|
50
|
+
stroke-width="2"
|
|
51
|
+
d="m19 9-7 7-7-7"
|
|
52
|
+
/>
|
|
53
|
+
</svg>
|
|
54
|
+
</div>
|
|
55
|
+
</button>
|
|
56
|
+
|
|
57
|
+
<div class="absolute z-10 bg-neutral-primary-medium border border-default-medium rounded-base shadow-lg w-full mt-1" data-has-many-target="dropdown">
|
|
58
|
+
<div class="p-2 border-b border-default-medium">
|
|
59
|
+
<%# Search input. This is the visible part that users type into %>
|
|
60
|
+
<label for="<%= dom_id_for_filter_query_input %>" class="sr-only">Search</label>
|
|
61
|
+
<%=
|
|
62
|
+
text_field_tag(
|
|
63
|
+
"filter_query",
|
|
64
|
+
nil,
|
|
65
|
+
class: "bg-neutral-secondary-strong border border-default-strong text-heading text-sm rounded focus:ring-brand focus:border-brand block w-full px-2.5 py-2 shadow-xs placeholder:text-body",
|
|
66
|
+
data: {
|
|
67
|
+
'has-many-target': 'input',
|
|
68
|
+
action: 'has-many#handleChange focus->has-many#handleFocus',
|
|
69
|
+
autocomplete: 'off'
|
|
70
|
+
},
|
|
71
|
+
id: dom_id_for_filter_query_input,
|
|
72
|
+
)
|
|
73
|
+
%>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="max-h-96 overflow-y-auto" data-has-many-target="list">
|
|
76
|
+
<!-- Options will be dynamically inserted here -->
|
|
77
|
+
<div class="grid place-items-center p-8">
|
|
78
|
+
<%= render(Uchi::Ui::Spinner.new(message: associated_repository.translate.loading_message)) %>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<% if hint.present? %>
|
|
84
|
+
<%= render(Uchi::Flowbite::Input::Hint.new(attribute: field.name, form: form)) { hint } %>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
)
|
|
23
23
|
)) do %>
|
|
24
24
|
<%= render(Uchi::Ui::Frame.new) do %>
|
|
25
|
-
<div class="grid
|
|
25
|
+
<div class="grid place-items-center p-8">
|
|
26
26
|
<%= render(Uchi::Ui::Spinner.new(message: repository.translate.loading_message)) %>
|
|
27
27
|
</div>
|
|
28
28
|
<% end %>
|
|
@@ -6,6 +6,52 @@ module Uchi
|
|
|
6
6
|
DEFAULT_COLLECTION_QUERY = ->(query) { query }.freeze
|
|
7
7
|
|
|
8
8
|
class Edit < Uchi::Field::Base::Edit
|
|
9
|
+
def associated_records
|
|
10
|
+
records = field.value(record)
|
|
11
|
+
return [] if records.nil?
|
|
12
|
+
|
|
13
|
+
associated_repository.find_all(scope: records)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def associated_repository
|
|
17
|
+
@associated_repository ||= begin
|
|
18
|
+
model = reflection.klass
|
|
19
|
+
repository_class = Uchi::Repository.for_model(model)
|
|
20
|
+
repository_class.new
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def attribute_name
|
|
25
|
+
"#{field.name.to_s.singularize}_ids"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dom_id_for_filter_query_input
|
|
29
|
+
"#{form.object_name}_#{attribute_name}_has_many_filter_query"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dom_id_for_toggle
|
|
33
|
+
"#{form.object_name}_#{attribute_name}_has_many_toggle"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def field_name_for_input
|
|
37
|
+
"#{form.object_name}[#{attribute_name}][]"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_title(record)
|
|
41
|
+
return "" if record.nil?
|
|
42
|
+
|
|
43
|
+
associated_repository.title(record)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def selected_titles
|
|
47
|
+
associated_records.map { |record| record_title(record) }.join(", ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def reflection
|
|
53
|
+
@reflection ||= record.class.reflect_on_association(field.name)
|
|
54
|
+
end
|
|
9
55
|
end
|
|
10
56
|
|
|
11
57
|
class Index < Uchi::Field::Base::Index
|
|
@@ -19,10 +65,15 @@ module Uchi
|
|
|
19
65
|
end
|
|
20
66
|
|
|
21
67
|
def associated_repository
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
68
|
+
raise NameError, "No association named #{field.name.inspect} found on #{record.class}" unless reflection
|
|
69
|
+
|
|
70
|
+
associated_model = reflection.klass
|
|
71
|
+
repository_class = Uchi::Repository.for_model(associated_model)
|
|
72
|
+
unless repository_class
|
|
73
|
+
raise \
|
|
74
|
+
NameError,
|
|
75
|
+
"No repository found for associated model #{associated_model}"
|
|
76
|
+
end
|
|
26
77
|
|
|
27
78
|
repository_class.new
|
|
28
79
|
end
|
|
@@ -46,13 +97,12 @@ module Uchi
|
|
|
46
97
|
#
|
|
47
98
|
# @return [ActiveRecord::Reflection, nil]
|
|
48
99
|
def inverse_association
|
|
49
|
-
reflection = record.class.reflect_on_association(field.name)
|
|
50
100
|
reflection&.inverse_of
|
|
51
101
|
end
|
|
52
102
|
|
|
53
103
|
# Returns the ActiveRecord::Reflection for this association.
|
|
54
104
|
#
|
|
55
|
-
# @return [ActiveRecord::Reflection]
|
|
105
|
+
# @return [ActiveRecord::Reflection, nil]
|
|
56
106
|
def reflection
|
|
57
107
|
@reflection ||= record.class.reflect_on_association(field.name)
|
|
58
108
|
end
|
|
@@ -94,13 +144,11 @@ module Uchi
|
|
|
94
144
|
def param_key
|
|
95
145
|
# TODO: This is too naive. We need to match this to the actual foreign
|
|
96
146
|
# key of the model.
|
|
97
|
-
:"#{name}
|
|
147
|
+
:"#{name.to_s.singularize}_ids"
|
|
98
148
|
end
|
|
99
149
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def default_on
|
|
103
|
-
[:show]
|
|
150
|
+
def permitted_param
|
|
151
|
+
{param_key => []}
|
|
104
152
|
end
|
|
105
153
|
end
|
|
106
154
|
end
|
|
@@ -93,7 +93,13 @@ module Uchi::Flowbite
|
|
|
93
93
|
!!@disabled
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# Returns true if the object has errors. Returns false if there is no
|
|
97
|
+
# object.
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] true if there are errors, false otherwise.
|
|
96
100
|
def errors?
|
|
101
|
+
return false unless @object
|
|
102
|
+
|
|
97
103
|
@object.errors.include?(@attribute.intern)
|
|
98
104
|
end
|
|
99
105
|
|
|
@@ -67,7 +67,11 @@ module Uchi::Flowbite
|
|
|
67
67
|
renders_one :label
|
|
68
68
|
|
|
69
69
|
# Returns the errors for attribute
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<String>] An array of error messages for the attribute.
|
|
70
72
|
def errors
|
|
73
|
+
return [] unless @object
|
|
74
|
+
|
|
71
75
|
@object.errors[@attribute] || []
|
|
72
76
|
end
|
|
73
77
|
|
|
@@ -185,7 +189,13 @@ module Uchi::Flowbite
|
|
|
185
189
|
end
|
|
186
190
|
|
|
187
191
|
def id_for_hint_element
|
|
188
|
-
|
|
192
|
+
[
|
|
193
|
+
@form.object_name,
|
|
194
|
+
@attribute,
|
|
195
|
+
"hint"
|
|
196
|
+
]
|
|
197
|
+
.compact_blank
|
|
198
|
+
.join("_")
|
|
189
199
|
end
|
|
190
200
|
|
|
191
201
|
# @return [Hash] The keyword arguments for the input component.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uchi::Flowbite
|
|
4
|
+
class Styles
|
|
5
|
+
class StyleNotFoundError < ::KeyError; end
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def from_hash(styles_hash)
|
|
9
|
+
styles = Styles.new
|
|
10
|
+
styles_hash.each do |style_name, states_hash|
|
|
11
|
+
styles.add_style(style_name, states_hash)
|
|
12
|
+
end
|
|
13
|
+
styles
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add_style(style_name, states_hash)
|
|
18
|
+
@styles[style_name] = Uchi::Flowbite::Style.new(states_hash)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(style_name)
|
|
22
|
+
return @styles[style_name] if @styles.key?(style_name)
|
|
23
|
+
|
|
24
|
+
raise \
|
|
25
|
+
StyleNotFoundError,
|
|
26
|
+
"Style not found: #{style_name}. Available styles: " \
|
|
27
|
+
"#{@styles.keys.sort.join(", ")}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@styles = {}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
>
|
|
43
43
|
<% actions.each do |action| %>
|
|
44
44
|
<li role="menuitem">
|
|
45
|
-
<%= form_with url: helpers.actions_executions_path, method: :post, class: "block" do %>
|
|
45
|
+
<%= form_with url: helpers.uchi.actions_executions_path, method: :post, class: "block" do %>
|
|
46
46
|
<%= hidden_field_tag :model, repository.model.name %>
|
|
47
47
|
<%= hidden_field_tag :action_name, action.class.name %>
|
|
48
48
|
<%= hidden_field_tag :id, record.id %>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<ul class="space-y-2 font-medium">
|
|
2
2
|
<% items.each do |item| %>
|
|
3
3
|
<li>
|
|
4
|
-
<%= link_to(item[:path], :class => "flex items-center
|
|
4
|
+
<%= link_to(item[:path], :class => "flex items-center py-1.5 text-body rounded-base hover:bg-neutral-tertiary hover:text-fg-brand group md:px-2") do %>
|
|
5
5
|
<span class="ms-3"><%= item[:label] %></span>
|
|
6
6
|
<% end %>
|
|
7
7
|
</li>
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
<header class="px-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<header class="px-0 mb-6 space-y-6 md:px-0">
|
|
2
|
+
<% if breadcrumb? %>
|
|
3
|
+
<div class="px-2">
|
|
4
4
|
<%= breadcrumb %>
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
</div>
|
|
6
|
+
<% end %>
|
|
7
7
|
|
|
8
|
-
<div class="items-start justify-between space-x-6 md:flex md:px-0">
|
|
8
|
+
<div class="items-start justify-between space-y-2 space-x-6 md:flex md:px-0 md:space-y-0">
|
|
9
9
|
<div>
|
|
10
|
-
<h1 class="text-3xl font-semibold tracking-tight text-heading group">
|
|
10
|
+
<h1 class="px-1 text-3xl font-semibold tracking-tight text-heading group">
|
|
11
11
|
<%= title %>
|
|
12
12
|
</h1>
|
|
13
13
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uchi/pagination/controller"
|
|
4
|
+
|
|
5
|
+
module Uchi
|
|
6
|
+
module BelongsTo
|
|
7
|
+
# Companion controller for the Stimulus-based belongs_to_controller.
|
|
8
|
+
#
|
|
9
|
+
# Provides backend support for fetching associated records for a belongs_to
|
|
10
|
+
# field via AJAX.
|
|
11
|
+
class AssociatedRecordsController < Uchi::ApplicationController
|
|
12
|
+
layout false
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@current_value = field.value(parent_record)
|
|
16
|
+
|
|
17
|
+
@field_name = params[:field]
|
|
18
|
+
@records = field.collection_query.call(find_all_records_from_association)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
protected
|
|
22
|
+
|
|
23
|
+
helper_method def record_title(record)
|
|
24
|
+
return "" if record.nil?
|
|
25
|
+
|
|
26
|
+
associated_repository.title(record)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
helper_method def source_repository
|
|
30
|
+
@source_repository ||= begin
|
|
31
|
+
model_name = params[:model]
|
|
32
|
+
repository_class = Uchi::Repository.for_model(model_name)
|
|
33
|
+
raise NameError, "No repository found for model #{model_name}" unless repository_class
|
|
34
|
+
|
|
35
|
+
repository_class.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def associated_repository
|
|
42
|
+
@associated_repository ||= begin
|
|
43
|
+
associated_repository = Uchi::Repository.for_model(association.klass)&.new
|
|
44
|
+
raise NameError, "No repository found for associated model #{association.klass}" unless associated_repository
|
|
45
|
+
|
|
46
|
+
associated_repository
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def association
|
|
51
|
+
@association ||= begin
|
|
52
|
+
association = source_repository.model.reflect_on_association(field.name.to_sym)
|
|
53
|
+
raise NameError, "No association named #{field.name} on #{source_repository.model}" unless association
|
|
54
|
+
|
|
55
|
+
association
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def field
|
|
60
|
+
@field ||= begin
|
|
61
|
+
field_name = params[:field]
|
|
62
|
+
field = source_repository.fields.find { |f| f.name == field_name.to_sym }
|
|
63
|
+
raise NameError, "No field named #{field_name} on #{source_repository.model}" unless field
|
|
64
|
+
|
|
65
|
+
field
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_all_records(repository:, scope: nil)
|
|
70
|
+
# Duplicated from Uchi::RepositoryController; consider refactoring.
|
|
71
|
+
repository
|
|
72
|
+
.find_all(
|
|
73
|
+
scope: scope,
|
|
74
|
+
search: params[:query]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def find_all_records_from_association
|
|
79
|
+
find_all_records(repository: associated_repository)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parent_record
|
|
83
|
+
return nil unless params[:record_id].present?
|
|
84
|
+
|
|
85
|
+
source_repository.find(params[:record_id])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|