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
|
@@ -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.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,6 +12,13 @@ module Uchi
|
|
|
12
12
|
|
|
13
13
|
def associated_repository
|
|
14
14
|
reflection = record.class.reflect_on_association(field.name)
|
|
15
|
+
|
|
16
|
+
unless reflection
|
|
17
|
+
raise \
|
|
18
|
+
ArgumentError,
|
|
19
|
+
"No association named #{field.name.inspect} found on #{record.class}"
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
model = reflection.klass
|
|
16
23
|
repository_class = Uchi::Repository.for_model(model)
|
|
17
24
|
repository_class.new
|
|
@@ -23,48 +30,41 @@ module Uchi
|
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
class Edit < Uchi::Field::Base::Edit
|
|
33
|
+
include Helpers
|
|
34
|
+
|
|
26
35
|
def associated_repository
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
@associated_repository ||= begin
|
|
37
|
+
model = reflection.klass
|
|
38
|
+
repository_class = Uchi::Repository.for_model(model)
|
|
39
|
+
repository_class.new
|
|
40
|
+
end
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
def attribute_name
|
|
33
44
|
reflection.foreign_key
|
|
34
45
|
end
|
|
35
46
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
field.collection_query.call(query)
|
|
47
|
+
def dom_id_for_filter_query_input
|
|
48
|
+
"#{form.object_name}_#{attribute_name}_belongs_to_filter_query"
|
|
39
49
|
end
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
def dom_id_for_toggle
|
|
52
|
+
"#{form.object_name}_#{attribute_name}_belongs_to_toggle"
|
|
53
|
+
end
|
|
42
54
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
items + collection.map do |item|
|
|
48
|
-
[repository.title(item), item.id]
|
|
49
|
-
end
|
|
55
|
+
def record_title(record)
|
|
56
|
+
return "" if record.nil?
|
|
57
|
+
|
|
58
|
+
associated_repository.title(record)
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
private
|
|
62
|
+
|
|
52
63
|
# Returns true if the association is optional.
|
|
53
64
|
def optional?
|
|
54
65
|
reflection.options[:optional] == true
|
|
55
66
|
end
|
|
56
67
|
|
|
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
68
|
def reflection
|
|
69
69
|
@reflection ||= record.class.reflect_on_association(field.name)
|
|
70
70
|
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.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
|
|
@@ -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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uchi/pagination/controller"
|
|
4
|
+
|
|
5
|
+
module Uchi
|
|
6
|
+
module HasMany
|
|
7
|
+
# Companion controller for the Stimulus-based has_many_controller.
|
|
8
|
+
#
|
|
9
|
+
# Provides backend support for fetching associated records for a has_many
|
|
10
|
+
# field via AJAX.
|
|
11
|
+
class AssociatedRecordsController < Uchi::ApplicationController
|
|
12
|
+
layout false
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@current_values = 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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= javascript_include_tag "uchi/application", "data-turbo-track": "reload" %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= stylesheet_link_tag "uchi/application", media: "all", "data-turbo-track": "reload" %>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
|
|
3
|
-
<html>
|
|
3
|
+
<html class="h-full">
|
|
4
4
|
<head>
|
|
5
5
|
<title>
|
|
6
6
|
<%= content_for?(:page_title) ? yield(:page_title) : "Uchi" %>
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
13
13
|
|
|
14
|
-
<%=
|
|
15
|
-
<%=
|
|
14
|
+
<%= render "layouts/uchi/javascript" %>
|
|
15
|
+
<%= render "layouts/uchi/stylesheets" %>
|
|
16
16
|
</head>
|
|
17
17
|
|
|
18
|
-
<body class="antialiased bg-neutral-secondary-medium p-2">
|
|
18
|
+
<body class="antialiased bg-neutral-secondary-medium h-full p-2">
|
|
19
19
|
<div class="md:flex">
|
|
20
20
|
<%= render(partial: "uchi/navigation/main") %>
|
|
21
21
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<ul class="p-2 text-sm text-body font-medium">
|
|
2
|
+
<% @records.each do |record| %>
|
|
3
|
+
<li
|
|
4
|
+
class="inline-flex items-center w-full p-2 hover:bg-neutral-tertiary-medium hover:text-heading rounded aria-selected:bg-brand aria-selected:text-white"
|
|
5
|
+
data-action="belongs-to#selectOption"
|
|
6
|
+
data-id="<%= record.id %>"
|
|
7
|
+
id="<%= dom_id(record, [source_repository.model, @field_name].join("-")) %>"
|
|
8
|
+
role="option"
|
|
9
|
+
>
|
|
10
|
+
<%= record_title(record) %>
|
|
11
|
+
</li>
|
|
12
|
+
<% end %>
|
|
13
|
+
</ul>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<ul class="p-4 text-sm text-body font-medium space-y-4">
|
|
2
|
+
<% @records.each do |record| %>
|
|
3
|
+
<%
|
|
4
|
+
checkbox_id = dom_id(record, [source_repository.model, @field_name, 'checkbox'].join("-"))
|
|
5
|
+
%>
|
|
6
|
+
<li
|
|
7
|
+
data-id="<%= record.id %>"
|
|
8
|
+
id="<%= dom_id(record, [source_repository.model, @field_name].join("-")) %>"
|
|
9
|
+
role="option"
|
|
10
|
+
>
|
|
11
|
+
<div class="flex items-center">
|
|
12
|
+
<input
|
|
13
|
+
type="checkbox"
|
|
14
|
+
id="<%= checkbox_id %>"
|
|
15
|
+
class="w-4 h-4 border border-default-strong rounded-xs bg-neutral-secondary-strong focus:ring-2 focus:ring-brand-soft cursor-pointer"
|
|
16
|
+
data-action="change->has-many#handleCheckboxChange"
|
|
17
|
+
data-has-many-target="checkbox"
|
|
18
|
+
<%= 'checked' if @current_values.include?(record) %>
|
|
19
|
+
>
|
|
20
|
+
<label for="<%= checkbox_id %>" class="ms-2 text-sm font-medium text-heading cursor-pointer">
|
|
21
|
+
<%= record_title(record) %>
|
|
22
|
+
</label>
|
|
23
|
+
</div>
|
|
24
|
+
</li>
|
|
25
|
+
<% end %>
|
|
26
|
+
</ul>
|