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
@@ -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
@@ -9,7 +9,7 @@ module Uchi
9
9
  before_action :set_repository
10
10
 
11
11
  def create
12
- @record = build_record
12
+ @record = build_record_for_new
13
13
  if @record.save
14
14
  flash[:success] = @repository.translate.successful_create
15
15
  redirect_to(@repository.routes.path_for(:show, id: @record.id), status: :see_other)
@@ -54,7 +54,7 @@ module Uchi
54
54
  end
55
55
 
56
56
  def new
57
- @record = build_record
57
+ @record = build_record_for_new
58
58
  end
59
59
 
60
60
  def show
@@ -63,7 +63,7 @@ module Uchi
63
63
 
64
64
  def update
65
65
  @record = find_record
66
- if @record.update(record_params)
66
+ if @record.update(record_params_for_update)
67
67
  flash[:success] = @repository.translate.successful_update
68
68
  redirect_to(@repository.routes.path_for(:show, id: @record.id, uniq: rand), status: :see_other)
69
69
  else
@@ -73,8 +73,8 @@ module Uchi
73
73
 
74
74
  private
75
75
 
76
- def build_record
77
- @repository.build(record_params)
76
+ def build_record_for_new
77
+ @repository.build(record_params_for_new)
78
78
  end
79
79
 
80
80
  # Returns the path to use for the cancel link
@@ -128,10 +128,26 @@ module Uchi
128
128
  25
129
129
  end
130
130
 
131
- def record_params
132
- permitted_params = @repository.fields_for_edit.map(&:permitted_param)
131
+ def permitted_params_for_edit
132
+ @repository.fields_for_edit.map(&:permitted_param)
133
+ end
134
+
135
+ def permitted_params_for_new
136
+ @repository.fields_for_new.map(&:permitted_param)
137
+ end
138
+
139
+ def record_params_for_edit
140
+ (params[@repository.model_param_key] || ActionController::Parameters.new)
141
+ .permit(*permitted_params_for_edit)
142
+ end
143
+
144
+ def record_params_for_update
145
+ record_params_for_edit
146
+ end
147
+
148
+ def record_params_for_new
133
149
  (params[@repository.model_param_key] || ActionController::Parameters.new)
134
- .permit(*permitted_params)
150
+ .permit(*permitted_params_for_new)
135
151
  end
136
152
 
137
153
  # Returns the repository class associated with this controller.
@@ -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,12 +11,12 @@
11
11
 
12
12
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
13
13
 
14
- <%= javascript_include_tag "uchi/application", "data-turbo-track" => "reload" %>
15
- <%= stylesheet_link_tag "uchi/application", media: "all", "data-turbo-track": "reload" %>
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">
19
- <div class="md:flex">
18
+ <body class="antialiased bg-neutral-secondary-medium h-full p-2">
19
+ <div class="md:flex h-full">
20
20
  <%= render(partial: "uchi/navigation/main") %>
21
21
 
22
22
  <main class="py-6 overflow-x-hidden grow md:px-6"><%= yield %></main>
@@ -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>
@@ -2,8 +2,91 @@
2
2
  class="
3
3
  w-64 h-full px-3 py-4 overflow-y-auto shrink-0
4
4
  bg-neutral-secondary-medium
5
+ hidden md:block
5
6
  "
6
7
  aria-label="Main"
7
8
  >
8
9
  <%= render Uchi::Ui::Navigation.new %>
9
10
  </nav>
