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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class RepositoryGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ def create_repository_file
8
+ destination = File.join("app/uchi/repositories")
9
+ template "repository.rb", File.join(destination, "#{file_name}.rb")
10
+ end
11
+
12
+ def generate_controller
13
+ generate("uchi:controller", name)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ module Repositories
5
+ class <%= class_name %> < Repository
6
+ def fields
7
+ []
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :uchi do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,5 @@
1
+ module Uchi
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,24 @@
1
+ module Uchi
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Uchi
4
+
5
+ initializer "uchi.assets" do |app|
6
+ if app.config.respond_to?(:assets)
7
+ app.config.assets.paths << root.join("app/assets/stylesheets")
8
+ app.config.assets.paths << root.join("app/assets/javascripts")
9
+ app.config.assets.precompile += %w[uchi_manifest]
10
+ end
11
+ end
12
+
13
+ initializer "uchi.autoload" do |app|
14
+ # Preserve Uchi as a namespace for autoloading from app/uchi
15
+ # See https://github.com/fxn/zeitwerk/issues/250 for details
16
+ ActiveSupport::Dependencies.autoload_paths.delete("#{Rails.root}/app/uchi")
17
+
18
+ uchi_directory = Rails.root.join("app/uchi")
19
+ if uchi_directory.exist?
20
+ Rails.autoloaders.main.push_dir(uchi_directory, namespace: Uchi)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Field
5
+ module Configuration
6
+ class Unset; end
7
+
8
+ DEFAULT_READER = ->(record, field_name) { record.public_send(field_name) }
9
+
10
+ def initialize(*args)
11
+ super
12
+ @on = default_on
13
+ @reader = DEFAULT_READER
14
+ @searchable = default_searchable?
15
+ @sortable = default_sortable?
16
+ end
17
+
18
+ # Sets or gets which actions this field should appear on.
19
+ #
20
+ # When called with arguments, sets the actions and returns self for chaining.
21
+ # When called without arguments, returns the current actions.
22
+ #
23
+ # @param actions [Array<Symbol>] The actions where this field should appear
24
+ # (e.g., :index, :show, :new, :edit)
25
+ # @return [self, Array<Symbol>] Returns self for method chaining when setting,
26
+ # or the actions array when getting
27
+ #
28
+ # @example Setting
29
+ # Field::Number.new(:id).on(:index, :show)
30
+ #
31
+ # @example Getting
32
+ # field.on # => [:index, :show]
33
+ def on(*actions)
34
+ return @on if actions.empty?
35
+
36
+ @on = actions.flatten
37
+ self
38
+ end
39
+
40
+ # Sets or gets a custom reader for this field.
41
+ #
42
+ # When called with an argument, sets the reader and returns self for chaining.
43
+ # When called without arguments, returns the current reader.
44
+ #
45
+ # @param reader_proc [Proc, nil] A callable that reads the value from a record.
46
+ # The proc receives the model and field name, and should return the value.
47
+ # @return [self, Proc] Returns self for method chaining when setting,
48
+ # or the reader proc when getting
49
+ #
50
+ # @example Setting
51
+ # Field::String.new(:full_name).reader(->(record, field_name) {
52
+ # "#{record.first_name} #{record.last_name}"
53
+ # })
54
+ #
55
+ # @example Getting
56
+ # field.reader # => #<Proc...>
57
+ def reader(reader_proc = nil)
58
+ return @reader if reader_proc.nil? && !block_given?
59
+
60
+ @reader = reader_proc || Proc.new
61
+ self
62
+ end
63
+
64
+ # Sets or gets whether this field is searchable.
65
+ #
66
+ # When called with an argument, sets searchable and returns self for chaining.
67
+ # When called without arguments, returns whether the field is searchable.
68
+ #
69
+ # @param value [Boolean, nil] Whether the field is searchable in index views.
70
+ # Defaults to false for most fields, except text-based fields.
71
+ # @return [self, Boolean] Returns self for method chaining when setting,
72
+ # or boolean when getting
73
+ #
74
+ # @example Setting
75
+ # Field::String.new(:password).searchable(false)
76
+ # Field::Number.new(:id).searchable(true)
77
+ def searchable(value = Configuration::Unset)
78
+ return @searchable if value == Configuration::Unset
79
+
80
+ @searchable = value
81
+ self
82
+ end
83
+
84
+ # Returns true if the field is searchable and should be included in the
85
+ # query when a search term has been entered.
86
+ def searchable?
87
+ return default_searchable? if @searchable.nil?
88
+
89
+ !!@searchable
90
+ end
91
+
92
+ # Sets or gets whether and how this field is sortable.
93
+ #
94
+ # When called with an argument, sets sortable and returns self for chaining.
95
+ # When called without arguments, returns the sortable value.
96
+ #
97
+ # @param value [Boolean, Proc, Symbol] Whether the field is sortable. Pass true/false
98
+ # for simple sorting, or a lambda that receives the query and direction
99
+ # and returns an ActiveRecord::Relation for custom sorting.
100
+ # @return [self, Boolean, Proc] Returns self for method chaining when setting,
101
+ # or the sortable value when getting
102
+ #
103
+ # @example Setting with boolean
104
+ # Field::Number.new(:calculated_sum).sortable(false)
105
+ #
106
+ # @example Setting with lambda
107
+ # Field::Number.new(:users_count).sortable(lambda { |query, direction|
108
+ # query.joins(:users).group(:id).order("COUNT(users.id) #{direction}")
109
+ # })
110
+ #
111
+ # @example Getting
112
+ # field.sortable # => true
113
+ def sortable(value = Configuration::Unset)
114
+ return @sortable if value == Configuration::Unset
115
+
116
+ @sortable = value
117
+ self
118
+ end
119
+
120
+ # Returns true if the field is sortable
121
+ def sortable?
122
+ return default_sortable? if @sortable.nil?
123
+
124
+ !!@sortable
125
+ end
126
+
127
+ protected
128
+
129
+ def default_on
130
+ [:edit, :index, :show]
131
+ end
132
+
133
+ def default_searchable?
134
+ false
135
+ end
136
+
137
+ def default_sortable?
138
+ true
139
+ end
140
+ end
141
+ end
142
+ end
data/lib/uchi/field.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "field/configuration"
4
+
5
+ module Uchi
6
+ class Field
7
+ include Configuration
8
+
9
+ attr_reader :name
10
+
11
+ # The repository this field is associated with.
12
+ attr_accessor :repository
13
+
14
+ def column_name
15
+ name.to_s.humanize
16
+ end
17
+
18
+ def group_as(_action)
19
+ :attributes
20
+ end
21
+
22
+ def edit_component(form:, repository:, label: nil, hint: nil)
23
+ edit_component_class.new(
24
+ field: self,
25
+ form: form,
26
+ repository: repository,
27
+ label: label,
28
+ hint: hint
29
+ )
30
+ end
31
+
32
+ def edit_component_class
33
+ self.class.const_get(:Edit)
34
+ end
35
+
36
+ def index_component(record:, repository:)
37
+ index_component_class.new(
38
+ field: self,
39
+ record: record,
40
+ repository: repository
41
+ )
42
+ end
43
+
44
+ def index_component_class
45
+ self.class.const_get(:Index)
46
+ end
47
+
48
+ # @param name [String, Symbol] The name of the field.
49
+ def initialize(name)
50
+ super()
51
+ @name = name.to_sym
52
+ end
53
+
54
+ # Returns the key that this field is expected to use in params
55
+ def param_key
56
+ name.to_sym
57
+ end
58
+
59
+ # Returns the values to use for permitting this field in strong parameters
60
+ def permitted_param
61
+ param_key
62
+ end
63
+
64
+ def show_component(record:, repository:)
65
+ show_component_class.new(
66
+ field: self,
67
+ record: record,
68
+ repository: repository
69
+ )
70
+ end
71
+
72
+ def show_component_class
73
+ self.class.const_get(:Show)
74
+ end
75
+
76
+ def value(record)
77
+ reader.call(record, name)
78
+ end
79
+ end
80
+ end
data/lib/uchi/i18n.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ module I18n
5
+ module_function
6
+
7
+ def translate(key, **options)
8
+ scope = options.delete(:scope)
9
+ scope ||= "uchi" unless key.to_s.start_with?("uchi.")
10
+ ::I18n.translate(key, **options, scope: scope)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uchi/pagy"
4
+
5
+ require "uchi/pagination/page"
6
+
7
+ module Uchi
8
+ module Pagination
9
+ module Controller
10
+ include Pagy::Method
11
+
12
+ # Set up pagination for the given records. Returns a Page object and the
13
+ # paginated records for that page.
14
+ #
15
+ # @param records [ActiveRecord::Relation] The records to paginate
16
+ #
17
+ # @param records_per_page [Integer] The number of records per page
18
+ #
19
+ # @return [Array<(Uchi::Pagination::Page, ActiveRecord::Relation)>]
20
+ def paginate(records, records_per_page:)
21
+ page, records = pagy(:offset, records, limit: records_per_page)
22
+ [Uchi::Pagination::Page.new(page), records]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ module Pagination
5
+ class Page
6
+ delegate \
7
+ :last,
8
+ :next,
9
+ :page,
10
+ :page_url,
11
+ to: :pagy
12
+
13
+ private attr_reader :pagy
14
+
15
+ def initialize(pagy)
16
+ @pagy = pagy
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017-2025 Domizio Demichelis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ # Generic option error
6
+ class OptionError < ArgumentError
7
+ attr_reader :pagy, :option, :value
8
+
9
+ # Set the options and prepare the message
10
+ def initialize(pagy, option, description, value)
11
+ @pagy = pagy
12
+ @option = option
13
+ @value = value
14
+ super("expected :#{@option} #{description}; got #{@value.inspect}")
15
+ end
16
+ end
17
+
18
+ # Specific range error
19
+ class RangeError < OptionError; end
20
+
21
+ # I18n localization error
22
+ class RailsI18nLoadError < LoadError; end
23
+
24
+ # Generic internal error
25
+ class InternalError < StandardError; end
26
+
27
+ # JsonApi :page param error
28
+ class JsonapiReservedParamError < StandardError
29
+ # Inform about the actual value
30
+ def initialize(value)
31
+ super("expected reserved :page param to be nil or Hash-like; got #{value.inspect}")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../modules/abilities/shiftable'
4
+ require_relative '../../modules/abilities/rangeable'
5
+
6
+ module Uchi
7
+ class Pagy
8
+ # Implements Offset Pagination
9
+ class Offset < Pagy
10
+ DEFAULT = { page: 1 }.freeze
11
+
12
+ autoload :Countless, Pathname.new(__dir__).join('countless')
13
+
14
+ include Rangeable
15
+ include Shiftable
16
+
17
+ def initialize(**) # rubocop:disable Lint/MissingSuper
18
+ assign_options(**)
19
+ assign_and_check(limit: 1, count: 0, page: 1)
20
+ assign_last
21
+ assign_offset
22
+ return unless in_range? { @page <= @last }
23
+
24
+ @from = [@offset + 1, @count].min
25
+ @to = [@offset + @limit, @count].min
26
+ @in = [@to - @from + 1, @count].min
27
+ assign_previous_and_next
28
+ end
29
+
30
+ attr_reader :offset, :count, :from, :to, :in, :previous, :last
31
+
32
+ def records(collection)
33
+ collection.offset(@offset).limit(@limit)
34
+ end
35
+
36
+ protected
37
+
38
+ def offset? = true
39
+
40
+ def assign_last
41
+ @last = [(@count.to_f / @limit).ceil, 1].max
42
+ @last = @options[:max_pages] if @options[:max_pages] && @last > @options[:max_pages]
43
+ end
44
+
45
+ def assign_offset
46
+ @offset = (@limit * (@page - 1))
47
+ end
48
+
49
+ # Called by false in_range?
50
+ def assign_empty_page_variables
51
+ @in = @from = @to = 0 # options relative to the actual page
52
+ @previous = @last # @previous relative to the actual page
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ # Decouple the reuest from the env, allowing non-rack apps to use pagy by passing a hash.
6
+ # Resolve :page and :limit, supporting the :jsonapi option. Support for URL composition.
7
+ class Request
8
+ def initialize(request, options = {})
9
+ @base_url, @path, @query, @cookie =
10
+ if request.is_a?(Hash)
11
+ request.values_at(:base_url, :path, :query, :cookie)
12
+ else
13
+ [request.base_url, request.path, request.GET, request.cookies['pagy']]
14
+ end
15
+ @jsonapi = @query['page'] && options[:jsonapi]
16
+ raise JsonapiReservedParamError, @query['page'] if @jsonapi && !@query['page'].respond_to?(:fetch)
17
+ end
18
+
19
+ attr_reader :base_url, :path, :query, :cookie
20
+
21
+ def resolve_page(options, force_integer: true)
22
+ page_key = options[:page_key] || DEFAULT[:page_key]
23
+ page = @jsonapi ? @query['page'][page_key] : @query[page_key]
24
+ page = nil if page == '' # fix for app-generated queries like ?page=
25
+ force_integer ? (page || 1).to_i : page
26
+ end
27
+
28
+ def resolve_limit(options)
29
+ limit_key = options[:limit_key] || DEFAULT[:limit_key]
30
+ return options[:limit] || DEFAULT[:limit] \
31
+ unless options[:client_max_limit] &&
32
+ (requested_limit = @jsonapi ? @query['page'][limit_key] : @query[limit_key])
33
+
34
+ [requested_limit.to_i, options[:client_max_limit]].min
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ # Add configurstion methods
6
+ module Configurable
7
+ # Sync the pagy javascript targets
8
+ def sync_javascript(destination, *targets)
9
+ names = %w[pagy.mjs pagy.js pagy.js.map pagy.min.js]
10
+ targets = names if targets.empty?
11
+ targets.each { |filename| FileUtils.cp(ROOT.join('javascripts', filename), destination) }
12
+ (names - targets).each { |filename| FileUtils.rm_f(File.join(destination, filename)) }
13
+ end
14
+
15
+ # Generate the script and style tags to help development
16
+ def dev_tools(wand_scale: 1)
17
+ <<~HTML
18
+ <script id="pagy-ai-widget">
19
+ document.addEventListener('wand-positioned', #{ROOT.join('javascripts/ai_widget.js').read});
20
+ </script>
21
+ <script id="pagy-wand" data-scale="#{wand_scale}">
22
+ #{ROOT.join('javascripts/wand.js').read}
23
+ </script>
24
+ <style id="pagy-wand-default">
25
+ #{ROOT.join('stylesheets/pagy.css').read}
26
+ </style>
27
+ HTML
28
+ end
29
+
30
+ # Setup pagy for using the i18n gem
31
+ def translate_with_the_slower_i18n_gem!
32
+ send(:remove_const, :I18n)
33
+ send(:const_set, :I18n, ::I18n)
34
+ ::I18n.load_path += Dir[ROOT.join('locales/*.yml')]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Uchi
6
+ class Pagy
7
+ # Provide the helpers to handle the url and anchor
8
+ module Linkable
9
+ module QueryUtils
10
+ module_function
11
+
12
+ # Extracted from Rack::Utils and reformatted for rubocop
13
+ # Add the 'unescaped' param, and use it for simple and safe url-templating.
14
+ # All string keyed hashes
15
+ def build_nested_query(value, prefix = nil, unescaped = [])
16
+ case value
17
+ when Array
18
+ value.map { |v| build_nested_query(v, "#{prefix}[]", unescaped) }.join('&')
19
+ when Hash
20
+ value.map do |k, v|
21
+ new_k = prefix ? "#{prefix}[#{escape(k)}]" : escape(k)
22
+ unescaped[unescaped.find_index(k)] = new_k if unescaped.size.positive? && new_k != k && unescaped.include?(k)
23
+ build_nested_query(v, new_k, unescaped)
24
+ end.delete_if(&:empty?).join('&')
25
+ when nil
26
+ escape(prefix)
27
+ else
28
+ raise ArgumentError, 'value must be a Hash' if prefix.nil?
29
+ return "#{escape(prefix)}=#{value}" if unescaped.include?(prefix)
30
+
31
+ "#{escape(prefix)}=#{escape(value)}"
32
+ end
33
+ end
34
+
35
+ def escape(str)
36
+ URI.encode_www_form_component(str)
37
+ end
38
+ end
39
+
40
+ protected
41
+
42
+ # Return the URL for the page, relying on the Pagy::Request
43
+ def compose_page_url(page, limit_token: nil, **options)
44
+ jsonapi, page_key, limit_key, limit, client_max_limit, querify, absolute, path, fragment =
45
+ @options.merge(options)
46
+ .values_at(:jsonapi, :page_key, :limit_key, :limit, :client_max_limit, :querify, :absolute, :path, :fragment)
47
+ query = @request.query.clone(freeze: false)
48
+ query.delete(jsonapi ? 'page' : page_key)
49
+ factors = {}.tap do |h|
50
+ h[page_key] = countless? ? "#{page || 1}+#{@last}" : page
51
+ h[limit_key] = limit_token || limit if client_max_limit
52
+ end.compact # No empty params
53
+ query.merge!(jsonapi ? { 'page' => factors } : factors) if factors.size.positive?
54
+ querify&.(query) # Must modify the query: the returned value is ignored
55
+ query_string = QueryUtils.build_nested_query(query, nil, [page_key, limit_key])
56
+ query_string = "?#{query_string}" unless query_string.empty?
57
+ fragment &&= %(##{fragment}) unless fragment&.start_with?('#')
58
+ "#{@request.base_url if absolute}#{path || @request.path}#{query_string}#{fragment}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ # Add method supporting range checking, range error and rescue
6
+ module Rangeable
7
+ # Check if in range
8
+ def in_range?
9
+ return @in_range if defined?(@in_range) || (@in_range = yield)
10
+ raise RangeError.new(self, :page, "in 1..#{@last}", @page) if @options[:raise_range_error]
11
+
12
+ assign_empty_page_variables
13
+ false
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ module Shiftable
6
+ protected
7
+
8
+ def assign_previous_and_next
9
+ @previous = @page - 1 unless @page == 1
10
+ @next = @page + 1 unless @page == @last
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Pagy
5
+ # Provide a ready to use pagy environment when included in irb/rails console
6
+ module Console
7
+ class Request
8
+ attr_accessor :base_url, :path, :params
9
+
10
+ def initialize
11
+ @base_url = 'http://www.example.com'
12
+ @path = '/path'
13
+ @params = { example: '123' }
14
+ end
15
+
16
+ def GET = @params # rubocop:disable Naming/MethodName
17
+
18
+ def cookies = {}
19
+ end
20
+
21
+ class Collection < Array
22
+ def initialize(arr = Array(1..1000))
23
+ super
24
+ @collection = clone
25
+ end
26
+
27
+ def offset(value) = tap { @collection = self[value..] }
28
+ def limit(value) = @collection[0, value]
29
+ def count(*) = size
30
+ end
31
+
32
+ include Method
33
+
34
+ # Direct reference to request.params via a method
35
+ def params = request.params
36
+ def request = @request ||= Request.new
37
+ def collection = Collection
38
+ end
39
+ end
40
+ end