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.
Files changed (43) 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 +60 -26
  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/flowbite/button.rb +1 -1
  16. data/app/components/uchi/flowbite/input/field.rb +6 -0
  17. data/app/components/uchi/flowbite/input/label.rb +2 -0
  18. data/app/components/uchi/flowbite/input_field.rb +11 -1
  19. data/app/components/uchi/flowbite/styles.rb +34 -0
  20. data/app/components/uchi/ui/actions/dropdown/dropdown.html.erb +1 -1
  21. data/app/components/uchi/ui/navigation/navigation.html.erb +1 -1
  22. data/app/components/uchi/ui/page_header/page_header.html.erb +7 -7
  23. data/app/controllers/uchi/belongs_to/associated_records_controller.rb +89 -0
  24. data/app/controllers/uchi/has_many/associated_records_controller.rb +89 -0
  25. data/app/controllers/uchi/repository_controller.rb +24 -8
  26. data/app/views/layouts/uchi/_javascript.html.erb +1 -0
  27. data/app/views/layouts/uchi/_stylesheets.html.erb +1 -0
  28. data/app/views/layouts/uchi/application.html.erb +5 -5
  29. data/app/views/uchi/belongs_to/associated_records/index.html.erb +13 -0
  30. data/app/views/uchi/has_many/associated_records/index.html.erb +26 -0
  31. data/app/views/uchi/navigation/_main.html.erb +83 -0
  32. data/app/views/uchi/repository/new.html.erb +2 -2
  33. data/lib/generators/uchi/controller/controller_generator.rb +0 -4
  34. data/lib/generators/uchi/install/install_generator.rb +1 -1
  35. data/lib/uchi/field/configuration.rb +2 -2
  36. data/lib/uchi/field.rb +11 -0
  37. data/lib/uchi/plugins.rb +21 -0
  38. data/lib/uchi/repository/routes.rb +9 -5
  39. data/lib/uchi/repository.rb +43 -5
  40. data/lib/uchi/routes.rb +67 -0
  41. data/lib/uchi/version.rb +1 -1
  42. data/lib/uchi.rb +9 -1
  43. 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
- /* Configure the source files of Flowbite */
21
- @source "../../../node_modules/flowbite";
20
+ /* Don't include classes used in docs into Uchis stylesheet */
21
+ @source not "docs";
@@ -1 +1,73 @@
1
- <%= render(Uchi::Flowbite::InputField::Select.new(**options)) %>
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
- model = reflection.klass
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
- model = reflection.klass
28
- repository_class = Uchi::Repository.for_model(model)
29
- repository_class.new
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 collection
37
- query = associated_repository.find_all
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
- private
65
+ def dom_id_for_toggle
66
+ "#{form.object_name}_#{attribute_name}_belongs_to_toggle"
67
+ end
42
68
 
43
- def collection_for_select
44
- repository = associated_repository
45
- items = []
46
- items << ["", nil] if optional?
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 grid place-items-center p-8">
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
- <%# This space intentionally left blank %>
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 grid place-items-center p-8">
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
- reflection = record.class.reflect_on_association(field.name)
23
- model = reflection.klass
24
- repository_class = Uchi::Repository.for_model(model)
25
- raise NameError, "No repository found for associated model #{model}" unless repository_class
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}_id"
147
+ :"#{name.to_s.singularize}_ids"
98
148
  end
99
149
 
100
- protected
101
-
102
- def default_on
103
- [:show]
150
+ def permitted_param
151
+ {param_key => []}
104
152
  end
105
153
  end
106
154
  end
@@ -20,7 +20,7 @@ module Uchi::Flowbite
20
20
 
21
21
  class << self
22
22
  def classes(size: :default, state: :default, style: :default)
23
- style = styles.fetch(style) or raise "wut"
23
+ style = styles.fetch(style)
24
24
  classes = style.fetch(state)
25
25
  classes + sizes.fetch(size)
26
26
  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
 
@@ -36,6 +36,8 @@ module Uchi::Flowbite
36
36
  end
37
37
 
38
38
  def errors?
39
+ return false unless @object
40
+
39
41
  @object.errors.include?(@attribute.intern)
40
42
  end
41
43
 
@@ -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
- "#{@form.object_name}_#{@attribute}_hint"
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 px-2 py-1.5 text-body rounded-base hover:bg-neutral-tertiary hover:text-fg-brand group") do %>
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-4 mb-6 space-y-6 md:px-0">
2
- <div>
3
- <% if breadcrumb? %>
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
- <% end %>
6
- </div>
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