11
+
12
+ <div
13
+ class="md:hidden">
14
+ <button
15
+ aria-controls="main-navigation"
16
+ class="
17
+ text-body bg-transparent hover:text-heading
18
+ hover:bg-neutral-tertiary rounded-base w-9 h-9
19
+ flex items-center justify-center
20
+ "
21
+ popovertarget="main-navigation"
22
+ type="button"
23
+ >
24
+ <svg
25
+ aria-hidden="true"
26
+ class="w-6 h-6"
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-width="2"
37
+ d="M5 7h14M5 12h14M5 17h14"
38
+ />
39
+ </svg>
40
+ </button>
41
+ </div>
42
+
43
+ <nav
44
+ aria-label="Main"
45
+ class="
46
+ fixed top-0 left-0 z-40 h-screen p-2
47
+ flex-col
48
+ bg-neutral-primary-soft w-80
49
+ border-e border-default
50
+ backdrop:bg-dark-backdrop/70
51
+ "
52
+ id="main-navigation"
53
+ popover
54
+ tabindex="-1"
55
+ >
56
+ <div>
57
+ <button
58
+ aria-controls="main-navigation"
59
+ class="
60
+ text-body bg-transparent hover:text-heading
61
+ hover:bg-neutral-tertiary rounded-base w-9 h-9
62
+ flex items-center justify-center
63
+ "
64
+ popovertarget="main-navigation"
65
+ type="button"
66
+ >
67
+ <svg
68
+ aria-hidden="true"
69
+ class="w-5 h-5"
70
+ fill="none"
71
+ height="24"
72
+ viewBox="0 0 24 24"
73
+ width="24"
74
+ xmlns="http://www.w3.org/2000/svg"
75
+ >
76
+ <path
77
+ stroke="currentColor"
78
+ stroke-linecap="round"
79
+ stroke-linejoin="round"
80
+ stroke-width="2"
81
+ d="M6 18 17.94 6M18 18 6.06 6"
82
+ />
83
+ </svg>
84
+
85
+ <span class="sr-only">Close menu</span>
86
+ </button>
87
+ </div>
88
+
89
+ <div class="overflow-y-auto max-h-full">
90
+ <%= render Uchi::Ui::Navigation.new %>
91
+ </div>
92
+ </nav>
@@ -15,10 +15,10 @@
15
15
  <%= render(Uchi::Flowbite::Card.new) do %>
16
16
  <%= form_with model: @record, url: @repository.routes.path_for(:create) do |form| %>
17
17
  <div class="space-y-6">
18
- <% @repository.fields_for_edit.each do |field| %>
18
+ <% @repository.fields_for_new.each do |field| %>
19
19
  <div>
