uchi 0.1.2 → 0.1.4

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +222 -0
  3. data/app/assets/config/uchi_manifest.js +2 -0
  4. data/app/assets/javascripts/uchi/application.js +6095 -0
  5. data/app/assets/javascripts/uchi.js +4 -0
  6. data/app/assets/stylesheets/uchi/application.css +3971 -0
  7. data/app/assets/stylesheets/uchi/uchi.css +17 -0
  8. data/app/assets/tailwind/uchi.css +21 -0
  9. data/app/components/flowbite/breadcrumb.rb +33 -0
  10. data/app/components/flowbite/breadcrumb_home.rb +26 -0
  11. data/app/components/flowbite/breadcrumb_item/current.rb +33 -0
  12. data/app/components/flowbite/breadcrumb_item/first.rb +35 -0
  13. data/app/components/flowbite/breadcrumb_item.rb +48 -0
  14. data/app/components/flowbite/breadcrumb_separator.rb +30 -0
  15. data/app/components/flowbite/button/outline.rb +22 -0
  16. data/app/components/flowbite/button/pill.rb +40 -0
  17. data/app/components/flowbite/button.rb +92 -0
  18. data/app/components/flowbite/card.rb +45 -0
  19. data/app/components/flowbite/input/checkbox.rb +73 -0
  20. data/app/components/flowbite/input/date.rb +11 -0
  21. data/app/components/flowbite/input/date_time.rb +11 -0
  22. data/app/components/flowbite/input/email.rb +12 -0
  23. data/app/components/flowbite/input/field.rb +117 -0
  24. data/app/components/flowbite/input/file.rb +30 -0
  25. data/app/components/flowbite/input/hint.rb +57 -0
  26. data/app/components/flowbite/input/label.rb +82 -0
  27. data/app/components/flowbite/input/number.rb +11 -0
  28. data/app/components/flowbite/input/password.rb +11 -0
  29. data/app/components/flowbite/input/phone.rb +11 -0
  30. data/app/components/flowbite/input/radio_button.rb +50 -0
  31. data/app/components/flowbite/input/select.rb +49 -0
  32. data/app/components/flowbite/input/textarea.rb +42 -0
  33. data/app/components/flowbite/input/url.rb +12 -0
  34. data/app/components/flowbite/input/validation_error.rb +11 -0
  35. data/app/components/flowbite/input_field/checkbox.html.erb +14 -0
  36. data/app/components/flowbite/input_field/checkbox.rb +54 -0
  37. data/app/components/flowbite/input_field/date.rb +13 -0
  38. data/app/components/flowbite/input_field/date_time.rb +13 -0
  39. data/app/components/flowbite/input_field/email.rb +13 -0
  40. data/app/components/flowbite/input_field/file.rb +13 -0
  41. data/app/components/flowbite/input_field/input_field.html.erb +8 -0
  42. data/app/components/flowbite/input_field/number.rb +13 -0
  43. data/app/components/flowbite/input_field/password.rb +13 -0
  44. data/app/components/flowbite/input_field/phone.rb +13 -0
  45. data/app/components/flowbite/input_field/radio_button.html.erb +14 -0
  46. data/app/components/flowbite/input_field/radio_button.rb +86 -0
  47. data/app/components/flowbite/input_field/select.rb +31 -0
  48. data/app/components/flowbite/input_field/text.rb +8 -0
  49. data/app/components/flowbite/input_field/textarea.rb +13 -0
  50. data/app/components/flowbite/input_field/url.rb +13 -0
  51. data/app/components/flowbite/input_field.rb +192 -0
  52. data/app/components/flowbite/link.rb +21 -0
  53. data/app/components/flowbite/style.rb +13 -0
  54. data/app/components/flowbite/toast/icon.html.erb +5 -0
  55. data/app/components/flowbite/toast/icon.rb +57 -0
  56. data/app/components/flowbite/toast/toast.html.erb +11 -0
  57. data/app/components/flowbite/toast.rb +34 -0
  58. data/app/components/uchi/field/base.rb +57 -0
  59. data/app/components/uchi/field/belongs_to/edit.html.erb +1 -0
  60. data/app/components/uchi/field/belongs_to/index.html.erb +1 -0
  61. data/app/components/uchi/field/belongs_to/show.html.erb +3 -0
  62. data/app/components/uchi/field/belongs_to.rb +105 -0
  63. data/app/components/uchi/field/blank/edit.html.erb +1 -0
  64. data/app/components/uchi/field/blank/index.html.erb +1 -0
  65. data/app/components/uchi/field/blank/show.html.erb +1 -0
  66. data/app/components/uchi/field/blank.rb +16 -0
  67. data/app/components/uchi/field/boolean/edit.html.erb +1 -0
  68. data/app/components/uchi/field/boolean/index.html.erb +9 -0
  69. data/app/components/uchi/field/boolean/show.html.erb +9 -0
  70. data/app/components/uchi/field/boolean.rb +27 -0
  71. data/app/components/uchi/field/date/edit.html.erb +1 -0
  72. data/app/components/uchi/field/date/index.html.erb +1 -0
  73. data/app/components/uchi/field/date/show.html.erb +1 -0
  74. data/app/components/uchi/field/date.rb +27 -0
  75. data/app/components/uchi/field/date_time/edit.html.erb +1 -0
  76. data/app/components/uchi/field/date_time/index.html.erb +1 -0
  77. data/app/components/uchi/field/date_time/show.html.erb +1 -0
  78. data/app/components/uchi/field/date_time.rb +27 -0
  79. data/app/components/uchi/field/file/edit.html.erb +1 -0
  80. data/app/components/uchi/field/file/index.html.erb +6 -0
  81. data/app/components/uchi/field/file/show.html.erb +8 -0
  82. data/app/components/uchi/field/file.rb +37 -0
  83. data/app/components/uchi/field/has_and_belongs_to_many/edit.html.erb +9 -0
  84. data/app/components/uchi/field/has_and_belongs_to_many/index.html.erb +1 -0
  85. data/app/components/uchi/field/has_and_belongs_to_many/show.html.erb +28 -0
  86. data/app/components/uchi/field/has_and_belongs_to_many.rb +131 -0
  87. data/app/components/uchi/field/has_many/edit.html.erb +1 -0
  88. data/app/components/uchi/field/has_many/index.html.erb +1 -0
  89. data/app/components/uchi/field/has_many/show.html.erb +28 -0
  90. data/app/components/uchi/field/has_many.rb +107 -0
  91. data/app/components/uchi/field/id/index.html.erb +4 -0
  92. data/app/components/uchi/field/id/show.html.erb +4 -0
  93. data/app/components/uchi/field/id.rb +26 -0
  94. data/app/components/uchi/field/image/edit.html.erb +1 -0
  95. data/app/components/uchi/field/image/index.html.erb +6 -0
  96. data/app/components/uchi/field/image/show.html.erb +6 -0
  97. data/app/components/uchi/field/image.rb +38 -0
  98. data/app/components/uchi/field/number/edit.html.erb +1 -0
  99. data/app/components/uchi/field/number/index.html.erb +1 -0
  100. data/app/components/uchi/field/number/show.html.erb +1 -0
  101. data/app/components/uchi/field/number.rb +32 -0
  102. data/app/components/uchi/field/string/edit.html.erb +1 -0
  103. data/app/components/uchi/field/string/index.html.erb +1 -0
  104. data/app/components/uchi/field/string/show.html.erb +1 -0
  105. data/app/components/uchi/field/string.rb +33 -0
  106. data/app/components/uchi/field/text/edit.html.erb +1 -0
  107. data/app/components/uchi/field/text/index.html.erb +1 -0
  108. data/app/components/uchi/field/text/show.html.erb +1 -0
  109. data/app/components/uchi/field/text.rb +38 -0
  110. data/app/components/uchi/ui/breadcrumb/breadcrumb.html.erb +13 -0
  111. data/app/components/uchi/ui/breadcrumb.rb +14 -0
  112. data/app/components/uchi/ui/form/footer/footer.html.erb +5 -0
  113. data/app/components/uchi/ui/form/footer.rb +15 -0
  114. data/app/components/uchi/ui/form/input/collection_checkboxes.html.erb +32 -0
  115. data/app/components/uchi/ui/form/input/collection_checkboxes.rb +125 -0
  116. data/app/components/uchi/ui/frame/frame.html.erb +3 -0
  117. data/app/components/uchi/ui/frame.rb +10 -0
  118. data/app/components/uchi/ui/index/records_table/records_table.html.erb +67 -0
  119. data/app/components/uchi/ui/index/records_table/search_form/search_form.html.erb +21 -0
  120. data/app/components/uchi/ui/index/records_table/search_form.rb +49 -0
  121. data/app/components/uchi/ui/index/records_table.rb +29 -0
  122. data/app/components/uchi/ui/index/turbo_frame.rb +50 -0
  123. data/app/components/uchi/ui/page_header/page_header.html.erb +24 -0
  124. data/app/components/uchi/ui/page_header.rb +18 -0
  125. data/app/components/uchi/ui/pagination/current_link.html.erb +3 -0
  126. data/app/components/uchi/ui/pagination/current_link.rb +10 -0
  127. data/app/components/uchi/ui/pagination/gap.html.erb +3 -0
  128. data/app/components/uchi/ui/pagination/gap.rb +10 -0
  129. data/app/components/uchi/ui/pagination/item.rb +24 -0
  130. data/app/components/uchi/ui/pagination/link.html.erb +3 -0
  131. data/app/components/uchi/ui/pagination/link.rb +10 -0
  132. data/app/components/uchi/ui/pagination/next_link.html.erb +8 -0
  133. data/app/components/uchi/ui/pagination/next_link.rb +22 -0
  134. data/app/components/uchi/ui/pagination/pagination.html.erb +15 -0
  135. data/app/components/uchi/ui/pagination/previous_link.html.erb +8 -0
  136. data/app/components/uchi/ui/pagination/previous_link.rb +22 -0
  137. data/app/components/uchi/ui/pagination.rb +48 -0
  138. data/app/components/uchi/ui/show/attribute_fields/attribute_fields.html.erb +14 -0
  139. data/app/components/uchi/ui/show/attribute_fields.rb +18 -0
  140. data/app/components/uchi/ui/spinner/spinner.html.erb +7 -0
  141. data/app/components/uchi/ui/spinner.rb +15 -0
  142. data/app/controllers/uchi/application_controller.rb +4 -0
  143. data/app/controllers/uchi/controller.rb +13 -0
  144. data/app/controllers/uchi/repository_controller.rb +166 -0
  145. data/app/helpers/uchi/application_helper.rb +17 -0
  146. data/app/jobs/uchi/application_job.rb +4 -0
  147. data/app/mailers/uchi/application_mailer.rb +6 -0
  148. data/app/views/layouts/uchi/_flash_messages.html.erb +10 -0
  149. data/app/views/layouts/uchi/application.html.erb +33 -0
  150. data/app/views/uchi/repository/edit.html.erb +40 -0
  151. data/app/views/uchi/repository/index.html.erb +49 -0
  152. data/app/views/uchi/repository/new.html.erb +37 -0
  153. data/app/views/uchi/repository/show.html.erb +41 -0
  154. data/lib/generators/uchi/controller/controller_generator.rb +16 -0
  155. data/lib/generators/uchi/controller/templates/controller.rb.tt +11 -0
  156. data/lib/generators/uchi/install/install_generator.rb +13 -0
  157. data/lib/generators/uchi/repository/repository_generator.rb +16 -0
  158. data/lib/generators/uchi/repository/templates/repository.rb.tt +11 -0
  159. data/lib/tasks/uchi_tasks.rake +4 -0
  160. data/lib/uchi/application_record.rb +5 -0
  161. data/lib/uchi/engine.rb +24 -0
  162. data/lib/uchi/field/configuration.rb +142 -0
  163. data/lib/uchi/field.rb +80 -0
  164. data/lib/uchi/i18n.rb +13 -0
  165. data/lib/uchi/pagination/controller.rb +26 -0
  166. data/lib/uchi/pagination/page.rb +20 -0
  167. data/lib/uchi/pagy/LICENSE.txt +21 -0
  168. data/lib/uchi/pagy/classes/exceptions.rb +35 -0
  169. data/lib/uchi/pagy/classes/offset/offset.rb +56 -0
  170. data/lib/uchi/pagy/classes/request.rb +38 -0
  171. data/lib/uchi/pagy/modules/abilities/configurable.rb +38 -0
  172. data/lib/uchi/pagy/modules/abilities/linkable.rb +62 -0
  173. data/lib/uchi/pagy/modules/abilities/rangeable.rb +17 -0
  174. data/lib/uchi/pagy/modules/abilities/shiftable.rb +14 -0
  175. data/lib/uchi/pagy/modules/console.rb +40 -0
  176. data/lib/uchi/pagy/toolbox/helpers/loader.rb +19 -0
  177. data/lib/uchi/pagy/toolbox/helpers/page_url.rb +25 -0
  178. data/lib/uchi/pagy/toolbox/paginators/method.rb +21 -0
  179. data/lib/uchi/pagy/toolbox/paginators/offset.rb +35 -0
  180. data/lib/uchi/pagy.rb +60 -0
  181. data/lib/uchi/repository/routes.rb +62 -0
  182. data/lib/uchi/repository/translate.rb +284 -0
  183. data/lib/uchi/repository.rb +156 -0
  184. data/lib/uchi/sort_order.rb +35 -0
  185. data/lib/uchi/version.rb +5 -0
  186. data/lib/uchi.rb +18 -0
  187. data/uchi.gemspec +35 -0
  188. metadata +197 -107
  189. data/.github/dependabot.yml +0 -17
  190. data/.github/workflows/build.yml +0 -23
  191. data/.github/workflows/lint.yml +0 -30
  192. data/package.json +0 -31
  193. data/sig/uchi.rbs +0 -4
  194. data/test/components/uchi/field/belongs_to_test.rb +0 -134
  195. data/test/components/uchi/field/blank_test.rb +0 -119
  196. data/test/components/uchi/field/boolean_test.rb +0 -163
  197. data/test/components/uchi/field/date_test.rb +0 -163
  198. data/test/components/uchi/field/date_time_test.rb +0 -152
  199. data/test/components/uchi/field/has_many_test.rb +0 -138
  200. data/test/components/uchi/field/id_test.rb +0 -113
  201. data/test/components/uchi/field/number_test.rb +0 -163
  202. data/test/components/uchi/field/string_test.rb +0 -159
  203. data/test/controllers/uchi/authors_controller_test.rb +0 -119
  204. data/test/controllers/uchi/repository_controller_test.rb +0 -93
  205. data/test/controllers/uchi/scoped_repository_controller_test.rb +0 -73
  206. data/test/dummy/Rakefile +0 -6
  207. data/test/dummy/app/assets/images/.keep +0 -0
  208. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  209. data/test/dummy/app/controllers/application_controller.rb +0 -4
  210. data/test/dummy/app/controllers/concerns/.keep +0 -0
  211. data/test/dummy/app/controllers/uchi/authors_controller.rb +0 -7
  212. data/test/dummy/app/controllers/uchi/books_controller.rb +0 -7
  213. data/test/dummy/app/controllers/uchi/titles_controller.rb +0 -7
  214. data/test/dummy/app/helpers/application_helper.rb +0 -2
  215. data/test/dummy/app/jobs/application_job.rb +0 -7
  216. data/test/dummy/app/mailers/application_mailer.rb +0 -4
  217. data/test/dummy/app/models/application_record.rb +0 -3
  218. data/test/dummy/app/models/author.rb +0 -3
  219. data/test/dummy/app/models/book.rb +0 -3
  220. data/test/dummy/app/models/concerns/.keep +0 -0
  221. data/test/dummy/app/models/title.rb +0 -3
  222. data/test/dummy/app/uchi/repositories/author.rb +0 -20
  223. data/test/dummy/app/uchi/repositories/book.rb +0 -16
  224. data/test/dummy/app/uchi/repositories/title.rb +0 -17
  225. data/test/dummy/app/views/layouts/application.html.erb +0 -27
  226. data/test/dummy/app/views/layouts/mailer.html.erb +0 -13
  227. data/test/dummy/app/views/layouts/mailer.text.erb +0 -1
  228. data/test/dummy/app/views/pwa/manifest.json.erb +0 -22
  229. data/test/dummy/app/views/pwa/service-worker.js +0 -26
  230. data/test/dummy/bin/dev +0 -2
  231. data/test/dummy/bin/rails +0 -4
  232. data/test/dummy/bin/rake +0 -4
  233. data/test/dummy/bin/setup +0 -34
  234. data/test/dummy/config/application.rb +0 -29
  235. data/test/dummy/config/boot.rb +0 -5
  236. data/test/dummy/config/cable.yml +0 -10
  237. data/test/dummy/config/database.yml +0 -32
  238. data/test/dummy/config/environment.rb +0 -5
  239. data/test/dummy/config/environments/development.rb +0 -69
  240. data/test/dummy/config/environments/production.rb +0 -89
  241. data/test/dummy/config/environments/test.rb +0 -53
  242. data/test/dummy/config/initializers/content_security_policy.rb +0 -25
  243. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -8
  244. data/test/dummy/config/initializers/inflections.rb +0 -16
  245. data/test/dummy/config/locales/da.yml +0 -51
  246. data/test/dummy/config/locales/en.yml +0 -31
  247. data/test/dummy/config/puma.rb +0 -38
  248. data/test/dummy/config/routes.rb +0 -9
  249. data/test/dummy/config/storage.yml +0 -34
  250. data/test/dummy/config.ru +0 -6
  251. data/test/dummy/db/migrate/20251002183635_create_authors.rb +0 -11
  252. data/test/dummy/db/migrate/20251005131726_create_books.rb +0 -9
  253. data/test/dummy/db/migrate/20251005131811_create_titles.rb +0 -11
  254. data/test/dummy/db/schema.rb +0 -38
  255. data/test/dummy/log/.keep +0 -0
  256. data/test/dummy/public/400.html +0 -114
  257. data/test/dummy/public/404.html +0 -114
  258. data/test/dummy/public/406-unsupported-browser.html +0 -114
  259. data/test/dummy/public/422.html +0 -114
  260. data/test/dummy/public/500.html +0 -114
  261. data/test/dummy/public/icon.png +0 -0
  262. data/test/dummy/public/icon.svg +0 -3
  263. data/test/dummy/storage/.keep +0 -0
  264. data/test/dummy/test/fixtures/authors.yml +0 -11
  265. data/test/dummy/test/models/author_test.rb +0 -7
  266. data/test/dummy/tmp/.keep +0 -0
  267. data/test/dummy/tmp/pids/.keep +0 -0
  268. data/test/dummy/tmp/storage/.keep +0 -0
  269. data/test/test_helper.rb +0 -15
  270. data/test/uchi/field_test.rb +0 -63
  271. data/test/uchi/i18n_test.rb +0 -18
  272. data/test/uchi/repository/routes_test.rb +0 -49
  273. data/test/uchi/repository/translate_test.rb +0 -263
  274. data/test/uchi/repository_test.rb +0 -137
  275. data/test/uchi/sort_order_test.rb +0 -47
  276. data/test/uchi_test.rb +0 -7
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ module Loader
6
+ paths = { public: { page_url: 'page_url' } }.freeze
7
+
8
+ paths.each do |visibility, methods|
9
+ send(visibility)
10
+ # Load the method, overriding its own alias. Next requests will call the method directly.
11
+ define_method(:"load_#{visibility}") do |*args, **kwargs|
12
+ require_relative methods[__callee__]
13
+ send(__callee__, *args, **kwargs)
14
+ end
15
+ methods.each_key { |method| alias_method method, :"load_#{visibility}" }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ # Return the page url for any page
6
+ # :nocov:
7
+ def page_url(page, **)
8
+ case page
9
+ when :first
10
+ compose_page_url(nil, **)
11
+ when :current
12
+ compose_page_url(@page, **) if @page
13
+ when :previous
14
+ compose_page_url(@previous, **) if @previous
15
+ when :next
16
+ compose_page_url(@next, **) if @next
17
+ when :last
18
+ compose_page_url(@last, **) if @last
19
+ when Integer, String
20
+ compose_page_url(page, **)
21
+ end
22
+ end
23
+ # :nocov:
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../classes/request'
4
+
5
+ module Uchi
6
+ class Pagy
7
+ paginators = { offset: :OffsetPaginator }.freeze
8
+
9
+ path = Pathname.new(__dir__)
10
+ paginators.each { |symbol, name| autoload name, path.join(symbol.to_s) }
11
+
12
+ # Defines the pagy method. Include in the app controller/view.
13
+ Method = Module.new do
14
+ protected
15
+
16
+ define_method :pagy do |paginator = :offset, collection, **options|
17
+ Pagy.const_get(paginators[paginator]).paginate(self, collection, **Pagy.options, **options)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ module OffsetPaginator
6
+ module_function
7
+
8
+ # Return instance and page of results
9
+ def paginate(context, collection, **options)
10
+ context.instance_eval do
11
+ request = Request.new(options[:request] || self.request, options)
12
+ options[:page] ||= request.resolve_page(options)
13
+ options[:limit] = request.resolve_limit(options)
14
+ options[:count] ||= collection.instance_of?(Array) ? collection.size : OffsetPaginator.get_count(collection, options)
15
+ pagy = Offset.new(**options, request:)
16
+ [pagy, collection.instance_of?(Array) ? collection[pagy.offset, pagy.limit] : pagy.records(collection)]
17
+ end
18
+ end
19
+
20
+ # Get the collection count
21
+ def get_count(collection, options)
22
+ return collection.count unless defined?(::ActiveRecord) && collection.is_a?(::ActiveRecord::Relation)
23
+
24
+ count = if options[:count_over] && !collection.group_values.empty?
25
+ # COUNT(*) OVER ()
26
+ sql = Arel.star.count.over(Arel::Nodes::Grouping.new([]))
27
+ collection.unscope(:order).pick(sql).to_i
28
+ else
29
+ collection.count(:all)
30
+ end
31
+ count.is_a?(Hash) ? count.size : count
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/uchi/pagy.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'pagy/classes/exceptions'
5
+ require_relative 'pagy/modules/abilities/linkable'
6
+ require_relative 'pagy/modules/abilities/configurable'
7
+ require_relative 'pagy/toolbox/helpers/loader'
8
+
9
+ module Uchi
10
+ # Top superclass: it defines only what's common to all the subclasses
11
+ class Pagy
12
+ VERSION = '43.0.0.rc4'
13
+ ROOT = Pathname.new(__dir__).parent.freeze
14
+ DEFAULT = { limit: 20, limit_key: 'limit', page_key: 'page' }.freeze
15
+ PAGE_TOKEN = 'P '
16
+ LIMIT_TOKEN = 'L '
17
+ LABEL_TOKEN = 'L'
18
+ A_TAG = '<a style="display: none;">#</a>'
19
+
20
+ path = Pathname.new(__FILE__).sub_ext('')
21
+ autoload :Method, path.join('toolbox/paginators/method')
22
+ autoload :I18n, path.join('modules/i18n/i18n')
23
+ autoload :Console, path.join('modules/console')
24
+ autoload :Offset, path.join('classes/offset/offset')
25
+
26
+ def self.options = @options ||= {}
27
+
28
+ extend Configurable
29
+ include Linkable
30
+ include Loader
31
+
32
+ attr_reader :page, :next, :in, :limit, :options
33
+
34
+ protected
35
+
36
+ # Define the hierarchical identity methods, overridden by the respective classes
37
+ def offset? = false
38
+ def countless? = false
39
+
40
+ # Validates and assign the passed options: var must be present and value.to_i must be >= to min
41
+ def assign_and_check(name_min)
42
+ name_min.each do |name, min|
43
+ raise OptionError.new(self, name, ">= #{min}", @options[name]) \
44
+ unless @options[name].respond_to?(:to_i) && instance_variable_set(:"@#{name}", @options[name].to_i) >= min
45
+ end
46
+ end
47
+
48
+ # Merge all the DEFAULT constants of the class hierarchy with the options
49
+ def assign_options(**options)
50
+ @request = options.delete(:request) # internal object
51
+ default = {}
52
+ current = self.class
53
+ begin
54
+ default = current::DEFAULT.merge(default)
55
+ current = current.superclass
56
+ end until current == Object # rubocop:disable Lint/Loop -- see https://github.com/rubocop/rubocop-performance/issues/362
57
+ @options = default.merge!(options.delete_if { |k, v| default.key?(k) && (v.nil? || v == '') }).freeze
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Repository
5
+ class Routes
6
+ attr_reader :plural, :repository, :singular
7
+
8
+ def initialize(repository)
9
+ @repository = repository
10
+ @singular = repository.controller_name.singularize
11
+ @plural = repository.controller_name.pluralize
12
+ end
13
+
14
+ def path_for(action, **options)
15
+ action = action.to_sym
16
+ case action
17
+ when :destroy, :edit, :new, :show, :update
18
+ singular_path_for(action, **options)
19
+ else
20
+ plural_path_for(action, **options)
21
+ end
22
+ end
23
+
24
+ def root_path
25
+ "/#{uchi_namespace}/"
26
+ end
27
+
28
+ private
29
+
30
+ def call_url_helper_in_main_app(parts, **options)
31
+ url_helper_name = parts.join("_")
32
+ Rails.application.routes.url_helpers.send(url_helper_name, **options)
33
+ end
34
+
35
+ def plural_path_for(_action, **options)
36
+ parts = [
37
+ uchi_namespace,
38
+ plural,
39
+ "path"
40
+ ].compact
41
+ call_url_helper_in_main_app(parts, **options)
42
+ end
43
+
44
+ def singular_path_for(action, **options)
45
+ action = nil if action == :show
46
+ action = nil if action == :update
47
+ action = nil if action == :destroy
48
+ parts = [
49
+ action,
50
+ uchi_namespace,
51
+ singular,
52
+ "path"
53
+ ].compact
54
+ call_url_helper_in_main_app(parts, **options)
55
+ end
56
+
57
+ def uchi_namespace
58
+ :uchi
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Repository
5
+ class Translate
6
+ attr_reader :repository
7
+
8
+ # Returns the breadcrumb label for the given page.
9
+ #
10
+ # Example translation key:
11
+ # uchi.repository.author.breadcrumb.edit.label
12
+ def breadcrumb_label(page, record: nil)
13
+ return breadcrumb_label_for_index if page.to_sym == :index
14
+
15
+ default = {
16
+ show: title_for_record(record)
17
+ }[page.to_sym].presence
18
+ default ||= translate("common.#{page}", default: page.to_s.capitalize)
19
+
20
+ translate(
21
+ "label",
22
+ default: default,
23
+ model: singular_name,
24
+ record: title_for_record(record),
25
+ scope: i18n_scope("breadcrumb.#{page}")
26
+ )
27
+ end
28
+
29
+ # Returns the breadcrumb label for the index page.
30
+ #
31
+ # Returns the first of the following that is present:
32
+ # 1. Translation from "uchi.repository.author.breadcrumb.index.label"
33
+ # 2. Translation from "uchi.repository.author.index.title"
34
+ # 3. plural name of the model
35
+ # 4. Translation from "common.index"
36
+ # 5. Capitalized page name ("Index")
37
+ def breadcrumb_label_for_index
38
+ first_present_value(
39
+ translate(i18n_scope("breadcrumb.index.label"), default: nil),
40
+ translate(i18n_scope("index.title"), default: nil),
41
+ plural_name,
42
+ translate("common.index"),
43
+ "Index"
44
+ )
45
+ end
46
+
47
+ # Returns a description for the given page, or nil if none is found.
48
+ # This description is intended to provide additional context for the page
49
+ # being shown.
50
+ def description(page, record: nil)
51
+ translate(
52
+ "description",
53
+ default: nil,
54
+ model: singular_name,
55
+ record: record,
56
+ scope: i18n_scope(page)
57
+ )
58
+ end
59
+
60
+ # Returns a title for a dialog with the given name, e.g. :destroy.
61
+ # The title may include interpolation keys such as %{record}.
62
+ #
63
+ # Example translation key:
64
+ # uchi.repository.author.dialog.destroy.title
65
+ #
66
+ # Example default value:
67
+ # Are you sure you want to delete %{record}?
68
+ #
69
+ # Note that the default value itself is also looked up in the "common"
70
+ # scope, so that it can be shared across repositories.
71
+ #
72
+ # Example fallback translation key:
73
+ # common.dialog.destroy.title
74
+ #
75
+ # Example fallback default value:
76
+ # Are you sure you want to delete this record?
77
+ def destroy_dialog_title(record = nil)
78
+ translate(
79
+ "dialog.destroy.title",
80
+ default: translate(
81
+ "common.dialog.destroy.title",
82
+ default: "Are you sure?",
83
+ record: repository.title(record)
84
+ ),
85
+ record: repository.title(record),
86
+ scope: "uchi.repository.#{i18n_key}"
87
+ )
88
+ end
89
+
90
+ def failed_destroy
91
+ translate(
92
+ "destroy.failure",
93
+ default: translate(
94
+ "destroy.failure",
95
+ default: "The record could not be deleted",
96
+ scope: "common"
97
+ ),
98
+ scope: "uchi.repository.#{i18n_key}"
99
+ )
100
+ end
101
+
102
+ def initialize(repository:)
103
+ @repository = repository
104
+ end
105
+
106
+ # Returns the label for the given field.
107
+ def field_label(field)
108
+ translate(
109
+ "label",
110
+ default: model.human_attribute_name(field.name),
111
+ scope: i18n_scope("field.#{field.name}")
112
+ )
113
+ end
114
+
115
+ # Returns the hint for the given field, or nil if none is found.
116
+ def field_hint(field)
117
+ translate(
118
+ "hint",
119
+ default: nil,
120
+ scope: i18n_scope("field.#{field.name}")
121
+ )
122
+ end
123
+
124
+ def link_to_cancel
125
+ translate("common.cancel", default: "Cancel")
126
+ end
127
+
128
+ def link_to_destroy(record)
129
+ translate(
130
+ "link_to_destroy",
131
+ default: "Delete",
132
+ model: singular_name,
133
+ record: repository.title(record),
134
+ scope: i18n_scope("button")
135
+ )
136
+ end
137
+
138
+ def link_to_edit(record)
139
+ translate(
140
+ "link_to_edit",
141
+ default: "Edit",
142
+ model: singular_name,
143
+ record: repository.title(record),
144
+ scope: i18n_scope("button")
145
+ )
146
+ end
147
+
148
+ # Returns the text for the "new" action link.
149
+ def link_to_new
150
+ translate(
151
+ "link_to_new",
152
+ default: "New %{model}", # rubocop:disable Style/FormatStringToken
153
+ model: singular_name,
154
+ scope: i18n_scope("button")
155
+ )
156
+ end
157
+
158
+ def loading_message
159
+ translate("loading", default: "Loading...", scope: "uchi.repository.common")
160
+ end
161
+
162
+ # Returns the localized, human-readable plural name of the model this
163
+ # repository manages.
164
+ def plural_name
165
+ ::I18n.translate(
166
+ "uchi.repository.#{i18n_key}.model",
167
+ count: 2,
168
+ default: model.model_name.plural.humanize
169
+ )
170
+ end
171
+
172
+ def no_records_found
173
+ translate("no_records_found", default: "No records found", scope: "uchi.common")
174
+ end
175
+
176
+ def search_label
177
+ translate("common.search", default: "Search")
178
+ end
179
+
180
+ def search_button
181
+ translate("common.search", default: "Search")
182
+ end
183
+
184
+ # Returns the label for a generic submit button
185
+ def submit_button
186
+ translate("common.save", default: "Save")
187
+ end
188
+
189
+ def successful_create
190
+ translate(
191
+ "create.success",
192
+ default: translate(
193
+ "create.success",
194
+ default: "Your changes have been saved",
195
+ scope: "common"
196
+ ),
197
+ scope: "uchi.repository.#{i18n_key}"
198
+ )
199
+ end
200
+
201
+ def successful_destroy
202
+ translate(
203
+ "destroy.success",
204
+ default: translate(
205
+ "destroy.success",
206
+ default: "The record has been deleted",
207
+ scope: "common"
208
+ ),
209
+ scope: "uchi.repository.#{i18n_key}"
210
+ )
211
+ end
212
+
213
+ def successful_update
214
+ translate(
215
+ "update.success",
216
+ default: translate(
217
+ "update.success",
218
+ default: "Your changes have been saved",
219
+ scope: "common"
220
+ ),
221
+ scope: "uchi.repository.#{i18n_key}"
222
+ )
223
+ end
224
+
225
+ # Returns the title for the given page.
226
+ def title(page, record: nil)
227
+ return repository.title(record) if record && page == :show
228
+
229
+ translate(
230
+ "title",
231
+ default: plural_name,
232
+ model: singular_name,
233
+ record: record,
234
+ scope: i18n_scope(page)
235
+ )
236
+ end
237
+
238
+ private
239
+
240
+ # Returns the first translation that yields a present value by looking
241
+ # through each key in keys in order.
242
+ def first_present_value(*values)
243
+ values.find(&:presence)
244
+ end
245
+
246
+ # Returns the segment of the i18n key specific to this repository.
247
+ def i18n_key
248
+ @repository.model.model_name.i18n_key
249
+ end
250
+
251
+ def i18n_scope(section = nil)
252
+ [
253
+ "uchi.repository",
254
+ i18n_key,
255
+ section
256
+ ].compact.join(".")
257
+ end
258
+
259
+ def model
260
+ @model ||= repository.model
261
+ end
262
+
263
+ # Returns the localized, human-readable singular name of the model this
264
+ # repository manages.
265
+ def singular_name
266
+ ::I18n.translate(
267
+ "uchi.repository.#{i18n_key}.model",
268
+ count: 1,
269
+ default: model.model_name.human(count: 1)
270
+ )
271
+ end
272
+
273
+ def title_for_record(record)
274
+ return nil unless record
275
+
276
+ repository.title(record)
277
+ end
278
+
279
+ def translate(key, **options)
280
+ Uchi::I18n.translate(key, **options)
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "repository/routes"
4
+
5
+ module Uchi
6
+ class Repository
7
+ class << self
8
+ # Returns all defined Uchi::Repository classes
9
+ def all
10
+ Uchi::Repositories.constants.map { |const_name|
11
+ Uchi::Repositories.const_get(const_name)
12
+ }
13
+ end
14
+
15
+ # Returns the repository for the given model, or nil if none is found.
16
+ def for_model(model)
17
+ all.find { |repository| repository.model.to_s == model.to_s }
18
+ end
19
+
20
+ # Returns the model class this repository manages.
21
+ def model
22
+ @model ||= name.demodulize.constantize
23
+ end
24
+ end
25
+
26
+ # Returns a new, unsaved instance of the model this repository manages.
27
+ def build(attributes = {})
28
+ model.new(attributes)
29
+ end
30
+
31
+ # Returns the "name" of the controller that handles requests for this
32
+ # repository. Note that this is different from the controllers class name
33
+ # and is intended for generating URLs.
34
+ def controller_name
35
+ model_param_key.pluralize
36
+ end
37
+
38
+ def default_sort_order
39
+ SortOrder.new(:id, :asc)
40
+ end
41
+
42
+ # Returns an array of fields to show on the edit page.
43
+ def fields_for_edit
44
+ fields.select { |field| field.on.include?(:edit) }.each { |field| field.repository = self }
45
+ end
46
+
47
+ # Returns an array of fields to show on the index page.
48
+ def fields_for_index
49
+ fields.select { |field| field.on.include?(:index) }.each { |field| field.repository = self }
50
+ end
51
+
52
+ # Returns an array of fields to show on the show page.
53
+ def fields_for_show
54
+ fields.select { |field| field.on.include?(:show) }.each { |field| field.repository = self }
55
+ end
56
+
57
+ def find_all(search: nil, scope: model.all, sort_order: default_sort_order)
58
+ scope ||= model.all
59
+ query = scope.includes(includes)
60
+ query = apply_search(query, search)
61
+ apply_sort_order(query, sort_order)
62
+ end
63
+
64
+ def find(id)
65
+ model.find(id)
66
+ end
67
+
68
+ # Returns the list of associations to include when querying for records.
69
+ #
70
+ # See
71
+ # https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-includes
72
+ # for further details.
73
+ def includes
74
+ []
75
+ end
76
+
77
+ def model
78
+ self.class.model
79
+ end
80
+
81
+ def model_param_key
82
+ model.model_name.param_key
83
+ end
84
+
85
+ # Returns an instance of Uchi::Repository::Routes for this repository,
86
+ # which can be used to generate paths and URLs.
87
+ #
88
+ # @return [Uchi::Repository::Routes]
89
+ def routes
90
+ @routes ||= Routes.new(self)
91
+ end
92
+
93
+ # Returns true if this repository has at least one searchable field.
94
+ #
95
+ # @return [Boolean]
96
+ def searchable?
97
+ searchable_fields.any?
98
+ end
99
+
100
+ # Returns the title to show for a given record. By default, this method
101
+ # returns the value of the first of the following methods that exist:
102
+ #
103
+ # 1. `name`
104
+ # 2. `title`
105
+ # 3. `to_s`
106
+ #
107
+ # You can override this method in your repository subclass to provide
108
+ # custom logic.
109
+ def title(record)
110
+ return nil unless record
111
+
112
+ [:name, :title, :to_s].each do |method|
113
+ if record.respond_to?(method)
114
+ return record.public_send(method)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Provides access to translation helpers specific to this repository.
120
+ def translate
121
+ @translate ||= Translate.new(repository: self)
122
+ end
123
+
124
+ private
125
+
126
+ def apply_search(query, search)
127
+ return query unless search.present?
128
+ return query if searchable_fields.empty?
129
+
130
+ search = search.strip
131
+ conditions = searchable_fields.map { |field|
132
+ arel_field = model.arel_table[field.name]
133
+ Arel::Nodes::NamedFunction.new(
134
+ "CAST",
135
+ [arel_field.as(Arel::Nodes::SqlLiteral.new("VARCHAR"))]
136
+ ).matches("%#{search}%")
137
+ }
138
+ query.where(conditions.inject(:or))
139
+ end
140
+
141
+ def apply_sort_order(query, sort_order)
142
+ field_to_sort_by = fields.find { |field| field.name == sort_order.name }
143
+ return query unless field_to_sort_by
144
+
145
+ if field_to_sort_by.sortable.respond_to?(:call)
146
+ field_to_sort_by.sortable.call(query, sort_order.direction)
147
+ else
148
+ sort_order.apply(query)
149
+ end
150
+ end
151
+
152
+ def searchable_fields
153
+ @searchable_fields ||= fields.select { |field| field.searchable? }
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,35 @@
1
+ module Uchi
2
+ class SortOrder
3
+ attr_reader :name, :direction
4
+
5
+ class << self
6
+ def from_params(params)
7
+ sort_params = params[:sort] || {}
8
+ return nil if sort_params.empty?
9
+
10
+ by = sort_params[:by]
11
+ return by unless by
12
+
13
+ direction = sort_params[:direction] || :asc
14
+ new(by, direction)
15
+ end
16
+ end
17
+
18
+ def ascending?
19
+ direction == :asc
20
+ end
21
+
22
+ def apply(query)
23
+ query.order(name => direction)
24
+ end
25
+
26
+ def descending?
27
+ direction == :desc
28
+ end
29
+
30
+ def initialize(name, direction)
31
+ @name = name.to_sym
32
+ @direction = direction.to_sym
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ VERSION = "0.1.4"
5
+ end