20
20
  <%= render(
21
- field.edit_component(
21
+ field.new_component(
22
22
  form: form,
23
23
  repository: @repository,
24
24
  label: @repository.translate.field_label(field),
@@ -8,9 +8,5 @@ module Uchi
8
8
  destination = File.join("app/controllers/uchi")
9
9
  template "controller.rb", File.join(destination, "#{plural_file_name}_controller.rb")
10
10
  end
11
-
12
- def add_route
13
- route "resources :#{plural_file_name}", namespace: :uchi
14
- end
15
11
  end
16
12
  end
@@ -7,7 +7,7 @@ module Uchi
7
7
  end
8
8
 
9
9
  def mount_engine
10
- route "mount Uchi::Engine, at: \"/uchi\""
10
+ route "Uchi.routes.mount(self)"
11
11
  end
12
12
  end
13
13
  end
@@ -5,7 +5,7 @@ module Uchi
5
5
  module Configuration
6
6
  class Unset; end
7
7
 
8
- DEFAULT_READER = ->(record, field_name) { record.public_send(field_name) }
8
+ DEFAULT_READER = ->(record, field_name) { record&.public_send(field_name) }
9
9
 
10
10
  def initialize(*args)
11
11
  super
@@ -127,7 +127,7 @@ module Uchi
127
127
  protected
128
128
 
129
129
  def default_on
130
- [:edit, :index, :show]
130
+ [:edit, :index, :new, :show]
131
131
  end
132
132
 
133
133
  def default_searchable?
data/lib/uchi/field.rb CHANGED
@@ -51,6 +51,17 @@ module Uchi
51
51
  @name = name.to_sym
52
52
  end
53
53
 
54
+ def new_component(form:, repository:, label: nil, hint: nil)
55
+ # For now all components use the same component for Edit and New. Override
56
+ # this method in a subclass to provide a different component.
57
+ edit_component(
58
+ form: form,
59
+ hint: hint,
60
+ label: label,
61
+ repository: repository
62
+ )
63
+ end
64
+
54
65
  # Returns the key that this field is expected to use in params
55
66
  def param_key
56
67
  name.to_sym
@@ -0,0 +1,21 @@
1
+ module Uchi
2
+ class Plugins
3
+ attr_reader :registered
4
+
5
+ def hook(hook_name, **kwargs)
6
+ registered.each do |plugin_class|
7
+ next unless plugin_class.hooks_into?(hook_name)
8
+
9
+ plugin_class.hook(hook_name, **kwargs)
10
+ end
11
+ end
12
+
13
+ def initialize
14
+ @registered = []
15
+ end
16
+
17
+ def register(plugin_class)
18
+ @registered << plugin_class
19
+ end
20
+ end
21
+ end
@@ -22,7 +22,7 @@ module Uchi
22
22
  end
23
23
 
24
24
  def root_path
25
- "/#{uchi_namespace}/"
25
+ "/#{uchi_path}/"
26
26
  end
27
27
 
28
28
  private
@@ -34,7 +34,7 @@ module Uchi
34
34
 
35
35
  def plural_path_for(_action, **options)
36
36
  parts = [
37
- uchi_namespace,
37
+ uchi_as,
38
38
  plural,
39
39
  "path"
40
40
  ].compact
@@ -47,15 +47,19 @@ module Uchi
47
47
  action = nil if action == :destroy
48
48
  parts = [
49
49
  action,
50
- uchi_namespace,
50
+ uchi_as,
51
51
  singular,
52
52
  "path"
53
53
  ].compact
54
54
  call_url_helper_in_main_app(parts, **options)
55
55
  end
56
56
 
57
- def uchi_namespace
58
- :uchi
57
+ def uchi_as
58
+ Uchi.routes.mount_as
59
+ end
60
+
61
+ def uchi_path
62
+ Uchi.routes.mount_at
59
63
  end
60
64
  end
61
65
  end
@@ -12,6 +12,13 @@ module Uchi
12
12
  }
13
13
  end
14
14
 
15
+ # Returns the "name" of the controller that handles requests for this
16
+ # repository. Note that this is different from the controllers class name
17
+ # and is intended for generating URLs.
18
+ def controller_name
19
+ model_param_key.pluralize
20
+ end
21
+
15
22
  # Returns the repository for the given model, or nil if none is found.
16
23
  def for_model(model)
17
24
  all.find { |repository| repository.model.to_s == model.to_s }
@@ -21,6 +28,10 @@ module Uchi
21
28
  def model
22
29
  @model ||= name.demodulize.constantize
23
30
  end
31
+
32
+ def model_param_key
33
+ model.model_name.param_key
34
+ end
24
35
  end
25
36
 
26
37
  # Returns a new, unsaved instance of the model this repository manages.
@@ -32,7 +43,7 @@ module Uchi
32
43
  # repository. Note that this is different from the controllers class name
33
44
  # and is intended for generating URLs.
34
45
  def controller_name
35
- model_param_key.pluralize
46
+ self.class.controller_name
36
47
  end
37
48
 
38
49
  def default_sort_order
@@ -40,18 +51,31 @@ module Uchi
40
51
  end
41
52
 
42
53
  # Returns an array of fields to show on the edit page.
54
+ #
55
+ # @return [Array<Uchi::Field>]
43
56
  def fields_for_edit
44
- fields.select { |field| field.on.include?(:edit) }.each { |field| field.repository = self }
57
+ fields_for(:edit)
45
58
  end
46
59
 
47
60
  # Returns an array of fields to show on the index page.
61
+ #
62
+ # @return [Array<Uchi::Field>]
48
63
  def fields_for_index
49
- fields.select { |field| field.on.include?(:index) }.each { |field| field.repository = self }
64
+ fields_for(:index)
65
+ end
66
+
67
+ # Returns an array of fields to show on the new page.
68
+ #
69
+ # @return [Array<Uchi::Field>]
70
+ def fields_for_new
71
+ fields_for(:new)
50
72
  end
51
73
 
52
74
  # Returns an array of fields to show on the show page.
75
+ #
76
+ # @return [Array<Uchi::Field>]
53
77
  def fields_for_show
54
- fields.select { |field| field.on.include?(:show) }.each { |field| field.repository = self }
78
+ fields_for(:show)
55
79
  end
56
80
 
57
81
  def find_all(search: nil, scope: model.all, sort_order: default_sort_order)
@@ -104,7 +128,7 @@ module Uchi
104
128
  end
105
129
 
106
130
  def model_param_key
107
- model.model_name.param_key
131
+ self.class.model_param_key
108
132
  end
109
133
 
110
134
  # Returns an instance of Uchi::Repository::Routes for this repository,
@@ -174,6 +198,20 @@ module Uchi
174
198
  end
175
199
  end
176
200
 
201
+ # Returns an array of fields to show for the given action.
202
+ #
203
+ # @param action [Symbol] The action to get fields for. One of :index, :show,
204
+ # :new, :edit.
205
+ #
206
+ # @return [Array<Uchi::Field>]
207
+ def fields_for(action)
208
+ fields_with_repository.select { |field| field.on.include?(action) }
209
+ end
210
+
211
+ def fields_with_repository
212
+ fields.each { |field| field.repository = self }
213
+ end
214
+
177
215
  def searchable_fields
178
216
  @searchable_fields ||= fields.select { |field| field.searchable? }
179
217
  end
@@ -0,0 +1,67 @@
1
+ module Uchi
2
+ class Routes
3
+ # Mounts the Uchi engine routes onto the host application's routes.
4
+ #
5
+ # Example usage in host application's routes.rb that install Uchi at /uchi:
6
+ #
7
+ # Rails.application.routes.draw do
8
+ # Uchi.routes.mount(self)
9
+ # end
10
+ #
11
+ # @param host_routes [ActionDispatch::Routing::Mapper] The host
12
+ # application's routes mapper.
13
+ #
14
+ # @param at [Symbol] The path segment where Uchi should be mounted.
15
+ def mount(host_routes, at: default_at)
16
+ @mount_at = (at || default_at).to_sym
17
+ host_routes.mount(
18
+ Uchi::Engine,
19
+ as: mount_as,
20
+ at: mount_at
21
+ )
22
+
23
+ draw_repository_routes(host_routes, at: mount_at)
24
+ end
25
+
26
+ def draw_root_route(routes, repository:, at: default_at)
27
+ return unless repository
28
+
29
+ routes.namespace(at, as: mount_as) do
30
+ routes.root to: "#{repository.controller_name}#index"
31
+ end
32
+ end
33
+
34
+ def draw_repository_routes(routes, at: default_at)
35
+ repositories = Uchi::Repository.all
36
+ repositories.each do |repository_class|
37
+ resources_name = repository_class.controller_name
38
+ routes.namespace(at, as: mount_as) do
39
+ routes.resources(resources_name)
40
+ end
41
+ end
42
+
43
+ draw_root_route(routes, at: at, repository: repositories.first)
44
+ end
45
+
46
+ # Returns the name to use when generating routing helper method names
47
+ def mount_as
48
+ :uchi
49
+ end
50
+
51
+ # Returns the path prefix for the routes, i.e. the first URL segment where
52
+ # Uchi can be requested.
53
+ def mount_at
54
+ @mount_at ||= default_at
55
+ end
56
+
57
+ private
58
+
59
+ def default_at
60
+ :uchi
61
+ end
62
+
63
+ def mount_path
64
+ mount_at
65
+ end
66
+ end
67
+ end
data/lib/uchi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Uchi
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.8"
5
5
  end
data/lib/uchi.rb CHANGED
@@ -11,10 +11,18 @@ require "uchi/action"
11
11
  require "uchi/action_response"
12
12
  require "uchi/field"
13
13
  require "uchi/i18n"
14
+ require "uchi/plugins"
14
15
  require "uchi/repository"
16
+ require "uchi/routes"
15
17
  require "uchi/sort_order"
16
18
  require "uchi/repository/translate"
17
19
 
18
20
  module Uchi
19
- # Your code goes here...
21
+ def self.plugins
22
+ @plugins ||= Plugins.new
23
+ end
24
+
25
+ def self.routes
26
+ @routes ||= Routes.new
27
+ end
20
28
  end